package eval

import (
	"fmt"
	"regexp"
	"strings"
	"unicode"
	"unicode/utf8"

	"github.com/kami/maven/internal/morph"
)

// The check names, in report order. Every check is a string or length test — no
// model grades another model here.
const (
	CheckMood     = "mood"     // mood is in the documented enum
	CheckLang     = "lang"     // the operator's language, not the prompt's
	CheckLength   = "length"   // a nudge is one sentence, not a paragraph
	CheckFeminine = "feminine" // her self-reference is feminine (hard constraint)
	CheckCringe   = "cringe"   // docs/design.md § Non-goals, "not a relationship"
	CheckOnTopic  = "ontopic"  // says the thing the rule is about

	// CheckHisGender — the other half of the persona rule: SHE is feminine, HE
	// is male. "ты давно не отдыхала" addresses the operator as a woman.
	CheckHisGender = "hisgender"

	// CheckAddress — she talks TO him, informally, one to one. Not "вы", not
	// "он". See the comment block above checkAddress.
	CheckAddress = "address"
)

// CheckNames — report order.
var CheckNames = []string{CheckMood, CheckLang, CheckLength, CheckFeminine, CheckHisGender, CheckAddress, CheckCringe, CheckOnTopic}

// Result — one check on one message.
type Result struct {
	Name   string
	Pass   bool
	Detail string
}

// Moods — the fixed enum from the LLM output contract. Not extended here; the
// contract lives in the daemon and the harness only reads it.
var Moods = map[string]bool{
	"neutral": true, "happy": true, "thinking": true, "tired": true, "confused": true,
}

// Length ceilings. Justification: the nudge is spoken by piper at roughly 14
// characters per second, so 120 characters is about 8 seconds of speech. The
// operator has AuDHD — past one short sentence a nudge stops being a nudge and
// becomes something to tune out, which is exactly the "not a nag" failure. The
// word ceiling catches the same thing for languages that pack more per byte.
const (
	MaxChars = 120
	MaxWords = 16
)

// RunChecks scores one message. Order matches CheckNames.
func RunChecks(c Case, body, mood string) []Result {
	return []Result{
		checkMood(mood),
		checkLang(body),
		checkLength(body),
		checkFeminine(body),
		checkHisGender(body),
		checkAddress(body),
		checkCringe(body),
		checkOnTopic(c, body),
	}
}

// Feminine, HisGender and Address expose three checks one at a time, so the
// daemon can run them on a phrased message before he hears it (Vikunja #399).
// Only these three: they are unambiguous string tests with nothing to compare
// against, while length is path-specific and ontopic needs the fixture's
// expected fragments, which do not exist at runtime.
func Feminine(body string) Result { return checkFeminine(body) }

// HisGender — see checkHisGender.
func HisGender(body string) Result { return checkHisGender(body) }

// Address — see checkAddress.
func Address(body string) Result { return checkAddress(body) }

func checkMood(mood string) Result {
	if Moods[mood] {
		return Result{CheckMood, true, ""}
	}
	return Result{CheckMood, false, fmt.Sprintf("mood %q not in the enum", mood)}
}

// checkLang — the operator is Russian-speaking and the nudge is spoken aloud by
// a Russian piper voice. An English nudge is not a tone problem, it is an
// unusable one.
func checkLang(body string) Result {
	cyr, lat := 0, 0
	for _, r := range body {
		switch {
		case unicode.Is(unicode.Cyrillic, r):
			cyr++
		case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z':
			lat++
		}
	}
	if cyr > lat {
		return Result{CheckLang, true, ""}
	}
	return Result{CheckLang, false, fmt.Sprintf("not Russian (%d cyrillic vs %d latin letters)", cyr, lat)}
}

func checkLength(body string) Result {
	chars := utf8.RuneCountInString(body)
	words := len(strings.Fields(body))
	if chars <= MaxChars && words <= MaxWords {
		return Result{CheckLength, true, ""}
	}
	return Result{CheckLength, false,
		fmt.Sprintf("%d chars / %d words, ceiling %d / %d", chars, words, MaxChars, MaxWords)}
}

// --- feminine self-reference ---------------------------------------------
//
// The hard constraint (CLAUDE.md, docs/design.md § Identity): Maven's Russian
// self-reference is feminine. The operator is male, so second-person forms
// addressed to him are MASCULINE and must not be flagged — "ты не пил воду" is
// correct, "я напомнил" is not. Both directions matter, which is why this is a
// windowed scan around "я" and not a bare search for masculine endings.

var wordRE = regexp.MustCompile(`[\p{Cyrillic}]+|[,.;:!?…—-]`)

// secondPerson — pronouns that end the self-reference window. Everything after
// one of these is about him, not about her.
var secondPerson = map[string]bool{
	"ты": true, "тебе": true, "тебя": true, "тобой": true,
	"вы": true, "вам": true, "вас": true,
	"он": true, "она": true, "оно": true, "они": true,
}

// masculinePredicative — short adjectives with no verb ending to key off.
var masculinePredicative = map[string]bool{
	"должен": true, "готов": true, "рад": true, "уверен": true,
	"обязан": true, "сам": true, "занят": true, "прав": true,
}

// masculinePast reports whether a word is a masculine past-tense verb. Russian
// past tense is gendered by suffix: -л for him, -ла for her. A small model with
// weak Russian defaults to the masculine form, which is the exact drift this
// check measures.
//
// The ending is only half the test, and the other half used to be a hand list of
// nineteen nouns that end in л — стол, файл, апрель — kept "small on purpose",
// which means incomplete on purpose. A list of exceptions to a pattern is the
// pattern conceding it is wrong, so the second half is now a dictionary lookup:
// ends in -л AND is a form of a verb (Vikunja #526). Every noun the list held is
// correctly not a verb, and so are the ones it had not got round to.
func masculinePast(w string) bool {
	if len([]rune(w)) < 3 {
		return false
	}
	if !strings.HasSuffix(w, "л") && !strings.HasSuffix(w, "лся") {
		return false
	}
	return morph.IsVerbForm(w)
}

func checkFeminine(body string) Result {
	words := wordRE.FindAllString(strings.ToLower(body), -1)
	for i, w := range words {
		if w != "я" {
			continue
		}
		// Scan the next few words. Stop at punctuation or at a second-person
		// pronoun: past that point the sentence is about him and masculine is
		// correct.
		for j := i + 1; j < len(words) && j <= i+3; j++ {
			nw := words[j]
			if len(nw) == 1 && !unicode.Is(unicode.Cyrillic, []rune(nw)[0]) {
				break
			}
			if secondPerson[nw] {
				break
			}
			if masculinePast(nw) || masculinePredicative[nw] {
				return Result{CheckFeminine, false,
					fmt.Sprintf("masculine self-reference %q after \"я\"", nw)}
			}
		}
	}
	// Second pass: self-reference with the pronoun dropped — "напомнил тебе",
	// "проверил за тебя". A masculine past-tense verb whose object is HIM can
	// only be her speaking about herself.
	//
	// Two guards, both from a false positive on the talk fixture: "ты заплатил
	// за домен до марта" scored as her drift and cost the run a point it had
	// earned (Vikunja #462). He is male, so a past-tense verb governed by "ты"
	// must be masculine. And a bare "за" is not evidence of anything — "за
	// домен" is a price, "за тебя" is her doing something on his behalf — so it
	// only counts when he is the one it points at.
	for i, w := range words {
		if !masculinePast(w) || i+1 >= len(words) || governedByYou(words, i) {
			continue
		}
		next := words[i+1]
		aboutHim := next == "тебе" || next == "тебя"
		if next == "за" && i+2 < len(words) && (words[i+2] == "тебя" || words[i+2] == "тебе") {
			aboutHim = true
		}
		if aboutHim {
			return Result{CheckFeminine, false,
				fmt.Sprintf("masculine self-reference %q before %q", w, next)}
		}
	}
	return Result{CheckFeminine, true, ""}
}

// --- he is male ----------------------------------------------------------
//
// The mirror of checkFeminine, and the failure it was written for: the model
// wrote "ты давно не отдыхала", which addresses the operator as a woman. That
// scored clean, because checkFeminine only ever looks at how SHE speaks about
// herself.
//
// How it works: Russian past tense is gendered by suffix, -л (m) / -ла (f). So
// this looks for feminine past-tense words in a sentence that also talks TO him
// ("ты", "тебя", "тебе", "твой", …). A feminine verb that belongs to her ("я
// заметила", "напомнила тебе") is skipped — that one is correct.
//
// Honest about the limits: this is a suffix rule, not a parser.
//   - False positives: a feminine noun can be the subject in the same sentence
//     ("зарядка была утром, ты её пропустил"). The guard below skips a verb whose
//     previous word looks like a feminine noun, which helps but will not always
//     be right.
//   - False negatives: gender also shows up outside the past tense (short
//     adjectives, "сама"), and none of that is checked here.
//
// That is acceptable for an eval check. It is a signal to read the message, not
// a grammar verdict, and every hit prints the word it tripped on so a human can
// disagree.

// hisMarkers — words that mean the sentence is addressed to him.
var hisMarkers = map[string]bool{
	"ты": true, "тебя": true, "тебе": true, "тобой": true, "тобою": true,
	"твой": true, "твоя": true, "твоё": true, "твое": true, "твои": true, "твою": true,
}

// notFeminineVerb — ordinary words ending in "-ла" that are not verbs. Small on
// purpose: it only has to cover words a nudge might actually use.
//
// Words that are both a noun and a verb are deliberately NOT here. "села",
// "мыла" and "стекла" are nouns on paper, but in a nudge they are almost always
// verbs ("ты села", "ты мыла"), and listing them would make the check miss the
// exact thing it is for. Missing a real hit is worse than one false alarm.
var notFeminineVerb = map[string]bool{
	"школа": true, "скала": true, "игла": true, "метла": true, "смола": true,
	"дела": true, "тела": true, "масла": true, "весла": true,
	"зола": true, "пчела": true, "числа": true,
}

// femininePast reports whether a word looks like a feminine past-tense verb:
// "отдыхала", "поела", "выспалась".
func femininePast(w string) bool {
	if len([]rune(w)) < 3 || notFeminineVerb[w] {
		return false
	}
	return strings.HasSuffix(w, "ла") || strings.HasSuffix(w, "лась")
}

// looksFeminineNoun — a crude guard against "зарядка была": a word right before
// the verb that ends in "а"/"я" and is not itself a verb is probably the subject.
func looksFeminineNoun(w string) bool {
	if femininePast(w) || len([]rune(w)) < 3 {
		return false
	}
	return strings.HasSuffix(w, "а") || strings.HasSuffix(w, "я")
}

// sentenceRE splits on sentence-ending punctuation, so a feminine verb in one
// sentence is not blamed on a "ты" in the next.
var sentenceRE = regexp.MustCompile(`[.!?;…]+`)

func checkHisGender(body string) Result {
	for _, sentence := range sentenceRE.Split(strings.ToLower(body), -1) {
		words := wordRE.FindAllString(sentence, -1)
		addressed := false
		for _, w := range words {
			if hisMarkers[w] {
				addressed = true
			}
		}
		if !addressed {
			continue
		}
		for i, w := range words {
			if !femininePast(w) || hersNotHis(words, i) {
				continue
			}
			if i > 0 && looksFeminineNoun(prevWord(words, i)) {
				continue
			}
			return Result{CheckHisGender, false,
				fmt.Sprintf("feminine %q addressed to him — he is male", w)}
		}
	}
	return Result{CheckHisGender, true, ""}
}

// hersNotHis — the verb is Maven's own if "я" comes shortly before it, or if the
// thing she did was done to him ("напомнила тебе", "проверила за тебя").
func hersNotHis(words []string, i int) bool {
	for j := i - 1; j >= 0 && j >= i-3; j-- {
		if words[j] == "я" {
			return true
		}
	}
	if i+1 < len(words) {
		switch words[i+1] {
		case "тебе", "тебя", "за", "тобой":
			return true
		}
	}
	return false
}

// prevWord — the word before i, skipping "не" and punctuation, so "не отдыхала"
// still sees the subject.
func prevWord(words []string, i int) string {
	for j := i - 1; j >= 0; j-- {
		w := words[j]
		if w == "не" || w == "ни" || !unicode.Is(unicode.Cyrillic, []rune(w)[0]) {
			continue
		}
		return w
	}
	return ""
}

// --- how she addresses him ------------------------------------------------
//
// Persona hard constraint: Maven speaks TO him, informally, one to one. The
// phrasing eval produced two breaks of it, and both scored clean:
//
//   - "Приходите… Жду вас" — the formal plural. Correct is ты/тебя/тебе and a
//     singular imperative ("приходи", "жду тебя").
//   - "Он не ел 11 дней" — she talks ABOUT him, in the third person, as if
//     reporting to somebody else. Correct is "ты не ел 11 дней".
//
// Like checkHisGender this is a keyword + suffix heuristic, NOT a parser. Every
// hit prints the word it tripped on, so a false alarm is obvious at a glance and
// can be dismissed.
//
// Part 1, formal address. Two signals:
//   - the "вы" pronoun family, matched as whole words, so there is nothing to
//     exclude — "вы" and "вас" are never anything else.
//   - a plural verb ending: -ите/-ете/-йте/-ьте ("приходите", "выпейте",
//     "не забудьте", "хотите"). Nouns in the prepositional case share those
//     endings ("в интернете", "в свете"), so a word right after a preposition is
//     skipped. That is the whole exclusion list, on purpose: a bigger one would
//     start swallowing real imperatives.
//
// Part 2, third person. "он" is perfectly fine when the message really is about
// somebody or something else ("сервис упал, он не отвечает"). The way to tell
// them apart: a legitimate third person has an ANTECEDENT — the thing it refers
// to was named earlier in the message. So "он" is only flagged when nothing
// before it in the message could be that thing.
//
// Where this gives up, plainly:
//   - it only looks BACKWARD. "Он не отвечает, сервис упал" names the subject
//     after the pronoun and is flagged wrongly.
//   - any noun earlier in the message counts as an antecedent, even when it is
//     not one ("после обеда он не ел", "выпей воды, он не пил" — both missed).
//     Verbs and time words no longer count, which covers the usual nudge, but a
//     plain noun before the pronoun still blinds it. The
//     common time words are stoplisted so the usual nudge opening does not
//     blind it, but a message with any other noun in front still slips through.
//     This is the check's real hole; widening it further would start flagging
//     legitimate third-party messages, so it stops here.
//   - a message that opens with "ты" and only later slips into "он" is missed,
//     because "ты" itself is skipped but the words around it are not.
//   - formal address outside these endings (short adjectives, "вашими" style
//     forms not listed) is missed.

// addressWordRE also takes Latin words, because "him"/"he" is the same break in
// English.
var addressWordRE = regexp.MustCompile(`[\p{Cyrillic}]+|[a-zA-Z]+|[,.;:!?…—-]`)

// formalPronouns — the "вы" family. Whole-word match, so no false hits.
var formalPronouns = map[string]bool{
	"вы": true, "вас": true, "вам": true, "вами": true,
	"ваш": true, "ваша": true, "ваше": true, "ваши": true,
	"вашего": true, "вашей": true, "вашему": true, "вашим": true,
	"вашими": true, "вашу": true,
}

// prepositions — used twice: to skip prepositional-case nouns that look like
// plural verbs, and as words that cannot be what "он" refers to.
var prepositions = map[string]bool{
	"в": true, "во": true, "на": true, "о": true, "об": true, "обо": true,
	"при": true, "по": true, "за": true, "из": true, "с": true, "со": true,
	"к": true, "ко": true, "до": true, "от": true, "у": true, "над": true,
	"под": true, "про": true, "без": true, "для": true, "через": true,
}

// pluralVerb reports whether a word looks like a plural/formal verb form:
// "приходите", "выпейте", "забудьте", "хотите".
func pluralVerb(w string) bool {
	if len([]rune(w)) < 5 {
		return false
	}
	return strings.HasSuffix(w, "ите") || strings.HasSuffix(w, "ете") ||
		strings.HasSuffix(w, "йте") || strings.HasSuffix(w, "ьте")
}

// thirdPersonHim — pronouns that would be talking about him instead of to him.
var thirdPersonHim = map[string]bool{
	"он": true, "его": true, "ему": true, "него": true, "нему": true, "ним": true,
	"he": true, "him": true, "his": true,
}

// notAnAntecedent — words that cannot be the thing "он" refers to: pronouns,
// particles, conjunctions, adverbs of time. If only these come before "он", the
// message never named a third party and "он" is him.
var notAnAntecedent = map[string]bool{
	"не": true, "ни": true, "и": true, "а": true, "но": true, "да": true,
	"же": true, "бы": true, "ли": true, "вот": true, "уже": true,
	"ещё": true, "еще": true, "тоже": true, "там": true, "тут": true,
	"здесь": true, "это": true, "что": true, "как": true, "когда": true,
	"чтобы": true, "потому": true, "сейчас": true, "потом": true,
	// Time words. A nudge almost always opens with one ("сегодня он не ел"),
	// and without them the very next word is read as the person being talked
	// about, so the check misses the exact break it was written for.
	"сегодня": true, "вчера": true, "завтра": true, "послезавтра": true,
	"утром": true, "днём": true, "днем": true, "вечером": true, "ночью": true,
	"опять": true, "снова": true, "весь": true, "всю": true, "целый": true,
	"я": true, "мне": true, "меня": true, "мной": true, "мы": true, "нас": true,
	"ты": true, "тебя": true, "тебе": true, "тобой": true,
	"твой": true, "твоя": true, "твоё": true, "твое": true, "твои": true, "твою": true,
}

// looksVerb — a verb is never the thing "он" refers to, so it must not count as
// an antecedent. Past tense keeps "сервис упал, он не отвечает" working off
// "сервис"; the infinitive and imperative endings are here because a nudge is
// mostly made of them ("попробуй встать и отдохнуть — у него есть перерыв"
// slipped through with "попробуй" taken for the person being talked about).
func looksVerb(w string) bool {
	r := []rune(w)
	if len(r) < 3 {
		return false
	}
	for _, suf := range []string{
		"л", "ла", "ло", "ли", // past tense
		"ть", "ться", "ти", "чь", // infinitive
		"й", "йся", "йте", // imperative
	} {
		if strings.HasSuffix(w, suf) {
			return true
		}
	}
	return false
}

func checkAddress(body string) Result {
	words := addressWordRE.FindAllString(strings.ToLower(body), -1)

	// Every break, not just the first. A bad reply usually breaks in more than
	// one way at once — "Смотрите на его потребление воды" is a plural imperative
	// AND third person about him — and reporting only the first hid the second,
	// which made the failure look milder than it was.
	var breaks []string
	seen := map[string]bool{}
	add := func(msg string) {
		if seen[msg] {
			return // the same word twice in one message is one problem, not two
		}
		seen[msg] = true
		breaks = append(breaks, msg)
	}

	for i, w := range words {
		if formalPronouns[w] {
			add(fmt.Sprintf("formal %q — she says ты/тебя/тебе", w))
		}
		if pluralVerb(w) && !(i > 0 && prepositions[words[i-1]]) {
			add(fmt.Sprintf("plural imperative %q — she uses the singular", w))
		}
	}

	for i, w := range words {
		if !thirdPersonHim[w] {
			continue
		}
		named := false
		for j := 0; j < i; j++ {
			p := words[j]
			if !unicode.Is(unicode.Cyrillic, []rune(p)[0]) && !isLatinWord(p) {
				continue // punctuation
			}
			// pluralVerb as well as looksVerb: looksVerb knows the imperative in
			// -й/-йте but not the -те plural ("смотрите"), so "Смотрите на его
			// потребление воды" counted "смотрите" as the person being talked
			// about and the "его" never printed. Third time a verb form has
			// blinded this check — if a fourth turns up, the antecedent test
			// wants a real morphology table, not another suffix.
			if notAnAntecedent[p] || prepositions[p] || thirdPersonHim[p] || looksVerb(p) || pluralVerb(p) {
				continue
			}
			named = true
			break
		}
		if !named {
			add(fmt.Sprintf("third person %q with nobody else named — she talks to him, not about him", w))
		}
	}

	if len(breaks) > 0 {
		return Result{CheckAddress, false, strings.Join(breaks, " + ")}
	}
	return Result{CheckAddress, true, ""}
}

func isLatinWord(w string) bool {
	r := []rune(w)[0]
	return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
}

// --- the cringe checks ---------------------------------------------------
//
// "Think Jarvis without the cringe part". docs/design.md § Non-goals: "Not a
// relationship — mom-tone is a function that makes nudges land, not emotional
// company. Names the drift a warm small model falls into." Each pattern below
// is one shape of that drift. They are deliberately specific: a check that
// flags any warmth at all would make the nudges robotic, which is the other
// failure.

type cringePattern struct {
	// what the pattern is defending against, shown in the failure detail.
	why string
	pat *regexp.Regexp
}

var cringePatterns = []cringePattern{
	{
		// Endearments. "Not a relationship" — a pet name reframes a nudge as
		// intimacy, and the operator asked for mother-like, not girlfriend-like.
		why: "pet name / endearment",
		// No \b around the Russian alternatives: Go's RE2 \b is ASCII-only and
		// never matches at a Cyrillic boundary, so anchoring them would make
		// this check silently always pass.
		pat: regexp.MustCompile(`(?i)(милый|дорогой|солнышко|солнце моё|солнце мое|зайчик|котик|сладкий|малыш|дружок|родной|любимый|\bhoney\b|\bsweetie\b|\bdarling\b|\bbuddy\b)`),
	},
	{
		// Emoji. The nudge is spoken aloud; an emoji is either silence or a TTS
		// artefact. Also the single loudest cringe signal in a small model.
		why: "emoji",
		pat: nil, // handled by hasEmoji, ranges don't fit a regexp cleanly
	},
	{
		// Exclamation pileup. One "!" is emphasis; two is a cheerleader.
		why: "more than one exclamation mark",
		pat: regexp.MustCompile(`!.*!|!!`),
	},
	{
		// Fake concern. She has no feelings to report, and reporting them makes
		// the nudge about her instead of about the water.
		why: "fake concern opener",
		pat: regexp.MustCompile(`(?i)(я волну|я беспоко|беспокоюсь|переживаю|я забочусь|я тревож|мне тревожно|i'?m worried)`),
	},
	{
		// Apologising. The rule decided she speaks. Apologising for a greenlit
		// nudge undermines the one thing that makes nudges land.
		why: "apology",
		pat: regexp.MustCompile(`(?i)(извини|прости|сожалею|прошу прощения|не хочу мешать|не хочу отвлекать|sorry|apolog)`),
	},
	{
		// Offering emotional support. The explicit "not emotional company" line.
		why: "offer of emotional support",
		pat: regexp.MustCompile(`(?i)(я рядом|я здесь для теб|ты не один|всё будет хорошо|все будет хорошо|не переживай|я с тобой|обнимаю|я поддерж|держись)`),
	},
	{
		// Asking how he feels. Turns a one-way nudge into a conversation he now
		// owes an answer to — the most reliable way to make him mute it.
		why: "asking how he feels",
		pat: regexp.MustCompile(`(?i)(как ты\s*[?.!]|как ты себя|как самочувств|как настроение|всё ли в порядке|все ли в порядке|ты в порядке|how are you)`),
	},
	{
		// Praise for compliance. Rewards make the nudge a training exercise;
		// "not a relationship" again, from the other side.
		why: "praise / reward framing",
		pat: regexp.MustCompile(`(?i)(молодец|умница|ты справ|гордюсь|горжусь|отличная работа|так держать|good job|proud of you)`),
	},
}

// hasEmoji — the pictographic ranges plus the variation selector. Cyrillic and
// ordinary punctuation are far below all of these.
func hasEmoji(s string) bool {
	for _, r := range s {
		switch {
		case r >= 0x1F000 && r <= 0x1FAFF,
			r >= 0x2600 && r <= 0x27BF,
			r >= 0x2B00 && r <= 0x2BFF,
			r == 0xFE0F, r == 0x203C, r == 0x2049:
			return true
		}
	}
	return false
}

func checkCringe(body string) Result {
	for _, c := range cringePatterns {
		if c.pat == nil {
			if hasEmoji(body) {
				return Result{CheckCringe, false, c.why}
			}
			continue
		}
		if m := c.pat.FindString(body); m != "" {
			return Result{CheckCringe, false, fmt.Sprintf("%s (%q)", c.why, m)}
		}
	}
	return Result{CheckCringe, true, ""}
}

// checkOnTopic — the message must name the thing the rule is about. A nudge
// that never mentions water leaves the operator with a chime and no action.
func checkOnTopic(c Case, body string) Result {
	return checkOnTopicAny(c.WantAny, body)
}

// checkOnTopicAny is the same test over a bare want-list, so the talk scorer can
// reuse it without owning a nudge Case.
func checkOnTopicAny(wantAny []string, body string) Result {
	low := strings.ToLower(body)
	for _, want := range wantAny {
		if strings.Contains(low, strings.ToLower(want)) {
			return Result{CheckOnTopic, true, ""}
		}
	}
	return Result{CheckOnTopic, false,
		fmt.Sprintf("mentions none of %v", wantAny)}
}

// --- shape checks for the free-form paths --------------------------------
//
// The nudge checks assume one short sentence. Chat and query replies are longer
// by design, so the only shape worth testing there is that the model produced a
// reply at all and did not trail off. Both are failure modes the fallbacks in
// llmphraser.go hide: a truncated or empty generation still returns nil error.

const (
	CheckNonEmpty = "nonempty" // she said something
	CheckEllipsis = "ellipsis" // she finished the sentence
)

// A reply needs words in it, not just characters. This check used to test for a
// non-empty string, which scored 27/27 on a run where two replies were "{" and
// "{\n  \"" — punctuation passed as content. Braces, quotes, digits and spaces
// are all empty in the only sense that matters.
//
// Digits alone fail too, and that is deliberate: the same run answered "сколько
// варить яйцо вкрутую?" with "15-16". No unit, no words, and it is also the
// wrong number. Whatever that is, it is not something she said.
func checkNonEmpty(body string) Result {
	if strings.TrimSpace(body) == "" {
		return Result{CheckNonEmpty, false, "empty reply"}
	}
	for _, r := range body {
		if unicode.IsLetter(r) {
			return Result{CheckNonEmpty, true, ""}
		}
	}
	return Result{CheckNonEmpty, false, fmt.Sprintf("no letters in the reply %q — punctuation or digits only", strings.TrimSpace(body))}
}

// checkEllipsis — a reply ending in "…" or "..." is a generation that ran out of
// tokens, not a stylistic pause. Mid-sentence ellipses are left alone.
func checkEllipsis(body string) Result {
	trimmed := strings.TrimRight(strings.TrimSpace(body), `"'»)`)
	if strings.HasSuffix(trimmed, "…") || strings.HasSuffix(trimmed, "...") {
		return Result{CheckEllipsis, false, "reply trails off in an ellipsis — likely truncated"}
	}
	return Result{CheckEllipsis, true, ""}
}

// governedByYou reports whether "ты" stands close enough in front of the verb
// at index i to be its subject. Three words, the same window checkFeminine's
// first pass uses after "я", and it stops at a first-person pronoun so "ты
// просил, я напомнил" still trips.
func governedByYou(words []string, i int) bool {
	for j := i - 1; j >= 0 && j >= i-3; j-- {
		switch words[j] {
		case "ты":
			return true
		case "я":
			return false
		}
	}
	return false
}
