package router

import (
	"context"
	"strconv"
	"strings"
	"time"

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

// DateTimeParser — resolves relative→absolute AT CAPTURE ("in 4h" → now+4h),
// per spec. The production impl is `dateparser` (ru+en relative+absolute) in a
// later module; the interface keeps slot extraction testable without it.
// Returns (time, true, nil) on a successful parse; (zero, false, nil) when the
// text carries no recognizable datetime — a missing slot, not an error.
type DateTimeParser interface {
	Parse(ctx context.Context, text string, now time.Time) (time.Time, bool, error)
}

// ActMatcher — fuzzy-matches an utterance's verb against the fn allowlist.
// Not on the list → refuse, don't improvise (per spec). The production matcher
// is fuzzy; the scaffold ships exact + exact-with-args. Destructive acts still
// gate behind confirm at the daemon layer — the matcher only identifies the fn.
type ActMatcher interface {
	Match(utterance string) (fn string, args []string, ok bool)
	Allowlist() []string
}

// FactParser — pulls a (key,value) pair out of a fact utterance. "drank water"
// → key=water; "slept 6h" → key=sleep, value=6h. The loop evaluates predicates
// against the key; the value is the structured payload the daemon json-encodes
// before WriteFact. Tiny at mvp; the table of recognizers grows as code (same
// instinct as rules-as-code).
type FactParser interface {
	Parse(utterance string) (key, value string, ok bool)
}

// Extractor — stage 2: per-intent slot extraction. Classification gives *what
// kind*, not *the args*. Each intent has its own parser; the router dispatches.
// The SLM's last-resort lane (free-form notes the parsers choke on) is NOT
// here — it lives in the phrasing module. The extractor is deterministic.
type Extractor struct {
	Time  DateTimeParser
	Acts  ActMatcher
	Facts FactParser
}

// Extract — dispatches on intent, fills the relevant Slots fields. Best-effort:
// a slot that doesn't parse leaves its Has* flag false; the daemon/SLM last-
// resort lane picks it up. Never returns an error for "couldn't parse" —
// missing slot ≠ failure.
func (e Extractor) Extract(ctx context.Context, intent Intent, utterance string, now time.Time) Slots {
	s := Slots{Text: utterance}
	switch intent {
	case IntentReminder:
		if e.Time != nil {
			if t, ok, err := e.Time.Parse(ctx, utterance, now); err == nil && ok {
				s.Time = t
				s.HasTime = true
			}
		}
	case IntentAct:
		if e.Acts != nil {
			if fn, args, ok := e.Acts.Match(utterance); ok {
				s.Fn = fn
				s.Args = args
				s.HasFn = true
			}
		}
	case IntentFact:
		if e.Facts != nil {
			if k, v, ok := e.Facts.Parse(utterance); ok {
				s.Key = k
				s.Value = v
				s.HasKey = true
			}
		}
	case IntentChat:
		// Chat has no structured slots — the full utterance is the payload.
		// Slots.Text is already set to utterance at the top of Extract.
	}
	return s
}

// --- default implementations (scaffold floors; production swaps wholesale) ---

// DefaultActMatcher — exact verb prefix + remainder-as-args. The production
// matcher is fuzzy; this is the scaffold floor. "restart nginx" → fn=restart,
// args=[nginx]. Not on the list → ok=false → the router refuses the act.
type DefaultActMatcher struct {
	Fns []string
}

func (m DefaultActMatcher) Allowlist() []string { return m.Fns }

func (m DefaultActMatcher) Match(utterance string) (string, []string, bool) {
	u := strings.TrimSpace(utterance)
	// longest-verb-first so "restart" can't be shadowed by a shorter prefix.
	sorted := append([]string(nil), m.Fns...)
	sortDescByLen(sorted)
	for _, fn := range sorted {
		if u == fn {
			return fn, nil, true
		}
		if strings.HasPrefix(u, fn+" ") {
			rest := strings.TrimSpace(strings.TrimPrefix(u, fn+" "))
			return fn, splitArgs(rest), true
		}
	}
	return "", nil, false
}

// DefaultFactParser — a handful of recognizers as code. Grows by append, not
// by config. Keys match the loop's rule keys (water/meal/sleep/break) so a
// captured fact actually feeds the predicates that read it.
type DefaultFactParser struct{}

func (DefaultFactParser) Parse(utterance string) (string, string, bool) {
	s := strings.ToLower(strings.TrimSpace(utterance))
	// Maven is ru-first (voice, tts). Each case carries the English tokens AND
	// Russian stems — matched by prefix (hasStem) because Russian inflects
	// (воды/воду/вода share "вод"), so exact-token matching would miss most
	// real utterances and silently drop the capture.
	switch {
	case (containsWord(s, "water") && containsWord(s, "drank")) ||
		(hasRoot(s, "вод") && (hasRoot(s, "пил") || hasRoot(s, "пью") || hasRoot(s, "пей"))):
		return "water", `"drank"`, true
	case containsWord(s, "meal") || (containsWord(s, "ate") && !containsWord(s, "backup")) || containsWord(s, "lunch") || containsWord(s, "dinner") ||
		hasRoot(s, "поел") || hasRoot(s, "поесть") || hasRoot(s, "куша") || hasRoot(s, "обед") || hasRoot(s, "ужин") || hasRoot(s, "завтрак") || hasRoot(s, "еда"):
		return "meal", `"ate"`, true
	case containsWord(s, "shower") || hasRoot(s, "душ"):
		return "shower", `"took"`, true
	case containsWord(s, "break") || hasRoot(s, "перерыв") || hasRoot(s, "отдох"):
		return "break", `"took"`, true
	case containsWord(s, "slept") || containsWord(s, "sleep") ||
		hasRoot(s, "спал") || hasRoot(s, "выспал"):
		if v, ok := parseDurationValue(afterWord(s, "slept")); ok {
			return "sleep", strconv.Quote(v), true
		}
		return "sleep", `"slept"`, true
	}
	return "", "", false
}

// hasRoot — substring match on the whole utterance. Russian inflects with BOTH
// prefixes and suffixes (вы-пил, по-пил, пил-и), so a prefix test misses the
// verb; the root as a substring catches all forms. A rare over-match (пил in
// пилот) is fine at this floor. ponytail: substring roots over a morphology lib
// until misfires actually bite.
func hasRoot(s, root string) bool { return strings.Contains(s, root) }

// containsWord — whole-token membership (avoids "breakfast" matching "break").
func containsWord(s, w string) bool {
	for _, tok := range strings.Fields(s) {
		if tok == w {
			return true
		}
	}
	return false
}

// afterWord — the remainder of s after the first occurrence of word w (tokens).
func afterWord(s, w string) string {
	toks := strings.Fields(s)
	for i, t := range toks {
		if t == w {
			return strings.Join(toks[i+1:], " ")
		}
	}
	return ""
}

// StubDateTimeParser — a tiny relative/absolute parser standing in for
// `dateparser` until the i18n module lands. Handles "in Nh"/"in Nm"/"in Ns" and
// "at HH:MM" / "HH:MM". The production path replaces this wholesale; the
// interface is the seam, not this implementation.
type StubDateTimeParser struct{}

func (StubDateTimeParser) Parse(_ context.Context, text string, now time.Time) (time.Time, bool, error) {
	s := strings.ToLower(strings.TrimSpace(SpellOutDigits(text)))
	toks := strings.Fields(s)
	// scan for "in <num> <unit>" anywhere — dateparser extracts the datetime
	// expression from surrounding text; the stub does the same naively.
	for i := 0; i+2 < len(toks); i++ {
		if toks[i] != "in" {
			continue
		}
		n, unit, ok := splitNumUnit(toks[i+1] + " " + toks[i+2])
		if !ok {
			continue
		}
		if d, ok := unitToDuration(n, unit); ok {
			return now.Add(d), true, nil
		}
	}
	// scan for "at <clock>" anywhere.
	for i := 0; i+1 < len(toks); i++ {
		if toks[i] != "at" {
			continue
		}
		if t, ok := parseClock(toks[i+1], now); ok {
			return t, true, nil
		}
	}

	// --- Russian time expressions (stub floor; dateparser replaces) ---

	// "в <clock>" anywhere — mirror of the English "at" scan. A qualifier
	// after the hour moves it into the afternoon: "в 7 вечера" is 19:00, and
	// with SpellOutDigits in front of this that is what "в семь вечера" reads
	// as too (Vikunja #469).
	for i := 0; i+1 < len(toks); i++ {
		if toks[i] != "в" {
			continue
		}
		t, ok := parseClock(toks[i+1], now)
		if !ok {
			continue
		}
		if i+2 < len(toks) {
			t = applyRuQualifier(t, toks[i+2], now)
		}
		return t, true, nil
	}

	// "через <N> <unit>" / "через <unit>" (bare = 1) / "через полчаса".
	for i := 0; i+1 < len(toks); i++ {
		if toks[i] != "через" {
			continue
		}
		// "через N unit" — three-token scan.
		if i+2 < len(toks) {
			n, unit, ok := splitNumUnit(toks[i+1] + " " + toks[i+2])
			if ok {
				if d, ok := unitToDuration(n, unit); ok {
					return now.Add(d), true, nil
				}
			}
		}
		// "через полчаса"
		if toks[i+1] == "полчаса" {
			return now.Add(30 * time.Minute), true, nil
		}
		// "через unit" (bare unit without number = 1, e.g. "через час")
		if d, ok := unitToDuration(1, toks[i+1]); ok {
			return now.Add(d), true, nil
		}
	}

	// Calendar day: "сегодня", "завтра", "послезавтра" [в] <clock>
	for i := 0; i < len(toks); i++ {
		var dayShift time.Duration
		switch toks[i] {
		case "сегодня":
			dayShift = 0
		case "завтра":
			dayShift = 24 * time.Hour
		case "послезавтра":
			dayShift = 48 * time.Hour
		default:
			continue
		}
		base := now.Truncate(24 * time.Hour).Add(dayShift)
		// Look for clock after the day word (optional "в").
		nextIdx := i + 1
		if nextIdx < len(toks) && toks[nextIdx] == "в" {
			nextIdx++
		}
		if nextIdx < len(toks) {
			if t, ok := parseClock(toks[nextIdx], now); ok {
				t = time.Date(base.Year(), base.Month(), base.Day(), t.Hour(), t.Minute(), 0, 0, now.Location())
				return t, true, nil
			}
		}
		// No clock — return midnight of that day.
		return base, true, nil
	}

	// bare clock at start ("7:30").
	if len(toks) > 0 {
		if t, ok := parseClock(toks[0], now); ok {
			return t, true, nil
		}
	}
	return time.Time{}, false, nil
}

// --- helpers ---

func splitArgs(rest string) []string {
	parts := strings.Fields(rest)
	if len(parts) == 0 {
		return nil
	}
	return parts
}

func sortDescByLen(ss []string) {
	for i := 1; i < len(ss); i++ {
		for j := i; j > 0 && len(ss[j]) > len(ss[j-1]); j-- {
			ss[j], ss[j-1] = ss[j-1], ss[j]
		}
	}
}

// parseClock — "7", "7:30" → today at that time; if already past today, roll
// to tomorrow (a "wake me 7" at 8pm fires tomorrow 7). Used by the stub scan.
func parseClock(clock string, now time.Time) (time.Time, bool) {
	parts := strings.SplitN(clock, ":", 2)
	h, err := strconv.Atoi(parts[0])
	if err != nil || h < 0 || h > 23 {
		return time.Time{}, false
	}
	m := 0
	if len(parts) == 2 {
		m, err = strconv.Atoi(parts[1])
		if err != nil || m < 0 || m > 59 {
			return time.Time{}, false
		}
	}
	t := time.Date(now.Year(), now.Month(), now.Day(), h, m, 0, 0, now.Location())
	if !t.After(now) {
		t = t.Add(24 * time.Hour)
	}
	return t, true
}

// splitNumUnit — "4h" → (4, "h"); "thirty minutes" → (30, "minutes"). Also
// handles a small set of English word numbers ("four", "thirty") so the stub
// parses natural reminder seeds; `dateparser` brings the full ru/en coverage.
func splitNumUnit(s string) (int, string, bool) {
	s = strings.TrimSpace(s)
	if s == "" {
		return 0, "", false
	}
	if n, rest, ok := leadingDigits(s); ok {
		return n, strings.TrimSpace(rest), true
	}
	if n, rest, ok := leadingWordNumber(s); ok {
		return n, strings.TrimSpace(rest), true
	}
	return 0, "", false
}

func leadingDigits(s string) (int, string, bool) {
	i := 0
	for i < len(s) && s[i] >= '0' && s[i] <= '9' {
		i++
	}
	if i == 0 {
		return 0, "", false
	}
	n, err := strconv.Atoi(s[:i])
	if err != nil {
		return 0, "", false
	}
	return n, s[i:], true
}

// leadingWordNumber reads a spoken number off the front of a phrase — "два
// часа", "twenty minutes". The number words are a closed class and live in
// internal/lexicon, complete: the inline table here stopped at "десять" in
// Russian, so "пятнадцать минут" was not a duration (Vikunja #525).

func leadingWordNumber(s string) (int, string, bool) {
	toks := strings.Fields(s)
	if len(toks) == 0 {
		return 0, "", false
	}
	n, ok := lexicon.Cardinal(toks[0])
	if !ok {
		return 0, "", false
	}
	return n, strings.Join(toks[1:], " "), true
}

func unitToDuration(n int, unit string) (time.Duration, bool) {
	switch unit {
	case "h", "hour", "hours", "hr", "hrs":
		return time.Duration(n) * time.Hour, true
	case "m", "min", "mins", "minute", "minutes":
		return time.Duration(n) * time.Minute, true
	case "s", "sec", "secs", "second", "seconds":
		return time.Duration(n) * time.Second, true
	// English day/week (pre-existing gap)
	case "day", "days":
		return time.Duration(n) * 24 * time.Hour, true
	// Russian units (inflected forms)
	case "час", "часа", "часов":
		return time.Duration(n) * time.Hour, true
	case "минута", "минуты", "минут":
		return time.Duration(n) * time.Minute, true
	case "день", "дня", "дней":
		return time.Duration(n) * 24 * time.Hour, true
	case "неделя", "недели", "недель":
		return time.Duration(n) * 7 * 24 * time.Hour, true
	}
	return 0, false
}

// parseDurationValue — used by the fact parser for "slept 6h" → value "6h".
func parseDurationValue(s string) (string, bool) {
	s = strings.TrimSpace(s)
	if s == "" {
		return "", false
	}
	n, unit, ok := splitNumUnit(s)
	if !ok {
		return "", false
	}
	if _, ok := unitToDuration(n, unit); !ok {
		return "", false
	}
	return strconv.Itoa(n) + unit, true
}

// AnaphoraResolver resolves pronouns like "это", "он", "она" to the prior
// turn's key entity. Returns a (key, value) pair the prior fact carried,
// or ("", "", false) when no pronoun is detected.
type AnaphoraResolver struct{}

// Resolve checks if text contains an anaphoric reference to a prior turn's
// entity. For MVP this handles the common Russian pronouns:
//   - "это" / "этого" / "этому" / "этим" / "этом" → "this" (most common)
//   - "он" / "его" / "ему" / "ним" → "he/it", masc
//   - "она" / "её" / "ей" / "ней" → "she/it", fem
//   - "оно" → "it", neuter
//
// Returns the matching pronoun type for cross-referencing with prior slots.
func (AnaphoraResolver) Resolve(text string) (ref string, ok bool) {
	s := strings.ToLower(strings.TrimSpace(text))
	toks := strings.Fields(s)
	for _, tok := range toks {
		switch tok {
		case "это", "этого", "этому", "этим", "этом", "эти", "эта":
			return "this", true
		case "он", "его", "ему", "ним":
			return "he", true
		case "она", "её", "ей", "ней":
			return "she", true
		case "оно":
			return "it", true
		case "тот", "та", "то", "те":
			return "that", true
		case "мой", "моего", "моему", "моим", "моём", "моя", "моей", "моё":
			return "mine", true
		}
	}
	return "", false
}

// ParseCalendarDate detects RU/EN calendar day words in text and returns
// midnight of that day in now's own time zone. Returns zero time + false if no
// match.
//
// The day words are a closed class and live in internal/lexicon, so this is a
// lookup rather than an ordered switch (Vikunja #525). The switch it replaced
// had to test "послезавтра" before "завтра" by hand, because one contains the
// other — and it matched on substrings, so "завтраком" was tomorrow. The lexicon
// matches on word boundaries and gained "позавчера", which was never here.
func ParseCalendarDate(text string, now time.Time) (time.Time, bool) {
	days, ok := lexicon.DayOffsetIn(text)
	if !ok {
		return time.Time{}, false
	}
	return midnight(now, days), true
}

// midnight returns the start of the day that is `days` away from now, in
// now's time zone (now.Truncate(24h) would cut on a UTC boundary instead).
func midnight(now time.Time, days int) time.Time {
	y, m, d := now.AddDate(0, 0, days).Date()
	return time.Date(y, m, d, 0, 0, 0, 0, now.Location())
}

// applyRuQualifier moves an hour into the afternoon when he said "вечера" or
// "дня" after it. Noon-crossing only: 7 becomes 19, and 19 stays 19. Morning
// qualifiers need no arithmetic, they only confirm the hour as spoken.
//
// The date is recomputed rather than shifted, so an hour that parseClock
// already pushed to tomorrow does not land two days out.
func applyRuQualifier(t time.Time, qualifier string, now time.Time) time.Time {
	h := t.Hour()
	switch strings.Trim(strings.ToLower(qualifier), ".,!?;:") {
	case "вечера", "дня":
		if h < 12 {
			h += 12
		}
	case "утра", "ночи":
		if h == 12 {
			h = 0
		}
	default:
		return t
	}
	out := time.Date(now.Year(), now.Month(), now.Day(), h, t.Minute(), 0, 0, now.Location())
	if !out.After(now) {
		out = out.Add(24 * time.Hour)
	}
	return out
}
