package appbackup

import (
	"fmt"
	"path"
	"strings"
)

// Backup classification (referential coupling) — Task 2 of the backup-classification-redesign arc
// (felhom.eu/documentation/audits/SPIKE-backup-classification-2026-07-14.md). This file is the SCHEMA
// + PURE CLASSIFIER only; it is deliberately INERT — no backup tier consumes it yet. Task 3 (tier
// policy engine) and Task 4 (manual .fab UI) are the consumers. Classes describe how a bind couples
// to the app's referential state:
//
//   - mandatory: COUPLED — restoring the app WITHOUT this bind yields a broken (not merely empty)
//     app, because the DB/state references the content (SQ3: immich DB-only restore = broken).
//   - optional:  DECOUPLED-precious — absent ⇒ empty-not-broken, but the content is user-precious
//     (not re-downloadable): an external photo library, a curated comic/ROM set.
//   - excluded:  DECOUPLED-bulk/transient — re-downloadable media, scraper caches, ingest inboxes,
//     transient export/download dirs; never shipped offsite, opt-in only for a manual .fab.

// BindClass is the referential-coupling class of a single host bind.
type BindClass string

const (
	ClassMandatory BindClass = "mandatory" // COUPLED: restore-without is broken, not empty (SQ3)
	ClassOptional  BindClass = "optional"  // DECOUPLED-precious: empty-not-broken, not re-downloadable
	ClassExcluded  BindClass = "excluded"  // DECOUPLED-bulk/transient: never offsite, .fab opt-in
)

// BindRoot names the deploy-time variable a bind's host path is relative to.
type BindRoot string

const (
	RootUserdata BindRoot = "userdata" // relative to ${USERDATA_PATH}
	RootHDD      BindRoot = "hdd"      // relative to ${HDD_PATH}
	// RootImport is relative to ${IMPORT_PATH} — the CANONICAL drop-zone root (R-75). Unlike the
	// other two it does NOT resolve against the app's own drive: it lives on the system drive's
	// namespace, so every app's ingest folder is in one place. Resolvers therefore need the import
	// root passed in separately; they cannot derive it from hddPath.
	RootImport BindRoot = "import"
)

// BackupSpec is the .felhom.yml `backup:` block. Paths are forward-slash, relative, path.Clean'd.
type BackupSpec struct {
	Userdata []BindSpec `yaml:"userdata,omitempty" json:"userdata,omitempty"`
	HDD      []BindSpec `yaml:"hdd,omitempty" json:"hdd,omitempty"`
	// Import classifies ${IMPORT_PATH}-relative binds (R-75). An app whose ingest bind moved from
	// ${USERDATA_PATH}/import/<app> to ${IMPORT_PATH}/<app> MUST move its backup entry here in the
	// same change: ValidateBackupSpec rejects an entry matching no compose bind, and the rejection is
	// WHOLE-BLOCK, so a stale `userdata: import/<app>` would discard the app's OTHER classifications
	// (e.g. an hdd appdata path classed mandatory) and silently degrade it to legacy.
	Import []BindSpec `yaml:"import,omitempty" json:"import,omitempty"`
}

// BindSpec is one classified entry in a BackupSpec.
type BindSpec struct {
	Path  string    `yaml:"path" json:"path"`
	Class BindClass `yaml:"class" json:"class"`
}

// ComposeBind is a ${VAR}-relative host bind extracted from docker-compose.yml (Part 2 parser). It
// lives in relative ${VAR} space (NOT resolved to an absolute path) and carries the :ro flag, both of
// which the classifier needs — this is why the classifier does NOT reuse ParseComposeHDDMounts (which
// resolves absolutes and drops the mode).
type ComposeBind struct {
	Root     BindRoot
	RelPath  string // path.Clean'd, forward-slash, relative; "" for a bare-root bind (${VAR} itself)
	ReadOnly bool
}

// ClassOrigin records HOW a bind's class was decided — for logs/UI and to prove the precedence rule.
type ClassOrigin string

const (
	OriginExplicit        ClassOrigin = "explicit"         // matched an entry in the backup block
	OriginDefaultWritable ClassOrigin = "default_writable" // unlisted + writable → mandatory (capture)
	OriginDefaultRO       ClassOrigin = "default_ro"       // unlisted + :ro → excluded (reader rule)
	OriginLegacy          ClassOrigin = "legacy"           // no backup block at all → no class semantics
)

// ClassifiedBind pairs a compose bind with its resolved class + origin.
type ClassifiedBind struct {
	ComposeBind
	Class  BindClass
	Origin ClassOrigin
}

// validClass reports whether c is one of the three known classes (empty is INVALID — a typoed
// `clas:` key makes yaml.v3 silently leave Class "", which must be rejected, not defaulted).
func validClass(c BindClass) bool {
	switch c {
	case ClassMandatory, ClassOptional, ClassExcluded:
		return true
	default:
		return false
	}
}

// ValidateRelPath is THE path-safety refusal set for every ${VAR}-relative catalog path — the
// `backup:` block and `data_paths:` both run through it, so there is exactly ONE definition of what
// a safe relative path is. Refuses: empty, backslash, absolute, non-path.Clean'd, and any leading
// ".." escape. It deliberately does NOT check "matches a compose bind" — that rule needs the bind
// list and differs per caller (whole-block reject for backup:, per-entry for data_paths:).
func ValidateRelPath(root BindRoot, p string) error {
	where := fmt.Sprintf("%s[%q]", root, p)
	if p == "" {
		return fmt.Errorf("%s: empty path", where)
	}
	if strings.ContainsRune(p, '\\') {
		return fmt.Errorf("%s: backslash in path (paths are forward-slash relative)", where)
	}
	if path.IsAbs(p) {
		return fmt.Errorf("%s: absolute path (must be relative to the %s root)", where, root)
	}
	if p != path.Clean(p) {
		return fmt.Errorf("%s: non-clean path (want %q)", where, path.Clean(p))
	}
	// path.Clean has run — ".." can only survive as a leading "../" segment.
	if p == ".." || strings.HasPrefix(p, "../") {
		return fmt.Errorf("%s: path escapes the root (..)", where)
	}
	return nil
}

// ValidRoot reports whether r is one of the three known bind roots.
func ValidRoot(r BindRoot) bool {
	switch r {
	case RootUserdata, RootHDD, RootImport:
		return true
	default:
		return false
	}
}

// ValidateBackupSpec checks a parsed backup block against the app's actual compose binds and returns
// the FIRST defect (whole-block semantics — the caller rejects the ENTIRE block on any error, so the
// app degrades to legacy rather than partially classifying). A nil spec is vacuously valid (legacy).
//
// Rejects: unknown/empty class; empty path; a path that is not already path.Clean'd, or is absolute,
// or contains "..", or contains a backslash; a duplicate (root, path); an entry whose (root, path)
// matches NO compose bind (a typo/stale entry must not silently shift the real bind onto the
// mandatory default). Match is exact (Root, RelPath) equality.
func ValidateBackupSpec(spec *BackupSpec, binds []ComposeBind) error {
	if spec == nil {
		return nil
	}
	present := make(map[BindRoot]map[string]bool)
	for _, b := range binds {
		if present[b.Root] == nil {
			present[b.Root] = make(map[string]bool)
		}
		present[b.Root][b.RelPath] = true
	}

	seen := make(map[string]bool) // "<root>\x00<path>"
	check := func(root BindRoot, list []BindSpec) error {
		for _, e := range list {
			where := fmt.Sprintf("%s[%q]", root, e.Path)
			if !validClass(e.Class) {
				return fmt.Errorf("%s: invalid class %q (want mandatory|optional|excluded)", where, e.Class)
			}
			if err := ValidateRelPath(root, e.Path); err != nil {
				return err
			}
			key := string(root) + "\x00" + e.Path
			if seen[key] {
				return fmt.Errorf("%s: duplicate path in the backup block", where)
			}
			seen[key] = true
			if !present[root][e.Path] {
				return fmt.Errorf("%s: matches no compose bind (stale or typoed path)", where)
			}
		}
		return nil
	}
	if err := check(RootUserdata, spec.Userdata); err != nil {
		return err
	}
	if err := check(RootHDD, spec.HDD); err != nil {
		return err
	}
	return check(RootImport, spec.Import)
}

// ClassifyBinds resolves every compose bind to a class + origin, applying the two-level default. The
// second return reports whether the app carries a backup block at all.
//
//   - spec == nil → every bind is emitted with Origin=legacy and an EMPTY Class (no class semantics),
//     and hasClassification=false. This is the block-ABSENT branch: nothing downstream may change
//     behavior for it (SQ5 two-level default — no block means today's per-tier legacy behavior).
//   - spec present → an explicit block entry ALWAYS wins, regardless of the bind's :ro flag (an
//     explicit `optional` on immich's :ro external library beats the reader default). An UNLISTED
//     bind defaults by mode: writable → mandatory (default_writable — the C6B-F1 direction: capture
//     rather than silently drop), read-only → excluded (default_ro — reader rule, SQ2).
//
// Pure. Assumes a validated spec (see ValidateBackupSpec) but never panics on an unvalidated one:
// unmatched/invalid spec entries simply don't match any bind here.
//
// A bare-root bind (RelPath "") can never be matched by an explicit entry — an empty path is invalid
// in the spec — so it always falls to the ro/writable default.
func ClassifyBinds(spec *BackupSpec, binds []ComposeBind) (classified []ClassifiedBind, hasClassification bool) {
	out := make([]ClassifiedBind, 0, len(binds))
	if spec == nil {
		for _, b := range binds {
			out = append(out, ClassifiedBind{ComposeBind: b, Origin: OriginLegacy})
		}
		return out, false
	}
	explicit := make(map[BindRoot]map[string]BindClass)
	add := func(root BindRoot, list []BindSpec) {
		for _, e := range list {
			if explicit[root] == nil {
				explicit[root] = make(map[string]BindClass)
			}
			explicit[root][e.Path] = e.Class
		}
	}
	add(RootUserdata, spec.Userdata)
	add(RootHDD, spec.HDD)
	add(RootImport, spec.Import)

	for _, b := range binds {
		cb := ClassifiedBind{ComposeBind: b}
		if cls, ok := explicit[b.Root][b.RelPath]; ok {
			cb.Class, cb.Origin = cls, OriginExplicit
		} else if b.ReadOnly {
			cb.Class, cb.Origin = ClassExcluded, OriginDefaultRO
		} else {
			cb.Class, cb.Origin = ClassMandatory, OriginDefaultWritable
		}
		out = append(out, cb)
	}
	return out, true
}
