package backup

import (
	"encoding/json"
	"os"
	"path/filepath"
	"strings"
	"testing"

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

// R-7b Part 4 — shares restore. Scenario D's contract: files come back byte-identical into the LIVE
// share folder, a deleted definition reappears in the registry, a definition that still exists is
// left alone, and the place step never writes outside a registered live root.

// seedSharesScratch lays down a completed restore scratch: the payload (manifest + credential) plus
// a restored file tree for each named share, mirroring what restic's absolute-path restore produces.
func seedSharesScratch(t *testing.T, env *sharesEnv, mf SharesManifest, files map[string]string) string {
	t.Helper()
	scratch, _, err := env.m.sharesRestoreScratchDir()
	if err != nil {
		t.Fatal(err)
	}
	payload := scratchJoin(scratch, env.m.sharesPayloadDir())
	if err := os.MkdirAll(payload, 0o755); err != nil {
		t.Fatal(err)
	}
	blob := mustJSON(t, mf)
	if err := os.WriteFile(filepath.Join(payload, sharesManifestName), blob, 0o600); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(payload, sharesPassdbName), []byte("FAKE-PASSDB-TAR"), 0o600); err != nil {
		t.Fatal(err)
	}
	for livePath, content := range files {
		p := scratchJoin(scratch, livePath)
		if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
			t.Fatal(err)
		}
		if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	return scratch
}

func mustJSON(t *testing.T, v any) []byte {
	t.Helper()
	b, err := jsonMarshalIndent(v)
	if err != nil {
		t.Fatal(err)
	}
	return b
}

// wirePlaceSeams installs a real (missing-only) copier and a fake passdb restorer.
func wirePlaceSeams(env *sharesEnv, passdbCalls *int) {
	env.m.offboxPlaceCopier = func(src, dst string) (int, error) {
		n := 0
		err := filepath.Walk(src, func(p string, fi os.FileInfo, err error) error {
			if err != nil || fi.IsDir() {
				return err
			}
			rel, rErr := filepath.Rel(src, p)
			if rErr != nil {
				return rErr
			}
			target := filepath.Join(dst, rel)
			if _, sErr := os.Stat(target); sErr == nil {
				return nil // missing-only: never overwrite
			}
			b, rErr := os.ReadFile(p)
			if rErr != nil {
				return rErr
			}
			if mErr := os.MkdirAll(filepath.Dir(target), 0o755); mErr != nil {
				return mErr
			}
			n++
			return os.WriteFile(target, b, 0o644)
		})
		return n, err
	}
	env.m.sharesPassdbRestore = func([]byte) error { *passdbCalls++; return nil }
}

// Scenario D: the round trip. A deleted file returns byte-identical; a deleted definition reappears
// and triggers the samba re-render; a definition that still exists is KEPT (a restore must never
// silently flip a live share's settings).
func TestSharesRestoreRoundTrip(t *testing.T) {
	env := newSharesEnv(t, "hdd_1", "hdd_2")
	keptPath := env.addShare(t, "hdd_1", "marad", true)
	deletedPath := filepath.Join(env.drives["hdd_1"], "torolt")

	mf := SharesManifest{
		Version: sharesManifestVersion, ServerName: "FELHOM",
		Shares: []settings.SMBShare{
			// Still live — its definition must be kept, not overwritten (note the flipped ReadOnly:
			// if the merge preferred the snapshot, this would silently change a live share).
			{Name: "marad", Path: keptPath, ReadOnly: true, Offsite: false, CreatedAt: "2020-01-01T00:00:00Z"},
			// Deleted from the registry — must come back.
			{Name: "torolt", Path: deletedPath, Offsite: true, CreatedAt: "2026-07-18T00:00:00Z"},
		},
	}
	seedSharesScratch(t, env, mf, map[string]string{
		filepath.Join(deletedPath, "fontos.txt"): "EREDETI-TARTALOM",
		filepath.Join(keptPath, "marad.txt"):     "SNAPSHOT-VERZIO",
	})

	var reconciled, passdbCalls int
	env.m.SetSharesReconciler(func() error { reconciled++; return nil })
	wirePlaceSeams(env, &passdbCalls)

	res, err := env.m.PlaceSharesRestore(t.Context())
	if err != nil {
		t.Fatalf("PlaceSharesRestore: %v", err)
	}

	// 1. File back, byte-identical, in the LIVE folder.
	b, rErr := os.ReadFile(filepath.Join(deletedPath, "fontos.txt"))
	if rErr != nil || string(b) != "EREDETI-TARTALOM" {
		t.Errorf("restored file wrong/missing: %q, %v", b, rErr)
	}
	// 2. Missing-only: the live file must NOT be clobbered by the snapshot version.
	b, _ = os.ReadFile(filepath.Join(keptPath, "marad.txt"))
	if string(b) != "content-of-marad" {
		t.Errorf("a live file was overwritten by the restore: %q", b)
	}
	// 3. Deleted definition reappears; live definition kept unchanged.
	if !containsStr(res.DefinitionsAdded, "torolt") {
		t.Errorf("deleted definition did not reappear: %+v", res)
	}
	if !containsStr(res.DefinitionsKept, "marad") {
		t.Errorf("live definition should be reported as kept: %+v", res)
	}
	for _, sh := range env.sett.GetSMBShares() {
		if sh.Name == "marad" && sh.ReadOnly {
			t.Error("a restore silently flipped a LIVE share's ReadOnly setting")
		}
	}
	// 4. smb.conf re-rendered so the restored share is actually exported.
	if reconciled != 1 {
		t.Errorf("ReconcileSamba should run exactly once after adding definitions, ran %d", reconciled)
	}
	// 5. Credential restored best-effort.
	if passdbCalls != 1 || !res.PasswordRestored {
		t.Errorf("credential restore did not run: calls=%d result=%v", passdbCalls, res.PasswordRestored)
	}
}

// THE PLACE-GUARD RED-PROOF TARGET. A manifest whose share path escapes every registered live root
// must be REFUSED with zero writes. Red-proof: make liveShareRootOK return true unconditionally and
// this test fails (the traversal destination gets created).
func TestSharesRestoreRefusesDestinationOutsideLiveRoots(t *testing.T) {
	env := newSharesEnv(t, "hdd_1", "hdd_2")
	outside := filepath.Join(env.tmp, "kivul", "gonosz")
	traversal := filepath.Join(env.drives["hdd_1"], "..", "eszkeipel")

	mf := SharesManifest{
		Version: sharesManifestVersion, ServerName: "FELHOM",
		Shares: []settings.SMBShare{
			{Name: "kivul", Path: outside, Offsite: true, CreatedAt: "2026-07-18T00:00:00Z"},
			{Name: "traverz", Path: traversal, Offsite: true, CreatedAt: "2026-07-18T00:00:00Z"},
		},
	}
	seedSharesScratch(t, env, mf, map[string]string{
		filepath.Join(outside, "x.txt"):   "SHOULD-NEVER-LAND",
		filepath.Join(traversal, "y.txt"): "SHOULD-NEVER-LAND",
	})

	var passdbCalls int
	wirePlaceSeams(env, &passdbCalls)

	res, err := env.m.PlaceSharesRestore(t.Context())
	if err != nil {
		t.Fatalf("a refused destination must be reported, not error out: %v", err)
	}
	if len(res.Refused) != 2 {
		t.Errorf("both out-of-root destinations must be refused, got %+v", res)
	}
	if res.FilesRestored != 0 {
		t.Errorf("zero files must be written when every destination is refused, got %d", res.FilesRestored)
	}
	if _, sErr := os.Stat(filepath.Join(outside, "x.txt")); !os.IsNotExist(sErr) {
		t.Error("PLACE GUARD BREACHED — a file landed outside every registered live root")
	}
	if _, sErr := os.Stat(filepath.Join(env.tmp, "eszkeipel", "y.txt")); !os.IsNotExist(sErr) {
		t.Error("PLACE GUARD BREACHED — a traversal destination was written")
	}
	// The definitions must not be registered either — a share pointing outside is not restorable.
	if len(env.sett.GetSMBShares()) != 0 {
		t.Errorf("refused shares must not enter the registry: %+v", env.sett.GetSMBShares())
	}
}

// The prefix assert's boundary cases, stated directly.
func TestLiveShareRootOKBoundaries(t *testing.T) {
	env := newSharesEnv(t, "hdd_1")
	root := env.drives["hdd_1"]

	if !env.m.liveShareRootOK(filepath.Join(root, "dokumentumok")) {
		t.Error("a strict descendant of a live root must be allowed")
	}
	if env.m.liveShareRootOK(root) {
		t.Error("the drive root ITSELF must be refused (a share is never the whole drive)")
	}
	if env.m.liveShareRootOK(filepath.Join(root, "..", "elsewhere")) {
		t.Error("a `..` segment must be refused")
	}
	if env.m.liveShareRootOK("") {
		t.Error("an empty destination must be refused")
	}
	// A drive that is away is not a LIVE root.
	if err := env.sett.SetDisconnected(root, true, nil); err != nil {
		t.Fatal(err)
	}
	if env.m.liveShareRootOK(filepath.Join(root, "dokumentumok")) {
		t.Error("a disconnected drive must not count as a live root")
	}
}

// A definitions-only snapshot (the quota-degraded shape) restores the CONFIGURATION without error
// even though no file tree exists — that is the whole point of the manifest-only floor.
func TestSharesRestoreDefinitionsOnlySnapshot(t *testing.T) {
	env := newSharesEnv(t, "hdd_1", "hdd_2")
	sharePath := filepath.Join(env.drives["hdd_1"], "dokumentumok")

	mf := SharesManifest{
		Version: sharesManifestVersion, ServerName: "FELHOM",
		Shares: []settings.SMBShare{{Name: "dokumentumok", Path: sharePath, Offsite: true, CreatedAt: "2026-07-18T00:00:00Z"}},
	}
	seedSharesScratch(t, env, mf, nil) // no file tree at all

	var passdbCalls int
	env.m.SetSharesReconciler(func() error { return nil })
	wirePlaceSeams(env, &passdbCalls)

	res, err := env.m.PlaceSharesRestore(t.Context())
	if err != nil {
		t.Fatalf("a definitions-only snapshot must restore cleanly: %v", err)
	}
	if !containsStr(res.DefinitionsAdded, "dokumentumok") {
		t.Errorf("the definition should be restored: %+v", res)
	}
	if res.FilesRestored != 0 {
		t.Errorf("no files exist in a definitions-only snapshot, got %d", res.FilesRestored)
	}
}

// The flash message must speak Hungarian and never leak the reserved key.
func TestSharesRestoreFlashMessage(t *testing.T) {
	res := SharesRestoreResult{FilesRestored: 3, DefinitionsAdded: []string{"a"}, DefinitionsKept: []string{"marad"}}
	msg := res.FlashMessage()
	if strings.Contains(msg, SharesPseudoStack) {
		t.Errorf("the reserved key leaked into the flash message: %q", msg)
	}
	if !strings.Contains(msg, SharesDisplayName) {
		t.Errorf("the flash message should name %q: %q", SharesDisplayName, msg)
	}
	if !strings.Contains(msg, "A(z) marad megosztás beállítása már létezik — a meglévő maradt.") {
		t.Errorf("the kept-definition sentence is missing: %q", msg)
	}
}

// jsonMarshalIndent is a tiny indirection so the test file needs no direct encoding/json import
// beyond this one helper.
func jsonMarshalIndent(v any) ([]byte, error) { return json.MarshalIndent(v, "", "  ") }
