package appbackup

import (
	"path"
	"sort"
	"strings"
)

// Capture-set computation — Task 3-core of the backup-classification-redesign arc
// (felhom.eu/documentation/architecture/07-backup-architecture.md §3; tier×class matrix §2; SQ2/SQ5
// in SPIKE-backup-classification-2026-07-14.md). This is a PURE path-algebra layer: given an app's
// classified binds, a tier, and the app's live hddPath, it returns the tier-filtered,
// structurally-guarded, containment-deduped absolute capture set that the 3a (offsite) and 3b
// (tier-2) engines will capture. Deliberately INERT — no engine consumes it yet.
//
// Purity contract: no os, no exec, no logging, no filepath. Reasons for refused captures are DATA
// (SkippedPath.Reason); the engines log them (a skipped MANDATORY is a capture GAP the engines must
// surface loudly). All resolution and prefix algebra uses path.Join/path.Clean and "/" string ops —
// never filepath.* — because RelPath is defined forward-slash (classify.go) and every resolved Abs
// is an in-container Linux path; filepath on the Windows `go test` host would flip separators and
// break both expectations and the containment prefix checks.

// CaptureTier selects the tier column of §2 that the filter applies.
type CaptureTier string

const (
	TierOffsite   CaptureTier = "offsite"   // §2: mandatory only (optional is the customer's local tier)
	TierSecondary CaptureTier = "secondary" // §2: mandatory + optional
)

// CapturePath is one resolved path in the capture set. Abs is the in-container Linux absolute path;
// Root/RelPath preserve the bind's identity (the tier-2 layout and restore relpath-mirroring need it).
type CapturePath struct {
	Abs     string
	Root    BindRoot
	RelPath string
	Class   BindClass
}

// SkippedPath is a would-be capture the tier filter selected but a structural guard refused. Reason
// is operator-English; the engines log it (a skipped mandatory path = a silent capture gap otherwise).
type SkippedPath struct {
	Root    BindRoot
	RelPath string
	Class   BindClass
	Reason  string
}

// CaptureSet is the result of ComputeCaptureSet. HasClassification mirrors the classifier's bool;
// engines derive unit-only as (!HasClassification || len(Paths)==0). Paths is sorted by Abs.
type CaptureSet struct {
	HasClassification bool
	Paths             []CapturePath
	Skipped           []SkippedPath
}

// Structural-guard reasons (distinct strings; each names the rule it enforces).
const (
	reasonEscape   = "path escapes the drive root"
	reasonBareRoot = "bare drive-root bind would capture the backups tree"
	reasonReserved = "path inside the reserved backups zone"
)

// ComputeCaptureSet resolves an app's classified binds into the tier-filtered absolute capture set.
// Pipeline (fixed order, §8): legacy short-circuit → tier filter (§2 columns) → structural guards →
// equal-Abs collapse (mandatory > optional) → containment dedup (keep ancestor) → sort by Abs.
//
// Tier columns (§2): TierOffsite carries mandatory only; TierSecondary carries mandatory + optional;
// excluded is silently dropped at every tier (never in Paths, never in Skipped). A legacy app
// (hasClassification=false) resolves NOTHING — {HasClassification:false} with nil Paths/Skipped —
// so the engines' no-block branch stays byte-identical to today (the SQ5 cost-regression guard).
//
// Resolution: RootHDD → path.Join(hddPath, relPath); RootUserdata → path.Join(hddPath, "userdata",
// relPath). Guards run AFTER the tier filter, so Skipped means exactly "would have been captured by
// this tier, refused for structural safety".
func ComputeCaptureSet(binds []ClassifiedBind, hasClassification bool, tier CaptureTier, hddPath string) CaptureSet {
	if !hasClassification {
		return CaptureSet{HasClassification: false}
	}
	cs := CaptureSet{HasClassification: true}

	// Stages 1–3: tier filter → structural guards → equal-Abs collapse (shared with ComputeFabBuckets).
	uniq, skipped := resolveGuardCollapse(binds, hddPath, func(c BindClass) bool { return tierKeeps(tier, c) })
	cs.Skipped = skipped

	// Stage 4: containment dedup — drop any path whose ancestor is already present (keep the ancestor).
	for _, cp := range uniq {
		if hasStrictAncestor(cp.Abs, uniq) {
			continue
		}
		cs.Paths = append(cs.Paths, cp)
	}

	// Stage 5: deterministic order.
	sort.Slice(cs.Paths, func(i, j int) bool { return cs.Paths[i].Abs < cs.Paths[j].Abs })
	sort.Slice(cs.Skipped, func(i, j int) bool {
		if cs.Skipped[i].Root != cs.Skipped[j].Root {
			return cs.Skipped[i].Root < cs.Skipped[j].Root
		}
		return cs.Skipped[i].RelPath < cs.Skipped[j].RelPath
	})
	return cs
}

// resolveGuardCollapse is the pipeline shared by ComputeCaptureSet and ComputeFabBuckets: keep-filter
// (the caller's predicate over class) → structural guards (Skipped) → resolve to Abs → equal-Abs
// collapse (mandatory > optional > excluded; ties by smaller Root/RelPath). It does NOT apply
// containment dedup — the caller decides (ComputeCaptureSet does; ComputeFabBuckets must not, so a
// mandatory child inside an excluded parent stays independently addressable).
func resolveGuardCollapse(binds []ClassifiedBind, hddPath string, keep func(BindClass) bool) (uniq []CapturePath, skipped []SkippedPath) {
	var resolved []CapturePath
	for _, b := range binds {
		if !keep(b.Class) {
			continue
		}
		if reason, bad := structuralGuard(b.Root, b.RelPath); bad {
			skipped = append(skipped, SkippedPath{Root: b.Root, RelPath: b.RelPath, Class: b.Class, Reason: reason})
			continue
		}
		resolved = append(resolved, CapturePath{
			Abs: resolveAbs(hddPath, b.Root, b.RelPath), Root: b.Root, RelPath: b.RelPath, Class: b.Class,
		})
	}
	byAbs := make(map[string]CapturePath, len(resolved))
	for _, cp := range resolved {
		if cur, ok := byAbs[cp.Abs]; ok {
			byAbs[cp.Abs] = strongerCapture(cur, cp)
			continue
		}
		byAbs[cp.Abs] = cp
	}
	uniq = make([]CapturePath, 0, len(byAbs))
	for _, cp := range byAbs {
		uniq = append(uniq, cp)
	}
	return uniq, skipped
}

// FabBuckets is the class-bucketed capture set for the manual `.fab` export (Task 4). Unlike
// ComputeCaptureSet it keeps ALL classes (structural guards run over every class — a traversal path is
// never plannable, opt-in or not) and does NOT collapse across containment (mandatory `media/books`
// inside excluded `media` both survive, in their own buckets). HasClassification=false ⇒ empty (the
// legacy full-root capture, unchanged).
type FabBuckets struct {
	HasClassification bool
	Mandatory         []CapturePath
	Optional          []CapturePath
	Excluded          []CapturePath
	Skipped           []SkippedPath
}

// ComputeFabBuckets resolves an app's classified binds into per-class buckets for the `.fab` export
// selection UI + plan. Same resolution + structural guards + equal-Abs collapse as ComputeCaptureSet
// (via resolveGuardCollapse), bucketed by class, no cross-bucket containment dedup. Each bucket is
// Abs-sorted (deterministic).
func ComputeFabBuckets(binds []ClassifiedBind, hasClassification bool, hddPath string) FabBuckets {
	if !hasClassification {
		return FabBuckets{HasClassification: false}
	}
	fb := FabBuckets{HasClassification: true}
	uniq, skipped := resolveGuardCollapse(binds, hddPath, func(BindClass) bool { return true })
	fb.Skipped = skipped
	for _, cp := range uniq {
		switch cp.Class {
		case ClassMandatory:
			fb.Mandatory = append(fb.Mandatory, cp)
		case ClassOptional:
			fb.Optional = append(fb.Optional, cp)
		default:
			fb.Excluded = append(fb.Excluded, cp)
		}
	}
	for _, b := range []*[]CapturePath{&fb.Mandatory, &fb.Optional, &fb.Excluded} {
		bk := *b
		sort.Slice(bk, func(i, j int) bool { return bk[i].Abs < bk[j].Abs })
	}
	sort.Slice(fb.Skipped, func(i, j int) bool {
		if fb.Skipped[i].Root != fb.Skipped[j].Root {
			return fb.Skipped[i].Root < fb.Skipped[j].Root
		}
		return fb.Skipped[i].RelPath < fb.Skipped[j].RelPath
	})
	return fb
}

// tierKeeps applies the §2 tier column: mandatory everywhere, optional only for secondary, excluded
// never.
func tierKeeps(tier CaptureTier, class BindClass) bool {
	switch class {
	case ClassMandatory:
		return true
	case ClassOptional:
		return tier == TierSecondary
	default: // ClassExcluded (and any unexpected value) — never captured automatically
		return false
	}
}

// structuralGuard refuses a (root, relPath) that would capture an unsafe location. Evaluated after
// the tier filter. RelPath arrives path.Clean'd from the compose parser but is NOT traversal-checked
// there (ParseComposeClassifiableBinds path.Cleans; ValidateBackupSpec vets only SPEC entries), so an
// unlisted writable "${HDD_PATH}/../x" bind reaches here classed mandatory — this guard is
// load-bearing security, not defence-in-depth.
func structuralGuard(root BindRoot, relPath string) (reason string, bad bool) {
	if relPathEscapes(relPath) {
		return reasonEscape, true
	}
	if root == RootHDD {
		if relPath == "" {
			return reasonBareRoot, true // bare ${HDD_PATH} would nest <hddPath>/backups into the capture
		}
		if relPath == "backups" || strings.HasPrefix(relPath, "backups/") {
			return reasonReserved, true
		}
	}
	// RootUserdata + "" is allowed: resolves to <hddPath>/userdata, which does not nest backups/.
	return "", false
}

// relPathEscapes reports whether relPath is absolute or contains a ".." path segment. Detection is
// SEGMENT-WISE on the slash-split path (".." as a whole component), so a legit dir literally named
// "a..b" passes.
func relPathEscapes(relPath string) bool {
	if path.IsAbs(relPath) {
		return true
	}
	for _, seg := range strings.Split(relPath, "/") {
		if seg == ".." {
			return true
		}
	}
	return false
}

// resolveAbs maps a guarded (root, relPath) to its in-container absolute path via slash algebra.
func resolveAbs(hddPath string, root BindRoot, relPath string) string {
	if root == RootUserdata {
		return path.Join(hddPath, "userdata", relPath)
	}
	return path.Join(hddPath, relPath)
}

// strongerCapture picks the winner of an equal-Abs collision: mandatory beats optional; on equal
// class strength, the lexicographically-smaller (Root, RelPath) wins (determinism).
func strongerCapture(a, b CapturePath) CapturePath {
	sa, sb := classStrength(a.Class), classStrength(b.Class)
	if sa != sb {
		if sa > sb {
			return a
		}
		return b
	}
	if a.Root != b.Root {
		if a.Root < b.Root {
			return a
		}
		return b
	}
	if a.RelPath <= b.RelPath {
		return a
	}
	return b
}

// classStrength ranks capture classes for the equal-Abs collapse (mandatory must never degrade).
func classStrength(c BindClass) int {
	switch c {
	case ClassMandatory:
		return 2
	case ClassOptional:
		return 1
	default:
		return 0
	}
}

// hasStrictAncestor reports whether some OTHER path in set is a strict directory ancestor of abs
// (abs == ancestor+"/"+…). Slash-aware prefix so "/x/paper" does not "contain" "/x/paperless".
func hasStrictAncestor(abs string, set []CapturePath) bool {
	for _, o := range set {
		if o.Abs == abs {
			continue
		}
		if strings.HasPrefix(abs, o.Abs+"/") {
			return true
		}
	}
	return false
}

// Overlap is one absolute path claimed non-excluded by more than one app's capture set (§4.2). Apps
// is sorted.
type Overlap struct {
	Abs  string
	Apps []string
}

// CrossAppOverlaps reports absolute paths that appear in ≥2 apps' Paths — the catalog-convention
// tripwire (§4: at most one app may class a host path non-excluded). Pure and advisory here; the
// WARN wiring lands in 3a/3b, not in 3-core. Match is EXACT-Abs only: cross-app CONTAINMENT
// (calibre-web's mandatory media/books sitting inside plex's excluded reader bind) is legitimate per
// §4 and must NOT report. Deterministic despite the map input: app names are scanned in sorted order
// and the output is sorted by Abs. Empty input / no overlap → empty (non-nil) slice.
func CrossAppOverlaps(sets map[string]CaptureSet) []Overlap {
	apps := make([]string, 0, len(sets))
	for app := range sets {
		apps = append(apps, app)
	}
	sort.Strings(apps)

	byAbs := make(map[string][]string)
	for _, app := range apps {
		seen := make(map[string]bool) // guard against an app listing the same Abs twice
		for _, cp := range sets[app].Paths {
			if seen[cp.Abs] {
				continue
			}
			seen[cp.Abs] = true
			byAbs[cp.Abs] = append(byAbs[cp.Abs], app)
		}
	}

	out := make([]Overlap, 0)
	for abs, owners := range byAbs {
		if len(owners) < 2 {
			continue
		}
		sorted := append([]string(nil), owners...)
		sort.Strings(sorted)
		out = append(out, Overlap{Abs: abs, Apps: sorted})
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Abs < out[j].Abs })
	return out
}
