package appbackup

import (
	"os"
	"path/filepath"
	"slices"
	"strings"
	"testing"
)

// R-75 Scenario C — DETERMINISM. This is the P6 gate.
//
// The spike measured the naive map-order derivation producing 20 DISTINCT outputs from 20 identical
// runs. fbNeedsRecreate force-recreates FileBrowser on ANY byte difference in the generated config,
// and SyncFileBrowserMounts has ~14 call sites — so a non-deterministic skeleton is a fleet-wide
// FileBrowser restart loop, the v0.151-class bug. 20 identical generations or this fails.
func TestScenarioC_SkeletonDeterminism(t *testing.T) {
	// Deliberately UNSORTED input, with duplicates and a deep path, so the function has real work to
	// normalise. A sort applied only to the input would not save a map-ordered implementation.
	derived := []string{
		"media/podcasts", "roms", "media/books", "downloads", "media",
		"media/photos", "media/books", "a/b/c/d",
	}
	const n = 20
	first := BuildUserdataSkeleton(derived)
	for i := 1; i < n; i++ {
		got := BuildUserdataSkeleton(derived)
		if !slices.Equal(got, first) {
			t.Fatalf("generation %d/%d differs — a non-deterministic skeleton force-recreates FileBrowser on every sync pass\n first: %v\n got:   %v",
				i+1, n, first, got)
		}
	}
	if !slices.IsSorted(first) {
		t.Errorf("skeleton must be sorted, got %v", first)
	}
	// Ancestor expansion: a deep derived path implies its whole chain.
	for _, want := range []string{"a", "a/b", "a/b/c", "a/b/c/d"} {
		if !slices.Contains(first, want) {
			t.Errorf("ancestor chain incomplete: %q missing from %v", want, first)
		}
	}
	// Dedup: "media/books" appeared twice in the input and "media" both derived and as an ancestor.
	for _, d := range []string{"media", "media/books"} {
		if c := countOf(first, d); c != 1 {
			t.Errorf("%q appears %d times, want exactly 1", d, c)
		}
	}
}

func countOf(xs []string, want string) int {
	n := 0
	for _, x := range xs {
		if x == want {
			n++
		}
	}
	return n
}

// R-75 Scenario D — ZERO REMOVALS, proven by construction.
//
// The derived set drops `documents` (implied by no catalog app) and, after the R-75 move, the two
// import/* entries. The carry-list is what keeps them. This asserts the merged set is a strict
// SUPERSET of the historical hardcoded skeleton for any derived input — including the empty one, the
// fresh-box case where the catalog has not synced yet.
func TestScenarioD_SkeletonNeverDropsACarriedDir(t *testing.T) {
	for _, derived := range [][]string{
		nil,                                   // fresh box, catalog not yet synced
		{"media/podcasts"},                    // the one genuinely new entry
		{"roms", "downloads", "media/photos"}, // a partial catalog
	} {
		got := BuildUserdataSkeleton(derived)
		for _, carried := range UserdataSkeletonCarry() {
			if !slices.Contains(got, carried) {
				t.Errorf("derived=%v: carried dir %q was DROPPED — zero-removals violated", derived, carried)
			}
		}
	}
	// And the new entry really is added when the catalog implies it.
	if !slices.Contains(BuildUserdataSkeleton([]string{"media/podcasts"}), "media/podcasts") {
		t.Error("media/podcasts must be added when the catalog implies it")
	}
	// `documents` is the specific entry the spike flagged: in the carry-list, in no catalog app.
	if !slices.Contains(BuildUserdataSkeleton([]string{"media/podcasts"}), "documents") {
		t.Error("`documents` must survive — it exists on both demo boxes and may hold customer files")
	}
}

// A traversal or absolute entry reaching the skeleton would make EnsureUserdataSkeleton create a
// directory outside the userdata root. The derived set comes from a compose parser, so this is a
// guard on untrusted-ish catalog input, not defence in depth.
func TestSkeletonRefusesEscapes(t *testing.T) {
	got := BuildUserdataSkeleton([]string{"../escape", "..", "", "/abs/path", "ok/dir"})
	for _, bad := range []string{"../escape", "..", "", "/abs/path"} {
		if slices.Contains(got, bad) {
			t.Errorf("escape entry %q must not reach the skeleton: %v", bad, got)
		}
	}
	for _, d := range got {
		if filepath.IsAbs(d) || d == ".." || len(d) > 3 && d[:3] == "../" {
			t.Errorf("unsafe skeleton entry %q", d)
		}
	}
	if !slices.Contains(got, "ok/dir") {
		t.Error("a legitimate entry alongside bad ones must still be kept")
	}
	// "/abs/path" is not dropped outright — it is normalised to a relative path and kept, which is
	// safe (it lands under the userdata root). Pin that so the behaviour is a decision, not a guess.
	if !slices.Contains(got, "abs/path") {
		t.Errorf("an absolute entry should be normalised to relative, got %v", got)
	}
}

// EnsureUserdataSkeleton creates every dir it is given and NOTHING ELSE, and never removes.
func TestEnsureUserdataSkeletonCreatesOnly(t *testing.T) {
	ns := t.TempDir()
	// A pre-existing customer dir that no catalog app implies and the carry-list does not contain.
	stray := filepath.Join(UserdataDir(ns), "sajat-mappa")
	if err := os.MkdirAll(stray, 0o755); err != nil {
		t.Fatal(err)
	}
	dirs := BuildUserdataSkeleton([]string{"media/podcasts"})
	if err := EnsureUserdataSkeleton(ns, dirs); err != nil {
		// chown to gid 1000 fails for a non-root test user; the dirs are still created.
		t.Logf("EnsureUserdataSkeleton returned %v (expected when not running as root)", err)
	}
	for _, d := range dirs {
		if fi, err := os.Stat(filepath.Join(UserdataDir(ns), d)); err != nil || !fi.IsDir() {
			t.Errorf("skeleton dir %q not created: %v", d, err)
		}
	}
	if _, err := os.Stat(stray); err != nil {
		t.Errorf("a pre-existing customer dir was removed — zero-removals violated: %v", err)
	}
}

// R-75: a DATA drive must never get a per-drive drop-zone from the skeleton. Carrying the old
// `import/*` entries would have the skeleton re-create a dead lookalike on every drive forever —
// one that is also never backed up, since import paths are class: excluded.
//
// This is NOT a zero-removals violation: nothing deletes the dirs a box already has (see
// TestEnsureUserdataSkeletonCreatesOnly). They stop being maintained and stop appearing on fresh boxes.
func TestSkeletonNeverCreatesAPerDriveDropZone(t *testing.T) {
	// The catalog no longer implies any ${USERDATA_PATH}/import path — the binds moved to
	// ${IMPORT_PATH} — so the only way one could appear is via the carry-list.
	for _, derived := range [][]string{nil, {"media/podcasts", "roms"}} {
		for _, d := range BuildUserdataSkeleton(derived) {
			if d == "import" || strings.HasPrefix(d, "import/") {
				t.Errorf("derived=%v: skeleton created a per-drive drop-zone %q — the canonical root is on the SYSTEM drive", derived, d)
			}
		}
	}
	for _, c := range UserdataSkeletonCarry() {
		if c == "import" || strings.HasPrefix(c, "import/") {
			t.Errorf("the carry-list still holds %q", c)
		}
	}
	// A catalog app that genuinely declares a ${USERDATA_PATH}/import/... bind would still be
	// honoured — the rule is "don't carry them", not "filter them out".
	if !slices.Contains(BuildUserdataSkeleton([]string{"import/valami"}), "import/valami") {
		t.Error("a genuinely derived userdata import path must still be created")
	}
}
