package appexport

import (
	"archive/tar"
	"io"
	"log"
	"os"
	"path/filepath"
	"reflect"
	"sort"
	"testing"

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

// fabProv is a minimal ExportStackProvider for the plan tests: configurable classified binds + hddPath.
type fabProv struct {
	*rtProvider
	hddPath string
	binds   []appbackup.ClassifiedBind
	has     bool
	mounts  []string
}

func (p *fabProv) GetStackHDDPath(string) string     { return p.hddPath }
func (p *fabProv) GetImportRoot() string             { return "" } // R-75: no import binds in this fixture
func (p *fabProv) GetStackHDDMounts(string) []string { return p.mounts }
func (p *fabProv) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) {
	return p.binds, p.has
}

func newFabExporter(binds []appbackup.ClassifiedBind, has bool, hddPath string, tree map[string][]string) *Exporter {
	e := NewExporter(&fabProv{rtProvider: &rtProvider{}, hddPath: hddPath, binds: binds, has: has}, log.New(io.Discard, "", 0), "test")
	e.dirLister = func(dir string) []string { return tree[dir] }
	return e
}

func mHDD(rel string) appbackup.ClassifiedBind {
	return appbackup.ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootHDD, RelPath: rel}, Class: appbackup.ClassMandatory}
}
func mUD(rel string) appbackup.ClassifiedBind {
	return appbackup.ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootUserdata, RelPath: rel}, Class: appbackup.ClassMandatory}
}
func oUD(rel string) appbackup.ClassifiedBind {
	return appbackup.ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootUserdata, RelPath: rel}, Class: appbackup.ClassOptional}
}
func xUD(rel string) appbackup.ClassifiedBind {
	return appbackup.ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootUserdata, RelPath: rel}, Class: appbackup.ClassExcluded}
}

const hp = "/srv/data" // hddPath (plan is string-only; the lister is injected)

// udDir builds an OS-native userdata dir path (matches appbackup.UserdataDir + the walk's
// filepath.Join, so injected-lister keys line up on any host).
func udDir(rel ...string) string {
	return filepath.Join(append([]string{hp, "userdata"}, rel...)...)
}

// A — legacy app: empty plan (byte-identical v0.130.0 capture).
func TestFabPlan_LegacyEmpty(t *testing.T) {
	e := newFabExporter(nil, false, hp, nil)
	mounts := []string{hp + "/appdata/app", hp + "/userdata"}
	plan := e.computeFabPlan(ExportRequest{StackName: "x"}, mounts)
	if len(plan.SkipMounts) != 0 || plan.SkipUserdataTar || plan.UserdataExcludeRels != nil {
		t.Errorf("legacy app must yield an EMPTY plan (every mount kept, no excludes), got %+v", plan)
	}
}

// B — all-excluded app: the userdata root tar is skipped entirely.
func TestFabPlan_AllExcludedSkipsUserdataTar(t *testing.T) {
	binds := []appbackup.ClassifiedBind{xUD("media/movies"), xUD("downloads")}
	e := newFabExporter(binds, true, hp, map[string][]string{"/srv/data/userdata": {"media", "downloads"}})
	plan := e.computeFabPlan(ExportRequest{StackName: "radarr"}, []string{hp + "/userdata"})
	if !plan.SkipUserdataTar {
		t.Error("no selected userdata bind → the whole root tar must be skipped (Scenario B)")
	}
}

// C — selected-complement: media/books kept, siblings excluded (R1-C).
func TestFabPlan_SelectedComplement(t *testing.T) {
	binds := []appbackup.ClassifiedBind{mUD("media/books"), xUD("media/movies")}
	tree := map[string][]string{
		udDir():        {"media", "music"},
		udDir("media"): {"books", "movies", "comics"},
	}
	e := newFabExporter(binds, true, hp, tree)
	plan := e.computeFabPlan(ExportRequest{StackName: "calibre-web"}, []string{hp + "/userdata"})
	if plan.SkipUserdataTar {
		t.Fatal("a selected userdata bind exists — the root tar must NOT be skipped")
	}
	want := []string{"media/comics", "media/movies", "music"}
	if got := plan.UserdataExcludeRels; !reflect.DeepEqual(got, want) {
		t.Errorf("excludes = %v, want %v (media/books kept, siblings excluded)", got, want)
	}
}

// D — optional default-in / uncheck-out / excluded opt-in.
func TestFabPlan_OptionalAndOptIn(t *testing.T) {
	// A mandatory anchor (data) keeps the root tar always produced, so comics/podcasts inclusion is
	// exercised via the EXCLUDE LIST (not the whole-tar skip).
	binds := []appbackup.ClassifiedBind{mUD("data"), oUD("media/comics"), xUD("media/podcasts")}
	tree := map[string][]string{
		udDir():        {"data", "media"},
		udDir("media"): {"comics", "podcasts", "junk"},
	}
	// D1 default: optional comics IN → not excluded; podcasts (excluded) + junk (unselected) excluded.
	e := newFabExporter(binds, true, hp, tree)
	p1 := e.computeFabPlan(ExportRequest{StackName: "komga"}, []string{hp + "/userdata"})
	if p1.SkipUserdataTar {
		t.Fatal("mandatory anchor selected — tar must be produced")
	}
	if effExcluded(p1.UserdataExcludeRels, "media/comics") {
		t.Error("D1: default → optional comics must be INCLUDED (not excluded)")
	}
	if !effExcluded(p1.UserdataExcludeRels, "media/podcasts") {
		t.Error("D1: excluded podcasts must be excluded by default")
	}
	// D2 uncheck the optional → excluded (effectively, via a topmost exclude covering it).
	p2 := e.computeFabPlan(ExportRequest{StackName: "komga", DeselectOptional: []string{"userdata/media/comics"}}, []string{hp + "/userdata"})
	if !effExcluded(p2.UserdataExcludeRels, "media/comics") {
		t.Errorf("D2: unchecked optional must be excluded, excludes=%v", p2.UserdataExcludeRels)
	}
	// D3 opt-in the excluded → included.
	p3 := e.computeFabPlan(ExportRequest{StackName: "komga", OptInExcluded: []string{"userdata/media/podcasts"}}, []string{hp + "/userdata"})
	if effExcluded(p3.UserdataExcludeRels, "media/podcasts") {
		t.Error("D3: opted-in excluded must be INCLUDED (not excluded)")
	}
}

// effExcluded reports whether rel (or an ancestor of it) is in the topmost exclude list.
func effExcluded(excludes []string, rel string) bool {
	for _, e := range excludes {
		if rel == e || len(rel) > len(e) && rel[:len(e)+1] == e+"/" {
			return true
		}
	}
	return false
}

// D floor — a request deselecting a MANDATORY path is IGNORED (mandatory stays in).
func TestFabPlan_MandatoryFloor(t *testing.T) {
	binds := []appbackup.ClassifiedBind{mUD("media/books")}
	tree := map[string][]string{udDir(): {"media"}, udDir("media"): {"books"}}
	e := newFabExporter(binds, true, hp, tree)
	// client tries to deselect the mandatory path — must be ignored (books NOT excluded).
	plan := e.computeFabPlan(ExportRequest{StackName: "x", DeselectOptional: []string{"userdata/media/books"}}, []string{hp + "/userdata"})
	if contains(plan.UserdataExcludeRels, "media/books") || plan.SkipUserdataTar {
		t.Errorf("mandatory floor breached — media/books must stay in the bundle; plan=%+v", plan)
	}
}

// §8 — an HDD mount matching NO classified bind is KEPT (fail toward capture).
func TestFabPlan_UnmatchedMountKept(t *testing.T) {
	binds := []appbackup.ClassifiedBind{mHDD("appdata/known")}
	e := newFabExporter(binds, true, hp, nil)
	mounts := []string{hp + "/appdata/known", hp + "/appdata/mystery"}
	plan := e.computeFabPlan(ExportRequest{StackName: "x"}, mounts)
	if plan.SkipMounts[filepath.Clean(hp+"/appdata/mystery")] {
		t.Error("an unmatched mount must be KEPT (fail toward capture, C6B-F1)")
	}
	if plan.SkipMounts[filepath.Clean(hp+"/appdata/known")] {
		t.Error("a mandatory-matched mount must be kept")
	}
}

// §8 — a classified HDD mount that is NOT selected is skipped.
func TestFabPlan_UnselectedHDDMountSkipped(t *testing.T) {
	binds := []appbackup.ClassifiedBind{xHDD("appdata/cache")}
	e := newFabExporter(binds, true, hp, nil)
	mounts := []string{hp + "/appdata/cache"}
	plan := e.computeFabPlan(ExportRequest{StackName: "x"}, mounts)
	if !plan.SkipMounts[filepath.Clean(hp+"/appdata/cache")] {
		t.Error("an excluded, un-opted-in HDD mount must be skipped")
	}
}

func xHDD(rel string) appbackup.ClassifiedBind {
	return appbackup.ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootHDD, RelPath: rel}, Class: appbackup.ClassExcluded}
}

// classifyFabRel keep-rule truth table (the R1-C core, pure).
func TestClassifyFabRel(t *testing.T) {
	sel := []string{"media/books"}
	cases := []struct {
		rel  string
		want fabRelClass
	}{
		{"media/books", fabKeepInside},
		{"media/books/covers", fabKeepInside},
		{"media", fabKeepAncestor},
		{"media/movies", fabStale},
		{"music", fabStale},
	}
	for _, c := range cases {
		if got := classifyFabRel(c.rel, sel); got != c.want {
			t.Errorf("classifyFabRel(%q) = %d, want %d", c.rel, got, c.want)
		}
	}
}

// tarDirectoryExcluding FS-level: excluded subtrees are absent, kept content present.
func TestTarDirectoryExcluding(t *testing.T) {
	src := t.TempDir()
	write := func(rel, content string) {
		p := filepath.Join(src, filepath.FromSlash(rel))
		os.MkdirAll(filepath.Dir(p), 0755)
		os.WriteFile(p, []byte(content), 0644)
	}
	write("media/books/a.epub", "BOOK")
	write("media/movies/big.mkv", "MOVIE")
	write("music/song.flac", "SONG")

	out := filepath.Join(t.TempDir(), "userdata.tar")
	if err := tarDirectoryExcluding(src, out, []string{"media/movies", "music"}); err != nil {
		t.Fatal(err)
	}
	got := tarEntries(t, out)
	if !containsSuffix(got, "media/books/a.epub") {
		t.Errorf("kept content missing: %v", got)
	}
	for _, bad := range []string{"media/movies/big.mkv", "music/song.flac", "media/movies", "music"} {
		if containsSuffix(got, bad) {
			t.Errorf("excluded path %q present in tar: %v", bad, got)
		}
	}
}

func tarEntries(t *testing.T, tarPath string) []string {
	t.Helper()
	f, err := os.Open(tarPath)
	if err != nil {
		t.Fatal(err)
	}
	defer f.Close()
	tr := tar.NewReader(f)
	var names []string
	for {
		h, err := tr.Next()
		if err == io.EOF {
			break
		}
		if err != nil {
			t.Fatal(err)
		}
		names = append(names, filepath.ToSlash(h.Name))
	}
	sort.Strings(names)
	return names
}

func contains(ss []string, want string) bool {
	for _, s := range ss {
		if s == want {
			return true
		}
	}
	return false
}
func containsSuffix(ss []string, suffix string) bool {
	for _, s := range ss {
		if s == suffix || filepath.ToSlash(s) == suffix {
			return true
		}
	}
	return false
}
