// Package routedecide is the PURE, importable core of the AI-bot router: the free
// Layer-0 regex pre-classification and the Layer-0+classifier combine. It holds no
// I/O, no vendor clients, no Bot/Config — only the decision math — so two callers can
// share exactly one decision function:
//
//   - package main (router.go) parses the live Gemini classifier JSON into a Verdict,
//     then calls Combine to resolve the route;
//   - cmd/routereval replays a golden set of recorded Verdicts through the same
//     ClassifyLayer0 + Combine to measure misroute / false-web / trivial-leak offline.
//
// Go forbids importing package main, so this core had to live in its own package for
// the offline harness to exercise the REAL routing logic instead of a drift-prone copy.
package routedecide

import (
	"regexp"
	"strings"
)

// Route names — the canonical wire/log/request_log tokens. package main aliases these
// (telemetry.go) so there is a single source of truth for the strings.
const (
	RouteTrivial    = "trivial_direct"
	RouteGrokDirect = "grok_direct"
	RouteWeb        = "web_then_grok"
	RouteReason     = "reason_then_grok"
	// RouteProject answers a question about the Vojo product itself from a curated KB
	// injected into the Grok prompt (about_project gate). Like RouteWeb it grounds Grok,
	// but the "digest" is an operator-authored static KB, not a web fetch.
	RouteProject = "project_then_grok"
)

// Confidence floors the combine uses. These are the values the offline eval (§11)
// tunes; keeping them here lets cmd/routereval sweep them without touching main.
//
//   - WebNeedsWebFloor: a classifier needs_web verdict must clear this to route to web
//     (paranoid-low — grounding is cheap, a confident wrong fact is not).
//   - TrivialFloor: the bar a trivial offload must clear (conservative — a false trivial
//     leaks a real question to the cheap model).
const (
	WebNeedsWebFloor = 0.55
	TrivialFloor     = 0.85
	// WebForceVetoFloor: the bar a classifier verdict must clear to DOWNGRADE a Layer-0
	// freshness (WebForce) hit back to grok_direct. «сейчас»/«сегодня» are high-frequency
	// Russian filler ("объясни, что сейчас делает этот код"), and a forced web route on
	// them ships a WORSE answer (the synth digest contract) — the context-aware classifier
	// is the right judge. Conservative: the veto needs an explicit confident
	// no-web-no-recency verdict; anything weaker keeps the freshness hit on web.
	WebForceVetoFloor = 0.7
)

// Floors are the confidence thresholds Combine applies, parameterised so the offline
// eval (cmd/routereval) can SWEEP them over a golden set without recompiling. Production
// uses DefaultFloors (the consts above).
type Floors struct {
	WebNeedsWeb float64
	Trivial     float64
	// WebForceVeto ≤ 0 means WebForceVetoFloor, so a two-field literal (routereval's
	// flag set, older callers) keeps the production veto rather than a 0-bar one.
	WebForceVeto float64
}

// DefaultFloors is the production threshold set.
func DefaultFloors() Floors {
	return Floors{WebNeedsWeb: WebNeedsWebFloor, Trivial: TrivialFloor, WebForceVeto: WebForceVetoFloor}
}

// web_decided_by attribution tokens (request_log.web_decided_by). Stable so analytics
// can GROUP BY them and tune WebNeedsWebFloor from data.
const (
	WebByNone       = "none"
	WebByFreshness  = "freshness"
	WebByNeedsWeb   = "classifier_needs_web"
	WebByObscure    = "entity_obscure"
	WebByTime       = "time_sensitive"
	WebByLookupHint = "lookup_hint"
	// WebByFreshVetoed marks a Layer-0 freshness hit DOWNGRADED by a confident
	// classifier all-clear (the WebForceVeto arm) that no other web arm re-routed.
	// The route is grok_direct, but the attribution is persisted so the veto's
	// hit-rate (and WebForceVetoFloor) is tunable from request_log.
	WebByFreshVetoed = "freshness_vetoed"
)

// Verdict is the classifier's parsed JSON output (§4.1). The json tags match the
// classifier schema exactly, so both routeLayer1 (live classifier reply) and
// cmd/routereval (recorded golden verdicts) unmarshal straight into it. Confidence is
// the model's honest certainty in needs_web; it doubles as the trivial-gate threshold
// (a clear greeting is high-certainty-no-web, so the gate passes).
type Verdict struct {
	NeedsWeb      bool    `json:"needs_web"`
	Verifiable    bool    `json:"verifiable"`
	EntityObscure bool    `json:"entity_obscure"`
	TimeSensitive bool    `json:"time_sensitive"`
	Trivial       bool    `json:"trivial"`
	SearchQuery   string  `json:"search_query"`
	Confidence    float64 `json:"confidence"`
	// AboutProject is true when the user is asking about the Vojo product itself (its
	// features/how-to/limits/privacy/pricing). It routes to the project KB on its own — the
	// classifier is the context-aware judge (it sees the conversation, so it resolves
	// follow-ups like "Про этот" → the app) and a false positive is bounded by the
	// entity-scoped KB note. (An earlier design also required a Layer-0 lexical hint, but live
	// traffic showed that blocked correct context-resolved follow-ups — see Combine.)
	AboutProject bool `json:"about_project"`
}

// Layer0 is the free-regex pre-classification result. Route is the verdict when the
// classifier is OFF; WebForce/Trivial/LookupHint feed the Combine when it is ON.
type Layer0 struct {
	Route      string // RouteWeb (freshness) | RouteTrivial | RouteGrokDirect
	WebForce   bool   // freshnessRe hit — a HARD web signal (survives the classifier being down)
	Trivial    bool   // a trivial candidate (greeting/ack/bare arithmetic)
	LookupHint bool   // lookupIntentRe hit — a SOFT hint only (never sets the route)
	Freshness  string // "recent" when WebForce, else ""
}

// Heuristic patterns. Kept deliberately tight. Freshness words route to web (a false
// web-route only costs a fetch and degrades cleanly). Trivial fires only on short,
// unmistakable greetings/acks or bare arithmetic.
var (
	greetingRe   = regexp.MustCompile(`^(привет(ик)?|здравствуй(те)?|хай|прив|ку|добрый\s+(день|вечер|утро)|спасибо|спс|благодарю|пока|ок(ей)?|угу|ага|hello|hi|hey|yo|thanks|thank\s+you|thx|ty|bye|goodbye|ok|okay|cool|nice)[\s!.,)]*$`)
	arithmeticRe = regexp.MustCompile(`^[\s(]*\d+(\s*[-+*/×÷]\s*\d+)+[\s)=?]*$`)
	// Russian tokens are deliberately STEM matches (новост→новости/новостей, погод→погода…)
	// so they stay un-anchored. English standalone tokens are \b-anchored so they fire on
	// whole words only — not inside scoreboard / concurrent / weathering / newsletter (a
	// pre-existing false-web source; \b removes that pointless grounding spend). RE2's \b is
	// ASCII-word-based, so it is used only around the ASCII tokens, never the Cyrillic stems.
	freshnessRe = regexp.MustCompile(`(новост|сегодня|сейчас|последн|курс\s|погод|котировк|расписани|прогноз|\bbreaking\b|\btoday\b|\bright now\b|\blatest\b|\bcurrent(ly)?\b|\bnews\b|\bweather\b|\bstock price\b|\bexchange rate\b|\bscore\b)`)

	// lookupIntentRe — SOFT HINT ONLY (§5): raises the classifier's needs_web prior via
	// the lookupHint && verifiable arm; must NEVER set the route. Anchored on
	// interrogative + lookup-verb so it fires on lookup INTENT, not entity presence.
	// Deliberately leaky (false negatives are caught by the classifier, the real safety
	// net). Do NOT add a capitalised-word or guillemet branch — those false-positive on
	// greetings/idioms ("Привет, Москва!", "«Война и мир» — топ", "ну ты прям Эйнштейн").
	// The leading [\s«"„(] class is only an OPTIONAL left boundary, never a trigger.
	lookupIntentRe_RU = regexp.MustCompile(`(?i)(^|[\s«"„(])(кто\s+(так(ой|ая|ие)|снимал(ся|ась|ись)|играл|написал|основал|изобрёл|изобрел|режисс[её]р|автор)|в\s+как(ом|ой)\s+(год[уе]|фильм[еа]|сериал[еа]|книг[еи]|игр[еы])|когда\s+(вышел|вышла|вышло|выйдет|основан[аы]?|родил(ся|ась)|умер(ла)?|состоял(ся|ась)|был[аои]?\s+выпущен)|в\s+каком\s+году|сколько\s+(лет|стоит\s+бил|серий|сезонов|эпизодов)|чем\s+(закончил|известен|знаменит))`)
	lookupIntentRe_EN = regexp.MustCompile(`(?i)(^|[\s"'(])(who\s+(is|are|was|were|starred|played|directed|wrote|founded|invented|created)\s|in\s+(what|which)\s+(year|film|movie|show|series|book|game)\b|when\s+(did|was|were|does|is)\b.*\b(release|released|come\s+out|came\s+out|born|die|died|found|founded|launch|launched|air|aired)\b|what\s+year\b|how\s+many\s+(seasons|episodes|films|movies|books))`)

	// recommendationRe — a recommendation/advice request ("посоветуй фильм", "что посмотреть",
	// "what to watch"). Used ONLY to suppress the freshness WebForce (see ClassifyLayer0): such
	// requests are answered from the model's own taste/knowledge, and force-routing them to web
	// is actively harmful — the web synth ("answer strictly from the digest") makes Grok parrot a
	// generic SEO listicle and recommend nothing (observed live: "посоветуй фильм … в этот вечер"
	// → a "домашний спа/почитать книгу" non-answer). Kept tight: only explicit recommend/advice
	// verbs and "что/чем/во что/куда + activity", never bare interrogatives, so it can't swallow a
	// genuine fresh lookup. Cyrillic stems unanchored (lowercased input), English \b-anchored.
	recommendationRe = regexp.MustCompile(`(посовету|порекоменд|что\s+(посмотреть|глянуть|почитать|приготовить|послушать|подарить|поиграть)|чем\s+(себя\s+)?заня|во\s+что\s+(поиграть|сыграть)|куда\s+(сходить|пойти)|\brecommend|\bsuggest|what\s+(to|should\s+i)\s+(watch|read|cook|do|play|listen|make|see)|what\s+(movie|film|book|show|series|game)s?\s+(to|should|do\s+you))`)
)

// NOTE: the project route used to require a Layer-0 lexical hint (literal "vojo" / an
// app-how-to phrase) AND the classifier's about_project. Live traffic showed that gate was
// too strict: the classifier correctly flagged context-resolved follow-ups ("Про этот",
// "Хочу репортнуть багу. Как?") as about_project=true, but the regex — which only sees the
// bare message and cannot resolve a pronoun to "the Vojo app" — missed them, so the KB never
// fired and Grok hallucinated (a dismissive "ничего особенного", an invented GitHub support
// channel). The classifier is the context-aware layer and is the right judge here, and a
// false positive is cheap (the entity-scoped KB note keeps Grok answering the real question).
// So the route now trusts about_project alone; the regex hint was removed (it saved no money —
// about_project is one field in the classifier JSON that runs on every message regardless).

// ClassifyLayer0 runs the free heuristic over a message body. The result drives routing
// only when the classifier is off; when it is on, WebForce/Trivial/LookupHint feed
// Combine. Empty body → grok_direct (the safe floor).
func ClassifyLayer0(body string) Layer0 {
	s := strings.ToLower(strings.TrimSpace(body))
	if s == "" {
		return Layer0{Route: RouteGrokDirect}
	}
	lookupHint := lookupIntentRe_RU.MatchString(s) || lookupIntentRe_EN.MatchString(s)
	// Freshness forces web — EXCEPT for a recommendation/advice request that merely happens to
	// carry a freshness lexeme ("посоветуй фильм … сегодня вечером"). Those are answered from the
	// model's own knowledge; force-routing them to web makes the synth parrot an SEO listicle and
	// recommend nothing (see recommendationRe). They fall through to the classifier, which keeps
	// them on grok_direct and still sends genuine "новинки"/"latest" recommendations to web via
	// time_sensitive. A non-recommendation freshness rumination ("сегодня я думаю…") still
	// force-routes — the accepted, designed cheap false-web.
	if freshnessRe.MatchString(s) && !recommendationRe.MatchString(s) {
		return Layer0{Route: RouteWeb, WebForce: true, Freshness: "recent", LookupHint: lookupHint}
	}
	if IsTrivial(s) {
		return Layer0{Route: RouteTrivial, Trivial: true, LookupHint: lookupHint}
	}
	return Layer0{Route: RouteGrokDirect, LookupHint: lookupHint}
}

// IsTrivial: a short greeting/ack or a bare arithmetic expression, with no sign of a
// real question. Length-bounded so "thanks, now explain quantum tunnelling" is NOT
// trivial. Expects an already-lowercased/trimmed string from ClassifyLayer0; callers
// passing raw input should lower/trim first (the greeting regex is lowercase-anchored).
func IsTrivial(s string) bool {
	if arithmeticRe.MatchString(s) {
		return true
	}
	if len(strings.Fields(s)) <= 4 && greetingRe.MatchString(s) {
		return true
	}
	return false
}

// Combined is the resolved route plus its web attribution (for request_log).
type Combined struct {
	Route        string
	WebDecidedBy string
}

// Combine resolves the Layer-0 decision + the classifier Verdict into the final route.
// It is the router's brain and it never blindly trusts the model:
//
//   - the PROJECT arm (the classifier's AboutProject) wins above everything, including the
//     hard freshness arm — the curated KB is the authoritative source for product facts and
//     the web is the worst (it would re-introduce product hallucination). It trusts the
//     classifier: about_project is a context-aware judgement (it sees the conversation, so it
//     resolves follow-ups like "Про этот" → the app) that a bare-message regex cannot make. A
//     false positive is cheap — the entity-scoped KB note keeps Grok answering the real
//     question. Combine stays flag-agnostic: it EMITS RouteProject on AboutProject; the cascade
//     gates EXECUTION on PROJECT_KB_ENABLED (mirroring how WebEnabled gates the web route), so
//     with the flag off a RouteProject decision cleanly falls through to grok_direct.
//   - freshnessRe (WebForce) is a STRONG web signal with two carve-outs. Upstream, a
//     recommendation/advice request ("посоветуй фильм … сегодня") never sets WebForce
//     (ClassifyLayer0) — force-routing a recommendation to web makes the synth parrot an
//     SEO listicle. Here, a CONFIDENT classifier all-clear (needs_web=false AND
//     time_sensitive=false AND confidence ≥ WebForceVeto) downgrades the hit to
//     grok_direct: the freshness lexemes are high-frequency conversational filler in
//     Russian, and the context-aware layer is the better judge of whether «сейчас» means
//     "fresh data" or just "right now" in an explanation. The outage-survival property is
//     untouched — Combine only runs when Layer-1 SUCCEEDED; on a classifier failure
//     router.go uses the pure Layer-0 verdict, where freshness still force-routes to web.
//   - Every OTHER web arm (the classifier's needs_web≥floor AND verifiable,
//     entity_obscure, time_sensitive, lookupHint && verifiable) is gated by `paranoid`
//     (WEB_PARANOID). The needs_web arm additionally requires `verifiable`: on a small
//     flash-lite classifier, `needs_web` over-fires on open-ended advice/explanations
//     (observed live: "посоветуй фильм", "объясни goroutines" → needs_web=true,
//     verifiable=false → a false-web). `verifiable` ("a checkable fact about a NAMED
//     entity") is the reliable discriminator; recency still routes via time_sensitive/
//     freshness and obscurity via entity_obscure, so no genuine grounding is lost.
//     With paranoid off, web routing equals today's freshness-only behavior — so
//     enabling the classifier is web-routing-neutral and WEB_PARANOID is the single
//     switch that activates epistemic grounding (clean canary; cost increase behind it).
//   - trivial is agreement-gated: a Layer-0 trivial candidate AND classifier.trivial AND
//     confidence ≥ TrivialFloor. A lone signal stays on grok_direct (no voice leak).
//   - everything else falls to grok_direct (the safe floor: opinion/chat/advice/code).
//
// The switch ORDER determines web_decided_by attribution; the boolean result is the OR.
func Combine(l0 Layer0, v Verdict, paranoid bool) Combined {
	return CombineWithFloors(l0, v, paranoid, DefaultFloors())
}

// CombineWithFloors is Combine with explicit thresholds (the offline-eval sweep entry).
func CombineWithFloors(l0 Layer0, v Verdict, paranoid bool, f Floors) Combined {
	veto := f.WebForceVeto
	if veto <= 0 {
		veto = WebForceVetoFloor
	}
	webForce, vetoed := l0.WebForce, false
	if webForce && !v.NeedsWeb && !v.TimeSensitive && v.Confidence >= veto {
		webForce, vetoed = false, true
	}
	switch {
	case v.AboutProject:
		return Combined{Route: RouteProject, WebDecidedBy: WebByNone}
	case webForce:
		return Combined{Route: RouteWeb, WebDecidedBy: WebByFreshness}
	case paranoid && v.NeedsWeb && v.Verifiable && v.Confidence >= f.WebNeedsWeb:
		return Combined{Route: RouteWeb, WebDecidedBy: WebByNeedsWeb}
	case paranoid && v.EntityObscure:
		return Combined{Route: RouteWeb, WebDecidedBy: WebByObscure}
	case paranoid && v.TimeSensitive:
		return Combined{Route: RouteWeb, WebDecidedBy: WebByTime}
	case paranoid && l0.LookupHint && v.Verifiable:
		return Combined{Route: RouteWeb, WebDecidedBy: WebByLookupHint}
	}
	if l0.Trivial && v.Trivial && v.Confidence >= f.Trivial {
		return Combined{Route: RouteTrivial, WebDecidedBy: WebByNone}
	}
	if vetoed {
		// Fell all the way through after the veto — record it (request_log GROUP BY).
		return Combined{Route: RouteGrokDirect, WebDecidedBy: WebByFreshVetoed}
	}
	return Combined{Route: RouteGrokDirect, WebDecidedBy: WebByNone}
}
