package report

import (
	"context"
	"log"
	"sync"
	"time"
)

// SLICE 3 — hub-verified escrow auto-confirm. Replaces operator trust ("I ran the ceremony, click
// confirm") with a verified fact: the hub's report ACK carries the sha256 of the repo password the
// stored escrow blob COVERS (recorded at ceremony time); the controller flips pending→escrowed ONLY
// when that hash matches sha256 of its CURRENT local repo password. Blob-presence alone must never
// confirm — a blob can predate the current password (re-provision, inject, drive history) and a
// truthful-looking claim on a stale blob would re-open the exact un-recoverable-ciphertext gap fork-4
// closed. Hashes are non-reversible (256-bit random secrets) and safe to log; passwords never are.

// EscrowStatus mirrors the hub ACK's `escrow` object (nil when the hub has no escrow row).
type EscrowStatus struct {
	IdentityBlobPresent bool   `json:"identity_blob_present"`
	ResticPwSHA256      string `json:"restic_pw_sha256"`
	CreatedAt           string `json:"created_at"`
}

// EscrowAutoConfirmer runs the auto-confirm check on each report ACK. Long-lived (one per process) so
// the mismatch warning dedupes per distinct hash instead of firing every 15-minute cycle.
type EscrowAutoConfirmer struct {
	// Pending reports whether the offbox target is configured AND EscrowState=="pending" — the
	// confirm-flip state. "escrowed" is never flipped back (auto-UN-confirm does not exist), but
	// since v0.127.0 it IS re-checked: see Escrowed + the stale-blob branch (Scenario F).
	Pending func() bool
	// Escrowed reports whether the offbox target is configured AND EscrowState=="escrowed" — the
	// v0.127.0 stale-blob re-check state (Scenario F: a superseding ceremony that did NOT cover
	// the current password — e.g. a CLI run without the staged secret — must be surfaced, not
	// silently ignored; the spike left the drill box in exactly that state). nil → no re-check.
	Escrowed func() bool
	// LocalHash returns the canonical hash of the local repo password (ok=false → no password file).
	LocalHash func() (hash string, ok bool)
	// Flip transitions EscrowState pending→escrowed (settings.UpdateOffboxStatus).
	Flip func() error
	// Wipe removes the agent-staged secret (best-effort — the flip is the primary effect).
	Wipe   func(ctx context.Context) error
	Logger *log.Logger

	mu         sync.Mutex
	warnedHash string // last mismatched hub hash we warned about (dedupe; shared by both branches)
	stale      bool   // Scenario F: the hub blob does not cover the CURRENT password (display-only)
}

// staleHashlessMarker is the warnedHash dedupe sentinel for the hash-less supersession case
// (the hub hash is EMPTY there, which must still warn exactly once, and must not collide with
// the zero value of warnedHash).
const staleHashlessMarker = "(hashless)"

// StaleBlob reports the Scenario-F display flag: EscrowState is escrowed but the hub's CURRENT
// blob does not cover the current repo password. In-memory only (recomputed from ACKs after a
// restart); NEVER blocks runs and NEVER flips state — the web card renders the warning + the
// re-ceremony CTA from it.
func (c *EscrowAutoConfirmer) StaleBlob() bool {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.stale
}

func (c *EscrowAutoConfirmer) logf(f string, a ...any) {
	if c.Logger != nil {
		c.Logger.Printf(f, a...)
	}
}

// Reconcile applies one ACK's escrow status. Scenarios: match → flip+wipe (A); mismatch → stay pending
// + warn once per hash (B); no status / no hash / no local file → stay pending silently (C, normal
// onboarding); escrowed → the v0.127.0 stale-blob re-check (F — warn-only, never a state change);
// otherwise → no-op (E — offbox not configured).
func (c *EscrowAutoConfirmer) Reconcile(es *EscrowStatus) {
	if es == nil {
		return
	}
	if !c.Pending() {
		// Scenario F (v0.127.0): an ESCROWED box re-checks the hash on every ACK — a superseding
		// blob that does not cover the current password must be surfaced (warn + card flag), while
		// runs continue and the state stays escrowed (no auto-UN-confirm, ever).
		if c.Escrowed != nil && c.Escrowed() {
			c.reconcileEscrowed(es)
		}
		return
	}
	// Fail-closed: the hash must exist AND ride a present identity blob (the hash-bearing container).
	// A hash-less blob is a legacy/password-less escrow — the deprecated manual confirm covers those.
	if es.ResticPwSHA256 == "" || !es.IdentityBlobPresent {
		return
	}
	localHash, ok := c.LocalHash()
	if !ok {
		return // no local repo password file — nothing to verify against
	}
	if localHash != es.ResticPwSHA256 {
		// The stored escrow does NOT cover the current key — flipping would be a false custody claim.
		c.mu.Lock()
		warned := c.warnedHash == es.ResticPwSHA256
		c.warnedHash = es.ResticPwSHA256
		c.mu.Unlock()
		if !warned {
			c.logf("[WARN] [escrow-confirm] the hub's escrow blob does not cover the CURRENT repo password (hub hash %.12s… != local %.12s…) — run the escrow ceremony (wizard /backup/escrow, or felhom-agent --selftest=escrow-create --upload); staying pending", es.ResticPwSHA256, localHash)
		}
		return
	}
	if err := c.Flip(); err != nil {
		c.logf("[ERROR] [escrow-confirm] hash matched but the escrowed flip failed (retries next cycle): %v", err)
		return
	}
	c.logf("[INFO] [escrow-confirm] hub-verified: the escrow covers the current repo password (hash %.12s…) — EscrowState auto-confirmed escrowed; offsite runs enabled", es.ResticPwSHA256)
	if c.Wipe != nil {
		wctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
		defer cancel()
		if err := c.Wipe(wctx); err != nil {
			c.logf("[ERROR] [escrow-confirm] escrowed but the agent-staged secret was NOT wiped: %v", err)
		}
	}
	c.mu.Lock()
	c.stale = false // a fresh hub-verified confirm clears any earlier stale flag
	c.mu.Unlock()
}

// reconcileEscrowed is the Scenario-F branch (§8 truth table, escrowed rows): compare the ACK's
// hash exactly as the pending branch does; a mismatch OR a present blob with an EMPTY hash (the
// hash-less supersession — the spike's exact case) raises the stale flag + ONE warn per distinct
// hub hash (warnedHash reuse); a match clears the flag. State is never flipped; runs never block
// (offsite backups still protect against non-total loss).
func (c *EscrowAutoConfirmer) reconcileEscrowed(es *EscrowStatus) {
	localHash, ok := c.LocalHash()
	if !ok {
		return // no local repo password file — nothing to compare against
	}
	hubHash := es.ResticPwSHA256
	if hubHash != "" && hubHash == localHash {
		c.mu.Lock()
		c.stale = false
		c.mu.Unlock()
		return
	}
	// Stale: hash mismatch, or a blob whose hash is empty (hash-less supersession). Dedupe the
	// warn per distinct hub hash; the empty hash dedupes under a sentinel so it still fires once.
	dedupeKey := hubHash
	if dedupeKey == "" {
		dedupeKey = staleHashlessMarker
	}
	c.mu.Lock()
	warned := c.warnedHash == dedupeKey
	c.warnedHash = dedupeKey
	c.stale = true
	c.mu.Unlock()
	if warned {
		return
	}
	if hubHash == "" {
		c.logf("[WARN] [escrow-confirm] STALE escrow: the hub's current blob carries NO password hash (hash-less supersession) — the stored recovery bundle does not cover the offsite password; create a new recovery code (wizard /backup/escrow). State stays escrowed; runs continue")
		return
	}
	c.logf("[WARN] [escrow-confirm] STALE escrow: the hub's current blob does not cover the CURRENT repo password (hub hash %.12s… != local %.12s…) — create a new recovery code (wizard /backup/escrow). State stays escrowed; runs continue", hubHash, localHash)
}
