package main

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

	"incredigo/internal/discover"
)

// runCLI drives the real root command with args, capturing os.Stdout/os.Stderr.
// It swaps the process stdio around Execute so the CLI's direct fmt.Fprintln(os.Stdout,…)
// writes are captured; goroutines drain the pipes to avoid blocking on large output.
func runCLI(t *testing.T, args ...string) (stdout, stderr string, err error) {
	t.Helper()

	outR, outW, perr := os.Pipe()
	if perr != nil {
		t.Fatal(perr)
	}
	errR, errW, perr := os.Pipe()
	if perr != nil {
		t.Fatal(perr)
	}
	origOut, origErr := os.Stdout, os.Stderr
	os.Stdout, os.Stderr = outW, errW

	var ob, eb bytes.Buffer
	var wg sync.WaitGroup
	wg.Add(2)
	go func() { defer wg.Done(); io.Copy(&ob, outR) }()
	go func() { defer wg.Done(); io.Copy(&eb, errR) }()

	// The file scanner's --path targets are process-global; reset so a prior test's
	// (now-deleted) fixture path can't leak into this invocation.
	discover.ClearPaths()
	root := newRootCmd()
	root.SetArgs(args)
	err = root.Execute()

	os.Stdout, os.Stderr = origOut, origErr
	outW.Close()
	errW.Close()
	wg.Wait()
	outR.Close()
	errR.Close()
	return ob.String(), eb.String(), err
}

// fakeGopassOnPath installs a stub `gopass` as the first entry on PATH and points it
// at a fresh file-backed store, so CLI commands that build sink.Gopass{} (using the
// default "gopass" binary) never touch the user's real store. failShow makes `gopass
// show` exit non-zero, used to force a backup-gate failure. Returns the store dir.
func fakeGopassOnPath(t *testing.T, failShow bool, seed map[string]string) string {
	t.Helper()
	dir := t.TempDir()
	bin := filepath.Join(dir, "gopass")
	showBody := `cat "$store/$3"`
	if failShow {
		showBody = `echo "show failed" >&2; exit 1`
	}
	script := `#!/usr/bin/env bash
store="$FAKE_GOPASS_STORE"
case "$1" in
  ls) (cd "$store" 2>/dev/null && find . -type f | sed 's#^\./##' | sort) ;;
  show) ` + showBody + ` ;;
  insert)
    shift; path=""
    for a in "$@"; do case "$a" in --multiline=false|-f) ;; *) path="$a";; esac; done
    mkdir -p "$store/$(dirname "$path")"; cat > "$store/$path" ;;
esac
`
	if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
		t.Fatal(err)
	}
	store := t.TempDir()
	for p, s := range seed {
		full := filepath.Join(store, p)
		if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
			t.Fatal(err)
		}
		if err := os.WriteFile(full, []byte(s), 0o600); err != nil {
			t.Fatal(err)
		}
	}
	t.Setenv("FAKE_GOPASS_STORE", store)
	t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
	return store
}

// fixtureDir writes files as credential material and returns the dir, for discovery
// via `--path <dir> --source file`. Isolating discovery to the file scanner keeps the
// host's real aws/env/ssh secrets out of the test.
func fixtureDir(t *testing.T, files map[string]string) string {
	t.Helper()
	dir := t.TempDir()
	for name, body := range files {
		if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
			t.Fatal(err)
		}
	}
	return dir
}

// isolateHome points HOME at a throwaway dir so no host dotfiles are ever scanned or
// written, and the rotate default backup dir (~/.incredigo) lands under the temp dir.
func isolateHome(t *testing.T) { t.Setenv("HOME", t.TempDir()) }

func TestScanFindsFixtureViaPath(t *testing.T) {
	isolateHome(t)
	dir := fixtureDir(t, map[string]string{"deploy.key": "unique-scan-secret-value"})
	out, serr, err := runCLI(t, "scan", "--source", "file", "--path", dir)
	if err != nil {
		t.Fatalf("scan: %v (stderr=%s)", err, serr)
	}
	if !strings.Contains(out, "deploy.key") {
		t.Errorf("scan output missing fixture identity:\n%s", out)
	}
	if strings.Contains(out, "unique-scan-secret-value") {
		t.Errorf("scan leaked the secret value:\n%s", out)
	}
	if !strings.Contains(serr, "credential(s) found") {
		t.Errorf("scan summary missing:\n%s", serr)
	}
}

func TestStatusHappyPath(t *testing.T) {
	isolateHome(t)
	dir := fixtureDir(t, map[string]string{"token.txt": "status-secret"})
	out, _, err := runCLI(t, "status", "--source", "file", "--path", dir)
	if err != nil {
		t.Fatalf("status: %v", err)
	}
	if !strings.Contains(out, "STATE") || !strings.Contains(out, "token.txt") {
		t.Errorf("status output unexpected:\n%s", out)
	}
}

func TestWorklistHappyPath(t *testing.T) {
	isolateHome(t)
	dir := fixtureDir(t, map[string]string{"aws.creds": "worklist-secret"})
	out, _, err := runCLI(t, "worklist", "--source", "file", "--path", dir)
	if err != nil {
		t.Fatalf("worklist: %v", err)
	}
	if strings.Contains(out, "worklist-secret") {
		t.Errorf("worklist leaked a secret:\n%s", out)
	}
	if !strings.Contains(strings.ToLower(out), "rotat") {
		t.Errorf("worklist output does not look like a worklist:\n%s", out)
	}
}

func TestMigrateDedupeAndForce(t *testing.T) {
	isolateHome(t)
	store := fakeGopassOnPath(t, false, nil)
	dir := fixtureDir(t, map[string]string{"svc.token": "migrate-secret"})

	// First migrate: stores the credential.
	out, _, err := runCLI(t, "migrate", "--source", "file", "--path", dir, "--prefix", "imported/")
	if err != nil {
		t.Fatalf("migrate: %v", err)
	}
	if !strings.Contains(out, "store imported/file/") {
		t.Errorf("first migrate did not store:\n%s", out)
	}
	// The secret must be in the store (streamed via stdin, never argv).
	got := readAnyUnder(t, filepath.Join(store, "imported/file"))
	if got != "migrate-secret" {
		t.Errorf("stored value = %q, want migrate-secret", got)
	}

	// Second migrate with --dedupe: the same entry is skipped, not overwritten.
	out2, _, err := runCLI(t, "migrate", "--source", "file", "--path", dir, "--prefix", "imported/", "--dedupe")
	if err != nil {
		t.Fatalf("migrate --dedupe: %v", err)
	}
	if !strings.Contains(out2, "(exists)") {
		t.Errorf("--dedupe did not skip an existing entry:\n%s", out2)
	}
}

func TestExportImportRoundTripCLI(t *testing.T) {
	isolateHome(t)
	t.Setenv("INCREDIGO_PASSPHRASE", "round-trip-pass")
	seed := map[string]string{
		"imported/aws/default": "AKIA/roundtrip+secret",
		"imported/env/api":     "tok-末-value",
	}
	store := fakeGopassOnPath(t, false, seed)
	bundle := filepath.Join(t.TempDir(), "backup.age")

	// export from store A
	_, serr, err := runCLI(t, "export", "--out", bundle, "--prefix", "imported/")
	if err != nil {
		t.Fatalf("export: %v (stderr=%s)", err, serr)
	}
	if fi, e := os.Stat(bundle); e != nil || fi.Size() == 0 {
		t.Fatalf("bundle missing/empty: %v", e)
	}
	if !strings.Contains(serr, "sealed 2") {
		t.Errorf("export summary unexpected:\n%s", serr)
	}
	// The bundle must not contain the plaintext secret.
	if b, _ := os.ReadFile(bundle); bytes.Contains(b, []byte("AKIA/roundtrip+secret")) {
		t.Fatal("sealed bundle leaked plaintext")
	}

	// import into a fresh empty store B
	storeB := t.TempDir()
	t.Setenv("FAKE_GOPASS_STORE", storeB)
	_, serr, err = runCLI(t, "import", "--in", bundle, "--force")
	if err != nil {
		t.Fatalf("import: %v (stderr=%s)", err, serr)
	}
	for path, want := range seed {
		got, e := os.ReadFile(filepath.Join(storeB, path))
		if e != nil {
			t.Fatalf("restored %s missing: %v", path, e)
		}
		if string(got) != want {
			t.Errorf("%s restored %q, want %q", path, got, want)
		}
	}
	_ = store
}

func TestExportRefusesToOverwrite(t *testing.T) {
	isolateHome(t)
	t.Setenv("INCREDIGO_PASSPHRASE", "pw")
	fakeGopassOnPath(t, false, map[string]string{"imported/env/api": "v"})
	bundle := filepath.Join(t.TempDir(), "exists.age")
	if err := os.WriteFile(bundle, []byte("preexisting"), 0o600); err != nil {
		t.Fatal(err)
	}
	if _, _, err := runCLI(t, "export", "--out", bundle, "--prefix", "imported/"); err == nil {
		t.Fatal("export overwrote an existing bundle")
	}
}

// --- Gate refusals (the safety-critical CLI contracts) ---

func TestRotateExecuteRefusedWithoutEnv(t *testing.T) {
	isolateHome(t)
	os.Unsetenv("INCREDIGO_ALLOW_EXECUTE")
	fakeGopassOnPath(t, false, map[string]string{"imported/env/api": "v"})
	_, _, err := runCLI(t, "rotate", "--execute", "--prefix", "imported/")
	if err == nil {
		t.Fatal("rotate --execute ran without INCREDIGO_ALLOW_EXECUTE=1")
	}
	if !strings.Contains(err.Error(), "refused") {
		t.Errorf("unexpected error: %v", err)
	}
}

func TestExportMissingPassphrase(t *testing.T) {
	isolateHome(t)
	t.Setenv("INCREDIGO_PASSPHRASE", "") // set-but-empty is an explicit error
	fakeGopassOnPath(t, false, map[string]string{"imported/env/api": "v"})
	bundle := filepath.Join(t.TempDir(), "b.age")
	_, _, err := runCLI(t, "export", "--out", bundle, "--prefix", "imported/")
	if err == nil {
		t.Fatal("export proceeded with an empty passphrase")
	}
	if !strings.Contains(err.Error(), "PASSPHRASE") {
		t.Errorf("unexpected error: %v", err)
	}
	// export opens the output file before reading the passphrase, so an empty file
	// may remain — but it must never contain sealed (or any) content.
	if fi, e := os.Stat(bundle); e == nil && fi.Size() != 0 {
		t.Errorf("bundle has %d bytes despite the passphrase error", fi.Size())
	}
}

// TestRotateExecuteBackupGateAborts proves the backup gate blocks execute BEFORE any
// driver runs: with authorization present but `gopass show` failing, Snapshot fails
// and the command aborts with a backup-gate error — no rotation is attempted.
func TestRotateExecuteBackupGateAborts(t *testing.T) {
	isolateHome(t)
	t.Setenv("INCREDIGO_ALLOW_EXECUTE", "1")
	t.Setenv("INCREDIGO_PASSPHRASE", "pw")
	// ls returns a path so ExportTo tries to read it, but show fails → Snapshot fails.
	fakeGopassOnPath(t, true, map[string]string{"imported/env/api": "v"})
	out, _, err := runCLI(t, "rotate", "--execute", "--prefix", "imported/")
	if err == nil {
		t.Fatal("rotate --execute proceeded despite a failing backup gate")
	}
	if !strings.Contains(err.Error(), "backup gate failed") {
		t.Errorf("expected a backup-gate error, got: %v", err)
	}
	// The rotation results table must never have been printed.
	if strings.Contains(out, "ROTATED") || strings.Contains(out, "DRIVER") {
		t.Errorf("rotation ran despite the backup gate failing:\n%s", out)
	}
}

func TestUnknownSealerRejected(t *testing.T) {
	isolateHome(t)
	t.Setenv("INCREDIGO_PASSPHRASE", "pw")
	fakeGopassOnPath(t, false, map[string]string{"imported/env/api": "v"})
	bundle := filepath.Join(t.TempDir(), "b.age")
	_, _, err := runCLI(t, "export", "--out", bundle, "--prefix", "imported/", "--sealer", "rot13")
	if err == nil || !strings.Contains(err.Error(), "unknown sealer") {
		t.Fatalf("expected unknown-sealer error, got: %v", err)
	}
}

// readAnyUnder returns the contents of the first regular file under root (tests seed
// exactly one), or fails.
func readAnyUnder(t *testing.T, root string) string {
	t.Helper()
	var found string
	filepath.Walk(root, func(p string, fi os.FileInfo, err error) error {
		if err == nil && !fi.IsDir() && found == "" {
			b, _ := os.ReadFile(p)
			found = string(b)
		}
		return nil
	})
	if found == "" {
		t.Fatalf("no file found under %s", root)
	}
	return found
}
