// Package scoop provides a Pipe that generates a scoop.sh App Manifest and pushes it to a bucket.
package scoop

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path"
	"path/filepath"
	"strings"

	"github.com/caarlos0/log"
	"github.com/goreleaser/goreleaser/v2/internal/artifact"
	"github.com/goreleaser/goreleaser/v2/internal/client"
	"github.com/goreleaser/goreleaser/v2/internal/commitauthor"
	"github.com/goreleaser/goreleaser/v2/internal/pipe"
	"github.com/goreleaser/goreleaser/v2/internal/skips"
	"github.com/goreleaser/goreleaser/v2/internal/tmpl"
	"github.com/goreleaser/goreleaser/v2/pkg/config"
	"github.com/goreleaser/goreleaser/v2/pkg/context"
)

// ErrIncorrectArchiveCount happens when a given filter evaluates 0 or more
// than 1 archives.
type ErrIncorrectArchiveCount struct {
	goamd64  string
	ids      []string
	archives []*artifact.Artifact
}

func (e ErrIncorrectArchiveCount) Error() string {
	b := strings.Builder{}

	_, _ = b.WriteString("scoop requires a single windows archive, ")
	if len(e.archives) == 0 {
		_, _ = b.WriteString("but no archives ")
	} else {
		_, _ = b.WriteString(fmt.Sprintf("but found %d archives ", len(e.archives)))
	}

	_, _ = b.WriteString(fmt.Sprintf("matching the given filters: goos=windows goarch=[386 amd64 arm64] goamd64=%s ids=%s", e.goamd64, e.ids))

	if len(e.archives) > 0 {
		names := make([]string, 0, len(e.archives))
		for _, a := range e.archives {
			names = append(names, a.Name)
		}
		_, _ = b.WriteString(fmt.Sprintf(": %s", names))
	}

	_, _ = b.WriteString("\nLearn more at https://goreleaser.com/errors/scoop-archive\n")
	return b.String()
}

const scoopConfigExtra = "ScoopConfig"

// Pipe that builds and publishes scoop manifests.
type Pipe struct{}

func (Pipe) String() string        { return "scoop manifests" }
func (Pipe) ContinueOnError() bool { return true }
func (Pipe) Skip(ctx *context.Context) bool {
	return skips.Any(ctx, skips.Scoop) || len(ctx.Config.Scoops) == 0
}

// Run creates the scoop manifest locally.
func (Pipe) Run(ctx *context.Context) error {
	cli, err := client.NewReleaseClient(ctx)
	if err != nil {
		return err
	}
	return runAll(ctx, cli)
}

// Publish scoop manifest.
func (Pipe) Publish(ctx *context.Context) error {
	client, err := client.New(ctx)
	if err != nil {
		return err
	}
	return publishAll(ctx, client)
}

// Default sets the pipe defaults.
func (Pipe) Default(ctx *context.Context) error {
	for i := range ctx.Config.Scoops {
		scoop := &ctx.Config.Scoops[i]
		if scoop.Name == "" {
			scoop.Name = ctx.Config.ProjectName
		}
		scoop.CommitAuthor = commitauthor.Default(scoop.CommitAuthor)
		if scoop.CommitMessageTemplate == "" {
			scoop.CommitMessageTemplate = "Scoop update for {{ .ProjectName }} version {{ .Tag }}"
		}
		if scoop.Goamd64 == "" {
			scoop.Goamd64 = "v1"
		}
	}
	return nil
}

func runAll(ctx *context.Context, cl client.ReleaseURLTemplater) error {
	for _, scoop := range ctx.Config.Scoops {
		err := doRun(ctx, scoop, cl)
		if err != nil {
			return err
		}
	}
	return nil
}

func doRun(ctx *context.Context, scoop config.Scoop, cl client.ReleaseURLTemplater) error {
	filters := []artifact.Filter{
		artifact.ByGoos("windows"),
		artifact.ByType(artifact.UploadableArchive),
		artifact.Or(
			artifact.And(
				artifact.ByGoarch("amd64"),
				artifact.ByGoamd64(scoop.Goamd64),
			),
			artifact.ByGoarch("arm64"),
			artifact.ByGoarch("386"),
		),
	}

	if len(scoop.IDs) > 0 {
		filters = append(filters, artifact.ByIDs(scoop.IDs...))
	}

	filtered := ctx.Artifacts.Filter(artifact.And(filters...))
	archives := filtered.List()
	for _, platArchives := range filtered.GroupByPlatform() {
		// there might be multiple archives, but only of for each platform
		if len(platArchives) != 1 {
			return ErrIncorrectArchiveCount{scoop.Goamd64, scoop.IDs, archives}
		}
	}
	// handle no archives found whatsoever
	if len(archives) == 0 {
		return ErrIncorrectArchiveCount{scoop.Goamd64, scoop.IDs, archives}
	}

	tp := tmpl.New(ctx)

	if err := tp.ApplyAll(
		&scoop.Name,
		&scoop.Description,
		&scoop.Homepage,
		&scoop.SkipUpload,
	); err != nil {
		return err
	}

	ref, err := client.TemplateRef(tmpl.New(ctx).Apply, scoop.Repository)
	if err != nil {
		return err
	}
	scoop.Repository = ref

	data, err := dataFor(ctx, scoop, cl, archives)
	if err != nil {
		return err
	}
	content, err := doBuildManifest(data)
	if err != nil {
		return err
	}

	filename := scoop.Name + ".json"
	path := filepath.Join(ctx.Config.Dist, "scoop", scoop.Directory, filename)
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return err
	}
	_ = content.WriteByte('\n')
	log.WithField("manifest", path).Info("writing")
	if err := os.WriteFile(path, content.Bytes(), 0o644); err != nil {
		return fmt.Errorf("failed to write scoop manifest: %w", err)
	}

	ctx.Artifacts.Add(&artifact.Artifact{
		Name: filename,
		Path: path,
		Type: artifact.ScoopManifest,
		Extra: map[string]any{
			scoopConfigExtra: scoop,
		},
	})
	return nil
}

func publishAll(ctx *context.Context, cli client.Client) error {
	// even if one of them skips, we run them all, and then show return the
	// skips all at once. this is needed so we actually create the
	// `dist/foo.json` file, which is useful for debugging.
	skips := pipe.SkipMemento{}
	for _, manifest := range ctx.Artifacts.Filter(artifact.ByType(artifact.ScoopManifest)).List() {
		err := doPublish(ctx, manifest, cli)
		if err != nil && pipe.IsSkip(err) {
			skips.Remember(err)
			continue
		}
		if err != nil {
			return err
		}
	}
	return skips.Evaluate()
}

func doPublish(ctx *context.Context, manifest *artifact.Artifact, cl client.Client) error {
	scoop := artifact.MustExtra[config.Scoop](*manifest, scoopConfigExtra)
	if strings.TrimSpace(scoop.SkipUpload) == "true" {
		return pipe.Skip("scoop.skip_upload is true")
	}
	if strings.TrimSpace(scoop.SkipUpload) == "auto" && ctx.Semver.Prerelease != "" {
		return pipe.Skip("release is prerelease")
	}

	commitMessage, err := tmpl.New(ctx).Apply(scoop.CommitMessageTemplate)
	if err != nil {
		return err
	}

	author, err := commitauthor.Get(ctx, scoop.CommitAuthor)
	if err != nil {
		return err
	}

	content, err := os.ReadFile(manifest.Path)
	if err != nil {
		return err
	}

	repo := client.RepoFromRef(scoop.Repository)
	gpath := path.Join(scoop.Directory, manifest.Name)

	if scoop.Repository.Git.URL != "" {
		return client.NewGitUploadClient(repo.Branch).
			CreateFile(ctx, author, repo, content, gpath, commitMessage)
	}

	cl, err = client.NewIfToken(ctx, cl, scoop.Repository.Token)
	if err != nil {
		return err
	}

	base := client.Repo{
		Name:   scoop.Repository.PullRequest.Base.Name,
		Owner:  scoop.Repository.PullRequest.Base.Owner,
		Branch: scoop.Repository.PullRequest.Base.Branch,
	}

	// try to sync branch
	fscli, ok := cl.(client.ForkSyncer)
	if ok && scoop.Repository.PullRequest.Enabled {
		if err := fscli.SyncFork(ctx, repo, base); err != nil {
			log.WithError(err).Warn("could not sync fork")
		}
	}

	if err := cl.CreateFile(ctx, author, repo, content, gpath, commitMessage); err != nil {
		return err
	}

	if !scoop.Repository.PullRequest.Enabled {
		log.Debug("scoop.pull_request disabled")
		return nil
	}

	log.Info("scoop.pull_request enabled, creating a PR")
	pcl, ok := cl.(client.PullRequestOpener)
	if !ok {
		return errors.New("client does not support pull requests")
	}

	return pcl.OpenPullRequest(ctx, base, repo, commitMessage, scoop.Repository.PullRequest.Draft)
}

// Manifest represents a scoop.sh App Manifest.
// more info: https://github.com/lukesampson/scoop/wiki/App-Manifests
type Manifest struct {
	Version      string              `json:"version"`                // The version of the app that this manifest installs.
	Architecture map[string]Resource `json:"architecture"`           // `architecture`: If the app has 32- and 64-bit versions, architecture can be used to wrap the differences.
	Homepage     string              `json:"homepage,omitempty"`     // `homepage`: The home page for the program.
	License      string              `json:"license,omitempty"`      // `license`: The software license for the program. For well-known licenses, this will be a string like "MIT" or "GPL2". For custom licenses, this should be the URL of the license.
	Description  string              `json:"description,omitempty"`  // Description of the app
	Persist      []string            `json:"persist,omitempty"`      // Persist data between updates
	PreInstall   []string            `json:"pre_install,omitempty"`  // An array of strings, of the commands to be executed before an application is installed.
	PostInstall  []string            `json:"post_install,omitempty"` // An array of strings, of the commands to be executed after an application is installed.
	Depends      []string            `json:"depends,omitempty"`      // A string or an array of strings.
	Shortcuts    [][]string          `json:"shortcuts,omitempty"`    // A two-dimensional array of string, specifies the shortcut values to make available in the startmenu.
}

// Resource represents a combination of a url and a binary name for an architecture.
type Resource struct {
	URL  string   `json:"url"`  // URL to the archive
	Bin  []string `json:"bin"`  // name of binary inside the archive
	Hash string   `json:"hash"` // the archive checksum
}

func doBuildManifest(manifest Manifest) (bytes.Buffer, error) {
	var result bytes.Buffer
	data, err := json.MarshalIndent(manifest, "", "    ")
	if err != nil {
		return result, err
	}
	_, err = result.Write(data)
	return result, err
}

func dataFor(ctx *context.Context, scoop config.Scoop, cl client.ReleaseURLTemplater, artifacts []*artifact.Artifact) (Manifest, error) {
	manifest := Manifest{
		Version:      ctx.Version,
		Architecture: map[string]Resource{},
		Homepage:     scoop.Homepage,
		License:      scoop.License,
		Description:  scoop.Description,
		Persist:      scoop.Persist,
		PreInstall:   scoop.PreInstall,
		PostInstall:  scoop.PostInstall,
		Depends:      scoop.Depends,
		Shortcuts:    scoop.Shortcuts,
	}

	if scoop.URLTemplate == "" {
		url, err := cl.ReleaseURLTemplate(ctx)
		if err != nil {
			return manifest, err
		}
		scoop.URLTemplate = url
	}

	for _, artifact := range artifacts {
		if artifact.Goos != "windows" {
			continue
		}

		var arch string
		switch artifact.Goarch {
		case "386":
			arch = "32bit"
		case "amd64":
			arch = "64bit"
		case "arm64":
			arch = "arm64"
		default:
			continue
		}

		url, err := tmpl.New(ctx).WithArtifact(artifact).Apply(scoop.URLTemplate)
		if err != nil {
			return manifest, err
		}

		sum, err := artifact.Checksum("sha256")
		if err != nil {
			return manifest, err
		}

		log.
			WithField("artifactExtras", artifact.Extra).
			WithField("fromURLTemplate", scoop.URLTemplate).
			WithField("templatedBrewURL", url).
			WithField("sum", sum).
			Debug("scoop url templating")

		manifest.Architecture[arch] = Resource{
			URL:  url,
			Bin:  binaries(*artifact),
			Hash: sum,
		}
	}

	return manifest, nil
}

func binaries(a artifact.Artifact) []string {
	//nolint:prealloc
	var result []string
	wrap := artifact.ExtraOr(a, artifact.ExtraWrappedIn, "")
	bins := artifact.MustExtra[[]string](a, artifact.ExtraBinaries)
	for _, b := range bins {
		result = append(result, filepath.ToSlash(filepath.Join(wrap, b)))
	}
	return result
}
