package pipeline

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"textmachine/backend/internal/config"
	"textmachine/backend/internal/ledger"
	"textmachine/backend/internal/llm"
	"textmachine/backend/internal/obs"
	"textmachine/backend/internal/store"
	"time"
)

// stagerun.go: исполнение ОДНОЙ стадии одного чанка до терминального disposition —
// resume-fast-path по chunk_status (контент-верифицированный), ось attempt
// (retry {length,empty} с удвоением бюджета до капа), затем один вызов
// reserve→call→settle+checkpoint на попытку (инвариант денег №1).

// runStage runs ONE stage of ONE chunk to a terminal disposition. It first
// resumes a resolved verdict (chunk_status read BEFORE any render — anti-wedge
// #1), otherwise walks the attempt axis: render → per-attempt checkpoint resume
// or a fresh reserve/call/settle → classify → retry the retryable {length,empty}
// subset with a doubled budget up to the regenerate cap, then flag. Returns an
// error ONLY on an infra failure; a bad completion is a disposition, never an
// error.
func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, snapID string, ch Chunk, prev, injection string) (*StageResult, error) {
	// Дополняем ReqInfo ПЕРВЫМ делом, СОХРАНЯЯ решения допуска (LogBodies) —
	// перезапись с нуля отрезала бы задокументированный debug-канал (находка
	// ревью). Раньше обогащение стояло ПОСЛЕ resume-fast-path — и все строки
	// resume/re-pin шли без book/role оси contextHandler (боль smoke-прогона №4).
	ri, _ := obs.ReqInfoFromContext(ctx)
	ri.Book, ri.Chapter, ri.Chunk, ri.Stage, ri.Role = r.Book.BookID, ch.Chapter, ch.ChunkIdx, st.Name, st.Role
	ctx = obs.WithReqInfo(ctx, ri)

	job, err := r.Store.EnsureJob(r.Book.BookID, ch.Chapter, st.Name, snapID)
	if err != nil {
		return nil, fmt.Errorf("pipeline: ensure job %s/ch%d/%s: %w", r.Book.BookID, ch.Chapter, st.Name, err)
	}
	// Snapshot pinning (Р6): a job frozen on a stale snapshot is a loud stop
	// unless --resnapshot explicitly accepts the re-translation.
	if job.SnapshotID != snapID {
		if !r.Resnapshot {
			return nil, fmt.Errorf("pipeline: job %s/ch%d/%s was started under snapshot %.12s, current config renders snapshot %.12s — конфиг/промпты изменились; уже оплаченные чекпоинты станут недействительны и вызовы будут пере-оплачены; повторить с --resnapshot, чтобы принять это явно",
				r.Book.BookID, ch.Chapter, st.Name, job.SnapshotID, snapID)
		}
		if err := r.Store.UpdateJobSnapshot(job.ID, snapID); err != nil {
			return nil, fmt.Errorf("pipeline: re-pin job %s/ch%d/%s to snapshot %.12s: %w", r.Book.BookID, ch.Chapter, st.Name, snapID, err)
		}
		r.Log.WarnContext(ctx, "job re-pinned to new snapshot (--resnapshot)", "stage", st.Name, "old", job.SnapshotID[:12], "new", snapID[:12])
	}

	// Render the wire messages up front. This is cheap (string substitution — the
	// expensive part is the LLM call, still gated below) and it lets the resume
	// fast-path be CONTENT-VERIFIED: the source bytes are not in the snapshot, so
	// a positional chunk_status row must be checked against the current rendered
	// content, else an edited source would serve a stale, divergent translation.
	msgs, err := MessagesWithInjection(r.templates[st.Name], RenderVars{Book: r.Book, Text: ch.Text, Draft: prev}, injection)
	if err != nil {
		return nil, err
	}
	contentHash := msgsContentHash(msgs)

	// Anti-wedge #1 + content-guard (self-review): resume resolves from
	// chunk_status BEFORE any LLM call. A flagged chunk is not in TM (garbage
	// never commits), so absence-in-TM ≠ "not done" — the disposition row is the
	// resume authority, and a terminally-flagged chunk is NOT re-attacked (no
	// infinite paid loop, even if the regenerate budget was raised). The row is
	// trusted ONLY when snapshot AND content both match: a stale-snapshot row
	// (post --resnapshot) or a stale-content row (source edited, not in the
	// snapshot) is ignored → the attempt loop below re-derives it content-safely
	// (a source edit becomes a silent per-chunk re-translate, §3.4, not a stale
	// serve). A `skipped` row is ignored too: translateChunk re-derives the skip.
	if cs, err := r.Store.GetChunkStatus(r.Book.BookID, ch.Chapter, ch.ChunkIdx, st.Name); err != nil {
		return nil, fmt.Errorf("pipeline: read chunk_status %s/ch%d/chunk%d/%s: %w", r.Book.BookID, ch.Chapter, ch.ChunkIdx, st.Name, err)
	} else if cs != nil && cs.SnapshotID == snapID && cs.ContentHash == contentHash && cs.Disposition != string(DispSkipped) {
		return r.resumeFromChunkStatus(ctx, st, ch, cs)
	}

	// max_tokens base is sized from the text THIS stage processes: the source for
	// the translator, the prior draft for later stages (D2.5 — the monolingual
	// editor works over the Russian draft ≈1.9× the CJK source, so sizing edit
	// from source under-budgets and false-triggers a length retry).
	sizingText := ch.Text
	if stageIdx > 0 {
		sizingText = prev
	}
	baseMaxTokens := int(float64(EstimateTokens(sizingText)) * r.Pipeline.Defaults.MaxOutputRatio)
	if baseMaxTokens < r.Pipeline.Defaults.MinMaxTokens {
		baseMaxTokens = r.Pipeline.Defaults.MinMaxTokens
	}
	// Per-model floor (D24.3): a thinking model needs a minimum budget or its
	// reasoning truncates the content (finish=length / empty). Applied to the
	// PRIMARY model here; the escalation hop re-floors against ITS own model
	// (maybeEscalate) — the fix for stage-A's draft→deepseek-v4-pro hops that
	// inherited 2048/3291 and returned length.
	baseMaxTokens = r.applyModelFloor(baseMaxTokens, st.Model)

	maxRegen := r.Pipeline.Retries.RegenerateBeforeEscalate
	if maxRegen < 0 {
		maxRegen = 0
	}

	// isFinal marks the LAST stage of the chunk: the output-sanitizer (D30.3) runs only on the
	// text that SHIPS, never on an intermediate draft the editor still cleans (chunkrun.go).
	isFinal := stageIdx == len(r.Pipeline.Stages)-1

	var cumCost, runCost float64
	var last stageAttempt
	anyFresh := false
	attemptsMade := 0
	for attempt := 0; ; attempt++ {
		maxTokens := maxTokensForAttempt(baseMaxTokens, attempt)
		att, err := r.runAttempt(ctx, st, st.Model, snapID, ch, job, attempt, maxTokens, msgs, false, isFinal)
		if err != nil {
			return nil, err // infra failure
		}
		attemptsMade = attempt + 1
		cumCost += att.cumCost
		runCost += att.runCost
		anyFresh = anyFresh || att.freshCall
		last = att
		if att.cls.ok() {
			break
		}
		// Flagged: re-attack only the retryable subset, only while regenerations
		// remain (a bigger budget on the attempt axis, D2.3). Everything else is
		// deterministic — a same-model retry would re-refuse and re-bill (D2.2).
		if att.cls.Reason.retryable() && attempt < maxRegen {
			r.Log.WarnContext(ctx, "stage flagged, regenerating with a larger budget",
				"stage", st.Name, "chapter", ch.Chapter, "chunk", ch.ChunkIdx,
				"attempt", attempt, "reason", string(att.cls.Reason), "next_max_tokens", maxTokensForAttempt(baseMaxTokens, attempt+1))
			continue
		}
		break
	}

	// Single-hop escalation (D12; the full policy lives in escalation.go). The hop
	// runs at most ONCE, is re-gated, and never resets the retry budget; a ceiling-
	// denied hop keeps the primary flag instead of aborting the book.
	escalated, escModel := false, ""
	esc, err := r.maybeEscalate(ctx, st, snapID, ch, job, baseMaxTokens, msgs, last, isFinal)
	if err != nil {
		return nil, err
	}
	if esc.attempted {
		cumCost += esc.fb.cumCost
		runCost += esc.fb.runCost
		anyFresh = anyFresh || esc.fb.freshCall
		escalated, escModel = true, st.EscalateTo
		if esc.fb.cls.ok() {
			last = esc.fb // the fallback draft passed the re-gate → it is authoritative
		}
		// else: the fallback also failed → keep the primary flag (last unchanged);
		// the fallback call is billed and counted, the chunk stays flagged (1 hop).
	}

	disposition := last.cls.Reason.disposition()
	finalHash := ""
	if disposition == DispOK {
		finalHash = last.reqHash
	}
	// chunk_status is a RESOLVE over the checkpoints: cost_usd sums every attempt
	// (F3-honest — retries are counted), final_hash points at the authoritative
	// checkpoint the ok path serves on resume.
	if err := r.Store.UpsertChunkStatus(store.ChunkStatus{
		BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, Stage: st.Name,
		SnapshotID: snapID, ContentHash: contentHash, Disposition: string(disposition), FlagReason: string(last.cls.Reason),
		Attempts: attemptsMade, FinalHash: finalHash, CostUSD: cumCost, Detail: last.cls.Detail,
		Escalated: escalated, EscalationModel: escModel,
	}); err != nil {
		return nil, fmt.Errorf("pipeline: record chunk_status: %w", err)
	}

	sr := &StageResult{
		Stage: st.Name, Role: st.Role, Model: last.modelActual,
		FromResume:      !anyFresh,
		Usage:           last.usage,
		CostUSD:         runCost,
		CumCostUSD:      cumCost,
		LatencyMS:       last.latency,
		FinishReason:    last.finish,
		Disposition:     disposition,
		FlagReason:      last.cls.Reason,
		Detail:          last.cls.Detail,
		Attempts:        attemptsMade,
		Escalated:       escalated,
		EscalationModel: escModel,
	}
	if disposition == DispOK {
		sr.Text = last.text
	} else {
		r.Log.WarnContext(ctx, "stage flagged", "stage", st.Name, "chapter", ch.Chapter,
			"chunk", ch.ChunkIdx, "reason", string(last.cls.Reason), "attempts", attemptsMade,
			"cum_cost_usd", fmt.Sprintf("%.6f", cumCost))
	}
	return sr, nil
}

// stageAttempt is the result of one attempt (a checkpoint hit or a fresh call).
type stageAttempt struct {
	reqHash     string
	cls         classification
	text        string
	usage       llm.Usage
	finish      string
	modelActual string
	latency     int
	runCost     float64 // billed THIS run (0 on a checkpoint hit)
	cumCost     float64 // this attempt's cost (checkpoint cost on a hit, fresh cost on a call)
	freshCall   bool    // a provider call was made this run
}

// runAttempt executes exactly one attempt on the request-hash axis: a checkpoint
// hit is classified for free (self-heal, incl. legacy Фаза-0 empty/decode
// checkpoints — no re-billing), otherwise a fresh reserve → call → settle+
// checkpoint. It returns an error only on an infra failure; a bad completion
// comes back as a classification on the attempt.
func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID string, ch Chunk, job *store.Job, attempt, maxTokens int, msgs []llm.Message, escalation, isFinal bool) (stageAttempt, error) {
	reqHash := RequestHash(r.Book.BookID, ch.Chapter, ch.ChunkIdx, attempt, st.Name, st.Role, model,
		st.Temperature, st.Reasoning, false, maxTokens, snapID, msgs)
	att := stageAttempt{reqHash: reqHash, modelActual: model}

	// Resume on the attempt axis: a checkpoint means THIS attempt already happened
	// and was billed — classify its text and never re-bill (kill -9 loses ≤1 call;
	// legacy empty/decode checkpoints self-heal by classification, not a crash).
	if cp, err := r.Store.GetCheckpoint(reqHash); err != nil {
		return att, fmt.Errorf("pipeline: read checkpoint for %s/ch%d/chunk%d/%s attempt %d: %w", r.Book.BookID, ch.Chapter, ch.ChunkIdx, st.Name, attempt, err)
	} else if cp != nil {
		var usage llm.Usage
		if uerr := json.Unmarshal([]byte(cp.UsageJSON), &usage); uerr != nil {
			// Деньги не страдают (cumCost — из cp.CostUSD), но токены отчёта будут
			// нулями — не молчать об этом совсем (боль аудита цепочек, info-класс).
			r.Log.DebugContext(ctx, "checkpoint usage_json unreadable; tokens report as zero", "hash", reqHash[:12], "err", uerr)
		}
		att.usage = usage
		att.text = cp.ResponseText
		att.finish = cp.FinishReason
		att.modelActual = cp.ModelActual
		att.cumCost = cp.CostUSD
		att.cls = r.classifyOutput(st.Role, ch.Text, cp.ResponseText, cp.FinishReason, isFinal)
		rl := r.baseRequestLog(st, ch, model, reqHash)
		rl.ModelActual = cp.ModelActual
		rl.TMHit, rl.OK, rl.FinishReason, rl.Degraded = true, att.cls.ok(), cp.FinishReason, degradedTag(att.cls)
		r.Store.LogRequest(ctx, r.Log, rl)
		r.setJobStatus(ctx, job.ID, "done")
		r.Log.InfoContext(ctx, "attempt served from checkpoint", "stage", st.Name,
			"attempt", attempt, "hash", reqHash[:12], "disposition", string(att.cls.Reason.disposition()))
		return att, nil
	}

	// Fresh call: reserve → call → settle+checkpoint (atomic, §3.3).
	price := r.Pricer.PriceFor(model)
	promptEst := 0
	for _, m := range msgs {
		promptEst += EstimateTokens(m.Content)
	}
	// D13.6: reserve the additive reasoning buffer for the ACTUAL model being called (the
	// escalate_to fallback may differ from st.Model and have its own provider). 0 for
	// subset providers / reasoning-off — the ceiling then sees only completion, as before.
	reasoningBudget := r.Models.AdditiveReasoningTokens(model, st.Reasoning, st.ReasoningMaxTokens)
	estimate := ledger.EstimateUSD(price, promptEst, maxTokens, reasoningBudget)

	resv, verdict, err := r.Store.Reserve(r.Book.BookID, estimate, store.Ceilings{
		BookUSD: r.Book.Ceilings.BookUSD, DayUSD: r.Book.Ceilings.DayUSD,
	})
	if err != nil {
		r.setJobStatus(ctx, job.ID, "failed")
		return att, fmt.Errorf("pipeline: reserve $%.6f for %s/ch%d/chunk%d/%s: %w", estimate, r.Book.BookID, ch.Chapter, ch.ChunkIdx, st.Name, err)
	}
	switch verdict {
	case store.ReserveDeniedBook, store.ReserveDeniedDay:
		// Ceiling is a hard, book-wide stop (not a per-chunk flag): the job stays
		// 'pending' and resume continues once the ceiling is raised. Wrapped so an
		// optional escalation hop can degrade instead of aborting the book. Деньги
		// в тексте — это ПОСЛЕДНЯЯ строка, которую оператор читает в 3 часа ночи:
		// без committed/reserved/estimate он не может выбрать новый потолок, а
		// «%.2f» рендерил маленький потолок как «0.00$» (боль smoke-прогона).
		committed, reserved, serr := r.Store.SpentUSD(r.Book.BookID)
		money := ""
		if serr != nil {
			// Не молчать (собственная доктрина пакета): деталь денег пропадает из
			// сообщения — пусть хотя бы след останется в логе.
			r.Log.WarnContext(ctx, "SpentUSD read failed while formatting the ceiling error; money detail omitted", "err", serr)
		} else {
			money = fmt.Sprintf(" (committed=$%.6f reserved=$%.6f, denied estimate=$%.6f, ch%d/chunk%d/%s)",
				committed, reserved, estimate, ch.Chapter, ch.ChunkIdx, st.Name)
		}
		if verdict == store.ReserveDeniedBook {
			return att, fmt.Errorf("pipeline: book USD ceiling reached ($%g)%s — raise ceilings.book_usd or stop: %w", r.Book.Ceilings.BookUSD, money, errReserveCeiling)
		}
		return att, fmt.Errorf("pipeline: daily USD ceiling reached ($%g)%s: %w", r.Book.Ceilings.DayUSD, money, errReserveCeiling)
	}

	client, err := r.client(model)
	if err != nil {
		r.releaseReservation(ctx, resv)
		r.setJobStatus(ctx, job.ID, "failed")
		return att, err
	}
	if err := r.Store.SetJobStatus(job.ID, "running"); err != nil {
		r.releaseReservation(ctx, resv)
		return att, err
	}
	att.freshCall = true

	// «Вызов в полёте» обязан быть виден: между этой строкой и «attempt completed»
	// могут пройти минуты (attempt_timeout × ретраи транспорта), и без неё «висит
	// провайдер» неотличимо от «умер процесс» — главная боль smoke-прогона №4.
	// Ось book/chapter/chunk/stage/role приезжает из ctx через contextHandler.
	r.Log.InfoContext(ctx, "calling model", "model", model, "attempt", attempt,
		"max_tokens", maxTokens, "escalation", escalation, "estimate_usd", fmt.Sprintf("%.6f", estimate))

	start := time.Now()
	resp, err := client.Complete(ctx, llm.LLMRequest{
		Model:           model,
		Messages:        msgs,
		MaxTokens:       maxTokens,
		Temperature:     st.Temperature,
		ReasoningEffort: st.Reasoning,
	})
	att.latency = int(time.Since(start).Milliseconds())
	if err != nil {
		var bde *llm.BilledDecodeError
		if errors.As(err, &bde) {
			// 2xx with an unreadable body: the provider ALREADY billed. Do not
			// release; conservatively settle the ESTIMATE with an empty decode
			// checkpoint, then FLAG (anti-wedge #2 — the loop continues, this is
			// not an infra crash). A settle failure here is a real infra fault.
			if serr := r.Store.SettleWithCheckpoint(resv, estimate, store.Checkpoint{
				RequestHash: reqHash, JobID: job.ID, ChunkIdx: ch.ChunkIdx, Attempt: attempt,
				Stage: st.Name, Role: st.Role, ModelRequested: model, ModelActual: model,
				ResponseText: "", UsageJSON: "{}", CostUSD: estimate, FinishReason: decodeErrorFinish,
				Escalation: escalation,
			}); serr != nil {
				return att, fmt.Errorf("pipeline: settle after billed decode failure: %w", serr)
			}
			att.finish = decodeErrorFinish
			att.cumCost, att.runCost = estimate, estimate
			att.cls = classification{FlagDecodeError, "billed 2xx with an unreadable body: " + err.Error()}
			rl := r.baseRequestLog(st, ch, model, reqHash)
			rl.CostUSD, rl.LatencyMS, rl.FinishReason = estimate, att.latency, decodeErrorFinish
			rl.Degraded, rl.Err, rl.OK = "billed_2xx_decode_failed", err.Error(), false
			r.Store.LogRequest(ctx, r.Log, rl)
			r.setJobStatus(ctx, job.ID, "done")
			return att, nil
		}
		// No 2xx ever arrived: nothing was billed. Release and surface as an INFRA
		// failure — a long book pauses/resumes on an outage or terminal 4xx (D4),
		// rather than flag-storming every remaining chunk on a dead provider.
		r.releaseReservation(ctx, resv)
		rl := r.baseRequestLog(st, ch, model, reqHash)
		rl.LatencyMS, rl.Err, rl.OK = att.latency, err.Error(), false
		r.Store.LogRequest(ctx, r.Log, rl)
		r.setJobStatus(ctx, job.ID, "failed")
		// Полная ось в ПОСЛЕДНЕЙ строке, которую читает оператор: одна «stage draft
		// call: …» не говорит, КАКОЙ чанк какой моделью умер (боль smoke-прогона).
		return att, fmt.Errorf("pipeline: stage %s call (ch%d/chunk%d, model %s): %w", st.Name, ch.Chapter, ch.ChunkIdx, model, err)
	}

	modelActual := resp.Model
	if modelActual == "" {
		modelActual = model
	}
	att.modelActual = modelActual
	att.text = resp.Text
	att.finish = resp.FinishReason
	att.usage = resp.Usage

	// Цена по фактически ответившей модели, с fallback на ЗАПРОШЕННУЮ (не на
	// дешёвый глобальный якорь), если провайдер вернул канонизированный слаг.
	price = r.Pricer.PriceForResponse(model, modelActual)
	cost := ledger.CostUSD(price, resp.Usage)
	// Платная модель (InputPerM>0) вернула 2xx с нулевым usage — $0 ослепил бы
	// потолок: берём консервативную оценку. Для local ($0 цена) нулевой usage
	// штатен — остаётся $0.
	if cost == 0 && price.InputPerM > 0 && resp.Usage.PromptTokens == 0 && resp.Usage.CompletionTokens == 0 {
		r.Log.WarnContext(ctx, "paid 2xx with zero usage; settling the reservation estimate to keep the ceiling honest",
			"stage", st.Name, "model", modelActual, "estimate_usd", fmt.Sprintf("%.6f", estimate))
		cost = estimate
	}
	usageJSON, err := json.Marshal(resp.Usage)
	if err != nil {
		return att, err
	}
	att.cumCost, att.runCost = cost, cost

	// Деньги: settle + сырой ответ — одна транзакция (§3.3). ВСЕГДА, даже для
	// пустого/усечённого/отказного ответа: он оплачен провайдером, чекпоинт
	// хранит его до цента, а годность решает classify ПОСЛЕ (деньги ≠ вердикт).
	if err := r.Store.SettleWithCheckpoint(resv, cost, store.Checkpoint{
		RequestHash: reqHash, JobID: job.ID, ChunkIdx: ch.ChunkIdx, Attempt: attempt,
		Stage: st.Name, Role: st.Role,
		ModelRequested: model, ModelActual: modelActual, ResponseText: resp.Text,
		UsageJSON: string(usageJSON), CostUSD: cost, FinishReason: resp.FinishReason,
		ProviderRequestID: resp.ProviderRequestID, Escalation: escalation,
	}); err != nil {
		// The provider HAS billed this 2xx; failing to persist means the money
		// state is behind reality — fail loud, never continue on top.
		return att, fmt.Errorf("pipeline: settle stage %s: %w", st.Name, err)
	}

	// Classify AFTER the money is durably settled+checkpointed. F4 lives here: a
	// non-empty truncated length draft is now classified (flagged/retried), never
	// silently passed downstream as OK; an empty completion is flagged too, not a
	// run-crash.
	att.cls = r.classifyOutput(st.Role, ch.Text, resp.Text, resp.FinishReason, isFinal)

	rl := r.baseRequestLog(st, ch, model, reqHash)
	rl.ModelActual = modelActual
	rl.PromptTokens, rl.CachedTokens = resp.Usage.PromptTokens, resp.Usage.CachedTokens
	rl.CacheCreationTokens = resp.Usage.CacheCreationTokens
	rl.CompletionTokens, rl.ReasoningTokens = resp.Usage.CompletionTokens, resp.Usage.ReasoningTokens
	rl.CostUSD, rl.LatencyMS, rl.FinishReason = cost, att.latency, resp.FinishReason
	rl.OK, rl.Degraded = att.cls.ok(), degradedTag(att.cls)
	r.Store.LogRequest(ctx, r.Log, rl)
	r.setJobStatus(ctx, job.ID, "done")
	r.Log.InfoContext(ctx, "attempt completed", "stage", st.Name, "attempt", attempt, "model", modelActual,
		"cost_usd", fmt.Sprintf("%.6f", cost), "latency_ms", att.latency,
		"disposition", string(att.cls.Reason.disposition()), "reason", string(att.cls.Reason))
	return att, nil
}

// baseRequestLog builds the attribution prefix EVERY request_log row must carry
// (book/chapter/chunk/stage/role/model/request_hash) — the join keys that tie a
// telemetry row to spend and chunk_status. One constructor instead of five hand
// copies: a new call path (annotator stage, channel B hop) cannot forget a key
// and produce an unjoinable row. The outcome-specific tail (tokens, cost, tm_hit,
// err, degraded) deliberately stays at each call site — the asymmetries there
// are real (a decode-failure row has no ModelActual, a resume row no latency).
func (r *Runner) baseRequestLog(st config.Stage, ch Chunk, model, reqHash string) store.RequestLog {
	return store.RequestLog{
		BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx,
		Stage: st.Name, Role: st.Role, ModelRequested: model, RequestHash: reqHash,
	}
}

// setJobStatus updates the advisory jobs.status row, WARNING on failure instead of
// silently swallowing it (6 call sites used to `_ =` the error): job.status is
// telemetry, not the resume authority (chunk_status/checkpoints are), so a failed
// write must not abort the pipeline — but a jobs table silently stuck in
// running/failed desyncs the status/redrive read-models with zero trace.
func (r *Runner) setJobStatus(ctx context.Context, jobID int64, status string) {
	if err := r.Store.SetJobStatus(jobID, status); err != nil {
		r.Log.WarnContext(ctx, "job status update failed (non-fatal; jobs table may lag chunk_status)",
			"job_id", jobID, "status", status, "err", err)
	}
}

// releaseReservation releases an unspent reservation, logging an ERROR on failure
// (the level the one non-swallowing site already used): a leaked reserved_usd
// silently tightens the book/day ceiling until the next process restart recovers
// it (2 of 3 release paths used to swallow this — аудит цепочек).
func (r *Runner) releaseReservation(ctx context.Context, resv store.Reservation) {
	if err := r.Store.Release(resv); err != nil {
		r.Log.ErrorContext(ctx, "reservation release failed (reserved_usd leaks and tightens ceilings until the next process restart)",
			"err", err)
	}
}

// degradedTag surfaces a flag reason into the request_log `degraded` column.
func degradedTag(cls classification) string {
	if cls.ok() {
		return ""
	}
	return string(cls.Reason)
}
