package router

import (
	"regexp"
	"strings"
)

// Task capture and task listing, matched deterministically (Vikunja #130).
//
// No new intent. The router's intent enum is a contract shared with the
// relabelling prompt in the training workspace (`llm/check_prompt_parity.py`
// enforces it), so adding an eighth intent would mean retraining before a task
// could be captured at all. A task phrased out loud is a note-shaped or
// query-shaped utterance with an explicit marker in it, and the marker is a
// lookup — the same reasoning the calendar, plan and habit matchers already
// follow. What the model classifies is unchanged; what these functions decide
// is which store the turn lands in.

// The phrase tables this file matches against — taskCapturePrefixes,
// urgencyMarkers, taskListWords, taskListWordsShortcut — are loaded from the
// embedded task_phrases.json. See task_phrases.go.

// TaskCapture — a parsed capture: the task itself, plus the importance he
// stated out loud if he stated one (Vikunja #129). Weight 0 means he said
// nothing about importance, which the ranker treats as exactly that — no
// urgency is inferred from the wording.
type TaskCapture struct {
	Text   string
	Weight int
}

// ParseTaskCapture reports whether an utterance explicitly files a task, and
// returns the task text with the marker stripped. A marker with nothing after it
// is not a capture (there is no task in "добавь в задачи") — the caller falls
// through to whatever it would otherwise have done with the turn.
func ParseTaskCapture(text string) (TaskCapture, bool) {
	trimmed := strings.TrimSpace(text)
	lower := strings.ToLower(trimmed)
	best := ""
	for _, p := range taskCapturePrefixes {
		if strings.HasPrefix(lower, p) && len(p) > len(best) {
			best = p
		}
	}
	if best == "" {
		return TaskCapture{}, false
	}
	// Cut on the rune length of the matched prefix. ToLower does not change the
	// byte length of Russian or English letters, so the index carries over.
	rest := strings.TrimSpace(trimmed[len(best):])
	rest = strings.TrimLeft(rest, ":—- ")
	rest = strings.TrimSpace(rest)
	// The question mark goes too. Whisper punctuates dictated Russian, and
	// "добавь в задачи позвонить в банк?" must not store the mark or carry it
	// into the dedupe key.
	rest = strings.TrimRight(rest, ".!?")
	rest, weight := stripUrgency(rest)
	if rest == "" {
		return TaskCapture{}, false
	}
	return TaskCapture{Text: rest, Weight: weight}, true
}

// urgencyIntensifiers — words that may sit between the edge and the marker.
// "очень срочно оплатить интернет" is the marker at the edge with one word in
// front of it, and it means exactly what "срочно оплатить интернет" means.
var urgencyIntensifiers = []string{"очень", "прям", "прямо", "really", "very", "super"}

// urgencyEdgeTrim — punctuation to ignore around an edge token and to clean off
// the remainder afterwards.
const urgencyEdgeTrim = " .,;:!?—-"

// stripUrgency pulls a leading or trailing urgency word out of the task text
// and returns the weight it implies. Only at the edges: "срочно оплатить
// интернет" and "оплатить интернет срочно" are the same instruction, while
// "позвонить в срочную помощь" is a task whose text happens to contain the
// stem, and cutting a word out of the middle of it would mangle the task.
//
// Matched as a TOKEN, not as a fixed prefix or suffix string. The old shape
// required exactly one space before a trailing marker, so "оплатить интернет,
// срочно" — which is what whisper produces from dictated Russian — kept weight
// 0 and stored the comma and the word as part of the task, polluting the dedupe
// key with the very flag he was trying to set.
//
// The word is removed from the text, because the list should read "оплатить
// интернет (важно)" and not "важно оплатить интернет (важно)".
func stripUrgency(text string) (string, int) {
	fields := strings.Fields(text)
	if len(fields) == 0 {
		return text, 0
	}
	for _, m := range urgencyMarkers {
		// Strongest marker first (task_phrases.go sorts them), leading edge
		// before trailing, so a text carrying both keeps the stronger one.
		if lo, hi, ok := urgencySpan(fields, m.Word); ok {
			rest := strings.Join(append(append([]string{}, fields[:lo]...), fields[hi+1:]...), " ")
			rest = strings.Trim(rest, urgencyEdgeTrim)
			if rest == "" {
				// Nothing but the marker — no task in it.
				return "", 0
			}
			return rest, m.Weight
		}
	}
	return text, 0
}

// urgencySpan finds the marker at either edge, allowing intensifiers between
// the edge and the marker, and returns the inclusive token range to cut.
func urgencySpan(fields []string, word string) (lo, hi int, ok bool) {
	for i := 0; i < len(fields); i++ {
		if isUrgencyToken(fields[i], word) {
			return 0, i, true
		}
		if !isIntensifier(fields[i]) {
			break
		}
	}
	for i := len(fields) - 1; i >= 0; i-- {
		if isUrgencyToken(fields[i], word) {
			return i, len(fields) - 1, true
		}
		if !isIntensifier(fields[i]) {
			break
		}
	}
	return 0, 0, false
}

func isUrgencyToken(tok, word string) bool {
	return strings.Trim(strings.ToLower(tok), urgencyEdgeTrim) == word
}

func isIntensifier(tok string) bool {
	t := strings.Trim(strings.ToLower(tok), urgencyEdgeTrim)
	for _, w := range urgencyIntensifiers {
		if t == w {
			return true
		}
	}
	return false
}

// IsTaskListQuery reports whether an utterance asks for the outstanding task
// list — "какие у меня задачи?", "что мне нужно сделать?", "список дел".
//
// Narrow on purpose. "как дела?" is a greeting, not a query about work, and it
// contains a task noun; it is excluded explicitly. Anything that mentions a
// task noun without asking for the list falls through to ordinary recall.
func IsTaskListQuery(text string) bool {
	toks := planTokens(text)
	if len(toks) == 0 {
		return false
	}
	// "как дела" — the greeting. Excluded before anything else matches.
	if hasTok(toks, "как") && (hasTok(toks, "дела") || hasTok(toks, "делишки")) {
		return false
	}
	// "что мне нужно сделать" / "чем мне заняться" — no task noun at all, so
	// the pronoun is what carries the meaning. Without it these rules claimed
	// every question with a verb in them: "что нужно сделать чтобы перезапустить
	// сервер?" and "what does docker do?" both answered "задач нет." from ahead
	// of recall and the model, which is the failure the source ordering exists
	// to avoid, pointed the other way.
	//
	// A "с"/"со" object excludes them too: "что мне сделать с этим файлом" has
	// the pronoun and is still a question about a file.
	if !hasTok(toks, "с") && !hasTok(toks, "со") {
		if hasTok(toks, "мне") && (hasTok(toks, "что") || hasTok(toks, "чем")) &&
			(hasTok(toks, "сделать") || hasTok(toks, "делать") || hasTok(toks, "заняться")) {
			return true
		}
		if hasTok(toks, "what") && hasTok(toks, "do") && hasTok(toks, "i") && !hasTok(toks, "you") {
			return true
		}
	}
	hasNoun := false
	for _, t := range toks {
		for _, w := range taskListWords {
			if t == w {
				hasNoun = true
			}
		}
	}
	if !hasNoun {
		return false
	}
	// A task noun plus any of: a question word, "список", or a bare
	// one/two-word ask ("задачи", "мои задачи").
	if hasTok(toks, "какие") || hasTok(toks, "какая") || hasTok(toks, "что") ||
		hasTok(toks, "сколько") || hasTok(toks, "список") || hasTok(toks, "покажи") ||
		hasTok(toks, "напомни") || hasTok(toks, "my") || hasTok(toks, "list") ||
		hasTok(toks, "show") {
		return true
	}
	if len(toks) <= 2 {
		for _, t := range toks {
			for _, w := range taskListWordsShortcut {
				if t == w {
					return true
				}
			}
		}
	}
	return false
}

// TaskCaptureGrammar — stage 0 for an explicit capture marker, so the resident
// model never sees it (Vikunja #467).
//
// Capture was built to ride the note intent, deliberately: #130 said no eighth
// intent, and while the classifier was routing, a note-shaped utterance with a
// marker in it reached actionNote and captureTaskFromNote claimed it there. The
// router pre-empted that. Measured 2026-08-02: "добавь в задачи купить молоко"
// routed act, so captureTaskFromNote was never consulted, the act arm found no
// allowlisted fn, and the gate asked "Что сделать?". Every capture utterance
// tried filed nothing.
//
// The model also rewrote the payload on the way — "купить молоко" came back as
// "сделать покупку молока". A task must read as the words he said, which is a
// second reason to answer this before the model rather than to prompt around
// it.
//
// The marker list is data (task_phrases.json) and the parse strips urgency, so
// the pattern here matches any utterance and the decision is ParseTaskCapture's
// to make — same shape as the wake-word act grammar, which also matches broadly
// and refuses in Build. Intent stays note: the daemon's note path is where
// capture lives, and nothing about the contract with the model changes.
func TaskCaptureGrammar() Grammar {
	return Grammar{
		Name:    "task-capture",
		Pattern: regexp.MustCompile(`(?s)^\s*(.+)$`),
		Build: func(m []string) (Decision, bool) {
			c, ok := ParseTaskCapture(m[1])
			if !ok {
				return Decision{}, false // not a capture — fall through
			}
			return Decision{
				Stage:      0,
				Intent:     IntentNote,
				Confidence: 1.0,
				// The capture text, not the raw utterance: it is what the
				// clarify gate reads as the payload. captureTaskFromNote
				// re-parses the utterance itself, so the task text comes from
				// the same place either way.
				Slots: Slots{Text: c.Text},
			}, true
		},
	}
}

// TaskListGrammar — stage 0 for "какие у меня задачи", "список дел", "что мне
// нужно сделать" (Vikunja #467).
//
// The same exposure the capture marker had, pointed the other way. IsTaskListQuery
// is a deterministic lookup that lives inside a query source, so it is only
// consulted once the turn is already IntentQuery. A phrasing the model calls
// system or note never reaches it, and "пока не умею" is what he hears — the
// failure the agenda and feed rules were written for.
//
// Placed after the agenda rules, which already send "какие у меня задачи" to
// query. What this adds is the phrasings with no possessive in them.
func TaskListGrammar() Grammar {
	return Grammar{
		Name:    "task-list-query",
		Pattern: regexp.MustCompile(`(?s)^\s*(.+)$`),
		Build: func(m []string) (Decision, bool) {
			if !IsTaskListQuery(m[1]) {
				return Decision{}, false
			}
			return Decision{
				Stage:      0,
				Intent:     IntentQuery,
				Confidence: 1.0,
				Slots:      Slots{Text: strings.TrimSpace(m[1])},
			}, true
		},
	}
}
