package backup

import (
	"io"
	"log"
	"os"
	"path/filepath"
	"strings"
	"testing"
)

// captureFixtureUnit writes a real stack tree, runs the REAL CaptureRecoveryUnit over it, and returns
// the drive (namespace root) holding the resulting unit.
//
// Fixtures come from a CAPTURED unit rather than hand-written YAML deliberately: the capture side and
// the restore side must meet at real bytes on a real filesystem, so a change to the on-disk shape
// (header text, key ordering, the non-secret/portable split) cannot pass by having a test agree with
// itself. Everything from buildUnitAppYaml through readUnitEnv is production code here.
func captureFixtureUnit(t *testing.T, portable map[string]string) (drive string) {
	t.Helper()
	tmp := t.TempDir()
	stackDir := filepath.Join(tmp, "stack")
	drive = filepath.Join(tmp, "drive")
	if err := os.MkdirAll(stackDir, 0755); err != nil {
		t.Fatal(err)
	}
	mustWrite(t, filepath.Join(stackDir, "docker-compose.yml"),
		"services:\n  app:\n    image: example/app:1\n  db:\n    image: postgres:16\n")
	mustWrite(t, filepath.Join(stackDir, ".felhom.yml"), "display_name: App\n")
	mustWrite(t, filepath.Join(stackDir, "app.yaml"), "deployed: true\nenv:\n  SUBDOMAIN: trips\n")

	var names []string
	for _, n := range []string{"DB_PASSWORD", "SECRET_KEY"} {
		if _, ok := portable[n]; ok {
			names = append(names, n)
		}
	}
	info := RecoveryInfo{
		StackDir:              stackDir,
		DisplayName:           "App",
		ImagePins:             []string{"example/app:1"},
		NonSecretEnv:          map[string]string{"SUBDOMAIN": "trips"},
		SecretEnvVars:         []string{"DB_PASSWORD", "SECRET_KEY"},
		DataKeyEnvVars:        []string{"SECRET_KEY"},
		PortableSecretEnvVars: names,
		PortableSecrets:       portable,
	}
	m := &Manager{
		logger:         log.New(io.Discard, "", 0),
		systemDataPath: filepath.Join(tmp, "system"),
		stackProvider:  &fakeRecoveryProvider{info: info, hdd: drive},
		version:        "vtest",
	}
	if err := m.CaptureRecoveryUnit("app"); err != nil {
		t.Fatalf("capture fixture: %v", err)
	}
	return drive
}

// TestRestoreFromRecoveryUnitWithGuestAbsent is D5's entire claim, as a test rather than a
// description: a Tier-1/2 restore SUCCEEDS when the guest's app.yaml is unavailable.
//
// SEAM (R-125): injection is at Manager.stackProvider only — i.e. the docker/compose operations and the
// guest's app.yaml decrypt. RecoverStackSecrets returning nil IS the guest being gone: it is exactly
// what the real adapter returns when the stack or its app.yaml cannot be read (main.go
// GetStack/LoadAppConfigDecrypted nil paths). Everything under test is production code: the unit was
// written by the real CaptureRecoveryUnit, read back by the real readUnitEnv, and reconciled by the
// real reconcileRestoreSecrets.
func TestRestoreFromRecoveryUnitWithGuestAbsent(t *testing.T) {
	const (
		dataKey = "deadbeefdeadbeef"
		dbPw    = "pw-from-the-drive"
	)
	drive := captureFixtureUnit(t, map[string]string{"DB_PASSWORD": dbPw, "SECRET_KEY": dataKey})

	// The guest is GONE: no secrets recoverable from it at all.
	fake := &fakeRecoveryProvider{hdd: drive, running: true, secrets: nil}
	m := &Manager{logger: log.New(io.Discard, "", 0),
		systemDataPath: filepath.Join(drive, "..", "sys"), stackProvider: fake}

	if err := m.RestoreFromRecoveryUnit("app"); err != nil {
		t.Fatalf("restore must succeed from the drive alone, got: %v", err)
	}
	if fake.gotEnv == nil {
		t.Fatal("recreate was not called — the restore did not reach the redeploy")
	}
	// The consequence: the app is redeployed with the key that decrypts the data beside it.
	if fake.gotEnv["SECRET_KEY"] != dataKey {
		t.Errorf("data-encrypting key not recovered from the unit: %q", fake.gotEnv["SECRET_KEY"])
	}
	if fake.gotEnv["DB_PASSWORD"] != dbPw {
		t.Errorf("DB password not recovered from the unit: %q", fake.gotEnv["DB_PASSWORD"])
	}
	if fake.gotEnv["SUBDOMAIN"] != "trips" {
		t.Errorf("plain config lost: %v", fake.gotEnv)
	}
}

// TestRestoreFromRecoveryUnitGuestAbsentStillFailsClosed proves D5 did not soften the gate: with the
// data key in NEITHER the unit nor the guest, the restore still REFUSES and mutates nothing.
func TestRestoreFromRecoveryUnitGuestAbsentStillFailsClosed(t *testing.T) {
	// Unit carries only the DB password — the data key is absent from both sources.
	drive := captureFixtureUnit(t, map[string]string{"DB_PASSWORD": "pw"})
	fake := &fakeRecoveryProvider{hdd: drive, running: true, secrets: nil}
	m := &Manager{logger: log.New(io.Discard, "", 0),
		systemDataPath: filepath.Join(drive, "..", "sys"), stackProvider: fake}

	err := m.RestoreFromRecoveryUnit("app")
	if err == nil {
		t.Fatal("expected fail-closed refusal when the data key is in neither source")
	}
	if !strings.Contains(err.Error(), "SECRET_KEY") {
		t.Errorf("refusal should name the missing data key, got: %v", err)
	}
	if fake.gotEnv != nil {
		t.Errorf("recreate must NOT be called on refusal, got %v", fake.gotEnv)
	}
	if fake.stopped {
		t.Error("the live app must not be stopped when the restore refuses")
	}
}

// TestRestoreFromRecoveryUnitOrchestration exercises the full in-process flow: read manifest →
// recover secrets → apply gate → recreate with the reconciled env. It proves (a) on success the
// recreate is called with non-secret env + recovered secrets merged, and (b) on a missing data-key the
// restore is REFUSED and recreate is never called.
func TestRestoreFromRecoveryUnitOrchestration(t *testing.T) {
	newUnit := func(t *testing.T) (drive string) {
		tmp := t.TempDir()
		drive = filepath.Join(tmp, "drive")
		// A schema-1 unit: no portable secrets, so the guest is the only source (pre-D5 behaviour).
		mustWrite(t, filepath.Join(RecoveryUnitComposePath(drive, "app"), "app.yaml"),
			"deployed: true\nenv:\n  SUBDOMAIN: trips\n")
		man := &RecoveryManifest{SchemaVersion: 1, AppName: "app", ControllerVer: "v",
			SecretEnvVars: []string{"DB_PASSWORD", "SECRET_KEY"}, DataKeyEnvVars: []string{"SECRET_KEY"}}
		if err := writeManifest(RecoveryUnitManifestPath(drive, "app"), man); err != nil {
			t.Fatal(err)
		}
		return drive
	}

	t.Run("success — recreate called with merged env", func(t *testing.T) {
		drive := newUnit(t)
		fake := &fakeRecoveryProvider{
			hdd:     drive,
			running: true, // so the post-restore health wait returns promptly
			secrets: map[string]string{"DB_PASSWORD": "pw", "SECRET_KEY": "deadbeef"},
		}
		m := &Manager{logger: log.New(io.Discard, "", 0), systemDataPath: filepath.Join(drive, "..", "sys"),
			stackProvider: fake}
		if err := m.RestoreFromRecoveryUnit("app"); err != nil {
			t.Fatalf("restore: %v", err)
		}
		if fake.gotEnv == nil {
			t.Fatal("recreate was not called")
		}
		if fake.gotEnv["SUBDOMAIN"] != "trips" || fake.gotEnv["DB_PASSWORD"] != "pw" || fake.gotEnv["SECRET_KEY"] != "deadbeef" {
			t.Errorf("recreate got wrong env: %v", fake.gotEnv)
		}
		if !fake.stopped {
			t.Errorf("app should be stopped before restore")
		}
	})

	t.Run("schema-1 unit still restores from the guest — no regression", func(t *testing.T) {
		// An old unit carries nothing; the guest must still be able to supply everything.
		drive := newUnit(t)
		fake := &fakeRecoveryProvider{hdd: drive, running: true,
			secrets: map[string]string{"DB_PASSWORD": "pw", "SECRET_KEY": "deadbeef"}}
		m := &Manager{logger: log.New(io.Discard, "", 0),
			systemDataPath: filepath.Join(drive, "..", "sys"), stackProvider: fake}
		if err := m.RestoreFromRecoveryUnit("app"); err != nil {
			t.Fatalf("a pre-D5 unit must still restore: %v", err)
		}
		if fake.gotEnv["SECRET_KEY"] != "deadbeef" {
			t.Errorf("guest fallback lost the data key: %v", fake.gotEnv)
		}
	})

	t.Run("data-key unrecoverable — REFUSED, recreate not called", func(t *testing.T) {
		drive := newUnit(t)
		fake := &fakeRecoveryProvider{
			hdd:     drive,
			secrets: map[string]string{"DB_PASSWORD": "pw"}, // SECRET_KEY (data_key) missing
		}
		m := &Manager{logger: log.New(io.Discard, "", 0), systemDataPath: filepath.Join(drive, "..", "sys"),
			stackProvider: fake}
		err := m.RestoreFromRecoveryUnit("app")
		if err == nil {
			t.Fatal("expected fail-closed refusal, got nil")
		}
		if fake.gotEnv != nil {
			t.Errorf("recreate must NOT be called on refusal, got %v", fake.gotEnv)
		}
	})
}

// TestReconcileRestoreSecretsPrecedence pins the D5 precedence rule in BOTH directions. An undefined
// precedence between two sources of a decryption key is a data-loss bug waiting for its first
// disagreement, so this is not a style question.
//
// The UNIT wins: its secrets were captured in the same run as the dumps beside them, so the unit's
// value is the one that matches the data about to be restored. The guest's value is merely the most
// recent — and a rotated key does not decrypt data encrypted with the old one.
func TestReconcileRestoreSecretsPrecedence(t *testing.T) {
	nonSecret := map[string]string{"SUBDOMAIN": "trips"}
	names := []string{"DB_PASSWORD", "SECRET_KEY"}
	dataKeys := []string{"SECRET_KEY"}

	t.Run("both sources disagree — the UNIT wins", func(t *testing.T) {
		unit := map[string]string{"DB_PASSWORD": "unit-pw", "SECRET_KEY": "unit-key"}
		guest := map[string]string{"DB_PASSWORD": "guest-pw", "SECRET_KEY": "guest-key"}
		full, missing, err := reconcileRestoreSecrets(nonSecret, unit, guest, names, dataKeys)
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if len(missing) != 0 {
			t.Errorf("missing: %v", missing)
		}
		if full["SECRET_KEY"] != "unit-key" {
			t.Errorf("data key: got %q, want the UNIT's value (it matches the restored data)", full["SECRET_KEY"])
		}
		if full["DB_PASSWORD"] != "unit-pw" {
			t.Errorf("DB password: got %q, want the UNIT's value (it matches the restored data dir hash)", full["DB_PASSWORD"])
		}
	})

	t.Run("unit silent — the GUEST fills in", func(t *testing.T) {
		// The withheld class (admin logins) is never in the unit, so this direction must work too.
		guest := map[string]string{"DB_PASSWORD": "guest-pw", "SECRET_KEY": "guest-key"}
		full, _, err := reconcileRestoreSecrets(nonSecret, nil, guest, names, dataKeys)
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if full["SECRET_KEY"] != "guest-key" || full["DB_PASSWORD"] != "guest-pw" {
			t.Errorf("guest fallback not applied: %v", full)
		}
	})

	t.Run("unit present but EMPTY for a name — the guest fills in", func(t *testing.T) {
		// An empty value is not a value; it must not shadow a good one from the guest.
		unit := map[string]string{"SECRET_KEY": ""}
		guest := map[string]string{"SECRET_KEY": "guest-key", "DB_PASSWORD": "guest-pw"}
		full, _, err := reconcileRestoreSecrets(nonSecret, unit, guest, names, dataKeys)
		if err != nil {
			t.Fatalf("an empty unit value must fall through to the guest, got: %v", err)
		}
		if full["SECRET_KEY"] != "guest-key" {
			t.Errorf("empty unit value shadowed the guest: %q", full["SECRET_KEY"])
		}
	})

	t.Run("a portable secret never shadows plain config", func(t *testing.T) {
		// GetStackRecoveryInfo keeps the two sets disjoint; if that ever breaks, the merge order in
		// buildUnitAppYaml decides silently. Pin the intended outcome.
		full, _, err := reconcileRestoreSecrets(map[string]string{"DB_PASSWORD": "should-not-win"},
			map[string]string{"DB_PASSWORD": "unit-pw"}, nil, []string{"DB_PASSWORD"}, nil)
		if err != nil {
			t.Fatal(err)
		}
		if full["DB_PASSWORD"] != "unit-pw" {
			t.Errorf("the secret source must win over a stray non-secret entry: %q", full["DB_PASSWORD"])
		}
	})
}

// TestReconcileRestoreSecrets covers the safety-critical fail-closed gate + secret reconciliation.
func TestReconcileRestoreSecrets(t *testing.T) {
	nonSecret := map[string]string{"SUBDOMAIN": "trips", "DOMAIN": "demo-felhom.eu"}

	t.Run("all recovered, no data_key — full env, no error", func(t *testing.T) {
		guest := map[string]string{"DB_PASSWORD": "pw", "SECRET_KEY": "deadbeef"}
		full, missing, err := reconcileRestoreSecrets(nonSecret, nil, guest,
			[]string{"DB_PASSWORD", "SECRET_KEY"}, nil)
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if len(missing) != 0 {
			t.Errorf("missing: %v", missing)
		}
		// Non-secret + both secrets present, and recovered values used VERBATIM (regenerate nothing).
		if full["SUBDOMAIN"] != "trips" || full["DB_PASSWORD"] != "pw" || full["SECRET_KEY"] != "deadbeef" {
			t.Errorf("full env wrong: %v", full)
		}
	})

	t.Run("data_key missing from BOTH sources — FAIL CLOSED (refuse)", func(t *testing.T) {
		guest := map[string]string{"DB_PASSWORD": "pw"} // SECRET_KEY (a data_key) is gone
		full, _, err := reconcileRestoreSecrets(nonSecret, nil, guest,
			[]string{"DB_PASSWORD", "SECRET_KEY"}, []string{"SECRET_KEY"})
		if err == nil {
			t.Fatal("expected fail-closed error for missing data-encrypting key, got nil")
		}
		if full != nil {
			t.Errorf("full env should be nil on refusal, got %v", full)
		}
	})

	t.Run("data_key empty in both — FAIL CLOSED", func(t *testing.T) {
		guest := map[string]string{"SECRET_KEY": ""} // present but empty == unrecoverable
		_, _, err := reconcileRestoreSecrets(nonSecret, map[string]string{"SECRET_KEY": ""}, guest,
			[]string{"SECRET_KEY"}, []string{"SECRET_KEY"})
		if err == nil {
			t.Fatal("empty data-key value must fail closed")
		}
	})

	t.Run("data_key recovered from the UNIT — no refusal", func(t *testing.T) {
		// The D5 case: the guest is gone but the unit carries the key, so the gate must NOT fire.
		unit := map[string]string{"SECRET_KEY": "deadbeef", "DB_PASSWORD": "pw"}
		full, missing, err := reconcileRestoreSecrets(nonSecret, unit, nil,
			[]string{"DB_PASSWORD", "SECRET_KEY"}, []string{"SECRET_KEY"})
		if err != nil {
			t.Fatalf("the unit's data key must satisfy the gate: %v", err)
		}
		if len(missing) != 0 {
			t.Errorf("nothing should be missing: %v", missing)
		}
		if full["SECRET_KEY"] != "deadbeef" {
			t.Errorf("data key wrong: %v", full)
		}
	})

	t.Run("resettable secret missing — proceed with warning", func(t *testing.T) {
		guest := map[string]string{"SECRET_KEY": "deadbeef"} // data_key ok; DB_PASSWORD missing
		full, missing, err := reconcileRestoreSecrets(nonSecret, nil, guest,
			[]string{"DB_PASSWORD", "SECRET_KEY"}, []string{"SECRET_KEY"})
		if err != nil {
			t.Fatalf("a missing resettable secret must NOT fail closed: %v", err)
		}
		if len(missing) != 1 || missing[0] != "DB_PASSWORD" {
			t.Errorf("missing should be [DB_PASSWORD], got %v", missing)
		}
		if full["SECRET_KEY"] != "deadbeef" {
			t.Errorf("data-key should be preserved verbatim: %v", full)
		}
		if _, present := full["DB_PASSWORD"]; present {
			t.Errorf("missing resettable secret should be absent, not regenerated")
		}
	})
}

// TestReadUnitEnvSplitsByManifest proves the split is driven by the manifest's portable names, and that
// a schema-1 unit (no names) degrades to "everything is plain config" rather than losing entries.
func TestReadUnitEnvSplitsByManifest(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "app.yaml")
	mustWrite(t, path, "deployed: true\nenv:\n  SUBDOMAIN: trips\n  DB_PASSWORD: pw\n  SECRET_KEY: key\n")

	t.Run("named secrets land in unitSecrets, the rest in nonSecret", func(t *testing.T) {
		nonSecret, unitSecrets := readUnitEnv(path, []string{"DB_PASSWORD", "SECRET_KEY"})
		if nonSecret["SUBDOMAIN"] != "trips" || len(nonSecret) != 1 {
			t.Errorf("nonSecret = %v, want only SUBDOMAIN", nonSecret)
		}
		if unitSecrets["DB_PASSWORD"] != "pw" || unitSecrets["SECRET_KEY"] != "key" || len(unitSecrets) != 2 {
			t.Errorf("unitSecrets = %v, want the two named secrets", unitSecrets)
		}
	})

	t.Run("schema-1 (no portable names) — everything is plain config, nothing lost", func(t *testing.T) {
		nonSecret, unitSecrets := readUnitEnv(path, nil)
		if len(unitSecrets) != 0 {
			t.Errorf("a schema-1 unit carries no secrets, got %v", unitSecrets)
		}
		if len(nonSecret) != 3 {
			t.Errorf("nonSecret should keep every entry, got %v", nonSecret)
		}
	})

	t.Run("absent file — empty maps, no panic", func(t *testing.T) {
		nonSecret, unitSecrets := readUnitEnv(filepath.Join(dir, "nope.yaml"), []string{"DB_PASSWORD"})
		if len(nonSecret) != 0 || len(unitSecrets) != 0 {
			t.Errorf("want empty maps, got %v / %v", nonSecret, unitSecrets)
		}
	})
}
