package cmd

import (
	"context"
	"errors"
	"fmt"
	"os"
	"slices"
	"strings"

	tea "charm.land/bubbletea/v2"
	"charm.land/fang/v2"
	"github.com/spf13/cobra"

	"git.secluded.site/keld/internal/config"
	"git.secluded.site/keld/internal/form"
	"git.secluded.site/keld/internal/restic"
	"git.secluded.site/keld/internal/theme"
	"git.secluded.site/keld/internal/ui"
	"git.secluded.site/keld/internal/ui/screens"
)

var (
	flagPreset     string
	flagShowCmd    bool
	flagConfigFile string
)

const overrideArgumentsKey = "_arguments"

var rootCmd = &cobra.Command{
	Use:   "keld [keld flags] <command> [restic flags...]",
	Short: "A friendly wrapper around restic",
	Long:  "keld resolves layered TOML config presets and executes restic with the merged result.",
	Example: `  keld backup
	keld --preset home backup
	keld --preset home@nas backup --tag daily /src
	keld --show-command --preset home backup
	keld --config ./keld.toml --preset home backup
	keld backup --help
	keld`,

	SilenceUsage:     true,
	SilenceErrors:    true,
	TraverseChildren: true,

	RunE: func(cmd *cobra.Command, _ []string) error {
		// Bare keld with no subcommand requires a tty.
		if !isInteractive() {
			return fmt.Errorf("keld: no subcommand specified; non-interactive mode requires a subcommand (e.g. 'keld backup')")
		}

		commandName, preset, overrides, err := runInteractive("")
		if err != nil {
			return err
		}
		if commandName == "" {
			// User cancelled.
			return nil
		}

		return runCommand(commandName, cmd, nil, overrides, &preset)
	},
}

func registerRootFlags() {
	flags := rootCmd.PersistentFlags()
	flags.StringVarP(&flagPreset, "preset", "P", "", "config preset to apply before running the command")
	flags.BoolVar(&flagShowCmd, "show-command", false, "print the resolved restic command and exit")
	flags.StringVarP(&flagConfigFile, "config", "C", "", "path to keld config file")
}

// runCommand resolves config and executes restic.
//
// sessionPreset signals that a TUI session already handled preset
// selection. When non-nil, its value is used as the preset and the
// standalone preset prompt is skipped. When nil, the subcommand was
// invoked non-interactively (with arguments) and no prompting
// occurs.
func runCommand(commandName string, cmd *cobra.Command, rawArgs []string, sessionOverrides map[string][]string, sessionPreset *string) error {
	if flagConfigFile != "" {
		if err := os.Setenv("KELD_CONFIG_FILE", flagConfigFile); err != nil {
			return fmt.Errorf("setting KELD_CONFIG_FILE: %w", err)
		}
	}
	if flagShowCmd {
		if err := os.Setenv("KELD_DRYRUN", "1"); err != nil {
			return fmt.Errorf("setting KELD_DRYRUN: %w", err)
		}
	}

	preset := flagPreset
	// Interactive mode when launched from root command or when a
	// TUI session ran (sessionPreset != nil). The session handles
	// preset selection, but runCommand may still need to collect
	// values for commands without dedicated TUI screens (e.g.
	// backup paths).
	interactive := cmd == cmd.Root() || sessionPreset != nil

	if sessionPreset != nil {
		preset = *sessionPreset
	}

	// In non-interactive mode with no preset and multiple presets
	// available, require explicit --preset selection.
	if !interactive && preset == "" {
		presets := config.Presets()
		if len(presets) > 1 {
			return fmt.Errorf("keld: multiple presets defined (%s); specify --preset in non-interactive mode", strings.Join(presets, ", "))
		}
	}

	if preset != "" {
		if err := validatePreset(preset); err != nil {
			return err
		}
	}

	overrides := mergeOverrides(parsePassthrough(rawArgs), sessionOverrides)

	// In interactive mode, fill missing command-specific inputs
	// via prompts. The session already handles preset selection,
	// so only command-level prompts are needed here.
	if interactive {
		// Resolve config before prompting so we can skip questions for values
		// already provided by presets.
		peek, err := config.Resolve(preset, commandName, overrides)
		if err != nil {
			return err
		}

		cmdOverrides, err := promptForCommand(commandName, peek)
		if err != nil {
			return err
		}
		overrides = mergeOverrides(overrides, cmdOverrides)
	}

	cfg, err := config.Resolve(preset, commandName, overrides)
	if err != nil {
		return err
	}

	// In non-interactive mode, validate that required inputs are present.
	if !interactive {
		if commandName == "backup" && len(cfg.Arguments) == 0 {
			return fmt.Errorf("keld backup: no paths specified; pass paths as arguments or set _arguments in the preset")
		}
	}

	restic.WarnUnknownFlags(cfg.Command, cfg.Flags)

	if config.IsDryRun() {
		fmt.Print(restic.DryRun(cfg))
		return nil
	}

	return restic.Run(cfg)
}

func mergeOverrides(base, extra map[string][]string) map[string][]string {
	if len(extra) == 0 {
		return base
	}
	if base == nil {
		base = make(map[string][]string, len(extra))
	}
	for key, values := range extra {
		base[key] = values
	}
	return base
}

// Execute is the main entry point, called from main.go.
func Execute() {
	if err := fang.Execute(context.Background(), rootCmd); err != nil {
		var exitErr interface{ ExitCode() int }
		if errors.As(err, &exitErr) {
			os.Exit(exitErr.ExitCode())
		}
		os.Exit(1)
	}
}

// parsePassthrough converts restic-style CLI flags into a map suitable for
// config.Resolve's cliOverrides parameter.
func parsePassthrough(args []string) map[string][]string {
	if len(args) == 0 {
		return nil
	}

	overrides := make(map[string][]string)
	var positional []string
	for i := 0; i < len(args); i++ {
		arg := args[i]
		if arg == "--" {
			if i+1 < len(args) {
				positional = append(positional, args[i+1:]...)
			}
			break
		}

		if !isFlagToken(arg) {
			positional = append(positional, arg)
			continue
		}

		name := strings.TrimLeft(arg, "-")

		// --flag=value form
		if eqIdx := strings.IndexByte(name, '='); eqIdx >= 0 {
			overrides[name[:eqIdx]] = append(overrides[name[:eqIdx]], name[eqIdx+1:])
			continue
		}

		// Boolean flag (no next arg, or next arg is also a flag)
		if i+1 >= len(args) || args[i+1] == "--" || isFlagToken(args[i+1]) {
			if _, ok := overrides[name]; !ok {
				overrides[name] = nil
			}
			continue
		}

		// --flag value form
		i++
		overrides[name] = append(overrides[name], args[i])
	}

	if len(positional) > 0 {
		overrides[overrideArgumentsKey] = append([]string(nil), positional...)
	}

	if len(overrides) == 0 {
		return nil
	}

	return overrides
}

func isFlagToken(arg string) bool {
	return strings.HasPrefix(arg, "-") && arg != "-" && arg != "--"
}

// runInteractive launches the unified TUI session with a command
// menu, preset selector (if needed), and command-specific screens
// (e.g. the restore flow). Returns the chosen command, preset, any
// collected overrides, or ("", "", nil, nil) if the user cancelled.
func runInteractive(preselectedCommand string) (command, preset string, overrides map[string][]string, err error) {
	styles := theme.New(true)

	var screenList []ui.Screen

	// When a command is pre-selected (e.g. "keld restore"), skip the
	// menu entirely. Otherwise show the interactive command menu.
	var menuScreen *screens.Menu
	if preselectedCommand == "" {
		menuScreen = screens.NewMenu(menuItems, &styles)
		screenList = append(screenList, menuScreen)
	}

	// Build the preset screen only when needed:
	// - --preset on CLI: skip (already resolved).
	// - Zero presets: skip (global defaults only).
	// - One preset: auto-select, skip.
	// - Multiple presets: show the filterable selector.
	presets := config.Presets()
	var presetScreen *screens.Preset
	if flagPreset != "" {
		preset = flagPreset
	} else if len(presets) > 1 {
		presetScreen = screens.NewPreset(presets, &styles)
		screenList = append(screenList, presetScreen)
	} else if len(presets) == 1 {
		preset = presets[0]
	}

	// cmdScreens is populated by the resolve screen's builder function
	// so the caller can extract results after the session completes.
	var cmdScreens *commandScreens

	// The resolve screen sits between menu/preset and command-specific
	// screens. It resolves the config and dynamically builds the
	// remaining screens via ExtendMsg.
	resolveScreen := screens.NewResolve(func() []ui.Screen {
		cmd := preselectedCommand
		if menuScreen != nil {
			cmd = menuScreen.Selection()
		}
		p := preset
		if presetScreen != nil {
			p = presetScreen.Value()
		}

		cmdScreens = buildCommandScreens(cmd, p, &styles)

		// Build the confirmation screen. Its preview function
		// resolves the config with all current screen values so
		// it shows exactly what will be executed.
		previewFn := func() string {
			var overrides map[string][]string
			if cmdScreens != nil {
				overrides = extractCommandOverrides(cmd, cmdScreens)
			}
			cfg, err := config.Resolve(p, cmd, overrides)
			if err != nil {
				return fmt.Sprintf("config error: %v\n", err)
			}
			return restic.DryRun(cfg)
		}
		confirmScreen := screens.NewConfirm(previewFn, flagShowCmd)

		// When the command has no dedicated TUI screens, the
		// preview may be incomplete — additional prompts can
		// follow after confirmation.
		if cmdScreens == nil {
			confirmScreen.SetPartial(true)
		}

		var result []ui.Screen
		if cmdScreens != nil {
			result = append(result, cmdScreens.list...)
		}
		result = append(result, confirmScreen)
		return result
	})
	screenList = append(screenList, resolveScreen)

	session := ui.New(screenList, &styles)
	prog := tea.NewProgram(session)
	result, err := prog.Run()
	if err != nil {
		return "", "", nil, fmt.Errorf("interactive session: %w", err)
	}

	s := result.(ui.Session)
	if !s.Completed() {
		return "", "", nil, nil
	}

	command = preselectedCommand
	if menuScreen != nil {
		command = menuScreen.Selection()
	}
	if !slices.Contains(knownCommands, command) {
		return "", "", nil, fmt.Errorf("unknown menu command %q", command)
	}

	if presetScreen != nil {
		preset = presetScreen.Value()
	}

	// Extract overrides from the completed command-specific screens.
	overrides = extractCommandOverrides(command, cmdScreens)

	return command, preset, overrides, nil
}

// commandScreens holds the screens built for a command's interactive
// flow along with typed references to each screen. This lets
// runInteractive read results from the completed screens without
// resorting to double-pointer output parameters.
type commandScreens struct {
	list       []ui.Screen
	snapshot   *screens.Snapshot
	filePicker *screens.FilePicker
	target     *screens.Target
	overwrite  *screens.Overwrite
}

// buildCommandScreens resolves the config for the given command and
// preset, then builds the command-specific screens that need user
// input. Returns nil when the command needs no interactive screens.
func buildCommandScreens(command, preset string, styles *theme.Styles) *commandScreens {
	switch command {
	case "restore":
		return buildRestoreScreens(preset, styles)
	default:
		return nil
	}
}

// buildRestoreScreens creates the restore flow screens, skipping any
// that are already provided by the resolved config.
func buildRestoreScreens(preset string, styles *theme.Styles) *commandScreens {
	cfg, err := config.Resolve(preset, "restore", nil)
	if err != nil {
		// Config resolution failed. Return no screens so the session
		// completes immediately; runCommand will call config.Resolve
		// again and surface the error to the user.
		return nil
	}

	cs := &commandScreens{}

	// Step 1: Snapshot ID (skip if already provided as an argument).
	if len(cfg.Arguments) == 0 {
		loader := screens.SnapshotLoader(func() ([]restic.Snapshot, error) {
			return restic.ListSnapshots(cfg)
		})
		cs.snapshot = screens.NewSnapshot(loader, styles)
		cs.list = append(cs.list, cs.snapshot)
	}

	// Step 2: File selection (skip if include flags set or snapshot
	// uses snapshotID:subfolder syntax). The file picker needs the
	// snapshot ID — it reads it from a closure.
	if !hasIncludeFlags(cfg) {
		// Determine the snapshot ID source: from the snapshot screen
		// if it exists, otherwise from the resolved config.
		snapshotIDFn := func() string {
			if cs.snapshot != nil {
				return cs.snapshot.Value()
			}
			if len(cfg.Arguments) > 0 {
				return cfg.Arguments[0]
			}
			return ""
		}

		// Build a loader that checks for the snapshotID:subfolder
		// skip condition at call time (the ID may not be known until
		// the snapshot screen completes).
		fileLoader := func(snapshotID string) ([]restic.LsNode, error) {
			if strings.Contains(snapshotID, ":") || snapshotID == "" {
				// Signal that listing should be skipped. Returning
				// nil nodes causes the picker to auto-advance.
				return nil, nil
			}
			return restic.RunLs(cfg, snapshotID)
		}

		cs.filePicker = screens.NewFilePicker(fileLoader, snapshotIDFn, styles)
		cs.list = append(cs.list, cs.filePicker)
	}

	// Step 3: Target directory (skip if already set).
	if !cfg.HasFlag("target") {
		cs.target = screens.NewTarget(styles)
		cs.list = append(cs.list, cs.target)
	}

	// Step 4: Overwrite behaviour (skip if already set).
	if !cfg.HasFlag("overwrite") {
		cs.overwrite = screens.NewOverwrite(styles)
		cs.list = append(cs.list, cs.overwrite)
	}

	return cs
}

// extractCommandOverrides reads the completed screen values and
// returns a map suitable for passing to runCommand as overrides.
func extractCommandOverrides(command string, cs *commandScreens) map[string][]string {
	if command != "restore" || cs == nil {
		return nil
	}

	overrides := make(map[string][]string)

	if cs.snapshot != nil {
		if v := cs.snapshot.Value(); v != "" {
			overrides[overrideArgumentsKey] = []string{v}
		}
	}

	if cs.filePicker != nil {
		if includes := cs.filePicker.Includes(); len(includes) > 0 {
			overrides["include"] = includes
		}
	}

	if cs.target != nil {
		if v := cs.target.Value(); v != "" {
			overrides["target"] = []string{v}
		}
	}

	if cs.overwrite != nil {
		if v := cs.overwrite.Value(); v != "" {
			overrides["overwrite"] = []string{v}
		}
	}

	if len(overrides) == 0 {
		return nil
	}
	return overrides
}

// validatePreset checks that the given preset name matches one of the usable
// presets derived from the config. Returns a descriptive error if not found.
func validatePreset(preset string) error {
	known := config.Presets()
	for _, p := range known {
		if p == preset {
			return nil
		}
	}
	if len(known) == 0 {
		return fmt.Errorf("unknown preset %q (no presets defined in config)", preset)
	}
	return fmt.Errorf("unknown preset %q; available presets: %s", preset, strings.Join(known, ", "))
}

// promptForCommand collects required inputs for commands that need them.
// The resolved config is checked first so prompts are skipped for values
// the preset already provides.
// Returns CLI overrides to merge, or nil if the command needs no extra input.
func promptForCommand(command string, cfg *config.ResolvedConfig) (map[string][]string, error) {
	switch command {
	case "restore":
		// Handled by the unified TUI session; nothing to prompt here.
		return nil, nil
	case "backup":
		return promptBackup(cfg)
	default:
		return nil, nil
	}
}

// includeFlags lists the restic flags that provide explicit file
// selection for restore. When any of these are already present in the
// resolved config, the interactive file picker is skipped — the user
// has already told restic what to include.
var includeFlags = []string{"include", "i", "include-file", "iinclude", "iinclude-file"}

// hasIncludeFlags reports whether the resolved config already contains
// any include-style flags.
func hasIncludeFlags(cfg *config.ResolvedConfig) bool {
	for _, name := range includeFlags {
		if cfg.HasFlag(name) {
			return true
		}
	}
	return false
}

// promptBackup collects backup paths when none are configured in the preset.
func promptBackup(cfg *config.ResolvedConfig) (map[string][]string, error) {
	if len(cfg.Arguments) > 0 {
		return nil, nil
	}

	paths, err := form.BackupPaths()
	if err != nil {
		return nil, fmt.Errorf("backup paths: %w", err)
	}
	return map[string][]string{
		overrideArgumentsKey: paths,
	}, nil
}
