package cmd

import (
	"bytes"
	"errors"
	"io"
	"os"
	"path/filepath"
	"reflect"
	"strconv"
	"strings"
	"sync"
	"testing"

	"github.com/regen-network/gocuke"
	"github.com/spf13/cobra"

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

func TestBackupHooksFeature(t *testing.T) {
	gocuke.NewRunner(t, &backupHooksSuite{}).
		Path("../features/backup_hooks.feature").
		NonParallel().
		Run()
}

type backupHooksSuite struct {
	gocuke.TestingT

	tmpDir       string
	configPath   string
	logPath      string
	preset       string
	resticCode   int
	resolved     *config.ResolvedConfig
	runErr       error
	runStdout    string
	runStderr    string
	oldPreset    string
	oldShowCmd   bool
	oldConfig    string
	oldDryRun    string
	oldConfigEnv string
	oldExec      string
	oldTestLog   string
	oldTestExit  string
}

func (s *backupHooksSuite) Before() {
	var err error
	s.tmpDir, err = os.MkdirTemp("", "keld-backup-hooks-*")
	if err != nil {
		s.Fatalf("creating temp dir: %v", err)
	}
	s.configPath = filepath.Join(s.tmpDir, "config.toml")
	s.logPath = filepath.Join(s.tmpDir, "events.log")
	s.preset = "home"
	s.resticCode = 0

	s.oldPreset, s.oldShowCmd, s.oldConfig = flagPreset, flagShowCmd, flagConfigFile
	s.oldDryRun = os.Getenv("KELD_DRYRUN")
	s.oldConfigEnv = os.Getenv("KELD_CONFIG_FILE")
	s.oldExec = os.Getenv("KELD_EXECUTABLE")
	s.oldTestLog = os.Getenv("KELD_TEST_LOG")
	s.oldTestExit = os.Getenv("KELD_TEST_RESTIC_EXIT")

	fakeRestic := filepath.Join(s.tmpDir, "restic")
	script := `#!/bin/sh
printf 'restic %s\n' "$*" >> "$KELD_TEST_LOG"
exit "${KELD_TEST_RESTIC_EXIT:-0}"
`
	if err := os.WriteFile(fakeRestic, []byte(script), 0o755); err != nil {
		s.Fatalf("writing fake restic: %v", err)
	}
	mustSetenv(s, "KELD_EXECUTABLE", fakeRestic)
	mustSetenv(s, "KELD_TEST_LOG", s.logPath)
	mustSetenv(s, "KELD_TEST_RESTIC_EXIT", "0")
}

func (s *backupHooksSuite) After() {
	flagPreset, flagShowCmd, flagConfigFile = s.oldPreset, s.oldShowCmd, s.oldConfig
	restoreEnv("KELD_DRYRUN", s.oldDryRun)
	restoreEnv("KELD_CONFIG_FILE", s.oldConfigEnv)
	restoreEnv("KELD_EXECUTABLE", s.oldExec)
	restoreEnv("KELD_TEST_LOG", s.oldTestLog)
	restoreEnv("KELD_TEST_RESTIC_EXIT", s.oldTestExit)
	_ = os.RemoveAll(s.tmpDir)
}

func (s *backupHooksSuite) TheSelectedPresetSuppliesBackupPaths() {
	s.writeConfig("home", nil, nil)
}

func (s *backupHooksSuite) BackupHooksAreConfiguredInMultipleMatchingSections() {
	s.preset = "home@cloud"
	s.writeRawConfig(`
[global.backup]
_arguments = [` + quote(filepath.Join(s.tmpDir, "source")) + `]
_pre_hooks = [` + quote(s.logHook("pre global")) + `]
_post_hooks = [` + quote(s.logHook("post global")) + `]

["@cloud".backup]
_pre_hooks = [` + quote(s.logHook("pre suffix")) + `]
_post_hooks = [` + quote(s.logHook("post suffix")) + `]

["home@".backup]
_pre_hooks = [` + quote(s.logHook("pre prefix")) + `]
_post_hooks = [` + quote(s.logHook("post prefix")) + `]

["home@cloud".backup]
_pre_hooks = [` + quote(s.logHook("pre full")) + `]
_post_hooks = [` + quote(s.logHook("post full")) + `]
`)
}

func (s *backupHooksSuite) TheBackupConfigurationIsResolved() {
	cfg, err := config.Resolve(s.preset, "backup", nil)
	if err != nil {
		s.Fatalf("resolving config: %v", err)
	}
	s.resolved = cfg
}

func (s *backupHooksSuite) ThePrehooksAreOrderedFromLowestToHighestPrioritySection() {
	want := []string{s.logHook("pre global"), s.logHook("pre suffix"), s.logHook("pre prefix"), s.logHook("pre full")}
	if !reflect.DeepEqual(s.resolved.PreHooks, want) {
		s.Fatalf("pre hook order mismatch:\n got: %#v\nwant: %#v", s.resolved.PreHooks, want)
	}
}

func (s *backupHooksSuite) ThePrehooksComeFromTheMostspecificSectionThatDefinesPrehooks() {
	want := []string{s.logHook("pre full")}
	if !reflect.DeepEqual(s.resolved.PreHooks, want) {
		s.Fatalf("pre hook override mismatch:\n got: %#v\nwant: %#v", s.resolved.PreHooks, want)
	}
}

func (s *backupHooksSuite) ThePosthooksAreOrderedFromLowestToHighestPrioritySection() {
	want := []string{s.logHook("post global"), s.logHook("post suffix"), s.logHook("post prefix"), s.logHook("post full")}
	if !reflect.DeepEqual(s.resolved.PostHooks, want) {
		s.Fatalf("post hook order mismatch:\n got: %#v\nwant: %#v", s.resolved.PostHooks, want)
	}
}

func (s *backupHooksSuite) ThePosthooksComeFromTheMostspecificSectionThatDefinesPosthooks() {
	want := []string{s.logHook("post full")}
	if !reflect.DeepEqual(s.resolved.PostHooks, want) {
		s.Fatalf("post hook override mismatch:\n got: %#v\nwant: %#v", s.resolved.PostHooks, want)
	}
}

func (s *backupHooksSuite) TheHooksAreNotPassedToResticAsFlags() {
	for _, f := range s.resolved.Flags {
		if strings.Contains(f.Name, "hook") {
			s.Fatalf("hook leaked into restic flags: %#v", f)
		}
	}
}

func (s *backupHooksSuite) BackupHooksAreConfiguredInALessspecificMatchingSection() {
	s.preset = "home"
	s.writeRawConfig(`
[global.backup]
_arguments = [` + quote(filepath.Join(s.tmpDir, "source")) + `]
_pre_hooks = [` + quote(s.logHook("pre inherited")) + `]
_post_hooks = [` + quote(s.logHook("post inherited")) + `]

[home.backup]
tag = "specific"
`)
}

func (s *backupHooksSuite) TheMostspecificMatchingSectionOmitsHookKeys() {}

func (s *backupHooksSuite) TheInheritedPrehooksAreUsed() {
	want := []string{s.logHook("pre inherited")}
	if !reflect.DeepEqual(s.resolved.PreHooks, want) {
		s.Fatalf("inherited pre hooks mismatch:\n got: %#v\nwant: %#v", s.resolved.PreHooks, want)
	}
}

func (s *backupHooksSuite) TheInheritedPosthooksAreUsed() {
	want := []string{s.logHook("post inherited")}
	if !reflect.DeepEqual(s.resolved.PostHooks, want) {
		s.Fatalf("inherited post hooks mismatch:\n got: %#v\nwant: %#v", s.resolved.PostHooks, want)
	}
}

func (s *backupHooksSuite) TheMostspecificMatchingSectionClearsHookKeys() {
	s.writeRawConfig(`
[global.backup]
_arguments = [` + quote(filepath.Join(s.tmpDir, "source")) + `]
_pre_hooks = [` + quote(s.logHook("pre inherited")) + `]
_post_hooks = [` + quote(s.logHook("post inherited")) + `]

[home.backup]
_pre_hooks = []
_post_hooks = []
`)
}

func (s *backupHooksSuite) NoPrehooksAreConfigured() {
	if len(s.resolved.PreHooks) != 0 {
		s.Fatalf("expected no pre-hooks, got %#v", s.resolved.PreHooks)
	}
}

func (s *backupHooksSuite) NoPosthooksAreConfigured() {
	if len(s.resolved.PostHooks) != 0 {
		s.Fatalf("expected no post-hooks, got %#v", s.resolved.PostHooks)
	}
}

func (s *backupHooksSuite) TheBackupConfigurationIncludesPrehooks() {
	s.writeConfig("home", []string{s.logHook("pre one"), s.logHook("pre two")}, nil)
}

func (s *backupHooksSuite) KeldRunsTheBackup() {
	flagPreset = s.preset
	flagShowCmd = false
	flagConfigFile = s.configPath
	mustSetenv(s, "KELD_TEST_RESTIC_EXIT", strconv.Itoa(s.resticCode))

	var err error
	s.runStdout, s.runStderr, err = captureOutput(s, func() error {
		return runCommand("backup", lookupSubcommandForHooks(s, "backup"), nil, nil, nil)
	})
	s.runErr = err
}

func (s *backupHooksSuite) EachPrehookRunsBeforeResticBackup() {
	lines := s.logLines()
	want := []string{"pre one", "pre two", "restic backup"}
	for i, prefix := range want {
		if len(lines) <= i || !strings.HasPrefix(lines[i], prefix) {
			s.Fatalf("event %d mismatch: got log %#v, want prefix %q", i, lines, prefix)
		}
	}
}

func (s *backupHooksSuite) ResticBackupIsAttempted() {
	if !s.logContains("restic backup") {
		s.Fatalf("restic backup was not attempted; log: %#v", s.logLines())
	}
}

func (s *backupHooksSuite) TheBackupConfigurationIncludesAFailingPrehook() {
	s.writeConfig("home", []string{s.logHook("pre fail") + "; exit 42"}, []string{s.logHook("post should-not-run")})
}

func (s *backupHooksSuite) ResticBackupIsNotAttempted() {
	if s.logContains("restic backup") {
		s.Fatalf("restic backup was attempted; log: %#v", s.logLines())
	}
}

func (s *backupHooksSuite) PosthooksAreNotRun() {
	if s.logContains("post") {
		s.Fatalf("post-hook ran unexpectedly; log: %#v", s.logLines())
	}
}

func (s *backupHooksSuite) KeldReportsThePrehookFailure() {
	if s.runErr == nil || !strings.Contains(s.runErr.Error(), "pre-hook") {
		s.Fatalf("expected pre-hook failure, got %v", s.runErr)
	}
}

func (s *backupHooksSuite) TheBackupConfigurationIncludesPosthooks() {
	s.writeConfig("home", nil, []string{s.postEnvHook("post")})
}

func (s *backupHooksSuite) ResticBackupExitsWithCode(a int64) {
	s.resticCode = int(a)
}

func (s *backupHooksSuite) EachPosthookRunsAfterResticBackup() {
	lines := s.logLines()
	if len(lines) < 2 || !strings.HasPrefix(lines[0], "restic backup") || !strings.HasPrefix(lines[1], "post") {
		s.Fatalf("post-hook did not run after restic; log: %#v", lines)
	}
}

func (s *backupHooksSuite) EachPosthookReceivesKeldresticexitcodeSetTo(a string) {
	if !s.logContains("code=" + a) {
		s.Fatalf("post-hook did not receive exit code %q; log: %#v", a, s.logLines())
	}
}

func (s *backupHooksSuite) EachPosthookReceivesKeldresticstatusSetTo(a string) {
	if !s.logContains("status=" + a) {
		s.Fatalf("post-hook did not receive status %q; log: %#v", a, s.logLines())
	}
}

func (s *backupHooksSuite) KeldExitsWithCode(a int64) {
	code := int(a)
	if code == 0 {
		if s.runErr != nil {
			s.Fatalf("expected successful exit, got %v", s.runErr)
		}
		return
	}

	var exitErr interface{ ExitCode() int }
	if !errors.As(s.runErr, &exitErr) {
		s.Fatalf("expected exit error with code %d, got %T: %v", code, s.runErr, s.runErr)
	}
	if got := exitErr.ExitCode(); got != code {
		s.Fatalf("exit code mismatch: got %d, want %d", got, code)
	}
}

func (s *backupHooksSuite) TheBackupConfigurationIncludesAFailingPosthook() {
	s.writeConfig("home", nil, []string{s.logHook("post fail") + "; exit 44"})
}

func (s *backupHooksSuite) KeldReportsThePosthookFailure() {
	if !strings.Contains(s.runStderr, "post-hook") {
		s.Fatalf("expected post-hook failure on stderr, got stdout=%q stderr=%q err=%v", s.runStdout, s.runStderr, s.runErr)
	}
}

func (s *backupHooksSuite) TheBackupConfigurationIncludesPrehooksAndPosthooks() {
	s.writeConfig("home", []string{s.logHook("pre preview")}, []string{s.logHook("post preview")})
}

func (s *backupHooksSuite) ResticBackupFailsToStart() {
	s.writeConfig("home", []string{s.logHook("pre preview")}, []string{s.postEnvHook("post preview")})
	fakeRestic := os.Getenv("KELD_EXECUTABLE")
	if err := os.WriteFile(fakeRestic, []byte("#!/definitely/missing/keld-restic-interpreter\n"), 0o755); err != nil {
		s.Fatalf("rewriting fake restic: %v", err)
	}
}

func (s *backupHooksSuite) EachPosthookRunsAfterTheFailedResticStart() {
	lines := s.logLines()
	if len(lines) < 2 || !strings.HasPrefix(lines[0], "pre preview") || !strings.HasPrefix(lines[1], "post preview") {
		s.Fatalf("post-hook did not run after failed restic start; log: %#v", lines)
	}
}

func (s *backupHooksSuite) EachPosthookReceivesNoKeldresticexitcode() {
	for _, line := range s.logLines() {
		if strings.HasPrefix(line, "post") && strings.Contains(line, "code=") && !strings.Contains(line, "code= ") {
			s.Fatalf("post-hook unexpectedly received exit code; log: %#v", s.logLines())
		}
	}
}

func (s *backupHooksSuite) KeldReportsTheResticStartFailure() {
	if s.runErr == nil || !strings.Contains(s.runErr.Error(), "starting restic") {
		s.Fatalf("expected restic start failure, got %v", s.runErr)
	}
}

func (s *backupHooksSuite) KeldShowsTheResolvedBackupCommand() {
	flagPreset = s.preset
	flagShowCmd = true
	flagConfigFile = s.configPath

	var err error
	s.runStdout, s.runStderr, err = captureOutput(s, func() error {
		return runCommand("backup", lookupSubcommandForHooks(s, "backup"), nil, nil, nil)
	})
	s.runErr = err
}

func (s *backupHooksSuite) TheOutputIncludesTheConfiguredPrehooks() {
	if !strings.Contains(s.runStdout, "pre-hooks:") || !strings.Contains(s.runStdout, "pre preview") {
		s.Fatalf("preview missing pre-hooks: %s", s.runStdout)
	}
}

func (s *backupHooksSuite) TheOutputIncludesTheConfiguredPosthooks() {
	if !strings.Contains(s.runStdout, "post-hooks:") || !strings.Contains(s.runStdout, "post preview") {
		s.Fatalf("preview missing post-hooks: %s", s.runStdout)
	}
}

func (s *backupHooksSuite) TheOutputDoesNotIncludeKeldresticexitcode() {
	if strings.Contains(s.runStdout, "KELD_RESTIC_EXIT_CODE") {
		s.Fatalf("preview included runtime exit code env: %s", s.runStdout)
	}
}

func (s *backupHooksSuite) TheOutputDoesNotIncludeKeldresticstatus() {
	if strings.Contains(s.runStdout, "KELD_RESTIC_STATUS") {
		s.Fatalf("preview included runtime status env: %s", s.runStdout)
	}
}

func (s *backupHooksSuite) writeConfig(preset string, preHooks, postHooks []string) {
	s.preset = preset
	var b strings.Builder
	b.WriteString("[" + quote(preset) + "]\n")
	b.WriteString("tag = \"bdd\"\n\n")
	b.WriteString("[" + quote(preset) + ".backup]\n")
	b.WriteString("_arguments = [" + quote(filepath.Join(s.tmpDir, "source")) + "]\n")
	if len(preHooks) > 0 {
		b.WriteString("_pre_hooks = [" + quotedList(preHooks) + "]\n")
	}
	if len(postHooks) > 0 {
		b.WriteString("_post_hooks = [" + quotedList(postHooks) + "]\n")
	}
	s.writeRawConfig(b.String())
}

func (s *backupHooksSuite) writeRawConfig(toml string) {
	if err := os.WriteFile(s.configPath, []byte(toml), 0o600); err != nil {
		s.Fatalf("writing config: %v", err)
	}
	flagConfigFile = s.configPath
	mustSetenv(s, "KELD_CONFIG_FILE", s.configPath)
}

func (s *backupHooksSuite) logHook(message string) string {
	return "printf " + quote(message+"\n") + " >> \"$KELD_TEST_LOG\""
}

func (s *backupHooksSuite) postEnvHook(prefix string) string {
	return "printf " + quote(prefix+" code=%s status=%s\n") + " \"$KELD_RESTIC_EXIT_CODE\" \"$KELD_RESTIC_STATUS\" >> \"$KELD_TEST_LOG\""
}

func (s *backupHooksSuite) logLines() []string {
	data, err := os.ReadFile(s.logPath)
	if errors.Is(err, os.ErrNotExist) {
		return nil
	}
	if err != nil {
		s.Fatalf("reading log: %v", err)
	}
	return strings.Split(strings.TrimRight(string(data), "\n"), "\n")
}

func (s *backupHooksSuite) logContains(needle string) bool {
	for _, line := range s.logLines() {
		if strings.Contains(line, needle) {
			return true
		}
	}
	return false
}

func quote(s string) string { return strconv.Quote(s) }

func quotedList(values []string) string {
	quoted := make([]string, len(values))
	for i, value := range values {
		quoted[i] = quote(value)
	}
	return strings.Join(quoted, ", ")
}

func mustSetenv(s *backupHooksSuite, key, value string) {
	if err := os.Setenv(key, value); err != nil {
		s.Fatalf("setting %s: %v", key, err)
	}
}

func restoreEnv(key, value string) {
	if value == "" {
		_ = os.Unsetenv(key)
		return
	}
	_ = os.Setenv(key, value)
}

func lookupSubcommandForHooks(s *backupHooksSuite, name string) *cobra.Command {
	s.Helper()
	for _, cmd := range rootCmd.Commands() {
		if cmd.Name() == name {
			return cmd
		}
	}
	s.Fatalf("subcommand %q not found", name)
	return nil
}

func captureOutput(s *backupHooksSuite, run func() error) (string, string, error) {
	s.Helper()

	oldStdout := os.Stdout
	oldStderr := os.Stderr
	stdoutReader, stdoutWriter, err := os.Pipe()
	if err != nil {
		s.Fatalf("creating stdout pipe: %v", err)
	}
	stderrReader, stderrWriter, err := os.Pipe()
	if err != nil {
		s.Fatalf("creating stderr pipe: %v", err)
	}
	os.Stdout = stdoutWriter
	os.Stderr = stderrWriter

	var stdoutBuf, stderrBuf bytes.Buffer
	var wg sync.WaitGroup
	var stdoutErr, stderrErr error
	wg.Add(2)
	go func() {
		defer wg.Done()
		_, stdoutErr = io.Copy(&stdoutBuf, stdoutReader)
	}()
	go func() {
		defer wg.Done()
		_, stderrErr = io.Copy(&stderrBuf, stderrReader)
	}()

	runErr := run()
	_ = stdoutWriter.Close()
	_ = stderrWriter.Close()
	os.Stdout = oldStdout
	os.Stderr = oldStderr
	wg.Wait()

	if stdoutErr != nil {
		s.Fatalf("reading stdout: %v", stdoutErr)
	}
	if stderrErr != nil {
		s.Fatalf("reading stderr: %v", stderrErr)
	}
	_ = stdoutReader.Close()
	_ = stderrReader.Close()

	return stdoutBuf.String(), stderrBuf.String(), runErr
}
