package pipeline

import (
	"context"
	"errors"

	"textmachine/backend/internal/config"
	"textmachine/backend/internal/llm"
	"textmachine/backend/internal/store"
)

// escalation.go: single-hop эскалация детерминированного контент-провала (D12/D3) —
// один хоп на другую модель под budget_usd, результат РЕ-гейтится, retry-бюджет не
// сбрасывается. Канал B (18+) Ф2 расширяет ИМЕННО этот файл (permissive-цепочки).

// applyModelFloor raises a derived max_tokens budget to the CALL model's per-model
// floor (D24.3, capabilities.min_max_tokens): a thinking model returns finish=length
// or empty content below a per-model minimum because reasoning consumes the budget
// before content is emitted. The floor is taken by the model of THIS call — a primary
// attempt floors against st.Model, an escalation hop against st.EscalateTo — so a hop
// that inherits a smaller primary budget re-derives its own (exactly what broke the
// draft→deepseek-v4-pro hops on acceptance stage A: they inherited 2048/3291 and
// returned length). 0 floor (reasoning-off models) leaves the budget untouched.
// Applied BEFORE the request_hash so it is wire-visible and snapshot-folded.
//
// ⚠ LATENT INTERACTION (D28.3б, note not a defect — no floored local model exists today): a
// `provider_local` kind overrides max_tokens on the wire AFTER the request-hash (prov.MaxTokens,
// snapshot.go ProviderMaxTok fold), so for a FLOORED local model the provider override would win
// over this floor on the actual wire. Both are in the snapshot (floor via Capability.MinMaxTokens,
// override via ProviderMaxTok), so it is a loud --resnapshot either way — but if a local model ever
// gets a min_max_tokens floor, decide explicitly whether the provider max_tokens override or the
// floor takes precedence (today the local provider max_tokens=8192 already covers thinking budgets).
func (r *Runner) applyModelFloor(base int, model string) int {
	if floor := r.Models.MinMaxTokens(model); floor > base {
		return floor
	}
	return base
}

// errReserveCeiling is wrapped into the error runAttempt returns when a book/day USD
// ceiling denies a reservation. The PRIMARY path propagates it (the book durably
// pauses and resumes once the ceiling is raised, D4); the OPTIONAL escalation hop
// catches it and degrades to keeping the primary flag instead of aborting the whole
// book on every run (self-review finding).
var errReserveCeiling = errors.New("reserve ceiling reached")

// escalationBudgetRemains reports whether the book may still spend on a single-hop
// fallback draft: escalation is OPT-IN via escalation.budget_usd (0 = disabled, the
// boevoy default — a stage's escalate_to is inert until a premium budget is set), and
// capped at that budget summed over the book's escalation checkpoints (money-path
// durable, resume-safe). A PRE-HOP soft cap: the gate admits a hop while spent <
// budget and does NOT pre-estimate the hop's own cost, so a single hop may overshoot
// the budget by up to its full cost; the NEXT chunk's escalation is then denied. Size
// the budget with that worst case in mind — it bounds TOTAL escalation, not per-hop.
func (r *Runner) escalationBudgetRemains() (bool, error) {
	budget := r.Pipeline.Escal.BudgetUSD
	if budget <= 0 {
		return false, nil
	}
	spent, err := r.Store.EscalationSpentUSD(r.Book.BookID)
	if err != nil {
		return false, err
	}
	return spent < budget, nil
}

// escalationOutcome is maybeEscalate's report to runStage. attempted is true ONLY
// when a hop actually executed (fresh or replayed from its checkpoint) — a chunk
// whose hop was skipped (not escalatable / no escalate_to / budget exhausted) or
// denied by a USD ceiling reports attempted=false, so runStage keeps the primary
// flag and records Escalated=false (the exact pre-extraction semantics).
type escalationOutcome struct {
	attempted bool
	fb        stageAttempt // the fallback attempt; meaningful only when attempted
}

// maybeEscalate runs the single-hop escalation (D12 deterministic-content-failure
// class): a flag that a DIFFERENT model might fix (echo / excision / refusal) is
// routed ONCE to the stage's named fallback, under the book's escalation.budget_usd,
// and RE-GATED (classifyOutput runs on the fallback output too — §3.8). The editor
// declares no escalate_to → pinned per book (its style must not drift to a foreign
// model, D12/2605.13368). The fallback uses its OWN model (the request_hash axis), so
// its checkpoint never collides with the failed primary's, and it is exactly ONE call
// (not a fresh retry budget): total calls/chunk = primary attempts + 1 hop, never
// reset on the fallback (the LiteLLM #19985 retry×fallback blow-up guard). primary is
// the attempt loop's terminal (flagged) attempt.
func (r *Runner) maybeEscalate(ctx context.Context, st config.Stage, snapID string, ch Chunk, job *store.Job, baseMaxTokens int, msgs []llm.Message, primary stageAttempt, isFinal bool) (escalationOutcome, error) {
	var out escalationOutcome
	if !primary.cls.Reason.escalatable() || st.EscalateTo == "" {
		return out, nil
	}
	// The hop budget is the primary's base re-floored against the HOP's own model
	// (D24.3): the fallback may be a thinking model with a larger minimum than the
	// primary (draft deepseek-v4-flash → hop deepseek-v4-pro both floor 8000; a
	// floor-less grok primary → a floored hop lifts to the hop's minimum). This is
	// exactly the stage-A fix — the hop no longer inherits a sub-floor 2048/3291.
	hopMaxTokens := r.applyModelFloor(baseMaxTokens, st.EscalateTo)
	// Idempotency (self-review): if the fallback hop already happened its
	// checkpoint exists and was already paid — REPLAY it for free regardless of the
	// budget, so a crash AFTER the hop settled but BEFORE chunk_status was written
	// re-serves it on resume rather than discarding a paid, successful translation
	// and flipping the verdict OK→flagged. Only a FRESH hop is budget-gated. The
	// hash mirrors runAttempt's for (model=EscalateTo, attempt=0, maxTokens=hopMaxTokens).
	fbHash := RequestHash(r.Book.BookID, ch.Chapter, ch.ChunkIdx, 0, st.Name, st.Role, st.EscalateTo,
		st.Temperature, st.Reasoning, false, hopMaxTokens, snapID, msgs)
	fbExists, err := r.Store.GetCheckpoint(fbHash)
	if err != nil {
		return out, err
	}
	mayHop := fbExists != nil
	if !mayHop {
		if mayHop, err = r.escalationBudgetRemains(); err != nil {
			return out, err
		}
	}
	if !mayHop {
		return out, nil
	}
	fb, err := r.runAttempt(ctx, st, st.EscalateTo, snapID, ch, job, 0, hopMaxTokens, msgs, true, isFinal)
	if err != nil {
		// An OPTIONAL hop that trips a USD ceiling must NOT abort the whole book
		// (and re-abort on every resume): the chunk is already flagged, so keep
		// the primary flag and continue. Any other infra error still aborts.
		if !errors.Is(err, errReserveCeiling) {
			return out, err
		}
		r.Log.WarnContext(ctx, "escalation hop denied by a USD ceiling; keeping the primary flag",
			"stage", st.Name, "chapter", ch.Chapter, "chunk", ch.ChunkIdx, "reason", string(primary.cls.Reason))
		return out, nil
	}
	out.attempted, out.fb = true, fb
	r.Log.WarnContext(ctx, "stage escalated to a fallback model",
		"stage", st.Name, "chapter", ch.Chapter, "chunk", ch.ChunkIdx,
		"primary_reason", string(primary.cls.Reason), "fallback", st.EscalateTo,
		"fallback_disposition", string(fb.cls.Reason.disposition()))
	return out, nil
}
