package backup

import (
	"context"
	"fmt"
	"strings"
	"testing"
)

// classifyResticProbe maps the exact restic stderr to a repo class (the 2026-07-17 diagnosis
// signatures). ORPHANED only on the definitive wrong-password line; ambiguous errors are NOT orphaned.
func TestClassifyResticProbe(t *testing.T) {
	cases := []struct {
		out  string
		err  error
		want string
	}{
		{"", nil, ""}, // success
		{"Fatal: wrong password or no key found", fmt.Errorf("exit status 1"), "orphaned"},
		{"Fatal: unable to open config file: <sftp:...> does not exist\nIs there a repository at the following location?", fmt.Errorf("exit status 1"), "norepo"},
		{"ssh: connect to host nas.local port 22: Connection timed out", fmt.Errorf("exit status 255"), "other"},
		{"Load(<lock/...>): permission denied", fmt.Errorf("exit status 1"), "other"},
	}
	for _, c := range cases {
		if got := classifyResticProbe([]byte(c.out), c.err); got != c.want {
			t.Errorf("classify(%q) = %q, want %q", c.out, got, c.want)
		}
	}
}

// wrongPwRunner: `cat config` returns the wrong-password signature; other restic steps succeed (so a
// post-reset run can proceed). Records the subcommands seen.
func wrongPwRunner(seen *[]string) offboxRunner {
	return func(_ context.Context, _ []string, args ...string) ([]byte, error) {
		sub := ""
		for i, a := range args {
			if a == "cat" && i+1 < len(args) && args[i+1] == "config" {
				sub = "cat-config"
			} else if a == "init" {
				sub = "init"
			}
		}
		if sub == "" && len(args) > 0 {
			sub = args[len(args)-1]
		}
		if seen != nil {
			*seen = append(*seen, sub)
		}
		if sub == "cat-config" {
			return []byte("Fatal: wrong password or no key found"), fmt.Errorf("exit status 1")
		}
		return nil, nil // init / unlock / backup / stats succeed
	}
}

// Scenario A (RED-PROOF = the incident): a CLAIMED box whose repo is wrong-keyed enters the explicit
// ORPHANED state — the run skips cleanly (no raw restic banner, ONE event, no nightly re-fire) instead
// of erroring nightly with "exit status 1". Pre-fix (no classification) surfaced the raw error and set
// no state → these assertions FAIL.
func TestOffbox_OrphanDetection_Claimed(t *testing.T) {
	m, sett := newOffboxManager(t)
	if err := sett.SetClaimed(); err != nil { // claimed → orphan card, NEVER auto-reset
		t.Fatal(err)
	}
	var events []string
	m.SetOffboxOrphanEvent(func(evt, _ string) { events = append(events, evt) })
	m.SetOffboxRunner(wrongPwRunner(nil))

	if err := m.RunOffboxBackup(context.Background()); err != nil {
		t.Fatalf("run should skip cleanly on an orphaned repo, got %v", err)
	}
	if !m.OffboxOrphaned() {
		t.Fatal("repo was not classified/persisted as ORPHANED")
	}
	if len(events) != 1 || events[0] != "offbox_repo_orphaned" {
		t.Fatalf("expected exactly one offbox_repo_orphaned event, got %v", events)
	}
	got := sett.GetOffboxTarget()
	if got.RepoState != "orphaned" || got.OrphanedAt == "" {
		t.Fatalf("RepoState=%q OrphanedAt=%q, want orphaned + a stamp", got.RepoState, got.OrphanedAt)
	}
	// The raw restic error must NOT be surfaced as the last-error banner (the card explains instead).
	if strings.Contains(got.LastError, "wrong password") || strings.Contains(got.LastError, "exit status") {
		t.Fatalf("raw restic error leaked into LastError: %q", got.LastError)
	}
	// A second scheduled run SKIPS (no nightly spam) — no new event.
	if err := m.RunOffboxBackup(context.Background()); err != nil {
		t.Fatalf("second run: %v", err)
	}
	if len(events) != 1 {
		t.Fatalf("nightly re-fire — events=%v, want the single transition event only", events)
	}
}

// Scenario B: an UNCLAIMED box auto-resets on detection — move-aside (never delete) + re-init; both
// events fire and the box ends un-orphaned (next run green).
func TestOffbox_OrphanDetection_UnclaimedAutoReset(t *testing.T) {
	m, sett := newOffboxManager(t) // unclaimed by default
	var events []string
	m.SetOffboxOrphanEvent(func(evt, _ string) { events = append(events, evt) })
	var sshCmds []string
	m.SetOffboxSSH(func(_ context.Context, _, _ string, _ int, _, _, remoteCmd string) ([]byte, error) {
		sshCmds = append(sshCmds, remoteCmd)
		if strings.HasPrefix(remoteCmd, "test -e") {
			return nil, fmt.Errorf("exit status 1") // absent → free name
		}
		return nil, nil // mv OK
	})
	m.SetOffboxRunner(wrongPwRunner(nil))

	if err := m.RunOffboxBackup(context.Background()); err != nil {
		t.Fatalf("unclaimed run should auto-reset + succeed, got %v", err)
	}
	if m.OffboxOrphaned() {
		t.Fatal("unclaimed box stayed orphaned — auto-reset did not clear the state")
	}
	got := sett.GetOffboxTarget()
	if got.OrphanedRenamedTo == "" || !strings.Contains(got.OrphanedRenamedTo, ".orphaned-") {
		t.Fatalf("move-aside path not recorded: %q", got.OrphanedRenamedTo)
	}
	var mvSeen bool
	for _, c := range sshCmds {
		if strings.HasPrefix(c, "mv ") {
			mvSeen = true
		}
	}
	if !mvSeen {
		t.Fatalf("no move-aside mv issued: %v", sshCmds)
	}
	// Both transition events fired (orphaned → reset). No delete anywhere.
	if len(events) != 2 || events[0] != "offbox_repo_orphaned" || events[1] != "offbox_repo_reset" {
		t.Fatalf("events = %v, want [orphaned reset]", events)
	}
}

// Scenario C: the claimed confirmed reset (ResetOrphanedRepo) refuses unless orphaned, then move-aside +
// re-init + clear state.
func TestOffbox_ConfirmedReset(t *testing.T) {
	m, sett := newOffboxManager(t)
	if err := sett.SetClaimed(); err != nil {
		t.Fatal(err)
	}
	// refuse when not orphaned
	if err := m.ResetOrphanedRepo(context.Background()); err == nil {
		t.Fatal("reset must refuse when the repo is not orphaned")
	}
	// mark orphaned, then confirm reset
	m.SetOffboxRunner(wrongPwRunner(nil))
	_ = m.RunOffboxBackup(context.Background())
	if !m.OffboxOrphaned() {
		t.Fatal("precondition: not orphaned")
	}
	var mv bool
	m.SetOffboxSSH(func(_ context.Context, _, _ string, _ int, _, _, cmd string) ([]byte, error) {
		if strings.HasPrefix(cmd, "test -e") {
			return nil, fmt.Errorf("exit 1")
		}
		if strings.HasPrefix(cmd, "mv ") {
			mv = true
		}
		return nil, nil
	})
	if err := m.ResetOrphanedRepo(context.Background()); err != nil {
		t.Fatalf("confirmed reset: %v", err)
	}
	if !mv {
		t.Fatal("confirmed reset did not move the old repo aside")
	}
	if m.OffboxOrphaned() {
		t.Fatal("state not cleared after confirmed reset")
	}
}
