// Package fix implements the `fix` command for osv-scanner.
// It scans a manifest and/or lockfile for vulnerabilities and suggests changes for remediating them.
package fix

import (
	"context"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"

	"charm.land/glamour/v2"
	"charm.land/glamour/v2/ansi"
	"charm.land/glamour/v2/styles"
	"charm.land/lipgloss/v2"

	cpb "github.com/google/osv-scalibr/binary/proto/config_go_proto"
	"github.com/google/osv-scalibr/clients/datasource"
	"github.com/google/osv-scalibr/clients/resolution"
	scalibrosvdev "github.com/google/osv-scalibr/enricher/vulnmatch/osvdev"
	"github.com/google/osv-scalibr/enricher/vulnmatch/osvlocal"
	"github.com/google/osv-scalibr/guidedremediation"
	"github.com/google/osv-scalibr/guidedremediation/options"
	"github.com/google/osv-scalibr/guidedremediation/strategy"
	"github.com/google/osv-scalibr/guidedremediation/upgrade"
	"github.com/google/osv-scalibr/plugin/config"
	"github.com/google/osv-scanner/v2/internal/cmdlogger"
	"github.com/google/osv-scanner/v2/internal/depsdev"
	"github.com/google/osv-scanner/v2/internal/version"
	"github.com/google/osv-scanner/v2/pkg/osvscanner"
	"github.com/urfave/cli/v3"
	"golang.org/x/term"
)

var strategies = []string{string(strategy.StrategyInPlace), string(strategy.StrategyRelax), string(strategy.StrategyOverride)}

const (
	vulnCategory     = "Vulnerability Selection Options:"
	upgradeCategory  = "Dependency Upgrade Options:"
	autoModeCategory = "non-interactive options:" // intentionally lowercase to force it to sort after the other categories
)

func Command(stdout, _ io.Writer, clientFactories config.ClientFactories) *cli.Command {
	return &cli.Command{
		Name:        "fix",
		Usage:       "scans a manifest and/or lockfile for vulnerabilities and suggests changes for remediating them",
		Description: "scans a manifest and/or lockfile for vulnerabilities and suggests changes for remediating them. This feature can be risky when run on untrusted projects. Please ensure you trust the source code and artifacts before proceeding.",
		Flags: []cli.Flag{
			&cli.StringFlag{
				Name:      "manifest",
				Aliases:   []string{"M"},
				Usage:     "manifest file to remediate vulnerabilities in",
				TakesFile: true,
			},
			&cli.StringFlag{
				Name:      "lockfile",
				Aliases:   []string{"L"},
				Usage:     "lockfile to remediate vulnerabilities in",
				TakesFile: true,
			},
			&cli.StringFlag{
				Name:  "data-source",
				Usage: "source to fetch package information from; value can be: deps.dev, native",
				Value: "deps.dev",
				Action: func(_ context.Context, _ *cli.Command, s string) error {
					if s != "deps.dev" && s != "native" {
						return fmt.Errorf("unsupported data-source \"%s\" - must be one of: deps.dev, native", s)
					}

					return nil
				},
			},
			&cli.StringFlag{
				Name:  "maven-registry",
				Usage: "URL of the default Maven registry to fetch metadata",
			},
			&cli.BoolFlag{
				Name:   "non-interactive",
				Usage:  "[DEPRECATED] run in the non-interactive mode",
				Hidden: true,
				Value:  true,
			},
			&cli.BoolFlag{
				Name:  "interactive",
				Usage: "run in the interactive mode",
				Action: func(_ context.Context, _ *cli.Command, b bool) error {
					if b && !term.IsTerminal(int(os.Stdin.Fd())) { //nolint:gosec // Fd() is safe to convert to int
						return errors.New("interactive mode only to be run in a terminal")
					}

					return nil
				},
			},
			&cli.StringFlag{
				Name:    "format",
				Aliases: []string{"f"},
				Usage:   "sets the non-interactive output format; value can be: text, json",
				Value:   "text",
				Action: func(_ context.Context, _ *cli.Command, s string) error {
					if s == "text" || s == "json" {
						if s == "json" {
							cmdlogger.SendEverythingToStderr()
						}

						return nil
					}

					return fmt.Errorf("unsupported output format \"%s\" - must be one of: text, json", s)
				},
			},
			&cli.StringFlag{
				Category: autoModeCategory,
				Name:     "strategy",
				Usage:    "remediation approach to use; value can be: " + strings.Join(strategies, ", "),
				Action: func(_ context.Context, cmd *cli.Command, s string) error {
					switch strategy.Strategy(s) {
					case strategy.StrategyInPlace:
						if !cmd.IsSet("lockfile") {
							return fmt.Errorf("%s strategy requires lockfile", strategy.StrategyInPlace)
						}
					case strategy.Strategy("relock"): // renamed
						fallthrough
					case strategy.StrategyRelax:
						if !cmd.IsSet("manifest") {
							return fmt.Errorf("%s strategy requires manifest file", strategy.StrategyRelax)
						}
					case strategy.StrategyOverride:
						if !cmd.IsSet("manifest") {
							return fmt.Errorf("%s strategy requires manifest file", strategy.StrategyOverride)
						}
					default:
						return fmt.Errorf("unsupported strategy \"%s\" - must be one of: %s", s, strings.Join(strategies, ", "))
					}

					return nil
				},
			},
			&cli.IntFlag{
				Category: autoModeCategory,
				Name:     "apply-top",
				Usage:    "apply the top N patches",
				Value:    -1,
			},
			&cli.BoolFlag{
				Category: autoModeCategory,
				Name:     "no-introduce",
				Usage:    "exclude patches that would introduce new vulnerabilities",
			},

			&cli.StringSliceFlag{
				Category:    upgradeCategory,
				Name:        "upgrade-config",
				Usage:       "the allowed package upgrades, in the format `[package-name:]level`. If package-name is omitted, level is applied to all packages. level must be one of (major, minor, patch, none).",
				DefaultText: "major",
			},
			&cli.IntFlag{
				Category: vulnCategory,
				Name:     "max-depth",
				Usage:    "maximum dependency depth of vulnerabilities to consider",
				Value:    -1,
			},
			&cli.Float64Flag{
				Category:    vulnCategory,
				Name:        "min-severity",
				Usage:       "minimum CVSS score of vulnerabilities to consider",
				Value:       0,
				DefaultText: "0.0",
			},
			&cli.StringSliceFlag{
				Category: vulnCategory,
				Name:     "vulns",
				Usage:    "explicit list of vulnerability IDs to consider",
			},
			&cli.StringSliceFlag{
				Category: vulnCategory,
				Name:     "ignore-vulns",
				Usage:    "list of vulnerability IDs to ignore",
			},
			&cli.BoolFlag{
				Category: vulnCategory,
				Name:     "ignore-dev",
				Usage:    "ignore vulnerabilities affecting only development dependencies",
			},
			&cli.BoolFlag{
				Category: vulnCategory,
				Name:     "maven-fix-management",
				Usage:    "(pom.xml) also remediate vulnerabilities in dependencyManagement packages that do not appear in the resolved dependency graph",
			},
			// Offline database flags, copied from osv-scanner scan
			&cli.BoolFlag{
				Name:    "offline-vulnerabilities",
				Aliases: []string{"offline"},
				Usage:   "checks for vulnerabilities using local databases that are already cached",
			},
			&cli.BoolFlag{
				Name:  "download-offline-databases",
				Usage: "downloads vulnerability databases for offline comparison",
			},
			&cli.StringFlag{
				Name:    "local-db-path",
				Usage:   "sets the path that local databases should be stored",
				Sources: cli.EnvVars("OSV_SCANNER_LOCAL_DB_CACHE_DIRECTORY"),
				Hidden:  true,
			},
		},
		Action: func(ctx context.Context, cmd *cli.Command) error {
			return action(ctx, cmd, stdout, clientFactories)
		},
	}
}

func action(ctx context.Context, cmd *cli.Command, stdout io.Writer, clientFactories config.ClientFactories) error {
	cmdlogger.Warnf("Guided remediation (the fix command) can be risky when run on untrusted projects. It may trigger the package manager to execute scripts or follow external registries specified in the project. Please ensure you trust the source code and artifacts before proceeding.")
	if !cmd.IsSet("manifest") && !cmd.IsSet("lockfile") {
		return errors.New("manifest or lockfile is required")
	}

	opts := options.FixVulnsOptions{
		RemediationOptions: options.RemediationOptions{
			ResolutionOptions: options.ResolutionOptions{
				MavenManagement: cmd.Bool("maven-fix-management"),
			},
			IgnoreVulns:   cmd.StringSlice("ignore-vulns"),
			ExplicitVulns: cmd.StringSlice("vulns"),
			DevDeps:       !cmd.Bool("ignore-dev"),
			MinSeverity:   cmd.Float64("min-severity"),
			MaxDepth:      cmd.Int("max-depth"),
			UpgradeConfig: upgrade.NewConfigFromStrings(cmd.StringSlice("upgrade-config")),
		},
		Manifest:          cmd.String("manifest"),
		Lockfile:          cmd.String("lockfile"),
		Strategy:          strategy.Strategy(cmd.String("strategy")),
		MaxUpgrades:       cmd.Int("apply-top"),
		NoIntroduce:       cmd.Bool("no-introduce"),
		NoMavenNewDepMgmt: false,
	}

	if opts.Strategy == "relock" {
		opts.Strategy = strategy.StrategyRelax
	}

	userAgent := "osv-scanner_fix/" + version.OSVVersion
	cf, cleanup := osvscanner.SetupClientFactories(clientFactories, nil, userAgent)
	defer cleanup()

	// MavenClient is required for Maven projects
	mc, err := datasource.NewMavenRegistryAPIClient(ctx, datasource.MavenRegistry{
		URL:             cmd.String("maven-registry"),
		ReleasesEnabled: true,
	}, "", false, cf.HTTPClient(), nil)
	if err != nil {
		return err
	}
	opts.MavenClient = mc

	switch cmd.String("data-source") {
	case "deps.dev":
		conn, err := cf.GRPCClientConn(depsdev.DepsdevAPI)
		if err != nil {
			return err
		}
		opts.ResolveClient = resolution.NewDepsDevClientWithConn(conn)

	case "native":
		var workDir string
		// Prefer to use the manifest's directory if available.
		if opts.Manifest != "" {
			workDir = filepath.Dir(opts.Manifest)
		} else {
			workDir = filepath.Dir(opts.Lockfile)
		}
		cl, err := resolution.NewCombinedNativeClient(resolution.CombinedNativeClientOptions{
			HTTPClient:        cf.HTTPClient(),
			DisableGoogleAuth: true,
			ProjectDir:        workDir,
			MavenRegistry:     cmd.String("maven-registry"),
		})
		if err != nil {
			return err
		}
		opts.ResolveClient = cl
	}

	cfg := &cpb.PluginConfig{
		UserAgent: userAgent,
		PluginSpecific: []*cpb.PluginSpecificConfig{
			{Config: &cpb.PluginSpecificConfig_Osvlocal{Osvlocal: &cpb.OSVLocalConfig{
				Download:   cmd.Bool("download-offline-databases"),
				LocalPath:  cmd.String("local-db-path"),
				RemoteHost: "https://osv-vulnerabilities.storage.googleapis.com",
			}}},
		},
	}
	pluginCfg := &config.PluginConfig{
		ProtoConfig:     cfg,
		ClientFactories: cf,
	}
	if cmd.Bool("offline-vulnerabilities") {
		enricher, err := osvlocal.New(pluginCfg)
		if err != nil {
			return err
		}
		opts.VulnEnricher = enricher
	} else {
		opts.VulnEnricher, err = scalibrosvdev.New(pluginCfg)
		if err != nil {
			return err
		}
	}

	if cmd.Bool("interactive") {
		return guidedremediation.FixVulnsInteractive(opts, GlamourRenderer{})
	}

	res, err := guidedremediation.FixVulns(opts)
	if err != nil {
		return err
	}

	outputJSON := cmd.String("format") == "json"

	return printResult(res, outputJSON, stdout)
}

type GlamourRenderer struct{}

func (GlamourRenderer) Render(details string, width int) (string, error) {
	var style ansi.StyleConfig
	if lipgloss.HasDarkBackground(os.Stdin, os.Stdout) {
		style = styles.DarkStyleConfig
	} else {
		style = styles.LightStyleConfig
	}
	*style.Document.Margin = 0
	style.Document.BlockPrefix = ""

	r, err := glamour.NewTermRenderer(
		glamour.WithStyles(style),
		glamour.WithWordWrap(width),
	)
	if err != nil {
		return "", err
	}

	return r.Render(details)
}
