package main

import (
	"bytes"
	"context"
	"log"
	"os"
	"strings"
	"testing"
	"time"

	"github.com/kami/maven/internal/dialogue"
	"github.com/kami/maven/internal/memory"
	"github.com/kami/maven/internal/router"
	"github.com/kami/maven/internal/store"
)

// Dialogue contract tests (V-563, child of V-558).
//
// Every other clarify test is single-shot: one ask, one answer, one assertion.
// Three bugs of the same family shipped in two days that way — V-554 (a parked
// question ate the three turns after it), V-557 (a confidently routed but
// incomplete reminder parked nothing, so the answer was web-searched) and the
// Rome case in V-558 (a side question was eaten as the time answer). None of
// them is visible in one turn. The dialogue path is a state machine, so it can
// be enumerated instead: whole traces, each with a per-turn expectation and an
// expected END state — what was written to the store, and what is still parked.
//
// Two rules for the rows below.
//
// Where today's behaviour is correct, it is asserted. Where it is WRONG, the row
// carries the CORRECT expectation and is skipped with the Vikunja id that will
// unskip it. A weakened expectation would be worse than no row: it would pin the
// bug as the contract.
//
// Everything runs on the offline floor — hash embedder, no llama-server, no
// ONNX, StubDateTimeParser. That has one consequence worth knowing before
// reading a fire time here: the stub reads "в 11:00" and "через час" and does
// not read "на 9" or "на завтра", so a trace that needs those is noted where it
// sits.

// claim — which claimant consumed an utterance. Not asserted: it is derived from
// the log lines the daemon already emits and printed on every failure, because
// "the reply differed" does not distinguish a wrong claimant from wrong copy,
// and that distinction is the whole point of V-558.
type claim struct {
	utterance string
	steps     []string
}

func (c claim) String() string { return c.utterance + " ⇒ " + strings.Join(c.steps, " → ") }

// claimMarkers — log fragment to claimant name, in the order runTurn checks
// them. The fragments are the daemon's own words (clarify.go, repair.go,
// voice.go); a rename there shows up here as an "unclaimed" step rather than a
// silent mislabel.
var claimMarkers = []struct{ fragment, name string }{
	{"parked question expired", "clarify:expired"},
	{"is its own request", "clarify:stepped-aside"},
	{"gave up on", "clarify:gave-up"},
	{"did not fill", "clarify:re-ask"},
	{"one gap filled", "clarify:ask-second-gap"},
	{"asked about", "clarify:ask"},
	{"repair —", "repair"},
	{"route result: intent=", "route"},
}

// claimsOf reads the turn's log output and names the claimants that touched it.
func claimsOf(utterance, logged string) claim {
	c := claim{utterance: utterance}
	for _, line := range strings.Split(logged, "\n") {
		for _, m := range claimMarkers {
			if strings.Contains(line, m.fragment) {
				name := m.name
				if m.name == "route" {
					name = "route:" + intentInLine(line)
				}
				c.steps = append(c.steps, name)
				break
			}
		}
	}
	if len(c.steps) == 0 {
		c.steps = []string{"unclaimed"}
	}
	return c
}

func intentInLine(line string) string {
	_, rest, ok := strings.Cut(line, "intent=")
	if !ok {
		return "?"
	}
	intent, _, _ := strings.Cut(rest, " ")
	return intent
}

// parkedWant — the question that must be armed after a turn. Attempt matters:
// a claimant that spends a retry on an utterance that was never an answer is
// exactly the V-554 shape, and the count is the only place it shows.
type parkedWant struct {
	slot    dialogue.Slot
	attempt int
	// carries — a substring the parked utterance must still hold, so a re-park
	// that lost the answered subject fails here rather than three turns later.
	carries string
}

// turn — one utterance and everything that must be true right after it.
type turn struct {
	say string
	// wait — the clock moves this far BEFORE the utterance. The only way to
	// reach the TTL without sleeping.
	wait time.Duration
	// question — the reply must be exactly this clarify question, worded for
	// this attempt. Zero slot ⇒ not checked.
	question dialogue.Slot
	attempt  int
	// gap — which part of the time she is asking about, for a SlotTime question
	// (V-579). Zero value is the missing hour, which is what she asks first.
	gap whenGap
	// took — the words of the PREVIOUS turn that this ask must acknowledge
	// before asking again (V-593). Empty ⇒ the ask carries no acknowledgement,
	// which is right for a first ask and for an answer that moved nothing.
	took string
	// differs — this reply must not be byte-identical to the one before it. Set
	// on a re-ask whose turn moved the request forward (V-593).
	differs    bool
	contains   []string
	notContain []string
	// noQuestion — the reply must not be any clarify question. Used where the
	// correct behaviour is known but her wording for it is not written yet: a
	// cancel must not be answered with another question, whatever it does say.
	noQuestion bool
	expired    bool // the reply must open with the TTL notice
	// parked — what is armed after the turn. nil ⇒ nothing may be armed.
	parked *parkedWant
}

// endState — what the store holds once the trace is over. Counts and
// substrings, not rows: a trace is about who claimed what, and a payload
// substring is enough to catch a request landing under the wrong words.
type endState struct {
	reminders []reminderWant
	factKeys  []string
	notes     int
	tasks     []string
}

type reminderWant struct {
	payload string // substring of the stored payload
	fireAt  string // "2006-01-02 15:04" in UTC, "" ⇒ not checked
}

// trace — a named conversation, its turns, and the end state.
type trace struct {
	name  string
	skip  string // non-empty ⇒ t.Skip: today's behaviour is wrong, this names the fix
	turns []turn
	end   endState
}

// newDialogueHandler — the offline floor with the real cascade and a movable
// clock: newClarifyHandler's wiring (stub date parser, real fact parser, tool
// matcher) plus the router newRoutingClarifyHandler builds, and the `now`
// pointer so a turn can carry a wait.
func newDialogueHandler(t *testing.T) (*reactiveHandler, *store.Store, *time.Time) {
	t.Helper()
	h, st, now := newClarifyHandler(t)
	// A minute no trace ever says, so "fires at the current clock" is a defect
	// and never a coincidence (V-577, V-579). checkEnd refuses any reminder
	// landing on it, and at 09:00 the row that answers "на 9" would trip that.
	*now = time.Date(2026, 7, 31, 9, 17, 0, 0, time.UTC)
	h.router = buildRouter(router.NewHashEmbedder(1024), h.matcher, 0.55, nil, nil)
	h.recall = recallWiring{embedder: router.NewHashEmbedder(1024), memStore: memory.NewInMemoryStore()}
	return h, st, now
}

// wantedQuestion builds the question a turn must be answered with, from the
// same code the daemon asks through. A time question is built from the gap,
// because she names the clock and asks about the part he left out (V-579).
func wantedQuestion(tn turn, now time.Time) (string, bool) {
	if tn.question == dialogue.SlotTime {
		gap := tn.gap
		if gap == whenComplete {
			gap = whenNoHour
		}
		return whenQuestion(gap, tn.attempt, now, whenTakenLine(tn.took))
	}
	return clarifyQuestionFor(tn.question, tn.attempt)
}

// runTrace drives one trace through handleText and checks every turn, then the
// end state. Every failure carries the decision trace so far, so a wrong
// claimant reads differently from wrong copy.
func runTrace(t *testing.T, tr trace) {
	t.Helper()
	// MAVEN_DIALOGUE_NO_SKIP=1 runs the rows that fail today. That is how
	// whoever lands V-560, V-561 or V-562 sees their row go green before
	// deleting its skip, and it is also the check that a skip is still earned:
	// a row that passes with the skip in place is a fix nobody noticed.
	if tr.skip != "" && os.Getenv("MAVEN_DIALOGUE_NO_SKIP") == "" {
		t.Skip(tr.skip)
	}
	ctx := context.Background()
	h, st, now := newDialogueHandler(t)
	const conversation = "web"
	id := dialogueIDFor(sourceText, conversation)

	var claims []claim
	var previous string
	fail := func(turnIdx int, format string, args ...any) {
		t.Helper()
		lines := make([]string, 0, len(claims))
		for _, c := range claims {
			lines = append(lines, "    "+c.String())
		}
		t.Fatalf("turn %d: "+format+"\n  who claimed what:\n%s",
			append([]any{turnIdx}, append(args, strings.Join(lines, "\n"))...)...)
	}

	for i, tn := range tr.turns {
		if tn.wait > 0 {
			*now = now.Add(tn.wait)
		}
		var logged bytes.Buffer
		prev := log.Writer()
		log.SetOutput(&logged)
		reply := h.handleText(ctx, conversation, tn.say)
		log.SetOutput(prev)
		claims = append(claims, claimsOf(tn.say, logged.String()))

		body := reply
		if tn.expired {
			if !isClarifyExpired(reply) {
				fail(i, "reply %q must open with the expiry notice", reply)
			}
			body = trimClarifyExpired(reply)
			// The notice is glued in front of this turn's reply, and both halves
			// have to survive: the words he just said are routed fresh, and
			// answering only "I let the old one go" drops them.
			if body == "" {
				fail(i, "the notice was the whole reply; the fresh words were never answered")
			}
		} else if isClarifyExpired(reply) {
			fail(i, "reply %q announced an expiry nothing asked for", reply)
		}
		if tn.question != "" {
			want, ok := wantedQuestion(tn, h.now())
			if !ok {
				fail(i, "no question exists for slot %s attempt %d", tn.question, tn.attempt)
			}
			if body != want {
				fail(i, "reply %q, want the %s question worded for attempt %d, %q", body, tn.question, tn.attempt, want)
			}
		}
		if tn.noQuestion && isAnyClarifyQuestion(body) {
			fail(i, "reply %q is another question; this turn is not something to ask about", body)
		}
		for _, want := range tn.contains {
			if !strings.Contains(body, want) {
				fail(i, "reply %q does not carry %q", body, want)
			}
		}
		for _, unwanted := range tn.notContain {
			if strings.Contains(body, unwanted) {
				fail(i, "reply %q carries %q and must not", body, unwanted)
			}
		}
		if tn.differs && reply == previous {
			fail(i, "reply %q is byte-identical to the one before it, and his turn between them answered part of the gap", reply)
		}
		previous = reply
		checkParked(t, fail, i, h.clarifyStore.Get(id, h.now()), tn.parked)
	}
	checkEnd(t, ctx, st, h, tr.end, claims)
}

// isAnyClarifyQuestion — is this reply one of her clarify questions, at any
// attempt wording? Reads the templates rather than a list of its own.
func isAnyClarifyQuestion(reply string) bool {
	for _, variants := range clarifyQuestionVariants {
		for _, v := range variants {
			// HasSuffix, not equality: a question about the time opens with the
			// clock she is reasoning from (V-579).
			if strings.HasSuffix(reply, v) {
				return true
			}
		}
	}
	// The two questions with no deck behind them, asked when the hour is said
	// and its half of the day or its day is not.
	return strings.HasSuffix(reply, "утра или вечера?") || strings.HasSuffix(reply, "В какой день?")
}

func checkParked(t *testing.T, fail func(int, string, ...any), i int, got *dialogue.PendingQuestion, want *parkedWant) {
	t.Helper()
	if want == nil {
		if got != nil {
			fail(i, "a question about %v is still armed and nothing should be: %+v", got.Missing, got.Slots)
		}
		return
	}
	if got == nil {
		fail(i, "nothing is armed, want a question about %s (attempt %d)", want.slot, want.attempt)
		return
	}
	if len(got.Missing) != 1 || got.Missing[0] != want.slot {
		fail(i, "armed question is about %v, want %s", got.Missing, want.slot)
	}
	if got.Attempts != want.attempt {
		fail(i, "armed question is on attempt %d, want %d — a retry spent on something that was never an answer is the V-554 shape", got.Attempts, want.attempt)
	}
	if want.carries != "" && !strings.Contains(got.Utterance, want.carries) {
		fail(i, "the parked request no longer carries %q: %q", want.carries, got.Utterance)
	}
}

func checkEnd(t *testing.T, ctx context.Context, st *store.Store, h *reactiveHandler, want endState, claims []claim) {
	t.Helper()
	lines := make([]string, 0, len(claims))
	for _, c := range claims {
		lines = append(lines, "    "+c.String())
	}
	trace := "\n  who claimed what:\n" + strings.Join(lines, "\n")

	reminders, err := st.DueReminders(ctx, h.now().Add(14*24*time.Hour))
	if err != nil {
		t.Fatalf("DueReminders: %v", err)
	}
	if len(reminders) != len(want.reminders) {
		t.Fatalf("end state: %d reminder(s), want %d: %+v%s", len(reminders), len(want.reminders), reminders, trace)
	}
	// No trace may leave a reminder at the current clock, whatever else it
	// asserts (V-577, V-579). Twice on the box a sentence naming a day and no
	// hour was completed from time.Now(): "что у меня сегодня?" became 01:28 and
	// "на завтра" became 01:38. Neither minute was ever spoken, and a row that
	// only checked the payload would have passed both.
	for _, r := range reminders {
		if r.FireTs.In(h.now().Location()).Format("15:04") == h.now().Format("15:04") {
			t.Fatalf("end state: reminder %q fires at %s, which is the clock — a time slot naming no hour is asked about, never filled from now()%s",
				r.Payload, r.FireTs.Format("15:04"), trace)
		}
	}
	for i, w := range want.reminders {
		if !strings.Contains(reminders[i].Payload, w.payload) {
			t.Fatalf("end state: reminder %d payload %q does not carry %q%s", i, reminders[i].Payload, w.payload, trace)
		}
		if w.fireAt != "" {
			if got := reminders[i].FireTs.UTC().Format("2006-01-02 15:04"); got != w.fireAt {
				t.Fatalf("end state: reminder %d fires at %s, want %s%s", i, got, w.fireAt, trace)
			}
		}
	}

	facts, err := st.RecentFacts(ctx, 20)
	if err != nil {
		t.Fatalf("RecentFacts: %v", err)
	}
	if len(facts) != len(want.factKeys) {
		t.Fatalf("end state: %d fact(s), want %d: %+v%s", len(facts), len(want.factKeys), facts, trace)
	}
	for i, key := range want.factKeys {
		if facts[i].Key != key {
			t.Fatalf("end state: fact %d is %q, want %q%s", i, facts[i].Key, key, trace)
		}
	}

	notes, err := st.RecentNotes(ctx, 20)
	if err != nil {
		t.Fatalf("RecentNotes: %v", err)
	}
	if len(notes) != want.notes {
		t.Fatalf("end state: %d note(s), want %d%s", len(notes), want.notes, trace)
	}

	tasks, err := st.ListTasks(ctx, store.TaskOpen)
	if err != nil {
		t.Fatalf("ListTasks: %v", err)
	}
	if len(tasks) != len(want.tasks) {
		t.Fatalf("end state: %d open task(s), want %d: %+v%s", len(tasks), len(want.tasks), tasks, trace)
	}
	for i, text := range want.tasks {
		if !strings.Contains(tasks[i].Text, text) {
			t.Fatalf("end state: task %d is %q, want it to carry %q%s", i, tasks[i].Text, text, trace)
		}
	}
}

func TestDialogueTraces(t *testing.T) {
	for _, tr := range dialogueTraces() {
		tr := tr
		t.Run(tr.name, func(t *testing.T) { runTrace(t, tr) })
	}
}

// dialogueTraces — the fixture. Order is the order the shapes were found, not a
// dependency: each trace builds its own handler and store.
func dialogueTraces() []trace {
	return []trace{
		// The plain two-turn shape, and the one every other row is a deviation
		// from: she asks for the time, he gives it, the reminder lands with the
		// subject he said in the FIRST turn.
		{
			// Three turns since V-579, not two. An hour with no day named is
			// not an answer she can act on: 11:00 today has passed as often as
			// not, and picking one for him is the invention the whole rule is
			// against. So she says the clock she is reasoning from and asks
			// which day.
			name: "reminder completed over three turns",
			turns: []turn{
				{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
				{say: "в 11:00", question: dialogue.SlotTime, attempt: 2, gap: whenNoDay, took: "в 11:00",
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2, carries: "маме"}},
				{say: "сегодня", contains: []string{"11:00"}, notContain: []string{"?"}},
			},
			end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-07-31 11:00"}}},
		},
		// The same shape on the fact path, where the answer carries both halves
		// of what was missing — the key and the value — in one breath.
		{
			name: "fact completed over two turns",
			turns: []turn{
				{say: "запиши", question: dialogue.SlotKey, attempt: 1,
					parked: &parkedWant{slot: dialogue.SlotKey, attempt: 1}},
				// His words back, not the key the parser filed them under
				// (V-592). "water" is machine vocabulary and he never said it.
				{say: "пил воду", contains: []string{"пил воду"}},
			},
			end: endState{factKeys: []string{"water"}},
		},
		// An answer past the TTL is a new request, not an answer (V-385). She
		// says the old one is gone and routes the words fresh. A bare time on
		// its own carries no request, so the fresh routing lands on the canned
		// reply — the point of the row is that NOTHING is created: a reminder
		// here would fire with the subject of a request she had already let go.
		{
			name: "answer arrives after the TTL",
			turns: []turn{
				{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
				{say: "в 11:00", wait: clarifyTTL + time.Second, expired: true},
			},
			end: endState{},
		},
		// Three questions is the budget, and running out is SPOKEN: a mute
		// give-up reads as "done" and he would wait for a reminder that was
		// never set. The wording changes with the attempt (V-457).
		{
			name: "three unclear answers then the give-up line",
			turns: []turn{
				{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
				{say: "ну не знаю", question: dialogue.SlotTime, attempt: 2,
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
				{say: "ну не знаю", question: dialogue.SlotTime, attempt: 3,
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
				{say: "ну не знаю", contains: []string{clarifyGaveUp}, noQuestion: true},
			},
			end: endState{},
		},
		// A correction points at the previous ACTED turn (repair.go): she redoes
		// it under the intent he names and says so out loud, because a
		// correction he cannot see is indistinguishable from one that was
		// dropped. The task she filed first stays filed — repair redoes, it does
		// not retract, and V-455 decided that deliberately.
		//
		// The corrected-to intent has to differ from the one she used, or repair
		// declines: teaching the classifier the label it already produced is
		// worse than doing nothing.
		{
			name: "correction of the previous turn",
			turns: []turn{
				{say: "добавь в задачи купить молоко", contains: []string{"купить молоко"}},
				{say: "нет, это был вопрос", contains: []string{"поняла, это вопрос"}},
			},
			end: endState{tasks: []string{"купить молоко"}},
		},
		// He walks away from his own request: a question is parked, the next
		// utterance is an unrelated request of its own, and nothing follows.
		// V-554's fix is what makes this row pass — the question steps aside
		// rather than scoring "добавь в задачи" as the time. The reminder is
		// dropped in silence and that is the decision: if he meant it he says it
		// again, and a question left armed eats the turn after next.
		{
			name: "abandoned flow: parked, then an unrelated request",
			turns: []turn{
				{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
				{say: "добавь в задачи купить молоко", contains: []string{"купить молоко"}},
				{say: "спасибо"},
			},
			end: endState{tasks: []string{"купить молоко"}},
		},

		// V-577 shape 1, the worst of the nine claimants measured on 2026-08-06.
		// Every token of "что у меня сегодня?" is frame — an interrogative, a
		// preposition, a particle and a day word — so the role classifier never
		// looked at the route, the parked reminder read "сегодня" as its time,
		// and the hour came from the clock. He got a reminder he never asked for
		// at a minute he never said, and his question was answered nowhere.
		//
		// Two claims: the calendar answers, and nothing is written. The flow
		// survives underneath, because a question of his own is not a request to
		// abandon the one he was making.
		{
			name: "an agenda question mid-flow is answered, not eaten",
			turns: []turn{
				{say: "напомни забрать посылку", question: dialogue.SlotTime, attempt: 1,
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "посылку"}},
				{say: "что у меня сегодня?", contains: []string{"31.07.2026"},
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "посылку"}},
			},
			end: endState{},
		},
		// V-577 shape 2. He states something in the middle of the flow. It is
		// neither a slot value nor a cancel, and it was scored as a failed
		// answer and dropped in silence: alone the same sentence is stored.
		// Silence is the one option that is wrong, so it is stored, no retry is
		// spent, and the question comes back on the end of the same reply.
		//
		// The words are a fact and not the owner's note, because the fact parser
		// is deterministic and the offline floor marks every classifier route
		// Clarify. The row below carries his own sentence and needs the model.
		//
		// What this floor can prove is the arbitration: no retry is spent, the
		// flow survives on the same attempt, and the words are answered as
		// themselves with the question coming back after them. Whether the fact
		// is then WRITTEN is the routing engine's business — the hash embedder
		// is unsure of every sentence it sees, and an unsure fact has never been
		// stored.
		{
			name: "a fact stated mid-flow steps aside without spending a retry",
			turns: []turn{
				{say: "напомни позвонить врачу", question: dialogue.SlotTime, attempt: 1,
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
				// Nothing she says about it may be a word he did not say
				// (V-592). On the box this sentence came back as "Проверила, что
				// ты выпел стакан воды": a non-word for the verb, a glass copied
				// out of the example in ReplySystemPrompt, and a claim to have
				// checked something. The store held key=water value="drank"
				// throughout, so all of it was generated from two tokens.
				//
				// The positive half of the contract — the confirmation IS his
				// sentence — is asserted by "fact completed over two turns"
				// above. It cannot be asserted here: the hash embedder marks
				// this route Clarify, and an unsure fact is answered with the
				// canned line rather than a confirmation of anything.
				{say: "я выпил воды", contains: []string{"напоминание?"},
					notContain: []string{"стакан", "выпел", "Проверила", "water"},
					parked:     &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
			},
			end: endState{},
		},
		// V-593: two asks about the same half of the day, with a turn between
		// them that answered the DAY. Asking again is right and asking in the
		// same bytes is not — from his side it is indistinguishable from not
		// having been heard, which is what the whole V-558 family is about.
		//
		// The clock still opens every ask (the owner's rule, V-579); the
		// acknowledgement goes after it and before the question.
		{
			name: "a re-ask names what the answer before it gave her",
			turns: []turn{
				{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
				{say: "на 9", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, took: "на 9",
					contains: []string{"Сейчас "},
					parked:   &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
				{say: "на завтра", question: dialogue.SlotTime, attempt: 3, gap: whenAmbiguousHour, took: "на завтра",
					contains: []string{"Сейчас ", "завтра"}, differs: true,
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
			},
			end: endState{},
		},
		// V-579 turn 3: the preposition decided whether the hour was read. "в 9"
		// set the reminder and "на 9" was not read at all, on the same build and
		// with the same cardinal.
		{
			// It is read, and being read is not the same as being enough: nine is
			// either half of the day, so she asks which and then which day
			// (V-579). Both answers are frame words and neither carries an hour
			// of its own, so this row is also the proof that an answer is read
			// against the whole request rather than alone.
			name: "на 9 answers the time question like в 9",
			turns: []turn{
				{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
				{say: "на 9", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, took: "на 9",
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
				{say: "утра", question: dialogue.SlotTime, attempt: 3, gap: whenNoDay, took: "утра",
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
				{say: "завтра", contains: []string{"09:00"}},
			},
			end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-08-01 09:00"}}},
		},
		// The owner's own four, ruled 2026-08-06 (V-579). A reminder commits
		// when what, what time and what day are all answered, and every ask
		// states the clock she is reasoning from.
		{
			name: "his first example: a bare 3 is asked about",
			turns: []turn{
				{say: "напомни завтра в 3 заказать цветы",
					question: dialogue.SlotTime, attempt: 1, gap: whenAmbiguousHour,
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "цветы"}},
			},
			end: endState{},
		},
		{
			// The hour is unambiguous and the day is still missing, so she asks.
			// Today being a valid reading is not the same as him saying it.
			name: "his second example: nine in the evening of which day",
			turns: []turn{
				{say: "напомни в 9 вечера разгрузить стиралку",
					question: dialogue.SlotTime, attempt: 1, gap: whenNoDay,
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "стиралку"}},
				{say: "завтра", contains: []string{"21:00"}},
			},
			end: endState{reminders: []reminderWant{{payload: "стиралку", fireAt: "2026-08-01 21:00"}}},
		},
		{
			// All three answered in one breath, so she does not ask at all.
			name: "his third example: a full time commits",
			turns: []turn{
				{say: "напомни завтра в 15:00 заказать цветы", notContain: []string{"?"}},
			},
			end: endState{reminders: []reminderWant{{payload: "цветы", fireAt: "2026-08-01 15:00"}}},
		},
		{
			// An interval is one instant, so it answers the hour and the day
			// together. Confirmed by the owner: "через час is fine as is".
			name: "an interval commits without a question",
			turns: []turn{
				{say: "напомни через час позвонить маме", notContain: []string{"?"}},
			},
			end: endState{reminders: []reminderWant{{payload: "маме", fireAt: "2026-07-31 10:17"}}},
		},
		// V-579 turn 4: he named a day and no hour, and got the day at the
		// current minute. She has to ask instead, and the global check in
		// checkEnd refuses the invented minute for every row at once.
		{
			name: "a day with no hour is asked about, not taken from the clock",
			turns: []turn{
				{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
				{say: "на завтра", question: dialogue.SlotTime, attempt: 2,
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
			},
			end: endState{},
		},

		// ---- rows below carry the CORRECT expectation and fail today ----

		// The owner's own sentence from V-577 shape 2, in his words. It needs
		// an engine that can route it: the hash embedder marks it note with
		// Clarify set, and a route she is not sure of is not evidence that he
		// stated anything. The row above is the same contract in words the
		// floor's deterministic fact parser reads.
		{
			name: "a note stated mid-flow is stored, not dropped",
			skip: "the offline floor cannot route «у меня новый ноутбук» confidently; needs the resident model",
			turns: []turn{
				{say: "напомни позвонить врачу", question: dialogue.SlotTime, attempt: 1,
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
				{say: "у меня новый ноутбук",
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
			},
			end: endState{notes: 1},
		},

		// The owner's target transcript, V-561. He asks for a reminder, she asks
		// when, he asks something else entirely, and then comes back to her
		// question. On the box this created a reminder at 00:12 and never
		// answered Rome; on the offline floor the side question is recognised as
		// its own request and the flow is dropped instead, so the wrong reminder
		// is not made and the right one is not either.
		//
		// Both are the same defect: there is no suspend and resume. The correct
		// shape is the middle turn answered on its own and the parked question
		// still standing, on the same attempt — a side query is not a failed
		// answer and must not spend a retry.
		//
		// The skip came off with V-579. What held it was the parser, not the
		// arbitration: neither the stub nor the production one read "на 9",
		// because only "в" framed a spoken hour, and "на завтра" was completed
		// from the clock.
		//
		// Turn 3 now closes the flow, where the transcript has one more exchange
		// in it. That is the 12-hour question — the owner's turn 4 answers "на
		// 9" with "сейчас 15:23, на 9 сегодня вечером?" — and it is a decision of
		// its own, not one to invent here. Nine o'clock is read as nine and, at
		// 09:17, as tomorrow's, which is where the transcript ends up anyway.
		// Turn 4 then has nothing to answer and must not write anything.
		{
			name: "the owner's transcript from V-561",
			turns: []turn{
				{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
				{say: "какая сейчас погода в Риме?",
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
				// His words, unchanged. What changed under V-579 is that "на 9"
				// is a question and not a commit: nine could be either half of
				// the day, so she says the clock she is reading from and asks.
				// "на завтра." then answers the day and leaves the half open, so
				// she asks that one again.
				// Each ask names what the turn before it gave her (V-593). The
				// two asks about the half of the day are the same question and
				// must not be the same sentence: he answered between them, and a
				// reply with no trace of that reads as not having been heard.
				{say: "а, да, прости - на 9.", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, took: "а, да, прости - на 9.",
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
				{say: "на завтра.", question: dialogue.SlotTime, attempt: 3, gap: whenAmbiguousHour, took: "на завтра.",
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
			},
			end: endState{},
		},
		// The same shape said in words StubDateTimeParser reads. GREEN since
		// V-561. Same three claims: Rome is answered, the question survives the
		// side query on the same attempt, and the answer after it completes the
		// reminder he actually asked for.
		//
		// It sits under the "fail today" header because the row above it still
		// does. Do not re-skip it to tidy that up: this is the owner's
		// acceptance test in the only words the offline floor can read.
		{
			name: "nested question: a parked question, then one of his own",
			turns: []turn{
				{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
				{say: "какая сейчас погода в Риме?",
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
				{say: "в 11:00", question: dialogue.SlotTime, attempt: 2, gap: whenNoDay, took: "в 11:00",
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2, carries: "маме"}},
				{say: "сегодня", contains: []string{"11:00"}},
			},
			end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-07-31 11:00"}}},
		},
		// A cancel is one of the five turn roles V-560 names, and today it is
		// none of them: "неважно" fills no slot and carries no request of its
		// own, so it reads as a failed answer and spends a retry. Two turns
		// later she is still asking about a reminder he called off.
		//
		// The row asserts what is knowable — nothing armed, nothing written, and
		// not another question — rather than her wording for it, which is not
		// written yet and is not this task's to invent.
		{
			name: "cancel: a parked question, then never mind",
			skip: "V-560: a cancel is scored as a failed answer, not as a cancel",
			turns: []turn{
				{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
				{say: "неважно", noQuestion: true},
			},
			end: endState{},
		},
		// Order in runTurn is the whole arbitration (V-558), and this is what it
		// costs: the clarify answer is checked at step 3 and the repair marker at
		// step 4d, so while a question is parked no correction can be made. She
		// scores "нет, это была заметка" as a bad time answer and asks again.
		{
			name: "correction while a question is parked",
			skip: "V-560: clarify pre-empts the repair marker, so a correction cannot be spoken mid-flow",
			turns: []turn{
				{say: "добавь в задачи купить молоко", contains: []string{"купить молоко"}},
				{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
				{say: "нет, это был вопрос", contains: []string{"поняла, это вопрос"},
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
			},
			end: endState{tasks: []string{"купить молоко"}},
		},
		// A reminder said whole, in one breath, with the hour in it — and she
		// asks when. ReminderGrammar (stage0.go) builds its slots by hand and
		// never runs the extractor, so a stage-0 reminder carries no time
		// whatever the sentence says, and the clarify gate reads the gap as
		// real. It costs a turn on the commonest reminder shape there is.
		//
		// Hermetic despite the date parser: stage 0 calls no parser at all, so
		// this fails the same way with or without python dateparser installed.
		{
			name: "a reminder said whole is not asked about",
			skip: "V-562: a stage-0 decision never meets the extractor, so its slots are never validated",
			turns: []turn{
				{say: "напомни в 11:00 позвонить маме", contains: []string{"11:00"}, noQuestion: true},
			},
			end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-07-31 11:00"}}},
		},
		// The same gap on the repair path. A correction redoes the request
		// through finishClarified, which goes straight to applyAction — it never
		// passes the clarify gate — so a redo that lands short answers with the
		// parse error V-557 removed from the routing path: "не поняла, на когда
		// напомнить." She should ask, exactly as she does for a fresh reminder
		// with no time.
		{
			name: "a correction that lands short asks rather than failing",
			skip: "V-562: finishClarified skips the clarify gate, so a repaired decision is never checked for gaps",
			turns: []turn{
				{say: "добавь в задачи купить молоко", contains: []string{"купить молоко"}},
				{say: "нет, это было напоминание", contains: []string{"поняла, это напоминание"},
					parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
			},
			end: endState{tasks: []string{"купить молоко"}},
		},
	}
}
