package router

import (
	"regexp"
	"sort"
	"strings"
	"unicode"

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

// Grammar — one stage-0 exact-match pattern. Wake-word + known command grammar
// hits the allowlist directly, skips the classifier (lowest latency — the vosk
// command path). Boring high-frequency acts for free.
//
// A Grammar returns a fully-formed Decision (intent + slots) at confidence 1.0
// when its pattern matches AND its Build returns ok=true; the router stops the
// cascade. Grammar rules are code, not config — same boundary as rules-as-code
// in the loop. The tool registry populates the verb set at daemon wiring time.
type Grammar struct {
	Name    string
	Pattern *regexp.Regexp // matched against the raw utterance
	Build   func(match []string) (Decision, bool)
	// Decide is the non-regexp form for a grammar whose evidence is structural
	// rather than textual. Exactly one of Decide or Pattern+Build is set. It
	// keeps token/grammar parsers first-class instead of wrapping them in a
	// catch-all regexp merely to fit this type.
	Decide func(utterance string) (Decision, bool)
}

// Evaluate applies one grammar. matched distinguishes a regexp whose outer
// shape matched but whose Build declined from a rule that never matched; the
// decision trace uses that distinction. A structural Decide has no weaker
// outer pattern, so accepted is also its matched result.
func (g Grammar) Evaluate(utterance string) (d Decision, matched, accepted bool) {
	if g.Decide != nil {
		d, accepted = g.Decide(utterance)
		return d, accepted, accepted
	}
	if g.Pattern == nil || g.Build == nil {
		return Decision{}, false, false
	}
	m := g.Pattern.FindStringSubmatch(utterance)
	if m == nil {
		return Decision{}, false, false
	}
	d, accepted = g.Build(m)
	return d, true, accepted
}

// wakeWordAct — "maven, restart nginx" / "maven restart nginx" → the remainder
// is matched against the act allowlist. A non-match returns ok=false so the
// cascade falls through to the classifier (a wakeword prefix alone doesn't
// guarantee a known command — "maven, i'm tired" is a fact, not an act).
var wakeWordAct = regexp.MustCompile(`(?i)^\s*(?:maven|мэйвен|мейвен|майвен|мавена?|мэвен)[,:.!\s]+(.+)$`)

// wakeToken matches a leading wake-word token in any script the STT commonly
// produces for "Maven" — Latin "maven" or a Cyrillic phonetic rendering. The
// STT is a Russian model, so it transcribes the spoken wake word phonetically
// almost every time; matching only the Latin spelling meant stage-0 grammars
// (time/date/reminder) silently missed nearly every wake-worded utterance and
// fell through to the classifier, which misroutes time queries into the
// reminder intent (dense time-vocab centroid, see SystemTimeDateGrammars).
var wakeToken = regexp.MustCompile(`(?i)^\s*(?:maven|мэйвен|мейвен|майвен|мавена?|мэвен)[,:.!\s]*`)

// StripWakeToken removes a leading wake-word token (any script/spelling seen
// in wakeToken) and reports whether one was found.
func StripWakeToken(u string) (string, bool) {
	loc := wakeToken.FindStringIndex(u)
	if loc == nil {
		return u, false
	}
	rest := strings.TrimSpace(u[loc[1]:])
	if rest == "" {
		return u, false
	}
	return rest, true
}

// DefaultGrammars — the wake-word act fast path. The ActMatcher is the same
// allowlist stage-2 act extraction uses (single source of truth for the fn
// list). Returns nil grammars if no matcher is wired (the daemon always wires
// one — the guard is for tests that only exercise the classifier).
func DefaultGrammars(actMatcher ActMatcher) []Grammar {
	if actMatcher == nil {
		return nil
	}
	return []Grammar{
		{
			Name:    "wakeword-act",
			Pattern: wakeWordAct,
			Build: func(m []string) (Decision, bool) {
				rest := strings.TrimSpace(m[1])
				fn, args, ok := actMatcher.Match(rest)
				if !ok {
					return Decision{}, false // fall through to classifier
				}
				return Decision{
					Stage:      0,
					Intent:     IntentAct,
					Confidence: 1.0,
					Slots:      Slots{Fn: fn, Args: args, HasFn: true, Text: rest},
				}, true
			},
		},
	}
}

// --- напомни / remind me stage-0 grammar ---
//
// "напомни через час выпить воды" / "remind me in 30 minutes to water plants"
// routes directly to IntentReminder, bypassing the classifier entirely.
// Without this grammar the reminder centroid (dense with time-lexicon) pulls
// non-reminder time queries toward it, and the verb+action overlap pushes
// actual reminders toward fact — a double contamination. Stage 0 fixes both.
//
// The grammar captures the part after "напомни"/"remind me" into Slots.Text —
// what she says at the hour. The grammar itself does NOT parse time; that is
// the extractor's job, and since V-572 the router runs the extractor over a
// stage-0 decision too (fillMatchedSlots in router.go). Before that it did not,
// so "напомни в 11:00 позвонить маме" reached the daemon with HasTime false and
// was asked "Когда?" about an hour he had just said.
//
// The verb alternation is built from lexicon.ReminderVerbs rather than written
// out (V-627). The literal here knew "напомни" and "remind me" and nothing
// else, so "разбуди меня в 6:30" never reached stage 0 — and it does not reach
// IntentReminder further down either, where the classifier calls it fact at
// 0.918. An alarm is a reminder that fires at the hour he gets up, and the
// verb that names one is her vocabulary, so it belongs in the lexicon with the
// rest of it.
//
// Longest-first ordering matters: Go's regexp alternation is leftmost-first,
// not longest-match, so "напомнить" listed after "напомни" would never match.
var reminderVerbPattern = regexp.MustCompile(
	`(?i)^\s*(?:` + longestFirstAlternation(lexicon.ReminderVerbs()) +
		`)\s*(?:мне|меня|me)?[\s,:]+(.+)$`)

// longestFirstAlternation joins a word set into a regexp alternation, longest
// alternative first, with every member escaped.
func longestFirstAlternation(set []string) string {
	out := make([]string, 0, len(set))
	for _, w := range set {
		out = append(out, regexp.QuoteMeta(w))
	}
	sort.Slice(out, func(i, j int) bool {
		if len(out[i]) != len(out[j]) {
			return len(out[i]) > len(out[j])
		}
		return out[i] < out[j]
	})
	return strings.Join(out, "|")
}

func ReminderGrammar() Grammar {
	return Grammar{
		Name:    "reminder-wakeword",
		Pattern: reminderVerbPattern,
		Build: func(m []string) (Decision, bool) {
			rest := strings.TrimSpace(m[1])
			if rest == "" {
				return Decision{}, false
			}
			return Decision{
				Stage:      0,
				Intent:     IntentReminder,
				Confidence: 1.0,
				Slots:      Slots{Text: rest},
			}, true
		},
	}
}

// SystemTimeDateGrammars — stage-0 grammars for high-frequency system queries
// that replySystem handles deterministically (time, date, day-of-week).
// "сколько времени" is seeded in BOTH system.txt and query.txt (a centroid
// collision), and the reminder centroid contaminates any utterance with time
// vocabulary. These grammars route directly to IntentSystem, skipping the
// classifier entirely — the answer is always deterministic.
//
// The time-query grammar uses a broad pattern (prefix match) with a Build
// filter: utterances containing "прошло"/"осталось" or starting with "до"
// after the time expression are elapsed/duration queries that belong to the
// classifier, not to replySystem's "what time is it" handler.
func SystemTimeDateGrammars() []Grammar {
	return []Grammar{
		{
			Name:    "time-query",
			Pattern: regexp.MustCompile(`(?i)^\s*сколько\s+(сейчас\s+)?времени(.*)$`),
			Build:   timeQueryBuild,
		},
		{
			Name:    "clock-query",
			Pattern: regexp.MustCompile(`(?i)^\s*который\s+(сейчас\s+)?час(\s+у\s+нас|\s+в\s+\w+)?\s*[?!.]?\s*$`),
			Build:   timeDateBuild,
		},
		{
			Name:    "date-query",
			Pattern: regexp.MustCompile(`(?i)^\s*(?:какой\s+сегодня\s+(?:день|день\s+недели|число)|какое\s+сегодня\s+число)\s*[?!.]?\s*$`),
			Build:   timeDateBuild,
		},
	}
}

// AgendaQueryGrammars — stage-0 grammars for "what have I got on" questions,
// routed to IntentQuery so they reach the query chain (queryDayPlan,
// queryCalendar) instead of replySystem.
//
// This exists because the model puts them in IntentSystem. Measured on the
// deployed daemon 01-08-2026: "что у меня сегодня" and "что у меня в календаре
// сегодня" both routed system, and replySystem has no agenda arm, so both
// answered "пока не умею". The fixture has said query since ru-query-019 was
// written ("the clock/date system rule must not swallow it"); the daemon
// disagreed with the fixture and the daemon was wrong.
//
// Routing, not answering. Two of the five also name the calendar as the
// destination (V-655), which narrows who may GUESS their way onto the turn and
// claims nothing. Every source that looks something up still runs, in the order
// it always did, so a question with no date still falls through queryCalendar
// to recall.
//
// Deliberately not folded into SystemTimeDateGrammars: those exist to send
// utterances TO system, these exist to keep utterances OUT of it, and one
// function returning both would read as a list of clock rules.
func AgendaQueryGrammars() []Grammar {
	return []Grammar{
		{
			// An explicit calendar noun is unambiguous wherever it appears:
			// "что в календаре на завтра", "покажи расписание на среду".
			//
			// The one agenda rule that names its destination, because an
			// explicit calendar noun leaves nothing to weigh (V-655). The
			// possessive rules below deliberately do not: "что у меня в списке
			// покупок" matches agenda-query, and naming the calendar there
			// would take the list source off the turn.
			Name:    "calendar-query",
			Pattern: regexp.MustCompile(`(?i)(календар|расписани|повестк)`),
			Build:   queryTo(SourceCalendar),
		},
		{
			// The agenda phrasing with no calendar noun. Anchored at the start
			// and requiring the possessive, so it reads as a question about his
			// day: "что у меня сегодня", "что у меня стоит на послезавтра".
			// "у меня кончилась вода" is a fact and does not match.
			Name: "agenda-query",
			// (\s|[?!.]|$) rather than \b: Go's \b is ASCII-only, so it does
			// not see a boundary after a Cyrillic letter and the pattern
			// silently never fires.
			// "во сколько у меня встреча" is the same agenda question with a
			// clock word in front, and the clock word is what sent it to
			// system (fixture ru-query-013).
			Pattern: regexp.MustCompile(`(?i)^\s*(что|чего|какие|сколько|во\s+сколько|когда)\s+у\s+меня(\s|[?!.]|$)`),
			Build:   agendaQueryBuild,
		},
		{
			// A plan noun aimed at a named day, with no possessive to anchor
			// on: "какие планы на завтра", "что по делам в среду". The rule
			// above wants "у меня" and this phrasing never has it, so
			// "какие планы на завтра" answered "пока не умею" while "какие
			// планы на сегодня" worked (Vikunja #471). The day word is what
			// makes it an agenda question rather than a topic.
			Name: "plan-day-query",
			// Only "план" and "дел". A verb stem like "встреч" would take
			// "встречаемся в среду", which is him telling her something, not
			// asking.
			Pattern: regexp.MustCompile(`(?i)(^|\s)(план|дел)[а-я]*\s+(на|в|во|по)\s+` + dayWordPattern + `(\s|[?!.]|$)`),
			Build:   agendaQueryBuild,
		},
		{
			// "что дальше?" — the rest of the day, with no possessive and no
			// plan word for the rules above to anchor on, so neither claimed
			// it and the model called it a fact (Vikunja #498). The predicate
			// for the same utterance already exists as IsRestOfDayQuery, one
			// layer down in the query chain; this is what gets the turn there.
			//
			// "и что там дальше" and "что потом дальше" are the same question,
			// and "what's next" splits into two tokens, hence the optional
			// middles rather than plain adjacency.
			Name:    "rest-of-day-query",
			Pattern: regexp.MustCompile(`(?i)^\s*(и\s+)?(что|чего|what'?s?)\s+(там\s+|ещё\s+|еще\s+|потом\s+|у\s+меня\s+)?(дальше|next)(\s|[?!.]|$)`),
			Build:   agendaQueryBuild,
		},
		{
			// A named event with no calendar word at all: "когда планёрка?",
			// "во сколько созвон". He is asking when something on his calendar
			// happens, and the noun is the only signal. Closed list, so "когда
			// битва при Ватерлоо" is still a world question.
			// Names the calendar (V-655): the noun list is closed and every
			// member of it is an event, so there is nothing else to weigh.
			Name:    "event-time-query",
			Pattern: regexp.MustCompile(`(?i)^\s*(когда|во\s+сколько|в\s+котором\s+часу)\s+(будет\s+|у\s+нас\s+)?(планёрк|планерк|встреч|созвон|митинг|совещани|звонок|созвон|приём|прием|интервью|собеседовани|тренировк|урок|занятие|пара)[а-я]*(\s|[?!.]|$)`),
			Build:   queryTo(SourceCalendar),
		},
	}
}

// chatNarrativeTopics — the things "расскажи X" asks for that are not
// questions about the world. She is being asked to entertain or to describe
// herself, and the query chain has no source for either.
var chatNarrativeTopics = regexp.MustCompile(`(?i)(анекдот|шутк|сказк|истори[юи]\s+на\s+ночь|о\s+себе|про\s+себя|о\s+нас|про\s+нас)`)

// NarrativeQueryGrammars — the stage-0 grammar for "расскажи про X", a question
// shape that carries no question mark and no interrogative, and so reached the
// resident model with nothing deterministic in front of it (Vikunja #498).
//
// The model routed it IntentFact. The fact gate catches the write and re-runs
// the turn as a query, so nothing broke; what it cost is a full model round trip
// to reach a decision one pattern makes offline, and a wrong row on the routing
// fixture.
//
// It held a second grammar named rest-of-day-query until V-530. fe489df merged
// task/467 into the sweep line and both sides had landed V-498, so the merge
// kept both blocks textually. buildRouter wires the agenda grammars first and
// the agenda copy claims every case this one did, so it could never fire.
//
// Wired after the agenda grammars, which is where the overlap resolves:
// "расскажи, что у меня сегодня" is claimed there as a query either way.
func NarrativeQueryGrammars() []Grammar {
	return []Grammar{
		{
			// "расскажи про X" — a world question phrased as an instruction.
			// The lexicon is narrativeRequests, already written for the
			// question-shaped test in question.go.
			//
			// Anchored at the start: "запиши что мне рассказали" is a capture,
			// and a narrative verb buried mid-utterance is not the shape.
			Name:    "narrative-query",
			Pattern: narrativeQueryPattern,
			Build:   narrativeQueryBuild,
		},
	}
}

// entertainmentNouns — what "расскажи" asks for when it is not asking for
// knowledge. "расскажи анекдот про программистов" is chat: he wants her to make
// something up, which is the one case where inventing is the right answer
// (fixture ru-chat-003).
var entertainmentNouns = []string{
	"анекдот", "анекдоты", "шутку", "шутки", "историю", "сказку", "сказки",
	"joke", "jokes", "story",
}

// narrativeQueryBuild — the narrative shape is a query carrying its topic,
// unless he also said one of the capture verbs, asked for entertainment, or
// asked about her. "расскажи и запиши" is him asking for a note, and stage 0
// must not take either off the cascade.
//
// The topic goes into Slots.Text rather than the whole utterance: the query
// chain looks things up by it, and "расскажи мне про Ватерлоо" is a question
// about Ватерлоо.
func narrativeQueryBuild(m []string) (Decision, bool) {
	topic := strings.TrimSpace(m[2])
	// "расскажи" with nothing after it is a conversational opener, and there is
	// no topic to look up.
	if topic == "" {
		return Decision{}, false
	}
	// Against the whole utterance, not the topic: "о себе" has its preposition
	// eaten by the pattern, leaving a bare "себе".
	if chatNarrativeTopics.MatchString(m[0]) {
		return Decision{}, false
	}
	toks := planTokens(topic)
	for _, v := range captureVerbs {
		if hasTok(toks, v) {
			return Decision{}, false
		}
	}
	for _, v := range entertainmentNouns {
		if hasTok(toks, v) {
			return Decision{}, false
		}
	}
	return Decision{
		Stage:      0,
		Intent:     IntentQuery,
		Confidence: 1.0,
		Slots:      Slots{Text: topic},
		// The world, because that is the shape this asks for and the rule has
		// already declined the two cases where it is not: entertainment, and
		// questions about her (V-655). His own notes are still read first — a
		// destination narrows who may guess and reorders nothing.
		Source: SourceWorld,
	}, true
}

// narrativeQueryPattern — "расскажи про X", built from the lexicon rather than
// spelled out here (Vikunja #527). The verbs used to be a second copy of
// lexicon.NarrativeRequests, and a second copy of a closed set is a set that
// drifts: adding "поясни" in the data file left this rule not knowing it.
//
// Anchored at the start, which was the point of the old literal and still is:
// "запиши что мне рассказали" is a capture, and a narrative verb buried
// mid-utterance is not the shape. The dative and the preposition are eaten so
// the topic slot comes out clean: "расскажи мне про Ватерлоо" leaves
// "Ватерлоо".
var narrativeQueryPattern = regexp.MustCompile(
	`(?is)^\s*(` + strings.Join(lexicon.NarrativeRequests(), "|") +
		`)(?:\s+(?:мне|нам|us|me))?(?:\s+(?:про|о|об|about))?(\s+.+)$`)

// FeedQueryGrammar — stage-0 rule for "что нового в лентах?", routed to
// IntentQuery so it reaches queryFeeds.
//
// Same shape of defect as the agenda grammars: the model calls it system, and
// replySystem has no feeds arm, so the documented utterance of task 258 step 1
// answered "пока не умею отвечать на этот вопрос." while the same question
// worded with "новостях" worked (Vikunja #474).
//
// An ask word at the front and a feed noun after it are both required, which
// is the same pair ParseFeedQuery wants. "что нового?" on its own is a greeting
// — the most common opener in the language — and vagueNouns in feeds.go exists
// to keep it out of the feed reader; routing it to query here would put it
// back. "у меня новая лента в инстаграме" carries the noun without the ask and
// stays the statement it is.
func FeedQueryGrammar() Grammar {
	return Grammar{
		Name: "feed-query",
		// (\s|[?!.]|$) rather than \b, which is ASCII-only and never fires next
		// to a Cyrillic letter.
		Pattern: regexp.MustCompile(`(?i)^\s*(что|какие|расскажи|покажи|почитай|прочитай)\s+.*(лент|новостн)[а-я]*(\s|[?!.]|$)`),
		Build:   agendaQueryBuild,
	}
}

// dayWordPattern — the day words an agenda question can name. Weekdays appear
// in the accusative and prepositional forms the questions actually use ("в
// среду", "на среде"), so each one contributes its stem plus an inflection
// tail; the relative day words are exact.
//
// Built from the lexicon for the same reason as above. The literal that stood
// here spelled all seven weekdays out a second time, in a third file after
// cmd/mavend/voice.go and internal/ttsnorm.
var dayWordPattern = buildDayWordPattern()

// buildDayWordPattern — one alternation over the relative day words, the
// weekday stems, and the two period words that are in no closed set ("на
// выходных", "на неделе" name a span, not a day).
func buildDayWordPattern() string {
	alts := []string{`выходн[а-я]+`, `недел[а-я]+`}
	for _, w := range lexicon.DayOffsetWords() {
		if strings.Contains(w, " ") || !isCyrillic(w) {
			// Multi-word and English members belong to the offset lookup, not
			// to a Russian agenda pattern.
			continue
		}
		alts = append(alts, regexp.QuoteMeta(w))
	}
	for i := 0; i < 7; i++ {
		day := lexicon.Weekday(i)
		if day == "" {
			continue
		}
		alts = append(alts, weekdayStem(day)+`[а-я]*`)
	}
	return `(` + strings.Join(alts, "|") + `)`
}

// weekdayStem trims the nominative ending off a weekday so the pattern matches
// the case forms an agenda question uses: "среда" has to reach "в среду", and
// "понедельник" already ends on its stem.
func weekdayStem(day string) string {
	r := []rune(day)
	switch r[len(r)-1] {
	case 'а', 'я', 'е', 'о', 'ь':
		return string(r[:len(r)-1])
	}
	return day
}

// isCyrillic reports whether every rune is Cyrillic. Used to keep the English
// members of a bilingual lexicon set out of a Russian-only pattern.
func isCyrillic(s string) bool {
	for _, r := range s {
		if !unicode.Is(unicode.Cyrillic, r) {
			return false
		}
	}
	return s != ""
}

// agendaQueryBuild — shared Build for the agenda grammars and the feed one,
// which all do the same single thing: keep the utterance out of IntentSystem
// and let the query chain decide who answers it. Confidence 1.0 on
// the intent only: the utterance travels intact and the query chain's own
// matchers decide the rest.
func agendaQueryBuild(m []string) (Decision, bool) {
	return Decision{
		Stage:      0,
		Intent:     IntentQuery,
		Confidence: 1.0,
	}, true
}

// timeQueryBuild — Build for the time-query grammar. Returns ok=false for
// elapsed/duration queries ("сколько времени прошло", "сколько времени
// осталось", "сколько времени до") so they fall through to the classifier.
// The classifier handles them as query intent (notes RAG), not system.
func timeQueryBuild(m []string) (Decision, bool) {
	suffix := strings.TrimSpace(m[2])
	if suffix != "" && !strings.HasPrefix(suffix, "?") {
		lower := strings.ToLower(suffix)
		// If the first word after "времени" is a duration marker, this is an
		// elapsed-time query, not a "what time is it" query.
		firstWord := strings.Fields(lower)
		if len(firstWord) > 0 {
			switch firstWord[0] {
			case "прошло", "осталось", "до", "пройдет", "минуло", "проходит":
				return Decision{}, false
			}
		}
	}
	return Decision{
		Stage:      0,
		Intent:     IntentSystem,
		Confidence: 1.0,
	}, true
}

// timeDateBuild — shared Build for clock-query and date-query grammars. Returns a
// Decision routed to IntentSystem with the original utterance intact, so the
// daemon's replySystem handler can keyword-match and answer it.
func timeDateBuild(m []string) (Decision, bool) {
	return Decision{
		Stage:      0,
		Intent:     IntentSystem,
		Confidence: 1.0,
	}, true
}
