// mavend/decisiontrace.go — the daemon's half of the per-turn decision record.
//
// V-564. The router says what the cascade did (internal/router/decisiontrace.go);
// this file covers the two claimant sets that live in the daemon: the stateful
// resolvers that run BEFORE routing and pre-empt it unconditionally, and the
// query source chain that runs after. Those two are where the arbitration is
// least visible, because both are a hardcoded order of functions that each
// answer "is this mine?" alone and none of which answers "is this more mine
// than yours?" (V-558).
//
// Recording changes no route. Every helper here is a no-op on a context with no
// record, which is what every test that does not ask for one gets.
package main

import (
	"context"

	"github.com/kami/maven/internal/decision"
	"github.com/kami/maven/internal/ipc"
	"github.com/kami/maven/internal/router"
)

// preRouteLadder — the resolvers runTurn offers the utterance to before the
// router sees it, in the order they get their say. Kept here as a roster rather
// than derived from the code, so a resolver that returns early and skips the
// rest still leaves the rest NAMED in the record: a claimant that never looked
// and one that looked and passed are the distinction the ordering hides, and
// they are the difference between a bug in the ladder and a bug in a resolver.
//
// Adding a step to runTurn means adding its name here. Nothing enforces that,
// and nothing should: a missing name costs one line of the record, while a
// check that walks the ladder would have to run the ladder.
var preRouteLadder = []string{
	"confirm", "repair", "repair-negative", "command-prohibition", "clarify-answer", "quiet-toggle",
	"snooze", "ack", "reminder-cancel", "ordinal",
}

// notePreRoute records one rung of that ladder and passes its verdict through
// unchanged, so the call site stays the single `if handled` it already was.
func notePreRoute(ctx context.Context, name string, handled bool) bool {
	rec := decision.From(ctx)
	if rec == nil {
		return handled
	}
	if handled {
		rec.Note(decision.Claim{
			Stage: decision.StagePreRoute, Claimant: name, Outcome: decision.Won,
			Reason: "it pre-empted routing, so the router never saw this turn",
		})
		return handled
	}
	rec.Note(decision.Claim{
		Stage: decision.StagePreRoute, Claimant: name, Outcome: decision.Declined,
		Reason: "nothing of its own was pending",
	})
	return handled
}

// noteTerminal records whoever actually produced the reply, but only if the
// turn is still unclaimed. A route decides the intent; it does not answer, and
// on a thinned route or a plain act nothing downstream keeps a scoreboard. So
// the record would otherwise close with an empty winner, which reads as a lost
// turn instead of an asked question.
func noteTerminal(ctx context.Context, claimant string, intent router.Intent, reason string) {
	decision.From(ctx).NoteIfUnclaimed(decision.Claim{
		Stage: decision.StageAction, Claimant: claimant,
		Intent: string(intent), Reason: reason,
	})
}

// noteMerge records the follow-up merge, which is the one claimant that edits
// the winning decision instead of taking the turn from it. It is compared on
// the four slots the merge can fill, because a Decision holds a slice and is
// not comparable.
func noteMerge(ctx context.Context, before, after router.Decision) {
	rec := decision.From(ctx)
	if rec == nil {
		return
	}
	changed := before.Slots.HasTime != after.Slots.HasTime ||
		before.Slots.HasKey != after.Slots.HasKey ||
		before.Slots.HasFn != after.Slots.HasFn ||
		before.Slots.Text != after.Slots.Text ||
		before.Intent != after.Intent
	if !changed {
		rec.Note(decision.Claim{
			Stage: decision.StageMerge, Claimant: "follow-up-merge", Outcome: decision.Declined,
			Reason: "no slot of this turn was left for a previous one to fill",
		})
		return
	}
	rec.Note(decision.Claim{
		Stage: decision.StageMerge, Claimant: "follow-up-merge", Intent: string(after.Intent),
		Outcome: decision.Merged, Reason: "filled this turn's gaps from the previous turn",
	})
}

// turnDecisionsFn — the reader mavweb gets, or nil when voice was never wired.
// Same shape as intakeEventsFn: the daemon holds the ring, the IPC layer only
// converts it.
func turnDecisionsFn(w *voiceWiring) func(int) []ipc.TurnDecision {
	if w == nil || w.handler == nil || w.handler.decisions == nil {
		return nil
	}
	ring := w.handler.decisions
	return func(n int) []ipc.TurnDecision {
		recs := ring.Recent(n)
		out := make([]ipc.TurnDecision, 0, len(recs))
		for _, rec := range recs {
			claims := make([]ipc.TurnClaim, 0, len(rec.Claims))
			for _, c := range rec.Claims {
				claims = append(claims, ipc.TurnClaim{
					Stage: c.Stage, Claimant: c.Claimant, Intent: c.Intent,
					Score: c.Score, HasScore: c.HasScore,
					Outcome: c.Outcome, Reason: c.Reason,
				})
			}
			out = append(out, ipc.TurnDecision{
				Ts: rec.Ts, Utterance: rec.Utterance, Winner: rec.Winner, Claims: claims,
			})
		}
		return out
	}
}

// querySourceNames — the query chain's roster, in chain order.
func querySourceNames() []string {
	names := make([]string, len(querySources))
	for i, src := range querySources {
		names[i] = src.name
	}
	return names
}
