package appexport

import (
	"archive/tar"
	"io"
	"os"
	"path/filepath"
	"sort"
	"strings"

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

// `.fab` class-scoped export plan (Task 4, architecture §2 `.fab` row + SQ5 exclusion-scoping verdict,
// R1-C). Mechanics unchanged from v0.130.0: ONE exclude-scoped userdata-root tar + per-mount skip for
// non-selected HDD binds. The manifest stays v1 (basename keying) and the import side is untouched.

// fabPlan is the class-scoped adjustment to the v0.130.0 mount/tar set. Empty (all zero) = the legacy
// full capture (Scenario A — a no-block app produces this).
type fabPlan struct {
	SkipMounts          map[string]bool // absolute HDD mount paths to skip entirely
	SkipUserdataTar     bool            // no selected userdata bind ⇒ the whole root tar is skipped (Scenario B)
	UserdataExcludeRels []string        // rels (relative to the userdata root) excluded from its tar (R1-C, Scenario C)
}

func relKey(root appbackup.BindRoot, rel string) string { return string(root) + "/" + rel }
func relKeyOf(cp appbackup.CapturePath) string          { return relKey(cp.Root, cp.RelPath) }

// computeFabPlan resolves the class buckets + the caller's selection into the mount/userdata plan
// (§8). Legacy / no-block ⇒ empty plan. The mandatory floor is enforced here: DeselectOptional can
// never drop a mandatory path.
func (e *Exporter) computeFabPlan(req ExportRequest, mounts []string) fabPlan {
	binds, has := e.provider.GetStackClassifiedBinds(req.StackName)
	if !has {
		return fabPlan{} // legacy: byte-identical v0.130.0 capture
	}
	hddPath := filepath.Clean(e.provider.GetStackHDDPath(req.StackName))
	fb := appbackup.ComputeFabBuckets(binds, has, hddPath)

	deselect := sliceSet(req.DeselectOptional)
	optIn := sliceSet(req.OptInExcluded)

	// Server-side floor: a request naming a mandatory path in DeselectOptional is ignored (loud WARN).
	for _, cp := range fb.Mandatory {
		if deselect[relKeyOf(cp)] {
			e.logger.Printf("[WARN] appexport: %s: request tried to deselect a MANDATORY path %s — ignored (floor enforced)", req.StackName, relKeyOf(cp))
		}
	}

	// Resolve the selected set (mandatory always; optional default-in; excluded default-out).
	var selectedHDD, selectedUD []appbackup.CapturePath
	add := func(cp appbackup.CapturePath) {
		if cp.Root == appbackup.RootUserdata {
			selectedUD = append(selectedUD, cp)
		} else {
			selectedHDD = append(selectedHDD, cp)
		}
	}
	for _, cp := range fb.Mandatory {
		add(cp)
	}
	for _, cp := range fb.Optional {
		if !deselect[relKeyOf(cp)] {
			add(cp)
		}
	}
	for _, cp := range fb.Excluded {
		if optIn[relKeyOf(cp)] {
			add(cp)
		}
	}

	// Every classified HDD bind (any class) — a mount matching NONE of these is "unclassified" and kept
	// (fail toward capture, the C6B-F1 direction).
	var classifiedHDD []string
	for _, bucket := range [][]appbackup.CapturePath{fb.Mandatory, fb.Optional, fb.Excluded} {
		for _, cp := range bucket {
			if cp.Root == appbackup.RootHDD {
				classifiedHDD = append(classifiedHDD, cp.Abs)
			}
		}
	}

	plan := fabPlan{SkipMounts: map[string]bool{}}
	ud := appbackup.UserdataDir(hddPath)
	for _, m := range mounts {
		mc := filepath.Clean(m)
		if mc == filepath.Clean(ud) {
			if len(selectedUD) == 0 {
				plan.SkipUserdataTar = true // Scenario B: no selected userdata bind → no root tar
			}
			continue
		}
		// HDD mount. Unmatched by ANY classified bind → keep (fail toward capture), log it.
		if !relatedToAny(mc, classifiedHDD) {
			e.logger.Printf("[INFO] appexport: %s: HDD mount %s matches no classified bind — kept (fail toward capture)", req.StackName, mc)
			continue
		}
		// Matched a classified bind: keep iff ancestor-or-descendant of a SELECTED HDD path.
		if !relatedToAny(mc, absList(selectedHDD)) {
			plan.SkipMounts[mc] = true
		}
	}

	if !plan.SkipUserdataTar && len(selectedUD) > 0 {
		plan.UserdataExcludeRels = e.fabUserdataExcludes(ud, udRels(selectedUD))
	}
	return plan
}

// fabEstimateSplit populates the class-split estimate fields for a classified app (Task 4). Legacy /
// no-block apps leave HasClassification=false (the UI shows the plain estimate). BaseBytes = config +
// volumes + mandatory; optional/excluded carry per-path sizes for client-side total recomputation.
func (e *Exporter) fabEstimateSplit(stackName string, est *ExportEstimate, volumeBytes int64) {
	binds, has := e.provider.GetStackClassifiedBinds(stackName)
	if !has {
		return
	}
	hddPath := filepath.Clean(e.provider.GetStackHDDPath(stackName))
	fb := appbackup.ComputeFabBuckets(binds, has, hddPath)
	est.HasClassification = true

	toItems := func(cps []appbackup.CapturePath) ([]FabItem, int64) {
		var items []FabItem
		var sum int64
		for _, cp := range cps {
			sz := duBytes(cp.Abs)
			items = append(items, FabItem{Key: relKeyOf(cp), Root: string(cp.Root), RelPath: cp.RelPath, Bytes: sz, Human: humanizeBytes(sz)})
			sum += sz
		}
		return items, sum
	}
	var mandSum int64
	est.MandatoryItems, mandSum = toItems(fb.Mandatory)
	est.OptionalItems, _ = toItems(fb.Optional)
	est.ExcludedItems, _ = toItems(fb.Excluded)
	est.BaseBytes = est.ConfigSizeBytes + volumeBytes + mandSum
	est.BaseHuman = humanizeBytes(est.BaseBytes)
}

func sliceSet(ss []string) map[string]bool {
	m := make(map[string]bool, len(ss))
	for _, s := range ss {
		m[s] = true
	}
	return m
}

func absList(cps []appbackup.CapturePath) []string {
	out := make([]string, len(cps))
	for i, cp := range cps {
		out[i] = cp.Abs
	}
	return out
}

// udRels returns the userdata rels (relative to ${USERDATA_PATH}) of selected userdata paths.
func udRels(cps []appbackup.CapturePath) []string {
	out := make([]string, 0, len(cps))
	for _, cp := range cps {
		out = append(out, cp.RelPath)
	}
	return out
}

// relatedToAny reports whether path p is an ancestor OR descendant (or equal) of any path in set.
func relatedToAny(p string, set []string) bool {
	pc := filepath.Clean(p)
	for _, s := range set {
		sc := filepath.Clean(s)
		if pc == sc || strings.HasPrefix(pc, sc+string(filepath.Separator)) || strings.HasPrefix(sc, pc+string(filepath.Separator)) {
			return true
		}
	}
	return false
}

// fabRelClass classifies a dir rel (slash-form, relative to the userdata root) against the selected
// userdata rels — the R1-C keep-rule (mirrors backup.classifyTier2Rel, copied not imported):
// keepInside = a selected rel or inside one (keep, don't descend); keepAncestor = on the path to a
// selected rel (keep, descend); else stale (exclude the topmost).
type fabRelClass int

const (
	fabStale fabRelClass = iota
	fabKeepInside
	fabKeepAncestor
)

func classifyFabRel(dirRel string, selectedRels []string) fabRelClass {
	for _, sr := range selectedRels {
		if dirRel == sr || strings.HasPrefix(dirRel, sr+"/") {
			return fabKeepInside
		}
	}
	for _, sr := range selectedRels {
		if strings.HasPrefix(sr, dirRel+"/") {
			return fabKeepAncestor
		}
	}
	return fabStale
}

// fabUserdataExcludes walks the userdata root (via the dirLister seam) and returns the topmost rels
// (relative to the root, slash-form) that are neither an ancestor nor a descendant of a selected
// userdata rel — the exclude list for the root tar (R1-C). Deterministic (sorted).
func (e *Exporter) fabUserdataExcludes(udRoot string, selectedRels []string) []string {
	lister := e.dirLister
	if lister == nil {
		lister = realDirLister
	}
	var excludes []string
	var walk func(dirAbs, dirRel string)
	walk = func(dirAbs, dirRel string) {
		for _, name := range lister(dirAbs) {
			childRel := name
			if dirRel != "" {
				childRel = dirRel + "/" + name
			}
			switch classifyFabRel(childRel, selectedRels) {
			case fabKeepInside:
				// selected leg or content inside it — keep, no descent
			case fabKeepAncestor:
				walk(filepath.Join(dirAbs, name), childRel)
			default:
				excludes = append(excludes, childRel) // topmost neither-ancestor-nor-descendant
			}
		}
	}
	walk(udRoot, "")
	sort.Strings(excludes)
	return excludes
}

func realDirLister(dir string) []string {
	entries, err := os.ReadDir(dir)
	if err != nil {
		return nil
	}
	var names []string
	for _, en := range entries {
		if en.IsDir() {
			names = append(names, en.Name())
		}
	}
	return names
}

// tarDirectoryExcluding is tarDirectory with an exclude list: any path whose rel (relative to
// sourceDir, slash-form) equals or descends from an exclude rel is skipped (a dir is pruned whole).
// Empty excludes == tarDirectory. Anchored to the tar root exactly like tarDirectory's rel names.
func tarDirectoryExcluding(sourceDir, outputPath string, excludeRels []string) error {
	excl := make([]string, len(excludeRels))
	for i, r := range excludeRels {
		excl[i] = filepath.ToSlash(r)
	}
	outFile, err := os.Create(outputPath)
	if err != nil {
		return err
	}
	defer outFile.Close()
	tw := tar.NewWriter(outFile)
	defer tw.Close()

	return filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		relPath, err := filepath.Rel(sourceDir, path)
		if err != nil {
			return err
		}
		if relPath == "." {
			return nil
		}
		rel := filepath.ToSlash(relPath)
		for _, e := range excl {
			if rel == e || strings.HasPrefix(rel, e+"/") {
				if info.IsDir() {
					return filepath.SkipDir
				}
				return nil
			}
		}
		header, err := tar.FileInfoHeader(info, "")
		if err != nil {
			return err
		}
		header.Name = relPath
		if err := tw.WriteHeader(header); err != nil {
			return err
		}
		if info.IsDir() {
			return nil
		}
		f, err := os.Open(path)
		if err != nil {
			return err
		}
		defer f.Close()
		_, err = io.Copy(tw, f)
		return err
	})
}
