package router

import (
	"strconv"
	"strings"
	"time"

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

// MentionsTime reports whether the sentence names a time at all, whether or not
// a parser could read it.
//
// The caller is the multi-turn seam. A reminder whose time slot is empty used to
// inherit the previous reminder's hour, so "напомни без четверти восемь
// выходить" landed at 07:30 because the turn before it had (V-543). Inheriting
// is right when the sentence names no time and wrong when it names one the
// parser missed, and this is the test that tells those apart. Missing the time
// he said means asking; inheriting means a wrong alarm he stops thinking about.
//
// Every signal here is a closed lexicon class or a digit, so this reads data and
// decides nothing about meaning.
func MentionsTime(text string) bool {
	toks := strings.Fields(strings.ToLower(text))
	for i, raw := range toks {
		tok := cleanWord(raw)
		if timeMarkers[tok] {
			return true
		}
		if isDigitClock(tok) {
			return true
		}
		if _, ok := numeralDigit(tok); ok && hasTimeNeighbour(toks, i) {
			return true
		}
		if _, _, ok := halfPastAt(toks, i); ok {
			return true
		}
		if _, _, _, ok := quarterToAt(toks, i); ok {
			return true
		}
		if isWeekday(tok) {
			return true
		}
	}
	return false
}

// isDigitClock reports whether the token is a written clock, "19:30". The
// minutes must be written as two digits, because a clock is and a score is not:
// "счёт 3:2" names no time.
func isDigitClock(tok string) bool {
	h, m, found := strings.Cut(tok, ":")
	if !found || len(m) != 2 {
		return false
	}
	hn, err := strconv.Atoi(h)
	if err != nil || hn < 0 || hn > 23 {
		return false
	}
	mn, err := strconv.Atoi(m)
	return err == nil && mn >= 0 && mn <= 59
}

// NamesADay reports whether the sentence names a calendar day: a weekday, a
// relative day word, or a month beside a date. A bare clock names none of them,
// which is what lets a parser roll it forward to the next occurrence.
//
// "напомни сегодня в 12:30" names the day, so it stays on it even when 12:30 has
// passed. Rolling that one forward would move a reminder he placed deliberately.
func NamesADay(text string) bool {
	for _, raw := range strings.Fields(strings.ToLower(text)) {
		tok := cleanWord(raw)
		if _, ok := lexicon.DayOffset(tok); ok {
			return true
		}
		if isWeekday(tok) || isMonth(tok) {
			return true
		}
	}
	return false
}

// NamesAnHour reports whether the sentence names a time of day or an interval
// away from now: a written clock, a numeral, a half or quarter past, or one of
// the words an interval is built from. A day word alone is not one — "завтра"
// says which day and says nothing about when in it.
//
// It is the gate on the reminder's time slot (V-577, V-579). A time slot that
// names no hour is never filled, it is asked about. Both parsers answer a bare
// day word with that day at the current minute, so "что у меня сегодня?" set a
// reminder at 01:28 and "на завтра" set one at 01:38 — the minute he happened
// to be speaking, in a request that never named one. The stub answers with
// midnight instead, which is a different invented hour and no better.
//
// Same discipline as MentionsTime above: every signal is a closed lexicon class
// or a digit, so this reads data and decides nothing about meaning.
func NamesAnHour(text string) bool {
	toks := strings.Fields(strings.ToLower(text))
	for i, raw := range toks {
		tok := cleanWord(raw)
		if isDigitClock(tok) || isAllDigits(tok) {
			return true
		}
		if _, ok := numeralDigit(tok); ok {
			return true
		}
		if hourMarkers[tok] {
			return true
		}
		if _, _, ok := halfPastAt(toks, i); ok {
			return true
		}
		if _, _, _, ok := quarterToAt(toks, i); ok {
			return true
		}
	}
	return false
}

// ResolvedTheHour reports whether a parse read the hour the sentence names,
// rather than inheriting the clock it was handed as its relative base.
//
// It is the second half of the gate NamesAnHour opens (V-610). NamesAnHour asks
// whether an hour was spoken and cannot ask whether it was read, so a
// preposition the parser half knew wrote a reminder at 03:53 for "напомни
// завтра к трём часам дня" and confirmed it as if it were the hour he said. A
// wrong instant she states as fact is worse than a question, because he stops
// thinking about it.
//
// The tell is the minute. A spoken hour lands on the hour, and the only three
// shapes that name a minute of their own are a written clock, a half hour and a
// quarter to. A parse that came back with any other minute took it from the
// clock it was handed, whatever hour it put in front of it. An interval is
// exempt, because it is measured from now and lands wherever the arithmetic
// says.
//
// Comparing the whole instant to now would be the obvious test and it is the
// wrong one: "напомни послезавтра в 12" resolves to 12:00 and the fixture's
// reference clock is 12:00, so an hour he did say would read as an hour nobody
// did.
func ResolvedTheHour(text string, t time.Time) bool {
	if !NamesAnHour(text) {
		return false
	}
	if NamesAnInterval(text) || t.Minute() == 0 {
		return true
	}
	return namesTheMinute(text)
}

// namesTheMinute reports whether the sentence says which minute of the hour it
// means, in any of the three ways it can.
func namesTheMinute(text string) bool {
	toks := strings.Fields(strings.ToLower(text))
	for i, raw := range toks {
		if isDigitClock(cleanWord(raw)) {
			return true
		}
		if _, _, ok := halfPastAt(toks, i); ok {
			return true
		}
		if _, _, _, ok := quarterToAt(toks, i); ok {
			return true
		}
	}
	return false
}

// NamesAnInterval reports whether the sentence measures the time from now
// instead of naming it: "через час", "через 10 минут", "in 30 minutes".
//
// An interval resolves to one instant, so it answers the hour and the day
// together and nothing about it is ambiguous. Callers that ask which day or
// which nine o'clock have to skip it (V-579).
func NamesAnInterval(text string) bool {
	for _, raw := range strings.Fields(strings.ToLower(text)) {
		switch cleanWord(raw) {
		case "через", "спустя", "in":
			return true
		}
	}
	return false
}

// HourIsAmbiguous reports whether the hour named could be either half of the
// day: "в 3" is three in the afternoon or three at night, and only he knows
// which (V-579, owner's rule of 2026-08-06).
//
// Three things settle it and any one is enough. A qualifier - "вечера", "pm",
// "полдень" - says which half. A written clock says it by being written. An
// hour above twelve says it by arithmetic. An interval names no hour at all.
func HourIsAmbiguous(text string) bool {
	if NamesAnInterval(text) {
		return false
	}
	toks := strings.Fields(strings.ToLower(text))
	for _, raw := range toks {
		tok := cleanWord(raw)
		if hourQualifiers[tok] || isDigitClock(tok) {
			return false
		}
	}
	for _, raw := range toks {
		tok := cleanWord(raw)
		d, ok := numeralDigit(tok)
		if !ok && isAllDigits(tok) {
			d, ok = tok, true
		}
		if !ok {
			continue
		}
		n, err := strconv.Atoi(d)
		if err == nil && n >= 1 && n <= 12 {
			return true
		}
	}
	return false
}

// hourQualifiers — the words that pin an hour to one half of the day. Closed,
// and every member is a lexicon class or the two English markers.
var hourQualifiers = buildHourQualifiers()

func buildHourQualifiers() map[string]bool {
	m := map[string]bool{
		"утра": true, "вечера": true, "дня": true, "ночи": true,
		"полдень": true, "полночь": true, "полудня": true,
		"am": true, "pm": true, "noon": true, "midnight": true,
	}
	for _, w := range lexicon.PartsOfDay() {
		m[w] = true
	}
	return m
}

func isAllDigits(tok string) bool {
	if tok == "" {
		return false
	}
	for _, r := range tok {
		if r < '0' || r > '9' {
			return false
		}
	}
	return true
}

// hourMarkers — timeMarkers minus the day words and the weekdays, which name a
// day and not an hour, and minus "сейчас", which IS the clock and so can never
// be the evidence that the clock was meant.
var hourMarkers = buildHourMarkers()

func buildHourMarkers() map[string]bool {
	m := map[string]bool{
		"утра": true, "вечера": true, "дня": true, "ночи": true,
		"через": true, "спустя": true, "полчаса": true,
		"полдень": true, "полночь": true,
		"am": true, "pm": true, "noon": true, "midnight": true, "in": true,
	}
	for _, w := range lexicon.HourUnits() {
		m[w] = true
	}
	for _, w := range lexicon.MinuteUnits() {
		m[w] = true
	}
	for _, w := range lexicon.PartsOfDay() {
		m[w] = true
	}
	for _, w := range lexicon.HalfHourWords() {
		m[w] = true
	}
	for w := range minutesTo {
		m[w] = true
	}
	return m
}

// isMonth reports whether the token is a month name. The lexicon holds the
// genitive, which is the form a spoken date uses: "10 июля".
func isMonth(tok string) bool {
	for m := 1; m <= 12; m++ {
		if tok == lexicon.MonthGenitive(m) {
			return true
		}
	}
	return false
}

// WeekdayIndex reports which day of the week a token names, in any case and in
// either language, or false when it names none.
//
// One matcher for the whole daemon (V-581). Four files used to keep a weekday
// list of their own and each one was short in a different direction: the habit
// map had "воскресеньях" but no "средах", the plan refusal had "среду" but not
// "среде", and cmd/mavend matched the STEM "сред" with strings.Contains, so
// "среди" and "средство" read as Wednesday. The lexicon lists the nominative,
// every Russian case lemmatises to it, and only English needs its forms written
// out — the vendored dictionary is Russian and leaves "mondays" alone.
func WeekdayIndex(tok string) (time.Weekday, bool) {
	t := strings.ToLower(strings.TrimSpace(tok))
	if n, ok := lexicon.WeekdayEnglish(t); ok {
		return time.Weekday(n), true
	}
	for i, name := range lexicon.Weekdays() {
		if morph.SameWord(t, name) {
			return time.Weekday(i), true
		}
	}
	return 0, false
}

// isWeekday reports whether the token is a day of the week, when the caller
// does not need to know which one.
func isWeekday(tok string) bool {
	_, ok := WeekdayIndex(tok)
	return ok
}

// timeMarkers — the words that name a time on their own: the qualifiers that
// turn an hour into a part of the day, the relative day words, the weekdays and
// the two relative openers. Built from the lexicon at init, so a word added
// there is a word this reads.
var timeMarkers = buildTimeMarkers()

func buildTimeMarkers() map[string]bool {
	m := map[string]bool{
		"утра": true, "вечера": true, "дня": true, "ночи": true,
		"через": true, "полчаса": true, "сейчас": true,
		"am": true, "pm": true, "noon": true, "midnight": true,
	}
	for _, w := range lexicon.HourUnits() {
		m[w] = true
	}
	for _, w := range lexicon.MinuteUnits() {
		m[w] = true
	}
	for _, w := range lexicon.PartsOfDay() {
		m[w] = true
	}
	for _, w := range lexicon.DayOffsetWords() {
		m[w] = true
	}
	for i := 0; i < 7; i++ {
		if w := lexicon.Weekday(i); w != "" {
			m[w] = true
		}
	}
	for _, w := range lexicon.HalfHourWords() {
		m[w] = true
	}
	for w := range minutesTo {
		m[w] = true
	}
	return m
}
