package stacks

import (
	"os"
	"path/filepath"
	"testing"

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

// R-75 Scenario B — THE regression gate for moving an ingest bind to ${IMPORT_PATH}.
//
// ValidateBackupSpec rejects an entry whose (Root, RelPath) matches NO compose bind, and the
// rejection is WHOLE-BLOCK: LoadMetadata sets Backup back to nil and the app degrades to LEGACY.
// So moving paperless's consume bind to ${IMPORT_PATH} while its backup block still said
// `userdata: import/paperless` would discard the ENTIRE block — taking
// `hdd: appdata/paperless/media class: mandatory` with it. The customer's document originals would
// silently fall back to legacy handling. That is the collateral this test exists to catch.
//
// The fixtures below are copied VERBATIM from app-catalog-felhom.eu templates/paperless-ngx/ (the
// bind lines and the whole backup block). If the catalog changes them, this test must be updated in
// the same train — that coupling is the point, and Part-8 leg 1 re-checks it against the live
// catalog on a real box.

const importPaperlessCompose = `services:
  paperless-webserver:
    image: ghcr.io/paperless-ngx/paperless-ngx:2.18.4
    volumes:
      - paperless_data:/usr/src/paperless/data
      - ${HDD_PATH}/appdata/paperless/media:/usr/src/paperless/media
      - ${HDD_PATH}/appdata/paperless/export:/usr/src/paperless/export
      - ${IMPORT_PATH}/paperless:/usr/src/paperless/consume
volumes:
  paperless_data:
`

const importPaperlessMeta = `display_name: Paperless-ngx
slug: paperless-ngx
category: documents
backup:
  hdd:
    - path: appdata/paperless/media
      class: mandatory
    - path: appdata/paperless/export
      class: excluded
  import:
    - path: paperless
      class: excluded
`

// writeApp lays out a stack dir with a compose file and a .felhom.yml.
func writeApp(t *testing.T, compose, meta string) string {
	t.Helper()
	dir := t.TempDir()
	if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte(compose), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), []byte(meta), 0o644); err != nil {
		t.Fatal(err)
	}
	return dir
}

func TestScenarioB_ImportMoveKeepsClassification(t *testing.T) {
	dir := writeApp(t, importPaperlessCompose, importPaperlessMeta)

	m := LoadMetadata(dir)
	if m.Backup == nil {
		t.Fatal("backup block was WHOLE-BLOCK REJECTED (degraded to legacy) — the import move lost the app's classification")
	}
	binds := ParseComposeClassifiableBinds(filepath.Join(dir, "docker-compose.yml"))
	classified, has := appbackup.ClassifyBinds(m.Backup, binds)
	if !has {
		t.Fatal("hasClassification=false — the app degraded to legacy")
	}

	type key struct {
		root appbackup.BindRoot
		rel  string
	}
	got := map[key]appbackup.ClassifiedBind{}
	for _, c := range classified {
		got[key{c.Root, c.RelPath}] = c
	}

	want := []struct {
		root   appbackup.BindRoot
		rel    string
		class  appbackup.BindClass
		origin appbackup.ClassOrigin
		why    string
	}{
		{appbackup.RootImport, "paperless", appbackup.ClassExcluded, appbackup.OriginExplicit,
			"the moved ingest bind must classify under the import root"},
		{appbackup.RootHDD, "appdata/paperless/media", appbackup.ClassMandatory, appbackup.OriginExplicit,
			"THE COLLATERAL: a whole-block reject would silently drop this to legacy"},
		{appbackup.RootHDD, "appdata/paperless/export", appbackup.ClassExcluded, appbackup.OriginExplicit,
			"second hdd entry must survive too"},
	}
	for _, w := range want {
		c, ok := got[key{w.root, w.rel}]
		if !ok {
			t.Errorf("%s/%s: bind missing entirely — %s", w.root, w.rel, w.why)
			continue
		}
		if c.Class != w.class {
			t.Errorf("%s/%s: class = %q, want %q — %s", w.root, w.rel, c.Class, w.class, w.why)
		}
		if c.Origin != w.origin {
			t.Errorf("%s/%s: origin = %q, want %q — %s", w.root, w.rel, c.Origin, w.origin, w.why)
		}
	}
	// The explicit WRONG outcome from the scenario: nothing may report legacy.
	for _, c := range classified {
		if c.Origin == appbackup.OriginLegacy {
			t.Errorf("%s/%s reported origin=legacy — the block was rejected", c.Root, c.RelPath)
		}
	}
}

// The companion in the other direction: a STALE `userdata: import/paperless` entry against the new
// ${IMPORT_PATH} compose must be REFUSED, and refused WHOLE-BLOCK. This is the state the catalog
// would be in if Part 2 moved the compose bind but forgot the backup block — it proves the trap is
// real rather than hypothetical, and that the guard catches it rather than silently mis-classifying.
func TestScenarioB_StaleUserdataEntryIsWholeBlockRejected(t *testing.T) {
	staleMeta := `display_name: Paperless-ngx
slug: paperless-ngx
backup:
  hdd:
    - path: appdata/paperless/media
      class: mandatory
  userdata:
    - path: import/paperless
      class: excluded
`
	dir := writeApp(t, importPaperlessCompose, staleMeta)
	if m := LoadMetadata(dir); m.Backup != nil {
		t.Error("a stale userdata entry matching no compose bind must be whole-block rejected")
	}

	// And prove the consequence the scenario names, so the reject is not mistaken for harmless:
	// with the block gone, the mandatory hdd path loses its class and goes legacy.
	m := LoadMetadata(dir)
	binds := ParseComposeClassifiableBinds(filepath.Join(dir, "docker-compose.yml"))
	classified, has := appbackup.ClassifyBinds(m.Backup, binds)
	if has {
		t.Fatal("precondition: block should be nil here")
	}
	for _, c := range classified {
		if c.Root == appbackup.RootHDD && c.RelPath == "appdata/paperless/media" {
			if c.Origin != appbackup.OriginLegacy || c.Class != "" {
				t.Errorf("expected the collateral to be legacy/unclassed, got class=%q origin=%q", c.Class, c.Origin)
			}
		}
	}
}

// calibre-web carries BOTH an import bind and a userdata library bind — the multi-root case.
func TestScenarioB_CalibreBothRoots(t *testing.T) {
	compose := `services:
  calibre-web:
    image: crocodilestick/calibre-web-automated:v4.0.6
    volumes:
      - calibre_web_config:/config
      - ${IMPORT_PATH}/calibre:/cwa-book-ingest
      - ${USERDATA_PATH}/media/books:/calibre-library
volumes:
  calibre_web_config:
`
	meta := `display_name: Calibre-Web
slug: calibre-web
backup:
  userdata:
    - path: media/books
      class: mandatory
  import:
    - path: calibre
      class: excluded
`
	dir := writeApp(t, compose, meta)
	m := LoadMetadata(dir)
	if m.Backup == nil {
		t.Fatal("calibre-web backup block was whole-block rejected")
	}
	classified, has := appbackup.ClassifyBinds(m.Backup, ParseComposeClassifiableBinds(filepath.Join(dir, "docker-compose.yml")))
	if !has {
		t.Fatal("calibre-web degraded to legacy")
	}
	seen := map[string]appbackup.ClassifiedBind{}
	for _, c := range classified {
		seen[string(c.Root)+"/"+c.RelPath] = c
	}
	if c := seen["import/calibre"]; c.Class != appbackup.ClassExcluded || c.Origin != appbackup.OriginExplicit {
		t.Errorf("import/calibre: class=%q origin=%q, want excluded/explicit", c.Class, c.Origin)
	}
	if c := seen["userdata/media/books"]; c.Class != appbackup.ClassMandatory || c.Origin != appbackup.OriginExplicit {
		t.Errorf("userdata/media/books: class=%q origin=%q, want mandatory/explicit", c.Class, c.Origin)
	}
}

// The import root resolves against the SYSTEM drive, never the app's own drive. Two apps on two
// different drives must resolve their ingest folders to the SAME parent — the canonical property.
func TestImportBindResolvesToSystemDrive(t *testing.T) {
	const importRoot = "/mnt/sys_drive/felhom-data/userdata/import"
	binds := []appbackup.ClassifiedBind{
		{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootImport, RelPath: "paperless"},
			Class: appbackup.ClassExcluded, Origin: appbackup.OriginExplicit},
	}
	for _, hdd := range []string{"/mnt/felhom-drives/hdd_1", "/mnt/felhom-drives/nvme-1tb"} {
		fb := appbackup.ComputeFabBuckets(binds, true, hdd, importRoot)
		if len(fb.Excluded) != 1 {
			t.Fatalf("hdd=%s: expected 1 excluded bucket entry, got %d (skipped=%v)", hdd, len(fb.Excluded), fb.Skipped)
		}
		if got, want := fb.Excluded[0].Abs, importRoot+"/paperless"; got != want {
			t.Errorf("hdd=%s: import bind resolved to %q, want %q — it must NOT follow the app's drive", hdd, got, want)
		}
	}
	// Unresolvable import root ⇒ refused LOUDLY into Skipped, never joined onto "".
	fb := appbackup.ComputeFabBuckets(binds, true, "/mnt/felhom-drives/hdd_1", "")
	if len(fb.Excluded) != 0 {
		t.Errorf("an unresolvable import root must not resolve: %+v", fb.Excluded)
	}
	if len(fb.Skipped) != 1 {
		t.Fatalf("expected the bind in Skipped, got %+v", fb.Skipped)
	}
}

// R-75 Scenario A — the deploy belt puts the drop-zone on the SYSTEM drive and NOWHERE ELSE.
// The wrong outcome this guards is a second, non-functional import/<app> appearing on the data
// drive: it would look exactly like a drop-zone, silently do nothing, and (import being
// class: excluded) never be backed up either.
func TestScenarioA_BeltCreatesImportOnSystemDriveOnly(t *testing.T) {
	m := newMigManager(t, "")
	stackDir := writeApp(t, importPaperlessCompose, importPaperlessMeta)

	dataDrive := t.TempDir() // stands in for /mnt/felhom-drives/hdd_1
	sysNS := t.TempDir()     // stands in for /mnt/sys_drive/felhom-data
	userdataPath := appbackup.UserdataDir(dataDrive)
	importPath := appbackup.ImportDir(sysNS)
	m.isMountPoint = func(string) bool { return true } // the data drive is attached

	m.ensureUserdataMounts(stackDir, []string{
		"HDD_PATH=" + dataDrive,
		"USERDATA_PATH=" + userdataPath,
		"IMPORT_PATH=" + importPath,
	})

	// (1) the drop-zone exists on the SYSTEM drive
	want := filepath.Join(importPath, "paperless")
	fi, err := os.Stat(want)
	if err != nil || !fi.IsDir() {
		t.Fatalf("belt did not create the canonical drop-zone %s (%v)", want, err)
	}
	// (2) with the userdata convention: setgid + group-rwx
	if perm := fi.Mode().Perm(); perm != 0o775 || fi.Mode()&os.ModeSetgid == 0 {
		t.Errorf("drop-zone mode = %v, want setgid + 0775 (2775)", fi.Mode())
	}
	// (3) and NOT on the data drive — the wrong outcome named in the scenario
	if _, err := os.Stat(filepath.Join(userdataPath, "import")); err == nil {
		t.Errorf("a second drop-zone was created on the data drive at %s — exactly the dead drop-zone R-75 removes",
			filepath.Join(userdataPath, "import"))
	}
}

// A detached data drive must not stop the system-drive drop-zone from being created: the two roots
// are on different devices and the drive-absent gate is about the data drive only.
func TestScenarioA_ImportBeltNotGatedByDetachedDataDrive(t *testing.T) {
	m := newMigManager(t, "")
	stackDir := writeApp(t, importPaperlessCompose, importPaperlessMeta)
	dataDrive := t.TempDir()
	sysNS := t.TempDir()
	importPath := appbackup.ImportDir(sysNS)
	m.isMountPoint = func(string) bool { return false } // drive DETACHED

	m.ensureUserdataMounts(stackDir, []string{
		"HDD_PATH=" + dataDrive,
		"USERDATA_PATH=" + appbackup.UserdataDir(dataDrive),
		"IMPORT_PATH=" + importPath,
	})

	if _, err := os.Stat(filepath.Join(importPath, "paperless")); err != nil {
		t.Errorf("the system-drive drop-zone must be created even when the DATA drive is detached: %v", err)
	}
	// the userdata half stays correctly gated (nothing written onto the rootfs)
	if _, err := os.Stat(appbackup.UserdataDir(dataDrive)); err == nil {
		t.Error("the drive-absent gate must still suppress userdata creation on a detached drive")
	}
}

// R-75: the canonical drop-zone must never migrate with an app. Migrating an app OFF the system
// drive would otherwise drag <sysNS>/userdata/import onto the destination data drive — a second,
// non-functional, unbacked drop-zone.
func TestImportRootExcludedFromMigration(t *testing.T) {
	m := newMigManager(t, "")
	sysNS := appbackup.NamespaceRoot(m.cfg.Paths.SystemDataPath, false)
	importRoot := appbackup.ImportDir(sysNS)

	// App migrating OFF the system drive: source namespace IS the system namespace.
	offSystem := m.appDataSkipSet(&MigrationJob{SourceNS: sysNS, Apps: []string{"paperless-ngx"}})
	if !offSystem[filepath.Clean(importRoot)] {
		t.Errorf("import root %q must be pruned from a migration off the system drive; skip set = %v",
			importRoot, offSystem)
	}

	// App migrating OFF a data drive: no import root there, nothing extra to prune.
	dataNS := "/mnt/felhom-drives/hdd_1"
	offData := m.appDataSkipSet(&MigrationJob{SourceNS: dataNS, Apps: []string{"paperless-ngx"}})
	if offData[filepath.Clean(importRoot)] {
		t.Error("a data-drive migration must not carry a system-drive skip entry")
	}
}

// pathUnder must be segment-wise: a sibling sharing a name prefix is NOT contained.
func TestPathUnderIsSegmentWise(t *testing.T) {
	root := filepath.Clean("/mnt/sys_drive")
	if !pathUnder(root, root) {
		t.Error("a path must be under itself")
	}
	if !pathUnder(filepath.Join(root, "felhom-data", "userdata"), root) {
		t.Error("a descendant must be under the root")
	}
	if pathUnder(filepath.Clean("/mnt/sys_drive-evil/x"), root) {
		t.Error("a name-prefix sibling must NOT be under the root")
	}
}

// EnsureImportRoot must apply the convention to the import dir AND its parent userdata dir.
// Observed live on demo-felhom (v0.172.0 first cut): ensuring only the leaf left
// <sysNS>/userdata at 755 root:root, the one userdata root on the box outside the convention.
func TestEnsureImportRoot_ParentCarriesTheConvention(t *testing.T) {
	m := newMigManager(t, "")
	m.cfg.Paths.SystemDataPath = t.TempDir() // writable stand-in for /mnt/sys_drive

	if err := m.EnsureImportRoot(); err != nil {
		// chown to gid 1000 fails for a non-root test user; the modes still land.
		t.Logf("EnsureImportRoot returned %v (expected off-root)", err)
	}
	sysNS := appbackup.NamespaceRoot(m.cfg.Paths.SystemDataPath, false)
	for _, p := range []string{appbackup.UserdataDir(sysNS), appbackup.ImportDir(sysNS)} {
		fi, err := os.Stat(p)
		if err != nil {
			t.Fatalf("%s not created: %v", p, err)
		}
		if fi.Mode().Perm() != 0o775 || fi.Mode()&os.ModeSetgid == 0 {
			t.Errorf("%s mode = %v, want setgid + 0775 (2775)", p, fi.Mode())
		}
	}
}
