package appbackup

import (
	"path"
	"reflect"
	"testing"
)

// absList extracts the sorted Abs slice from a CaptureSet's Paths (Paths is already Abs-sorted).
func absList(cs CaptureSet) []string {
	out := make([]string, 0, len(cs.Paths))
	for _, p := range cs.Paths {
		out = append(out, p.Abs)
	}
	return out
}

// classOfAbs finds the resolved class for an Abs in a CaptureSet (empty if absent).
func classOfAbs(cs CaptureSet, abs string) BindClass {
	for _, p := range cs.Paths {
		if p.Abs == abs {
			return p.Class
		}
	}
	return ""
}

const drv = "/mnt/drv"

func hdd(p string) string  { return path.Join(drv, p) }
func udat(p string) string { return path.Join(drv, "userdata", p) }

// --- Group A (Scenario A): classified per-tier split, immich shape ---

func TestComputeCaptureSet_PerTierSplit(t *testing.T) {
	binds := []ClassifiedBind{
		{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/immich"}, Class: ClassMandatory, Origin: OriginExplicit},
		{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media/photos", ReadOnly: true}, Class: ClassOptional, Origin: OriginExplicit},
	}

	off := ComputeCaptureSet(binds, true, TierOffsite, drv, "")
	if got, want := absList(off), []string{hdd("appdata/immich")}; !reflect.DeepEqual(got, want) {
		t.Errorf("offsite Paths = %v, want %v (mandatory only — the :ro optional must NOT ship offsite)", got, want)
	}

	sec := ComputeCaptureSet(binds, true, TierSecondary, drv, "")
	want := []string{hdd("appdata/immich"), udat("media/photos")}
	if got := absList(sec); !reflect.DeepEqual(got, want) {
		t.Errorf("secondary Paths = %v, want %v (sorted)", got, want)
	}
	// each CapturePath carries its originating identity
	for _, p := range sec.Paths {
		switch p.Abs {
		case hdd("appdata/immich"):
			if p.Root != RootHDD || p.RelPath != "appdata/immich" || p.Class != ClassMandatory {
				t.Errorf("immich CapturePath identity = %+v", p)
			}
		case udat("media/photos"):
			if p.Root != RootUserdata || p.RelPath != "media/photos" || p.Class != ClassOptional {
				t.Errorf("photos CapturePath identity = %+v", p)
			}
		}
	}
}

// --- Group B (Scenario B): legacy inertness — THE single most important test (SQ5 guard) ---

func TestComputeCaptureSet_LegacyInert(t *testing.T) {
	// A legacy app still has binds (the parser returns them), but with no class and origin=legacy.
	binds := []ClassifiedBind{
		{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media/tv"}, Origin: OriginLegacy},
		{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/sonarr"}, Origin: OriginLegacy},
	}
	for _, tier := range []CaptureTier{TierOffsite, TierSecondary} {
		cs := ComputeCaptureSet(binds, false, tier, drv, "")
		if cs.HasClassification {
			t.Errorf("%s: HasClassification=true for a legacy app", tier)
		}
		if cs.Paths != nil {
			t.Errorf("%s: legacy app resolved Paths=%v — MUST be nil (unmigrated-sonarr-ships-its-TV-library regression)", tier, cs.Paths)
		}
		if cs.Skipped != nil {
			t.Errorf("%s: legacy app Skipped=%v — MUST be nil", tier, cs.Skipped)
		}
	}
}

// --- Group C (Scenario C): excluded is invisible — not in Paths, not in Skipped ---

func TestComputeCaptureSet_ExcludedInvisible(t *testing.T) {
	binds := []ClassifiedBind{
		{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/paperless/media"}, Class: ClassMandatory, Origin: OriginExplicit},
		{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/paperless/export"}, Class: ClassExcluded, Origin: OriginExplicit},
		{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "import/paperless"}, Class: ClassExcluded, Origin: OriginExplicit},
	}
	for _, tier := range []CaptureTier{TierOffsite, TierSecondary} {
		cs := ComputeCaptureSet(binds, true, tier, drv, "")
		if got, want := absList(cs), []string{hdd("appdata/paperless/media")}; !reflect.DeepEqual(got, want) {
			t.Errorf("%s Paths = %v, want %v (excluded filtered)", tier, got, want)
		}
		if len(cs.Skipped) != 0 {
			t.Errorf("%s: excluded binds must NOT appear in Skipped, got %v", tier, cs.Skipped)
		}
	}
}

// --- Group D (Scenario D): structural guards + allowed bare-userdata + legit a..b name ---

func TestComputeCaptureSet_StructuralGuards(t *testing.T) {
	binds := []ClassifiedBind{
		{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "../evil"}, Class: ClassMandatory, Origin: OriginDefaultWritable},           // d1 traversal
		{ComposeBind: ComposeBind{Root: RootHDD, RelPath: ""}, Class: ClassMandatory, Origin: OriginDefaultWritable},                  // d2 bare hdd root
		{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "backups/primary/x"}, Class: ClassMandatory, Origin: OriginDefaultWritable}, // d3 reserved zone
		{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: ""}, Class: ClassMandatory, Origin: OriginDefaultWritable},             // d4 bare userdata — ALLOWED
	}
	cs := ComputeCaptureSet(binds, true, TierOffsite, drv, "")

	// Paths: ONLY d4's userdata root — no escaped root, no backups/ anywhere.
	if got, want := absList(cs), []string{udat("")}; !reflect.DeepEqual(got, want) {
		t.Errorf("Paths = %v, want %v (only the allowed bare-userdata)", got, want)
	}
	for _, p := range cs.Paths {
		if p.Abs == "/mnt/evil" {
			t.Fatal("escaped-root Abs present in Paths — traversal guard failed")
		}
		if containsSeg(p.Abs, "backups") {
			t.Fatalf("reserved backups/ path present in Paths: %s", p.Abs)
		}
	}

	// Skipped: d1,d2,d3 each with a DISTINCT reason naming its rule.
	reasons := map[string]string{} // "<root>/<rel>" -> reason
	for _, s := range cs.Skipped {
		reasons[string(s.Root)+"/"+s.RelPath] = s.Reason
	}
	if len(cs.Skipped) != 3 {
		t.Fatalf("want 3 skipped, got %d: %+v", len(cs.Skipped), cs.Skipped)
	}
	if reasons["hdd/../evil"] != reasonEscape {
		t.Errorf("../evil reason = %q, want %q", reasons["hdd/../evil"], reasonEscape)
	}
	if reasons["hdd/"] != reasonBareRoot {
		t.Errorf("bare-hdd reason = %q, want %q", reasons["hdd/"], reasonBareRoot)
	}
	if reasons["hdd/backups/primary/x"] != reasonReserved {
		t.Errorf("backups reason = %q, want %q", reasons["hdd/backups/primary/x"], reasonReserved)
	}
	// distinctness
	if reasonEscape == reasonBareRoot || reasonBareRoot == reasonReserved || reasonEscape == reasonReserved {
		t.Error("guard reasons are not distinct")
	}
}

// TestComputeCaptureSet_LegitDotDotName: a component literally named "a..b" is NOT traversal.
func TestComputeCaptureSet_LegitDotDotName(t *testing.T) {
	binds := []ClassifiedBind{
		{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/a..b"}, Class: ClassMandatory, Origin: OriginExplicit},
	}
	cs := ComputeCaptureSet(binds, true, TierOffsite, drv, "")
	if got, want := absList(cs), []string{hdd("appdata/a..b")}; !reflect.DeepEqual(got, want) {
		t.Errorf("Paths = %v, want %v (a..b is a legit name, not traversal)", got, want)
	}
	if len(cs.Skipped) != 0 {
		t.Errorf("a..b must not be skipped, got %v", cs.Skipped)
	}
}

// --- Group E (Scenario E): containment dedup + equal-Abs mandatory-wins + determinism ---

func TestComputeCaptureSet_ContainmentAndCollision(t *testing.T) {
	binds := []ClassifiedBind{
		{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/paperless"}, Class: ClassMandatory, Origin: OriginExplicit},
		{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/paperless/media"}, Class: ClassMandatory, Origin: OriginExplicit}, // descendant
		{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "userdata/media"}, Class: ClassOptional, Origin: OriginExplicit},           // Abs collides with next
		{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media"}, Class: ClassMandatory, Origin: OriginExplicit},              // same Abs, mandatory
	}
	cs := ComputeCaptureSet(binds, true, TierSecondary, drv, "")

	want := []string{hdd("appdata/paperless"), udat("media")}
	if got := absList(cs); !reflect.DeepEqual(got, want) {
		t.Errorf("Paths = %v, want %v (descendant dropped; two spellings collapsed)", got, want)
	}
	// mandatory beats optional on the equal-Abs collision
	if c := classOfAbs(cs, udat("media")); c != ClassMandatory {
		t.Errorf("collapsed /userdata/media class = %q, want mandatory (mandatory must never degrade)", c)
	}

	// determinism: recompute and compare full struct
	cs2 := ComputeCaptureSet(binds, true, TierSecondary, drv, "")
	if !reflect.DeepEqual(cs, cs2) {
		t.Error("ComputeCaptureSet is non-deterministic across runs")
	}
}

// --- Group F (Scenario F): cross-app overlap advisory (pure) ---

func TestCrossAppOverlaps(t *testing.T) {
	X, Y, Z := "/mnt/drv/x", "/mnt/drv/y", "/mnt/drv/z"
	sets := map[string]CaptureSet{
		"appA": {HasClassification: true, Paths: []CapturePath{{Abs: X}, {Abs: Y}}},
		"appB": {HasClassification: true, Paths: []CapturePath{{Abs: Y}}},
		"appC": {HasClassification: true, Paths: []CapturePath{{Abs: Z}}},
	}
	got := CrossAppOverlaps(sets)
	want := []Overlap{{Abs: Y, Apps: []string{"appA", "appB"}}}
	if !reflect.DeepEqual(got, want) {
		t.Errorf("CrossAppOverlaps = %+v, want %+v", got, want)
	}

	// exact-match only: cross-app CONTAINMENT is legitimate, must NOT report.
	cont := map[string]CaptureSet{
		"plex":        {Paths: []CapturePath{{Abs: "/mnt/drv/userdata/media"}}},
		"calibre-web": {Paths: []CapturePath{{Abs: "/mnt/drv/userdata/media/books"}}},
	}
	if got := CrossAppOverlaps(cont); len(got) != 0 {
		t.Errorf("containment across apps must NOT report an overlap, got %+v", got)
	}

	// empty input → empty (non-nil) slice, not a flaky nil
	if got := CrossAppOverlaps(map[string]CaptureSet{}); got == nil || len(got) != 0 {
		t.Errorf("empty input → empty non-nil slice, got %#v", got)
	}
}

// containsSeg reports whether abs has seg as a path component (test helper).
func containsSeg(abs, seg string) bool {
	for _, s := range splitSlash(abs) {
		if s == seg {
			return true
		}
	}
	return false
}

func splitSlash(s string) []string {
	var out []string
	cur := ""
	for _, r := range s {
		if r == '/' {
			out = append(out, cur)
			cur = ""
			continue
		}
		cur += string(r)
	}
	return append(out, cur)
}
