package backup

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

	"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
	"gitea.dooplex.hu/admin/felhom-controller/internal/config"
	"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)

// t2v2Provider is a configurable StackDataProvider for the v2 tier-2 tests (per-stack hdd/mounts/binds).
type t2v2Provider struct {
	hdd    string
	mounts map[string][]string
	binds  map[string][]ClassifiedBind
	has    map[string]bool
}

func (p *t2v2Provider) GetStackComposePath(string) (string, bool) { return "", false }
func (p *t2v2Provider) ListDeployedStacks() []StackSummary        { return nil }
func (p *t2v2Provider) GetStackHDDMounts(n string) []string       { return p.mounts[n] }
func (p *t2v2Provider) GetStackHDDPath(string) string             { return p.hdd }
func (p *t2v2Provider) GetDockerVolumes(string) []string          { return nil }
func (p *t2v2Provider) StopStack(string) error                    { return nil }
func (p *t2v2Provider) StartStack(string) error                   { return nil }
func (p *t2v2Provider) RefreshAndIsRunning(string) bool           { return true }
func (p *t2v2Provider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) {
	return RecoveryInfo{}, false
}
func (p *t2v2Provider) RecoverStackSecrets(string, []string) map[string]string { return nil }
func (p *t2v2Provider) RecreateStackDefinitionFromUnit(string, string, map[string]string) error {
	return nil
}
func (p *t2v2Provider) StartStackServices(string, []string) error { return nil }
func (p *t2v2Provider) GetStackClassifiedBinds(n string) ([]ClassifiedBind, bool) {
	return p.binds[n], p.has[n]
}

// newTier2V2 builds a Manager with a source drive (holding a recovery unit) + a registered off-drive
// target, a real (copyTree) mirror seam so the v2 dest tree forms on disk, and the SSD headroom seam
// forced to "fits" so the state-only branch is testable on any platform.
func newTier2V2(t *testing.T, stack string) (m *Manager, src, target string, prov *t2v2Provider) {
	t.Helper()
	tmp := t.TempDir()
	src = filepath.Join(tmp, "usb")
	target = filepath.Join(tmp, "flash")
	sys := filepath.Join(tmp, "sys")
	sett, err := settings.Load(filepath.Join(tmp, "settings.json"), log.New(io.Discard, "", 0))
	if err != nil {
		t.Fatal(err)
	}
	if err := sett.AddStoragePath(settings.StoragePath{Path: target, Label: "flash", Schedulable: true}); err != nil {
		t.Fatal(err)
	}
	mustWrite(t, filepath.Join(RecoveryUnitPath(src, stack), "manifest.json"), "{}")
	prov = &t2v2Provider{hdd: src, mounts: map[string][]string{}, binds: map[string][]ClassifiedBind{}, has: map[string]bool{}}
	cfg := &config.Config{}
	cfg.Paths.SystemDataPath = sys
	m = NewManager(cfg, sett, log.New(io.Discard, "", 0))
	m.stackProvider = prov
	m.systemDataPath = sys
	m.tier2Mirror = copyTree
	m.tier2SSDFits = func(string, int64) bool { return true }
	m.samePhysicalDevice = oneDrivePerSubtree // src/target/sys are separate "drives" on one filesystem
	return m, src, target, prov
}

func mHDD(rel string) ClassifiedBind {
	return ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootHDD, RelPath: rel}, Class: appbackup.ClassMandatory}
}
func oUD(rel string) ClassifiedBind {
	return ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootUserdata, RelPath: rel, ReadOnly: true}, Class: appbackup.ClassOptional}
}
func xHDD(rel string) ClassifiedBind {
	return ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootHDD, RelPath: rel}, Class: appbackup.ClassExcluded}
}
func exists(p string) bool { _, err := os.Stat(p); return err == nil }

// A — classified paperless: v2 tree, export ABSENT (copy shrinks), marker written.
func TestTier2V2_ClassifiedPaperless(t *testing.T) {
	m, src, target, prov := newTier2V2(t, "paperless-ngx")
	prov.has["paperless-ngx"] = true
	prov.binds["paperless-ngx"] = []ClassifiedBind{mHDD("appdata/paperless/media"), xHDD("appdata/paperless/export"), xHDD("import/paperless")}
	// mounts point at the whole appdata/paperless dir — the LEGACY resolver set (captured only if a bug
	// routes a classified app through it; the A red-proof). The classified path ignores mounts.
	prov.mounts["paperless-ngx"] = []string{filepath.Join(src, "appdata", "paperless", "media"), filepath.Join(src, "appdata", "paperless", "export")}
	mustWrite(t, filepath.Join(src, "appdata", "paperless", "media", "doc.pdf"), "PDF")
	mustWrite(t, filepath.Join(src, "appdata", "paperless", "export", "junk.zip"), "ZIP")

	if err := m.RunTier2("paperless-ngx"); err != nil {
		t.Fatalf("RunTier2: %v", err)
	}
	destBase := filepath.Join(target, "backups", "secondary", "paperless-ngx")
	if got, _ := os.ReadFile(filepath.Join(destBase, tier2LayoutMarker)); string(got) != "2" {
		t.Errorf("marker = %q, want 2", got)
	}
	if !exists(filepath.Join(destBase, "hdd", "appdata", "paperless", "media", "doc.pdf")) {
		t.Error("mandatory media leg missing from v2 dest")
	}
	if exists(filepath.Join(destBase, "hdd", "appdata", "paperless", "export")) {
		t.Error("excluded export must NOT appear in the tier-2 copy (legacy set leaking)")
	}
	if !exists(filepath.Join(destBase, "recovery-unit", "manifest.json")) {
		t.Error("recovery-unit leg missing")
	}
}

// B — legacy: resolver dirs as legs, N>1 works (no refusal).
func TestTier2V2_LegacyMultiDir(t *testing.T) {
	m, src, target, prov := newTier2V2(t, "twodir")
	prov.has["twodir"] = false
	prov.mounts["twodir"] = []string{filepath.Join(src, "appdata", "alpha", "x"), filepath.Join(src, "appdata", "beta", "y")}
	mustWrite(t, filepath.Join(src, "appdata", "alpha", "a.txt"), "A")
	mustWrite(t, filepath.Join(src, "appdata", "beta", "b.txt"), "B")

	if err := m.RunTier2("twodir"); err != nil {
		t.Fatalf("RunTier2 must SUCCEED for N>1 now (refusal lifted): %v", err)
	}
	destBase := filepath.Join(target, "backups", "secondary", "twodir")
	if !exists(filepath.Join(destBase, "hdd", "appdata", "alpha", "a.txt")) || !exists(filepath.Join(destBase, "hdd", "appdata", "beta", "b.txt")) {
		t.Error("both legacy appdata dirs must be mirrored as separate v2 legs")
	}
	if cd := m.settings.GetCrossDriveConfig("twodir"); cd == nil || cd.LastStatus != "ok" {
		t.Errorf("want recorded ok, got %+v", cd)
	}
}

// C — migration = rebuild, marker LAST; a leg failure leaves NO marker.
func TestTier2V2_MigrationAndMarkerLast(t *testing.T) {
	m, src, target, prov := newTier2V2(t, "app")
	prov.has["app"] = true
	prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/data")}
	mustWrite(t, filepath.Join(src, "appdata", "app", "data", "f"), "F")
	destBase := filepath.Join(target, "backups", "secondary", "app")
	// Pre-v2 dest: recovery-unit + old flat appdata, NO marker.
	mustWrite(t, filepath.Join(destBase, "recovery-unit", "old.json"), "{}")
	mustWrite(t, filepath.Join(destBase, "appdata", "stale.bin"), "STALE")

	if err := m.RunTier2("app"); err != nil {
		t.Fatalf("RunTier2: %v", err)
	}
	if exists(filepath.Join(destBase, "appdata")) {
		t.Error("old flat appdata/ must be removed on migration")
	}
	if !exists(filepath.Join(destBase, "hdd", "appdata", "app", "data", "f")) {
		t.Error("v2 legs must be mirrored")
	}
	if got, _ := os.ReadFile(filepath.Join(destBase, tier2LayoutMarker)); string(got) != "2" {
		t.Errorf("marker not written LAST: %q", got)
	}

	// Marker-LAST on failure: a fresh app whose leg mirror fails must leave NO marker.
	m2, src2, target2, prov2 := newTier2V2(t, "boom")
	prov2.has["boom"] = true
	prov2.binds["boom"] = []ClassifiedBind{mHDD("appdata/boom/x")}
	mustWrite(t, filepath.Join(src2, "appdata", "boom", "x", "f"), "F")
	m2.tier2Mirror = func(s, d string) error {
		if strings.Contains(d, "boom") && strings.Contains(d, filepath.Join("hdd", "appdata")) {
			return os.ErrPermission // fail the LEG (not the unit)
		}
		return copyTree(s, d)
	}
	if err := m2.RunTier2("boom"); err == nil {
		t.Fatal("a leg mirror failure must surface as an error")
	}
	if exists(filepath.Join(target2, "backups", "secondary", "boom", tier2LayoutMarker)) {
		t.Error("marker must NOT be written when a leg fails (self-heal next run)")
	}
}

// D — reconcile: stale dir removed, ancestor/descendant kept; + the safe-remove boundary proof.
func TestTier2V2_Reconcile(t *testing.T) {
	m, src, target, prov := newTier2V2(t, "app")
	prov.has["app"] = true
	prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/media")}
	mustWrite(t, filepath.Join(src, "appdata", "app", "media", "keep.jpg"), "J")
	destBase := filepath.Join(target, "backups", "secondary", "app")
	// Seed the dest with the marker + stale dirs a prior run left.
	mustWrite(t, filepath.Join(destBase, tier2LayoutMarker), "2")
	mustWrite(t, filepath.Join(destBase, "hdd", "appdata", "app", "media", "covers", "c.jpg"), "C") // descendant of leg → keep
	mustWrite(t, filepath.Join(destBase, "hdd", "appdata", "app", "export", "e.bin"), "E")          // sibling of leg → stale
	mustWrite(t, filepath.Join(destBase, "hdd", "appdata", "other", "o.bin"), "O")                  // unrelated → stale

	if err := m.RunTier2("app"); err != nil {
		t.Fatalf("RunTier2: %v", err)
	}
	if !exists(filepath.Join(destBase, "hdd", "appdata", "app", "media", "covers", "c.jpg")) {
		t.Error("descendant content under a leg must be kept")
	}
	if exists(filepath.Join(destBase, "hdd", "appdata", "app", "export")) {
		t.Error("stale sibling dir (re-classed/removed bind) must be reconciled away")
	}
	if exists(filepath.Join(destBase, "hdd", "appdata", "other")) {
		t.Error("unrelated stale dir must be reconciled away")
	}

	// Boundary proof: tier2SafeRemove refuses a target outside backups/secondary/.
	if err := tier2SafeRemove(destBase, filepath.Join(target, "live-appdata")); err == nil {
		t.Error("tier2SafeRemove must REFUSE a target outside destBase")
	}
	if err := tier2SafeRemove("/some/other/root", filepath.Join("/some/other/root", "x")); err == nil {
		t.Error("tier2SafeRemove must REFUSE a destBase not under backups/secondary/")
	}
}

// E — SSD state-only: optional legs dropped, mandatory kept, honest warning.
func TestTier2V2_SSDStateOnly(t *testing.T) {
	m, src, target, prov := newTier2V2(t, "app")
	// Remove the off-drive data-drive target so the auto-pick falls to the SSD (state-only).
	_ = target
	_ = m.settings.RemoveStoragePath(target)
	prov.has["app"] = true
	prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/state"), oUD("media/pics")}
	mustWrite(t, filepath.Join(src, "appdata", "app", "state", "db.sql"), "SQL")
	mustWrite(t, filepath.Join(src, "userdata", "media", "pics", "p.jpg"), "J")

	if err := m.RunTier2("app"); err != nil {
		t.Fatalf("RunTier2: %v", err)
	}
	// The SSD dest is under the system data path (NamespaceRoot(sys, false) → sys/felhom-data).
	destBase := filepath.Join(NamespaceRoot(m.systemDataPath, false), "backups", "secondary", "app")
	if !exists(filepath.Join(destBase, "hdd", "appdata", "app", "state", "db.sql")) {
		t.Error("mandatory state leg must be on the SSD")
	}
	if exists(filepath.Join(destBase, "userdata", "media", "pics")) {
		t.Error("optional leg must NOT be copied to the SSD (state-only tier)")
	}
	cd := m.settings.GetCrossDriveConfig("app")
	if cd == nil || !strings.Contains(cd.LastWarning, "választható tartalom nem került másolásra") {
		t.Errorf("state-only warning missing: %+v", cd)
	}
}

// F1/F2 — network exclusion, pinned AND auto (two separate assertions).
func TestTier2V2_NetworkExclusion(t *testing.T) {
	// F2 (auto): an NFS candidate registered BEFORE a real local drive → the NFS is skipped and the
	// local drive wins (NFS-first ordering makes this a genuine red-proof of the auto IsNetwork skip).
	t.Run("auto skips network, picks local", func(t *testing.T) {
		m, _, fixtureTarget, _ := newTier2V2(t, "app")
		_ = m.settings.RemoveStoragePath(fixtureTarget) // drop the fixture drive, re-add after the NAS
		nas := "/mnt/nas"
		if err := m.settings.AddStoragePath(settings.StoragePath{Path: nas, Label: "nas", Schedulable: true, Kind: settings.StorageKindNetwork, Protocol: "nfs"}); err != nil {
			t.Fatal(err)
		}
		local := t.TempDir()
		if err := m.settings.AddStoragePath(settings.StoragePath{Path: local, Label: "local", Schedulable: true}); err != nil {
			t.Fatal(err)
		}
		target, err := m.selectTier2Target("app", 1<<20, 1<<20)
		if err != nil {
			t.Fatalf("selectTier2Target: %v", err)
		}
		if strings.Contains(target.NamespaceRoot, "nas") {
			t.Errorf("the NFS path (iterated first) must be skipped, got %q", target.NamespaceRoot)
		}
		if target.NamespaceRoot != filepath.FromSlash(local) {
			t.Errorf("want the local drive %q, got %q", local, target.NamespaceRoot)
		}
	})
	// F2b: NAS is the ONLY off-disk candidate → the honest network reason.
	t.Run("network-only → honest reason", func(t *testing.T) {
		m, _, target, _ := newTier2V2(t, "app")
		_ = m.settings.RemoveStoragePath(target) // drop the local off-drive target
		nas := "/mnt/nas"
		if err := m.settings.AddStoragePath(settings.StoragePath{Path: nas, Label: "nas", Schedulable: true, Kind: settings.StorageKindNetwork, Protocol: "nfs"}); err != nil {
			t.Fatal(err)
		}
		m.systemDataPath = "" // no SSD either → network is the only candidate
		_, err := m.selectTier2Target("app", 1<<20, 1<<20)
		if err == nil || !strings.Contains(err.Error(), "Hálózati tároló nem lehet") {
			t.Fatalf("network-only must refuse with the honest reason, got %v", err)
		}
	})
	// F1 (pinned): a pinned NFS target is invalid → falls through to auto (the local drive).
	t.Run("pinned network invalid → falls through", func(t *testing.T) {
		m, _, target, _ := newTier2V2(t, "app")
		nas := "/mnt/nas"
		if err := m.settings.AddStoragePath(settings.StoragePath{Path: nas, Label: "nas", Schedulable: true, Kind: settings.StorageKindNetwork, Protocol: "nfs"}); err != nil {
			t.Fatal(err)
		}
		if err := m.settings.SetTier2Preference("app", false, nas); err != nil {
			t.Fatal(err)
		}
		got, err := m.selectTier2Target("app", 1<<20, 1<<20)
		if err != nil {
			t.Fatalf("selectTier2Target: %v", err)
		}
		if got.NamespaceRoot != filepath.FromSlash(target) {
			t.Errorf("pinned NFS must fall through to the local auto-pick %q, got %q", target, got.NamespaceRoot)
		}
	})
}

// G2 — restore refuses a pre-v2 (no marker) copy.
func TestTier2V2_RestoreRefusesOldLayout(t *testing.T) {
	m, fake, _, destDrive := newT2RManager(t)
	_ = fake
	// Remove the marker the v2 fixture wrote → simulate an old flat copy.
	if err := os.Remove(filepath.Join(destDrive, "backups", "secondary", "app", tier2LayoutMarker)); err != nil {
		t.Fatal(err)
	}
	m.restoreFilesCopier = func(string, string) (int, error) {
		t.Fatal("copier must not run on an old-layout refusal")
		return 0, nil
	}
	if _, err := m.RestoreTier2Files("app"); err == nil || !strings.Contains(err.Error(), "régi formátumú") {
		t.Fatalf("restore must refuse a pre-v2 copy with the marker-refusal string, got %v", err)
	}
}

// H — a structurally-refused MANDATORY path is a loud gap in the app's warning.
func TestTier2V2_CaptureGapLoud(t *testing.T) {
	m, src, _, prov := newTier2V2(t, "app")
	prov.has["app"] = true
	prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/ok"), mHDD("../evil")} // traversal → Skipped
	mustWrite(t, filepath.Join(src, "appdata", "app", "ok", "f"), "F")

	if err := m.RunTier2("app"); err != nil {
		t.Fatalf("RunTier2: %v", err)
	}
	cd := m.settings.GetCrossDriveConfig("app")
	if cd == nil || !strings.Contains(cd.LastWarning, "nem kerültek a másodlagos mentésbe") {
		t.Errorf("capture-gap warning missing from tier-2 status: %+v", cd)
	}
}

// classifyTier2Rel keep/remove truth table (the reconcile core, pure).
func TestClassifyTier2Rel(t *testing.T) {
	legs := []string{"hdd/appdata/app/media", "userdata/media/books"}
	cases := []struct {
		rel  string
		want tier2RelClass
	}{
		{"hdd/appdata/app/media", tier2KeepInside},        // exact leg
		{"hdd/appdata/app/media/covers", tier2KeepInside}, // descendant
		{"hdd/appdata", tier2KeepAncestor},                // ancestor
		{"hdd/appdata/app", tier2KeepAncestor},            // ancestor
		{"hdd/appdata/app/export", tier2Stale},            // sibling → stale
		{"hdd/other", tier2Stale},                         // unrelated → stale
		{"userdata/media", tier2KeepAncestor},             // ancestor of userdata leg
	}
	for _, c := range cases {
		if got := classifyTier2Rel(c.rel, legs); got != c.want {
			t.Errorf("classifyTier2Rel(%q) = %d, want %d", c.rel, got, c.want)
		}
	}
}
