// Package universalbinary can join multiple darwin binaries into a single universal binary.
package universalbinary

import (
	"debug/macho"
	"encoding/binary"
	"fmt"
	"maps"
	"os"
	"path/filepath"
	"strings"

	"github.com/caarlos0/go-shellwords"
	"github.com/caarlos0/log"
	"github.com/goreleaser/goreleaser/v2/internal/artifact"
	"github.com/goreleaser/goreleaser/v2/internal/gio"
	"github.com/goreleaser/goreleaser/v2/internal/ids"
	"github.com/goreleaser/goreleaser/v2/internal/pipe"
	"github.com/goreleaser/goreleaser/v2/internal/semerrgroup"
	"github.com/goreleaser/goreleaser/v2/internal/shell"
	"github.com/goreleaser/goreleaser/v2/internal/skips"
	"github.com/goreleaser/goreleaser/v2/internal/tmpl"
	"github.com/goreleaser/goreleaser/v2/pkg/build"
	"github.com/goreleaser/goreleaser/v2/pkg/config"
	"github.com/goreleaser/goreleaser/v2/pkg/context"
)

// Pipe for macos universal binaries.
type Pipe struct{}

func (Pipe) String() string                 { return "universal binaries" }
func (Pipe) Skip(ctx *context.Context) bool { return len(ctx.Config.UniversalBinaries) == 0 }

// Default sets the pipe defaults.
func (Pipe) Default(ctx *context.Context) error {
	ids := ids.New("universal_binaries")
	for i := range ctx.Config.UniversalBinaries {
		unibin := &ctx.Config.UniversalBinaries[i]
		if unibin.ID == "" {
			unibin.ID = ctx.Config.ProjectName
		}
		if len(unibin.IDs) == 0 {
			unibin.IDs = []string{unibin.ID}
		}
		if unibin.NameTemplate == "" {
			unibin.NameTemplate = "{{ .ProjectName }}"
		}
		ids.Inc(unibin.ID)
	}
	return ids.Validate()
}

// Run the pipe.
func (Pipe) Run(ctx *context.Context) error {
	g := semerrgroup.NewSkipAware(semerrgroup.New(ctx.Parallelism))
	for _, unibin := range ctx.Config.UniversalBinaries {
		g.Go(func() error {
			opts := build.Options{
				Target: unitarget{},
			}
			if !skips.Any(ctx, skips.PreBuildHooks) {
				if err := runHook(ctx, &opts, unibin.Hooks.Pre); err != nil {
					return fmt.Errorf("pre hook failed: %w", err)
				}
			}
			if err := makeUniversalBinary(ctx, &opts, unibin); err != nil {
				return err
			}
			if !skips.Any(ctx, skips.PostBuildHooks) {
				if err := runHook(ctx, &opts, unibin.Hooks.Post); err != nil {
					return fmt.Errorf("post hook failed: %w", err)
				}
			}
			if !unibin.Replace {
				return nil
			}
			return ctx.Artifacts.Remove(filterFor(unibin))
		})
	}
	return g.Wait()
}

func runHook(ctx *context.Context, opts *build.Options, hooks config.Hooks) error {
	if len(hooks) == 0 {
		return nil
	}

	for _, hook := range hooks {
		var envs []string
		envs = append(envs, ctx.Env.Strings()...)

		tpl := tmpl.New(ctx).WithBuildOptions(*opts)
		for _, rawEnv := range hook.Env {
			env, err := tpl.Apply(rawEnv)
			if err != nil {
				return err
			}

			envs = append(envs, env)
		}

		tpl = tpl.WithEnvS(envs)
		dir, err := tpl.Apply(hook.Dir)
		if err != nil {
			return err
		}

		sh, err := tpl.Apply(hook.Cmd)
		if err != nil {
			return err
		}

		log.WithField("hook", sh).Info("running hook")
		cmd, err := shellwords.Parse(sh)
		if err != nil {
			return err
		}

		if err := shell.Run(ctx, dir, cmd, envs, hook.Output); err != nil {
			return err
		}
	}
	return nil
}

type input struct {
	data   []byte
	cpu    uint32
	subcpu uint32
	offset int64
}

const (
	// Alignment wanted for each sub-file.
	// amd64 needs 12 bits, arm64 needs 14. We choose the max of all requirements here.
	alignBits = 14
	align     = 1 << alignBits
)

// heavily based on https://github.com/randall77/makefat
func makeUniversalBinary(ctx *context.Context, opts *build.Options, unibin config.UniversalBinary) error {
	if err := tmpl.New(ctx).ApplyAll(
		&unibin.NameTemplate,
		&unibin.ModTimestamp,
	); err != nil {
		return err
	}
	name := unibin.NameTemplate
	opts.Name = name

	path := filepath.Join(ctx.Config.Dist, unibin.ID+"_darwin_all", name)
	opts.Path = path
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return err
	}

	binaries := ctx.Artifacts.Filter(filterFor(unibin)).List()
	if len(binaries) == 0 {
		return pipe.Skipf("no darwin binaries found with ids: %s", strings.Join(unibin.IDs, ", "))
	}

	log.WithField("id", unibin.ID).
		WithField("binary", path).
		Infof("creating from %d binaries", len(binaries))

	var inputs []input
	offset := int64(align)
	for _, f := range binaries {
		data, err := os.ReadFile(f.Path)
		if err != nil {
			return fmt.Errorf("failed to read binary: %w", err)
		}
		inputs = append(inputs, input{
			data:   data,
			cpu:    binary.LittleEndian.Uint32(data[4:8]),
			subcpu: binary.LittleEndian.Uint32(data[8:12]),
			offset: offset,
		})
		offset += int64(len(data))
		offset = (offset + align - 1) / align * align
	}

	// Make output file.
	out, err := os.Create(path)
	if err != nil {
		return fmt.Errorf("failed to create file: %w", err)
	}
	defer out.Close()

	if err := out.Chmod(0o755); err != nil {
		return fmt.Errorf("failed to create file: %w", err)
	}

	// Build a fat_header.
	hdr := []uint32{macho.MagicFat, uint32(len(inputs))}

	// Build a fat_arch for each input file.
	for _, i := range inputs {
		hdr = append(hdr, i.cpu)
		hdr = append(hdr, i.subcpu)
		hdr = append(hdr, uint32(i.offset))
		hdr = append(hdr, uint32(len(i.data)))
		hdr = append(hdr, alignBits)
	}

	// Write header.
	// Note that the fat binary header is big-endian, regardless of the
	// endianness of the contained files.
	if err := binary.Write(out, binary.BigEndian, hdr); err != nil {
		return fmt.Errorf("failed to write to file: %w", err)
	}
	offset = int64(4 * len(hdr))

	// Write each contained file.
	for _, i := range inputs {
		if offset < i.offset {
			if _, err := out.Write(make([]byte, i.offset-offset)); err != nil {
				return fmt.Errorf("failed to write to file: %w", err)
			}
			offset = i.offset
		}
		if _, err := out.Write(i.data); err != nil {
			return fmt.Errorf("failed to write to file: %w", err)
		}
		offset += int64(len(i.data))
	}

	if err := out.Close(); err != nil {
		return fmt.Errorf("failed to close file: %w", err)
	}

	if err := gio.Chtimes(path, unibin.ModTimestamp); err != nil {
		return err
	}

	extra := map[string]any{}
	maps.Copy(extra, binaries[0].Extra)
	extra[artifact.ExtraReplaces] = unibin.Replace
	extra[artifact.ExtraID] = unibin.ID

	ctx.Artifacts.Add(&artifact.Artifact{
		Type:   artifact.UniversalBinary,
		Name:   name,
		Path:   path,
		Goos:   "darwin",
		Goarch: "all",
		Extra:  extra,
	})

	return nil
}

func filterFor(unibin config.UniversalBinary) artifact.Filter {
	return artifact.And(
		artifact.ByType(artifact.Binary),
		artifact.ByGoos("darwin"),
		artifact.ByIDs(unibin.IDs...),
	)
}
