package restic

import (
	"bytes"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"sort"
	"strings"

	"git.secluded.site/keld/internal/config"
)

// resticNativeCommands lists .environ keys that restic handles natively.
// Keld passes these through as-is rather than executing them.
var resticNativeCommands = map[string]bool{
	"RESTIC_PASSWORD_COMMAND":      true,
	"RESTIC_FROM_PASSWORD_COMMAND": true,
}

// DefaultExecutable is the restic binary name used when KELD_EXECUTABLE is
// unset.
const DefaultExecutable = "restic"

// ExitError reports that restic exited unsuccessfully while preserving its
// numeric exit code for callers that need to mirror restic's process status.
type ExitError struct {
	Code int
	Err  error
}

func (e *ExitError) Error() string {
	if e.Err == nil {
		return fmt.Sprintf("restic exited with code %d", e.Code)
	}
	return fmt.Sprintf("restic exited with code %d: %v", e.Code, e.Err)
}

func (e *ExitError) Unwrap() error { return e.Err }

func (e *ExitError) ExitCode() int { return e.Code }

// Run supervises restic, configured according to cfg.
func Run(cfg *config.ResolvedConfig) error {
	exe := executable()

	path, err := exec.LookPath(exe)
	if err != nil {
		return fmt.Errorf("finding %s: %w", exe, err)
	}

	if err := resolveEnvironCommands(cfg.Environ, cfg.Workdir); err != nil {
		return err
	}

	argv := buildArgv(exe, cfg)
	env := buildEnv(cfg.Environ)

	for _, hook := range cfg.PreHooks {
		if err := runHook(hook, cfg.Workdir, env); err != nil {
			return fmt.Errorf("pre-hook %q: %w", hook, err)
		}
	}

	cmd := exec.Command(path, argv[1:]...) //nolint:gosec
	cmd.Env = env
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	if cfg.Workdir != "" {
		cmd.Dir = cfg.Workdir
	}

	startErr := cmd.Start()
	var restErr error
	restCode := 0
	restStarted := startErr == nil
	if startErr == nil {
		stopSignals := watchResticSignals(cmd.Process)
		restErr = cmd.Wait()
		stopSignals()
		if restErr != nil {
			var exitErr *exec.ExitError
			if errors.As(restErr, &exitErr) {
				restCode = resticExitCode(exitErr)
			} else {
				restErr = fmt.Errorf("running restic: %w", restErr)
			}
		}
	} else {
		restErr = fmt.Errorf("starting restic: %w", startErr)
	}

	postEnv := append([]string(nil), env...)
	if restStarted {
		postEnv = append(
			postEnv,
			fmt.Sprintf("KELD_RESTIC_EXIT_CODE=%d", restCode),
			"KELD_RESTIC_STATUS="+ResticStatus(restCode),
		)
	} else {
		postEnv = append(postEnv, "KELD_RESTIC_STATUS=start_failed")
	}
	var postErr error
	for _, hook := range cfg.PostHooks {
		if err := runHook(hook, cfg.Workdir, postEnv); err != nil {
			if postErr == nil {
				postErr = err
			}
			fmt.Fprintf(os.Stderr, "post-hook %q failed: %v\n", hook, err)
		}
	}

	if restErr != nil {
		if !restStarted {
			return restErr
		}
		return &ExitError{Code: restCode, Err: restErr}
	}
	if postErr != nil {
		return fmt.Errorf("post-hook: %w", postErr)
	}

	return nil
}

// DryRun formats a human-readable summary of what Run would execute.
func DryRun(cfg *config.ResolvedConfig) string {
	var b strings.Builder

	if len(cfg.SectionsRead) > 0 {
		fmt.Fprintf(&b, "config sections: %s\n", strings.Join(cfg.SectionsRead, " → "))
	}

	if cfg.Workdir != "" {
		fmt.Fprintf(&b, "workdir: %s\n", cfg.Workdir)
	}

	if len(cfg.Environ) > 0 {
		fmt.Fprintln(&b, "environ:")

		// Sort keys for deterministic output.
		keys := make([]string, 0, len(cfg.Environ))
		for k := range cfg.Environ {
			keys = append(keys, k)
		}
		sort.Strings(keys)

		for _, k := range keys {
			fmt.Fprintf(&b, "  %s=%s\n", k, cfg.Environ[k])
		}
	}

	if len(cfg.PreHooks) > 0 {
		fmt.Fprintln(&b, "pre-hooks:")
		for _, hook := range cfg.PreHooks {
			fmt.Fprintf(&b, "  %s\n", hook)
		}
	}
	if len(cfg.PostHooks) > 0 {
		fmt.Fprintln(&b, "post-hooks:")
		for _, hook := range cfg.PostHooks {
			fmt.Fprintf(&b, "  %s\n", hook)
		}
	}

	argv := buildArgv(executable(), cfg)
	fmt.Fprintf(&b, "command: %s\n", quotedJoin(argv))

	return b.String()
}

// executable returns the restic binary name, respecting KELD_EXECUTABLE.
func executable() string {
	if e := os.Getenv("KELD_EXECUTABLE"); e != "" {
		return config.ExpandPath(e)
	}
	return DefaultExecutable
}

// buildArgv assembles the full argument vector for the restic process.
func buildArgv(exe string, cfg *config.ResolvedConfig) []string {
	argv := []string{exe}

	if cfg.Command != "" {
		argv = append(argv, cfg.Command)
	}

	for _, f := range cfg.Flags {
		argv = append(argv, f.Name)
		if f.Value != "" {
			argv = append(argv, f.Value)
		}
	}

	argv = append(argv, cfg.Arguments...)
	return argv
}

func runHook(hook, workdir string, env []string) error {
	cmd := exec.Command("sh", "-c", hook) //nolint:gosec
	cmd.Env = env
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	if workdir != "" {
		cmd.Dir = workdir
	}
	return cmd.Run()
}

// ResticStatus maps documented restic backup exit codes to script-friendly
// status labels. Unknown non-zero exit codes are failures by restic's
// documented scripting contract.
func ResticStatus(code int) string {
	switch code {
	case 0:
		return "success"
	case 1:
		return "fatal"
	case 2:
		return "runtime_error"
	case 3:
		return "partial"
	case 10:
		return "repository_missing"
	case 11:
		return "locked"
	case 12:
		return "wrong_password"
	case 130:
		return "interrupted"
	case 143:
		return "terminated"
	default:
		return "unknown_failure"
	}
}

// resolveEnvironCommands finds keys ending in _COMMAND in the environ map,
// executes their values as shell commands, and replaces them with the base key
// set to the command's stdout (trailing newline stripped). Keys that restic
// handles natively (like RESTIC_PASSWORD_COMMAND) are left untouched.
//
// If both FOO_COMMAND and FOO are present, the command result takes precedence
// and FOO_COMMAND is removed from the map.
func resolveEnvironCommands(environ map[string]string, workdir string) error {
	for key, cmd := range environ {
		if !strings.HasSuffix(key, config.CommandSuffix) {
			continue
		}
		if resticNativeCommands[key] {
			continue
		}

		baseKey := strings.TrimSuffix(key, config.CommandSuffix)
		if baseKey == "" {
			continue
		}

		var stdout, stderr bytes.Buffer
		proc := exec.Command("sh", "-c", cmd)
		proc.Stdout = &stdout
		proc.Stderr = &stderr
		if workdir != "" {
			proc.Dir = workdir
		}

		if err := proc.Run(); err != nil {
			return fmt.Errorf(
				"environ %s: running %q: %w\nstderr: %s",
				key, cmd, err, strings.TrimSpace(stderr.String()),
			)
		}

		environ[baseKey] = strings.TrimRight(stdout.String(), "\n")
		delete(environ, key)
	}
	return nil
}

// buildEnv merges extra environment variables into the current process env.
func buildEnv(extra map[string]string) []string {
	env := os.Environ()
	for k, v := range extra {
		env = append(env, k+"="+v)
	}
	return env
}

// quotedJoin formats an argv slice by wrapping every element in double
// quotes. This makes the dry-run output unambiguous — each argument
// boundary is visible even when values contain spaces.
func quotedJoin(argv []string) string {
	var b strings.Builder
	for i, arg := range argv {
		if i > 0 {
			b.WriteByte(' ')
		}
		b.WriteByte('"')
		b.WriteString(arg)
		b.WriteByte('"')
	}
	return b.String()
}
