// Package pbsdrheal is the hub-side PBS-DR self-heal reconciler (TASK 2026-07-15, from
// SPIKE-pbsdr-selfheal-2026-07-15 / e8f8c44).
//
// Root cause the spike proved (not reasoned): a customer box re-installed / restored / rolled back
// onto its STABLE host_id loses its agent-side converged marker; the hub still holds the durable
// pbs_dr descriptor (enabled) and the durable *consumed* one-time secret, and the WG peer still
// exists (same pubkey → changed==false → the provision cascade cannot re-fire). The agent gets the
// descriptor, verifies over the tunnel, but ConsumePBSToken returns "no secret" — it sits in
// pbs_dr.state="waiting_secret" forever. PBS-DR never converges, so escrow can't run and offsite
// never arms. The MISSING PIECE IS A CONSUMABLE SECRET, NOT THE DESCRIPTOR (spike SQ-2b′:
// re-staging the stored secret converged the box in one ~30 s agent tick, using the existing ep0
// token, zero churn).
//
// This reconciler, for each host whose descriptor is enabled+provisioned and whose LATEST report is
// a stuck state sustained across a debounce, RE-STAGES the stored secret (store.RestageHostPBSSecret
// — no ep0 call, no generation bump) and escalates to the existing Re-issue only when there is no
// stored secret to re-stage or the agent burned one (consumed_failed). It NEVER re-runs the whole
// provision atom (which refuses ErrTokenExists) and NEVER blind-timer-reissues (hash/gen thrash).
// A converged/healthy/disabled host is a pure no-op (idempotency — Scenario C).
package pbsdrheal

import (
	"context"
	"log"
	"time"

	"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)

// The reported agent pbs_dr.state strings (felhom-agent internal/pbsdr/manager.go). Only the two
// STUCK states below are acted on; everything else (applied, adopted, disabled, verify_failed, "")
// is a no-op. verify_failed is DELIBERATELY not healed: it is a descriptor/PBS-reachability problem
// (the secret is untouched by verify-before-consume), self-heals when the tunnel recovers, and must
// stay LOUD for the operator if it does not — re-staging a secret would not help it.
const (
	stateWaitingSecret  = "waiting_secret"
	stateConsumedFailed = "consumed_failed"
	// stateAuthFailed (R-39, agent >= 0.91.0) — the box HAS a credential, the descriptor is applied,
	// and PBS rejects it with 401. Before 0.91.0 this state could not exist: the agent's verify loop
	// read the credential file directly as non-root, always failed with "permission denied", and
	// skipped — so an applied-and-dead tier was invisible to both tiers. It is healed like
	// consumed_failed (escalate to a fresh mint), never by a re-stage: re-staging re-feeds the SAME
	// secret PBS just rejected.
	stateAuthFailed = "auth_failed"
)

// Audit event types (store.SaveEvent; hub-internal, not gated by allowedEventTypes). Distinct per
// remediation so the operator sees exactly what was auto-done (Scenario E wants a distinct signal).
const (
	eventRestaged       = "pbsdr_selfheal_restaged"        // re-armed the stored secret (routine)
	eventReissued       = "pbsdr_selfheal_reissued"        // no stored secret → minted a fresh one
	eventConsumedFailed = "pbsdr_selfheal_consumed_failed" // burned secret → minted a fresh one (a real problem was remediated)
	eventAuthFailed     = "pbsdr_selfheal_auth_failed"     // PBS rejected the box's credential (401) → minted a fresh one
	eventUnconsumed     = "pbsdr_unconsumed_secret"        // staged secret never consumed under an `applied` box (surfaced, NOT auto-healed)
)

// unconsumedGrace bounds how long a staged-but-unconsumed secret is NORMAL before it is a lie.
//
// A fresh mint (or a deliberate re-stage) is consumed on the agent's next tick — well inside one
// ~15-minute report cycle. Beyond this window, an unconsumed secret sitting under a box that reports
// `applied` is the exact fingerprint of the 2026-07-18 N100 failure: hub minted, agent
// short-circuited, both tiers green, box serving a revoked credential.
const unconsumedGrace = 15 * time.Minute

// Actions is the mutation seam — fakes in tests count calls without SSH/ep0. Restage flips a stored
// secret's consumed flag (returns restaged=false when NO row exists → the caller escalates). Reissue
// mints a fresh ep0 token + stores a fresh consume-once secret + bumps the descriptor.
type Actions interface {
	Restage(hostID string) (restaged bool, err error)
	Reissue(ctx context.Context, customerID string) error
}

// Reissuer is satisfied by *web.Server (its ReissuePBSDR). Kept here so main.go can wire the server
// as the escalation path without an import cycle.
type Reissuer interface {
	ReissuePBSDR(ctx context.Context, customerID string) error
}

// storeActions is the production Actions: Restage → the store primitive; Reissue → the web server.
type storeActions struct {
	st       *store.Store
	reissuer Reissuer
}

func (a storeActions) Restage(hostID string) (bool, error) { return a.st.RestageHostPBSSecret(hostID) }
func (a storeActions) Reissue(ctx context.Context, customerID string) error {
	return a.reissuer.ReissuePBSDR(ctx, customerID)
}

// NewActions builds the production mutation seam.
func NewActions(st *store.Store, reissuer Reissuer) Actions {
	return storeActions{st: st, reissuer: reissuer}
}

// debounceState tracks, per host, the last DISTINCT report observed and how many consecutive
// distinct reports it has held the current stuck state — so a fresh box that briefly shows
// waiting_secret between provision and its first consume (resolved within one 60 s agent tick, well
// inside one ~15 min report cycle) is NOT healed on a single report (Scenario D).
type debounceState struct {
	reportID int64
	state    string
	streak   int
}

// Reconciler re-arms stuck PBS-DR hosts. DECLARATIVE + IDEMPOTENT: a tick over a converged fleet
// writes nothing (Scenario C). It reads the hub DB (the source of truth) — never the box.
type Reconciler struct {
	store           *store.Store
	act             Actions
	interval        time.Duration
	debounceReports int    // distinct stuck reports required before healing (default 2)
	onlyHost        string // "" = whole fleet; non-empty restricts the work set to one host (supervised rollout)
	trigger         chan struct{}
	logger          *log.Logger

	deb map[string]debounceState
	// unconsumedSeen remembers the last report id already surfaced per host (Scenario F), so a
	// sustained disagreement produces one event per fresh report rather than one per tick.
	unconsumedSeen map[string]int64
}

// NewReconciler builds the reconciler. interval defaults to 5m, debounceReports to 2.
func NewReconciler(st *store.Store, act Actions, logger *log.Logger) *Reconciler {
	if logger == nil {
		logger = log.Default()
	}
	return &Reconciler{
		store:           st,
		act:             act,
		interval:        5 * time.Minute,
		debounceReports: 2,
		trigger:         make(chan struct{}, 1),
		logger:          logger,
		deb:             map[string]debounceState{},
		unconsumedSeen:  map[string]int64{},
	}
}

// RestrictToHost scopes the reconciler's work set to a single host_id (empty = whole fleet). Used
// for a supervised first rollout (PBSDRHEAL_ONLY_HOST): validate the converged-host no-op live on the
// drill guest before widening to the fleet. Set before Run.
func (r *Reconciler) RestrictToHost(hostID string) { r.onlyHost = hostID }

// Trigger requests an immediate reconcile (tests + the mutation handlers). Non-blocking.
func (r *Reconciler) Trigger() {
	select {
	case r.trigger <- struct{}{}:
	default:
	}
}

// Run loops until ctx is done, reconciling on each tick or Trigger. Never exits on an error.
func (r *Reconciler) Run(ctx context.Context) {
	ticker := time.NewTicker(r.interval)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-r.trigger:
		case <-ticker.C:
		}
		r.reconcileOnce(ctx)
	}
}

// reconcileOnce reads the whole fleet's DR-heal state and acts on the stuck, debounce-confirmed,
// enabled+provisioned hosts. Errors are logged and retried next tick.
func (r *Reconciler) reconcileOnce(ctx context.Context) {
	rows, err := r.store.PBSDRHealStates()
	if err != nil {
		r.logger.Printf("[ERROR] pbsdrheal: read heal states: %v (retry next tick)", err)
		return
	}
	seen := make(map[string]bool, len(rows))
	for _, row := range rows {
		if r.onlyHost != "" && row.HostID != r.onlyHost {
			continue // supervised rollout scope: only this host is in the work set
		}
		seen[row.HostID] = true
		// Work set: descriptor enabled AND provisioned (namespace set). A DR-OFF / disabled / bare-
		// enabled-but-never-provisioned host is out of scope (Scenario F) — forget any debounce.
		if !row.DescriptorEnabled || !row.DescriptorProvisioned {
			delete(r.deb, row.HostID)
			continue
		}
		// R-39 consumed_at HONESTY (Scenario F). Deliberately a SURFACE, not a heal: the remediation
		// for a genuinely stuck box is the auth_failed / waiting_secret / consumed_failed machinery
		// above, driven by what the BOX reports. This check catches the disagreement itself — the hub
		// staged a credential the box never took, while the box claims to be applied — which is a
		// state no single tier can detect alone. Auto-re-issuing on it would mint a second secret on
		// top of an unconsumed one: exactly the mint/consume race R-39(a) already recorded.
		r.checkUnconsumed(row)

		switch row.ReportedState {
		case stateWaitingSecret:
			if r.confirm(row) {
				r.healWaitingSecret(ctx, row)
			}
		case stateConsumedFailed:
			if r.confirm(row) {
				r.healConsumedFailed(ctx, row)
			}
		case stateAuthFailed:
			if r.confirm(row) {
				r.healAuthFailed(ctx, row)
			}
		default:
			// applied | adopted | disabled | verify_failed | "" (no report) | anything else → no-op.
			delete(r.deb, row.HostID)
		}
	}
	// Drop debounce state for hosts that vanished from the fleet.
	for h := range r.deb {
		if !seen[h] {
			delete(r.deb, h)
		}
	}
}

// checkUnconsumed surfaces the applied-but-never-consumed disagreement (Scenario F). One event per
// distinct report, so a sustained state does not spam the audit log every tick.
func (r *Reconciler) checkUnconsumed(row store.PBSDRHealRow) {
	if row.SecretUnconsumedFor <= unconsumedGrace {
		return // no staged secret, already consumed, or still inside the normal pickup window
	}
	// Only meaningful when the box claims to be converged. A box that honestly reports
	// waiting_secret / consumed_failed / auth_failed is already being healed above and its unconsumed
	// secret is the SYMPTOM being fixed, not a contradiction.
	if row.ReportedState != "applied" && row.ReportedState != "adopted" {
		return
	}
	if st, ok := r.unconsumedSeen[row.HostID]; ok && st == row.ReportID {
		return // already surfaced for this exact report
	}
	if r.unconsumedSeen == nil {
		r.unconsumedSeen = map[string]int64{}
	}
	r.unconsumedSeen[row.HostID] = row.ReportID
	r.logger.Printf("[WARN] pbsdrheal: host %s (customer %s) reports pbs_dr=%s while a staged one-time secret has been UNCONSUMED for %s — the box is not using the credential the hub issued (R-39 fingerprint)",
		row.HostID, row.CustomerID, row.ReportedState, row.SecretUnconsumedFor.Round(time.Minute))
	r.event(row.CustomerID, eventUnconsumed, "warning",
		"PBS-DR honesty check: the box reports the DR tier as applied, but a one-time credential issued by the hub has never been consumed. The tier may be authenticating with a superseded credential.")
}

// confirm advances the per-host debounce and reports whether the stuck state has held across
// >= debounceReports DISTINCT reports. A re-observed same report (same reportID) never advances the
// streak — the debounce counts fresh evidence, not reconciler ticks.
func (r *Reconciler) confirm(row store.PBSDRHealRow) bool {
	st := r.deb[row.HostID]
	if row.ReportID != st.reportID {
		if row.ReportedState == st.state {
			st.streak++
		} else {
			st.streak = 1
		}
		st.state = row.ReportedState
		st.reportID = row.ReportID
		r.deb[row.HostID] = st
	}
	return st.streak >= r.debounceReports
}

// healWaitingSecret re-stages the stored secret; if none is stored, escalates to Re-issue.
func (r *Reconciler) healWaitingSecret(ctx context.Context, row store.PBSDRHealRow) {
	restaged, err := r.act.Restage(row.HostID)
	if err != nil {
		r.logger.Printf("[ERROR] pbsdrheal: re-stage %s: %v (retry next tick)", row.HostID, err)
		return
	}
	if restaged {
		r.logger.Printf("[INFO] pbsdrheal: re-staged the stored one-time secret for host %s (customer %s) stuck in waiting_secret — the agent re-consumes on its next tick (no ep0 token minted, no generation bump)", row.HostID, row.CustomerID)
		r.event(row.CustomerID, eventRestaged, "info",
			"PBS-DR self-heal: re-staged the stored one-time credential for a box stuck awaiting a secret (re-install/rollback recovery). No endpoint token was minted.")
		r.resetAfterHeal(row)
		return
	}
	// No stored secret to re-stage → escalate to a fresh mint.
	r.logger.Printf("[INFO] pbsdrheal: host %s (customer %s) is waiting_secret with NO stored secret — escalating to Re-issue", row.HostID, row.CustomerID)
	if r.reissue(ctx, row) {
		r.event(row.CustomerID, eventReissued, "warning",
			"PBS-DR self-heal: re-issued endpoint credentials for a box awaiting a secret that the hub no longer had stored.")
		r.resetAfterHeal(row)
	}
}

// healConsumedFailed escalates a burned-secret box to a fresh mint — a re-stage of the SAME secret
// would only re-feed the credential the agent already burned into a failed apply (Scenario E).
func (r *Reconciler) healConsumedFailed(ctx context.Context, row store.PBSDRHealRow) {
	r.logger.Printf("[WARN] pbsdrheal: host %s (customer %s) reports consumed_failed (burned secret) — escalating to Re-issue", row.HostID, row.CustomerID)
	if r.reissue(ctx, row) {
		r.event(row.CustomerID, eventConsumedFailed, "warning",
			"PBS-DR self-heal: a box reported consumed_failed (it burned a one-time credential into a failed apply); re-issued fresh endpoint credentials so the agent can converge.")
		r.resetAfterHeal(row)
	}
}

// healAuthFailed escalates a box whose credential PBS rejects (401) to a fresh mint. This is the leg
// that closes the R-39 loop end to end: the agent now PROVES the credential is dead instead of
// silently skipping, the hub re-keys, the fresh mint advances the secret generation, the descriptor
// hash moves, and the agent finally re-consumes (Scenario A). Before this, an applied-and-401 tier
// stayed green forever and would have surfaced first at a real restore.
//
// A re-stage is deliberately NOT attempted: the stored secret IS the one PBS just rejected, so
// re-arming it would burn a tick and change nothing. Damping is the SHARED confirm() — a 401 flap
// must not turn into a secret-minting chain.
func (r *Reconciler) healAuthFailed(ctx context.Context, row store.PBSDRHealRow) {
	r.logger.Printf("[WARN] pbsdrheal: host %s (customer %s) reports auth_failed (PBS rejects its credential) — escalating to Re-issue", row.HostID, row.CustomerID)
	if r.reissue(ctx, row) {
		r.event(row.CustomerID, eventAuthFailed, "warning",
			"PBS-DR self-heal: a box reported auth_failed (the DR endpoint rejected its stored credential); re-issued fresh endpoint credentials so the agent can re-consume and converge.")
		r.resetAfterHeal(row)
	}
}

// reissue runs the escalation; returns true on success (the caller then records the audit event).
func (r *Reconciler) reissue(ctx context.Context, row store.PBSDRHealRow) bool {
	if err := r.act.Reissue(ctx, row.CustomerID); err != nil {
		r.logger.Printf("[ERROR] pbsdrheal: re-issue for customer %s (host %s): %v (retry next tick)", row.CustomerID, row.HostID, err)
		return false
	}
	return true
}

// resetAfterHeal clears the streak (keeping the report id) so the same stuck report does not re-heal
// on the next tick — a FRESH report must re-confirm the host is still stuck before acting again.
func (r *Reconciler) resetAfterHeal(row store.PBSDRHealRow) {
	r.deb[row.HostID] = debounceState{reportID: row.ReportID, state: row.ReportedState, streak: 0}
}

// event records an audit row; a failure to write it must never break the heal loop.
func (r *Reconciler) event(customerID, eventType, severity, message string) {
	if _, err := r.store.SaveEvent(customerID, eventType, severity, message, "", "hub"); err != nil {
		r.logger.Printf("[WARN] pbsdrheal: audit event %s for %s not stored: %v", eventType, customerID, err)
	}
}
