package commands

import (
	"context"
	"errors"
	"fmt"
	"os"
	"reflect"
	"strings"
	"time"

	"github.com/hashicorp/go-multierror"
	"github.com/spf13/cobra"
	"go.yaml.in/yaml/v3"

	"github.com/anchore/clio"
	"github.com/anchore/fangs"
	"github.com/anchore/go-collections"
	"github.com/anchore/stereoscope"
	"github.com/anchore/stereoscope/pkg/image"
	"github.com/anchore/syft/cmd/syft/internal/options"
	"github.com/anchore/syft/cmd/syft/internal/ui"
	"github.com/anchore/syft/internal"
	"github.com/anchore/syft/internal/bus"
	"github.com/anchore/syft/internal/file"
	"github.com/anchore/syft/internal/log"
	"github.com/anchore/syft/internal/task"
	"github.com/anchore/syft/syft"
	"github.com/anchore/syft/syft/sbom"
	"github.com/anchore/syft/syft/source"
	"github.com/anchore/syft/syft/source/sourceproviders"
)

const (
	scanExample = `  {{.appName}} {{.command}} alpine:latest                                a summary of discovered packages
  {{.appName}} {{.command}} alpine:latest -o json                        show all possible cataloging details
  {{.appName}} {{.command}} alpine:latest -o cyclonedx                   show a CycloneDX formatted SBOM
  {{.appName}} {{.command}} alpine:latest -o cyclonedx-json              show a CycloneDX JSON formatted SBOM
  {{.appName}} {{.command}} alpine:latest -o spdx                        show a SPDX 2.3 Tag-Value formatted SBOM
  {{.appName}} {{.command}} alpine:latest -o spdx@2.2                    show a SPDX 2.2 Tag-Value formatted SBOM
  {{.appName}} {{.command}} alpine:latest -o spdx-json                   show a SPDX 2.3 JSON formatted SBOM
  {{.appName}} {{.command}} alpine:latest -o spdx-json@2.2               show a SPDX 2.2 JSON formatted SBOM
  {{.appName}} {{.command}} alpine:latest -vv                            show verbose debug information
  {{.appName}} {{.command}} alpine:latest -o template -t my_format.tmpl  show a SBOM formatted according to given template file

  Supports the following image sources:
    {{.appName}} {{.command}} yourrepo/yourimage:tag     defaults to using images from a Docker daemon. If Docker is not present, the image is pulled directly from the registry.
    {{.appName}} {{.command}} path/to/a/file/or/dir      a Docker tar, OCI tar, OCI directory, SIF container, or generic filesystem directory
`

	schemeHelpHeader = "You can also explicitly specify the scheme to use:"
	imageSchemeHelp  = `    {{.appName}} {{.command}} docker:yourrepo/yourimage:tag            explicitly use the Docker daemon
    {{.appName}} {{.command}} podman:yourrepo/yourimage:tag            explicitly use the Podman daemon
    {{.appName}} {{.command}} registry:yourrepo/yourimage:tag          pull image directly from a registry (no container runtime required)
    {{.appName}} {{.command}} docker-archive:path/to/yourimage.tar     use a tarball from disk for archives created from "docker save"
    {{.appName}} {{.command}} oci-archive:path/to/yourimage.tar        use a tarball from disk for OCI archives (from Skopeo or otherwise)
    {{.appName}} {{.command}} oci-dir:path/to/yourimage                read directly from a path on disk for OCI layout directories (from Skopeo or otherwise)
    {{.appName}} {{.command}} singularity:path/to/yourimage.sif        read directly from a Singularity Image Format (SIF) container on disk
`
	nonImageSchemeHelp = `    {{.appName}} {{.command}} dir:path/to/yourproject                  read directly from a path on disk (any directory)
    {{.appName}} {{.command}} file:path/to/yourproject/file            read directly from a path on disk (any single file)
`
	modelSchemeHelp = `    {{.appName}} {{.command}} oci-model-registry:ai/llama3.2           scan an OCI model artifact from a registry (e.g. Docker Hub AI models)
`
	scanSchemeHelp = "\n  " + schemeHelpHeader + "\n" + imageSchemeHelp + modelSchemeHelp + nonImageSchemeHelp

	scanHelp = scanExample + scanSchemeHelp
)

type scanOptions struct {
	options.Config      `yaml:",inline" mapstructure:",squash"`
	options.Output      `yaml:",inline" mapstructure:",squash"`
	options.UpdateCheck `yaml:",inline" mapstructure:",squash"`
	options.Catalog     `yaml:",inline" mapstructure:",squash"`
	Cache               options.Cache `json:"-" yaml:"cache" mapstructure:"cache"`
}

func defaultScanOptions() *scanOptions {
	return &scanOptions{
		Output:      options.DefaultOutput(),
		UpdateCheck: options.DefaultUpdateCheck(),
		Catalog:     options.DefaultCatalog(),
		Cache:       options.DefaultCache(),
	}
}

func Scan(app clio.Application) *cobra.Command {
	id := app.ID()

	opts := defaultScanOptions()

	return app.SetupCommand(&cobra.Command{
		Use:   "scan [SOURCE]",
		Short: "Generate an SBOM",
		Long:  "Generate a packaged-based Software Bill Of Materials (SBOM) from container images and filesystems",
		Example: internal.Tprintf(scanHelp, map[string]any{
			"appName": id.Name,
			"command": "scan",
		}),
		Args:    validateScanArgs,
		PreRunE: applicationUpdateCheck(id, &opts.UpdateCheck),
		RunE: func(cmd *cobra.Command, args []string) error {
			restoreStdout := ui.CaptureStdoutToTraceLog()
			defer restoreStdout()

			return runScan(cmd.Context(), id, opts, args[0])
		},
	}, opts)
}

func (o *scanOptions) PostLoad() error {
	return o.validateLegacyOptionsNotUsed()
}

func (o *scanOptions) validateLegacyOptionsNotUsed() error {
	if len(fangs.Flatten(o.ConfigFile)) == 0 {
		return nil
	}

	// check for legacy config file shapes that are no longer valid
	type legacyConfig struct {
		BasePath                        *string `yaml:"base-path" json:"base-path" mapstructure:"base-path"`
		DefaultImagePullSource          *string `yaml:"default-image-pull-source" json:"default-image-pull-source" mapstructure:"default-image-pull-source"`
		ExcludeBinaryOverlapByOwnership *bool   `yaml:"exclude-binary-overlap-by-ownership" json:"exclude-binary-overlap-by-ownership" mapstructure:"exclude-binary-overlap-by-ownership"`
		File                            any     `yaml:"file" json:"file" mapstructure:"file"`
	}

	for _, f := range fangs.Flatten(o.ConfigFile) {
		by, err := os.ReadFile(f)
		if err != nil {
			return fmt.Errorf("unable to read config file during validations %q: %w", f, err)
		}

		var legacy legacyConfig
		if err := yaml.Unmarshal(by, &legacy); err != nil {
			return fmt.Errorf("unable to parse config file during validations %q: %w", f, err)
		}

		if legacy.DefaultImagePullSource != nil {
			return fmt.Errorf("the config file option 'default-image-pull-source' has been removed, please use 'source.image.default-pull-source' instead")
		}

		if legacy.ExcludeBinaryOverlapByOwnership != nil {
			return fmt.Errorf("the config file option 'exclude-binary-overlap-by-ownership' has been removed, please use 'package.exclude-binary-overlap-by-ownership' instead")
		}

		if legacy.BasePath != nil {
			return fmt.Errorf("the config file option 'base-path' has been removed, please use 'source.base-path' instead")
		}

		if legacy.File != nil && reflect.TypeOf(legacy.File).Kind() == reflect.String {
			return fmt.Errorf("the config file option 'file' has been removed, please use 'outputs' instead")
		}
	}
	return nil
}

func validateScanArgs(cmd *cobra.Command, args []string) error {
	return validateArgs(cmd, args, "an image/directory argument is required")
}

func validateArgs(cmd *cobra.Command, args []string, err string) error {
	if len(args) == 0 {
		// in the case that no arguments are given we want to show the help text and return with a non-0 return code.
		if err := cmd.Help(); err != nil {
			return fmt.Errorf("unable to display help: %w", err)
		}
		return fmt.Errorf("%v", err)
	}

	return cobra.MaximumNArgs(1)(cmd, args)
}

func runScan(ctx context.Context, id clio.Identification, opts *scanOptions, userInput string) error {
	scanStart := time.Now()
	defer func() {
		log.Infof("scan completed in %s", time.Since(scanStart).Round(time.Millisecond))
	}()

	writer, err := opts.SBOMWriter()
	if err != nil {
		return err
	}

	sources := opts.From
	if len(sources) == 0 {
		// extract a scheme if it matches any provider tag; this is a holdover for compatibility, using the --from flag is recommended
		explicitSource, newUserInput := stereoscope.ExtractSchemeSource(userInput, allSourceProviderTags()...)
		if explicitSource != "" {
			sources = append(sources, explicitSource)
			userInput = newUserInput
		}
	}

	src, err := getSource(ctx, &opts.Catalog, userInput, sources...)
	if err != nil {
		return err
	}

	defer func() {
		if src != nil {
			if err := src.Close(); err != nil {
				log.Tracef("unable to close source: %+v", err)
			}
		}
	}()

	s, err := generateSBOM(ctx, id, src, &opts.Catalog)
	if err != nil {
		return err
	}

	if s == nil {
		return fmt.Errorf("no SBOM produced for %q", userInput)
	}

	if err := writer.Write(*s); err != nil {
		return fmt.Errorf("failed to write SBOM: %w", err)
	}

	return nil
}

func getSource(ctx context.Context, opts *options.Catalog, userInput string, sources ...string) (source.Source, error) {
	cfg := syft.DefaultGetSourceConfig().
		WithRegistryOptions(opts.Registry.ToOptions()).
		WithAlias(source.Alias{
			Name:     opts.Source.Name,
			Version:  opts.Source.Version,
			Supplier: opts.Source.Supplier,
		}).
		WithExcludeConfig(source.ExcludeConfig{
			Paths: opts.Exclusions,
		}).
		WithBasePath(opts.Source.BasePath).
		WithSources(sources...).
		WithDefaultImagePullSource(opts.Source.Image.DefaultPullSource)

	var err error
	var platform *image.Platform

	if opts.Platform != "" {
		platform, err = image.NewPlatform(opts.Platform)
		if err != nil {
			return nil, fmt.Errorf("invalid platform: %w", err)
		}
		cfg = cfg.WithPlatform(platform)
	}

	if opts.Source.File.Digests != nil {
		hashers, err := file.Hashers(opts.Source.File.Digests...)
		if err != nil {
			return nil, fmt.Errorf("invalid hash algorithm: %w", err)
		}
		cfg = cfg.WithDigestAlgorithms(hashers...)
	}

	src, err := syft.GetSource(ctx, userInput, cfg)
	if err != nil {
		return nil, fmt.Errorf("could not determine source: %w", err)
	}

	return src, nil
}

func generateSBOM(ctx context.Context, id clio.Identification, src source.Source, opts *options.Catalog) (*sbom.SBOM, error) {
	s, err := syft.CreateSBOM(ctx, src, opts.ToSBOMConfig(id))
	if err != nil {
		expErrs := filterExpressionErrors(err)
		notifyExpressionErrors(expErrs)
		return nil, err
	}
	return s, nil
}

func filterExpressionErrors(err error) []task.ErrInvalidExpression {
	if err == nil {
		return nil
	}

	expErrs := processErrors(err)

	return expErrs
}

// processErrors traverses error chains and multierror lists and returns all ErrInvalidExpression errors found
func processErrors(err error) []task.ErrInvalidExpression {
	var result []task.ErrInvalidExpression

	var processError func(...error)
	processError = func(errs ...error) {
		for _, e := range errs {
			// note: using errors.As will result in surprising behavior (since that will traverse the error chain,
			// potentially skipping over nodes in a list of errors)
			if cerr, ok := e.(task.ErrInvalidExpression); ok {
				result = append(result, cerr)
				continue
			}
			var multiErr *multierror.Error
			if errors.As(e, &multiErr) {
				processError(multiErr.Errors...)
			}
		}
	}

	processError(err)

	return result
}

func notifyExpressionErrors(expErrs []task.ErrInvalidExpression) {
	helpText := expressionErrorsHelp(expErrs)
	if helpText == "" {
		return
	}

	bus.Notify(helpText)
}

func expressionErrorsHelp(expErrs []task.ErrInvalidExpression) string {
	// enrich all errors found with CLI hints
	if len(expErrs) == 0 {
		return ""
	}

	sb := strings.Builder{}

	sb.WriteString("Suggestions:\n\n")

	found := false
	for i, expErr := range expErrs {
		help := expressionSuggetions(expErr)
		if help == "" {
			continue
		}
		found = true
		sb.WriteString(help)
		if i != len(expErrs)-1 {
			sb.WriteString("\n")
		}
	}

	if !found {
		return ""
	}

	return sb.String()
}

const expressionHelpTemplate = " ❖ Given expression %q\n%s%s"

func expressionSuggetions(expErr task.ErrInvalidExpression) string {
	if expErr.Err == nil {
		return ""
	}

	hint := getHintPhrase(expErr)
	if hint == "" {
		return ""
	}

	return fmt.Sprintf(expressionHelpTemplate,
		getExpression(expErr),
		indentMsg(getExplanation(expErr)),
		indentMsg(hint),
	)
}

func indentMsg(msg string) string {
	if msg == "" {
		return ""
	}

	lines := strings.Split(msg, "\n")
	for i, line := range lines {
		lines[i] = "   " + line
	}

	return strings.Join(lines, "\n") + "\n"
}

func getExpression(expErr task.ErrInvalidExpression) string {
	flag := "--select-catalogers"
	if expErr.Operation == task.SetOperation {
		flag = "--override-default-catalogers"
	}
	return fmt.Sprintf("%s %s", flag, expErr.Expression)
}

func getExplanation(expErr task.ErrInvalidExpression) string {
	err := expErr.Err
	if errors.Is(err, task.ErrUnknownNameOrTag) {
		noun := ""
		switch expErr.Operation {
		case task.AddOperation:
			noun = "name"
		case task.SubSelectOperation:
			noun = "tag"
		default:
			noun = "name or tag"
		}

		return fmt.Sprintf("However, %q is not a recognized cataloger %s.", trimOperation(expErr.Expression), noun)
	}

	if errors.Is(err, task.ErrNamesNotAllowed) {
		if expErr.Operation == task.SubSelectOperation {
			return "However, " + err.Error() + ".\nIt seems like you are intending to add a cataloger in addition to the default set."
		}
		return "However, " + err.Error() + "."
	}

	if errors.Is(err, task.ErrTagsNotAllowed) {
		return "However, " + err.Error() + ".\nAdding groups of catalogers may result in surprising behavior (create inaccurate SBOMs)."
	}

	if errors.Is(err, task.ErrAllNotAllowed) {
		return "However, you " + err.Error() + ".\nIt seems like you are intending to use all catalogers (which is not recommended)."
	}

	if err != nil {
		return "However, this is not valid: " + err.Error()
	}

	return ""
}

func getHintPhrase(expErr task.ErrInvalidExpression) string {
	if errors.Is(expErr.Err, task.ErrUnknownNameOrTag) {
		return ""
	}

	switch expErr.Operation {
	case task.AddOperation:
		if errors.Is(expErr.Err, task.ErrTagsNotAllowed) {
			return fmt.Sprintf("If you are certain this is what you want to do, use %q instead.", "--override-default-catalogers "+trimOperation(expErr.Expression))
		}

	case task.SubSelectOperation:
		didYouMean := "... Did you mean %q instead?"
		if errors.Is(expErr.Err, task.ErrNamesNotAllowed) {
			return fmt.Sprintf(didYouMean, "--select-catalogers +"+expErr.Expression)
		}

		if errors.Is(expErr.Err, task.ErrAllNotAllowed) {
			return fmt.Sprintf(didYouMean, "--override-default-catalogers "+expErr.Expression)
		}
	}
	return ""
}

func trimOperation(x string) string {
	return strings.TrimLeft(x, "+-")
}

func allSourceProviderTags() []string {
	return collections.TaggedValueSet[source.Provider]{}.Join(sourceproviders.All("", nil)...).Tags()
}
