package router

import (
	"regexp"
	"strconv"
	"strings"
	"unicode"

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

// Praxis reach, at stage 0 (Vikunja #516).
//
// Measured in docs/evals/2026-08-04-ecosystem-reach.md: Praxis reach was 0/12 on
// the held-out fixture, and structurally so. handlePraxisAct dispatches on exact
// equality between Slots.Fn and a capability alias, and the fn slot is filled by
// DefaultActMatcher from the deployment's enabled tool names — no Praxis alias is
// on that list, so no utterance could ever put one in the slot. The Russian
// aliases in praxisCapabilities read as if they matched speech; they are compared
// against a fn slot and never against an utterance.
//
// So the fn slot is what these rules fill. Same move AgendaQueryGrammars made for
// agenda questions, and for the stronger reason: a lifecycle verb decides whether
// an item is acknowledged or resolved, and those are different words in the
// contract. That is not a similarity guess to leave to an embedder.
//
// Deliberately not here: a bare "готово" or "принято". Both are ordinary speech —
// he says "готово" about the thing he just finished, not about a Praxis item — so
// a lifecycle rule requires an item reference as well as a verb. What that costs
// is that he must say which item; what it buys is that no ordinary sentence
// silently transitions one.

// praxisLifecycleVerbs — the words that name a transition, per capability. Each
// word belongs to exactly one capability, and a sentence carrying two is refused
// below rather than guessed.
//
// Split into two columns, because the grammatical mood decides whether an item
// has to be named:
//
//   - imperative: addressed to her. "закрой" and "игнорируй" are instructions
//     and nothing else, so one claims the turn even with no item named — the
//     capability then asks which пункт, which is the honest reply.
//   - stative: a participle or a short adverb. "готово" and "принято" are how he
//     reports his own day, so one of those needs an item reference beside it or
//     it is not a Praxis turn at all.
//
// resolve is not acknowledge. ECOSYSTEM-SPEC §2.3 makes the distinction
// mechanical: "got it" acknowledges and "done" resolves, and Maven must not blur
// them just because both sound like agreement.
var praxisLifecycleVerbs = []struct {
	fn         string
	imperative []string
	stative    []string
}{
	{"resolve_item",
		[]string{"закрой", "закрывай", "закрыть", "resolve", "close"},
		[]string{"сделано", "сделан", "сделанный", "сделанное", "сделанным", "готово", "готов", "решено", "решён", "решен", "done", "resolved"}},
	{"acknowledge_item",
		[]string{"acknowledge", "ack"},
		[]string{"принято", "принял", "приняла", "принять", "понял", "поняла"}},
	{"ignore_item",
		[]string{"игнорируй", "игнорировать", "пропусти", "пропустить", "ignore", "skip"},
		[]string{"неважно"}},
	{"pin_item",
		[]string{"закрепи", "закрепить", "прикрепи", "pin"},
		nil},
}

// praxisMarkerVerbs — "отметь X как сделанное". The verb says record a state and
// the state is elsewhere in the sentence, so it cannot pick a capability on its
// own. With a state named, the state wins; with none, marking a пункт means
// acknowledging it, which is the weaker of the two transitions and the safer
// default. It is also a capture verb (internal/lexicon), which is why the marker
// alone is not enough to claim a turn.
var praxisMarkerVerbs = []string{"отметь", "отметить", "mark"}

// praxisDemonstratives — the words that point at the item she just read out.
// "отметь это как сделанное" names no пункт and still names one item, so these
// stand in for the noun. They resolve in the daemon and only against a digest she
// actually spoke; a demonstrative with no list behind it falls through to the
// rest of the cascade rather than asking, because "я это сделал" is a sentence he
// says about his own day (Vikunja #516).
var praxisDemonstratives = []string{"это", "этот", "эту", "этим", "этого", "том", "that", "this", "it"}

// praxisRefThis is the value slot for a demonstrative reference. Not a number,
// so it cannot be confused with a position, and not empty, so it cannot be
// confused with "he named no item".
const praxisRefThis = "this"

// PraxisLifecycle — a parsed transition: which capability, and which item.
//
// Ref is either a Praxis item id he read off a screen, or a position in the list
// she last spoke: "1".."N" as a decimal string, or "last". Resolving a position
// to an id needs the list, which lives in the daemon, so the router names the
// position and does not pretend to know the id.
type PraxisLifecycle struct {
	Fn  string
	Ref string
}

var praxisItemIDPattern = regexp.MustCompile(`(?i)\b(item[_-][a-z0-9_-]+)`)

// ParsePraxisLifecycle reads a lifecycle instruction: which transition, and which
// item. A stative word needs an item named beside it; an imperative does not.
func ParsePraxisLifecycle(text string) (PraxisLifecycle, bool) {
	lower := strings.ToLower(strings.TrimSpace(text))
	if lower == "" {
		return PraxisLifecycle{}, false
	}
	toks := praxisTokens(lower)
	var fn string
	imperative := false
	for _, c := range praxisLifecycleVerbs {
		imp := praxisHasToken(toks, c.imperative)
		if !imp && !praxisHasLemma(toks, c.stative) {
			continue
		}
		if fn != "" {
			// "готово, принято" names two transitions and they are not the same
			// state. Asking beats picking, so this declines and the act falls
			// through to the model and the clarify gate behind it.
			return PraxisLifecycle{}, false
		}
		fn, imperative = c.fn, imp
	}
	if fn == "" {
		// The marker verb alone: "отметь пункт" records a state and names none, so
		// it means the weaker transition. It still needs the item named, since
		// "отметь" is also how he opens a note.
		if !praxisHasToken(toks, praxisMarkerVerbs) {
			return PraxisLifecycle{}, false
		}
		fn = "acknowledge_item"
	}
	ref := ""
	if m := praxisItemIDPattern.FindStringSubmatch(lower); m != nil {
		ref = m[1]
	} else if !praxisNamesItem(toks) {
		// No noun and no id. A demonstrative stands in for the noun and means the
		// item she just read out.
		if praxisHasToken(toks, praxisDemonstratives) {
			return PraxisLifecycle{Fn: fn, Ref: praxisRefThis}, true
		}
		// Otherwise only a bare imperative claims the turn, with nothing in the
		// slot, so the capability asks which пункт. Bare is the whole condition:
		// "закрой шторы в комнате" is an imperative too and it closes the curtains
		// through Hexis, so anything naming its own object is not this rule's. A
		// stative word — "готово" — is him reporting his day and never claims.
		if !imperative || !praxisBareCommand(toks) {
			return PraxisLifecycle{}, false
		}
		return PraxisLifecycle{Fn: fn}, true
	} else {
		n, ok := praxisPosition(toks)
		if !ok {
			// "отметь пункт" names the verb and the noun and no item. The
			// capability's own "какой пункт?" is the right answer, so claim it.
			return PraxisLifecycle{Fn: fn}, true
		}
		if n == -1 {
			ref = "last"
		} else {
			ref = strconv.Itoa(n)
		}
	}
	return PraxisLifecycle{Fn: fn, Ref: ref}, true
}

// praxisChangesPattern — "что изменилось?", "какие изменения?". An ask word plus
// a change noun, the same pair FeedQueryGrammar wants, and "что нового" is
// deliberately absent: the feeds source claims that one and should.
var praxisChangesPattern = regexp.MustCompile(
	`(?i)^\s*(что|какие|покажи|расскажи|what)\s*(там|мне|has)?\s*(изменилось|изменения|изменени[а-я]*|нового в системе|changed|changes)(\s|[?!.]|$)`)

// praxisEntityPattern — scoped attention, which needs a subject and nothing else
// would give it one. Two framings only, both carrying an explicit subject:
// "статус X" and "как дела у X". A bare "как дела?" is a greeting and matches
// neither, because the subject is required after "у".
//
// Narrow on purpose. This rule claims the turn at stage 0, and the capability
// answers "не знаю, что это" when Nexus has no such entity — which is the right
// answer for "статус муzick" and the wrong one for anything the query chain could
// have looked up. So the framings must be ones he would only use about a thing he
// expects Maven to know by name.
var praxisEntityPattern = regexp.MustCompile(
	`(?i)^\s*(?:статус|status|как\s+дела\s+у|how\s+is)\s+(.+?)\s*[?!.]*$`)

// praxisAttentionPattern — "что требует внимания?" and the two scoped forms of
// it. queryAttention answers the same question from the query chain, and this
// rule does not replace it: the chain covers every phrasing the model routes to
// IntentQuery, and this covers the three explicit ones, more directly and without
// spending a model call. They call the same capability, so they cannot disagree.
//
// "что нового" alone is absent, because the feeds own it. "что нового по
// проектам" is here, because a project is a Praxis scope and no feed has one.
var praxisAttentionPattern = regexp.MustCompile(
	`(?i)^\s*(?:что|чего|what)\s+(?:сейчас\s+|там\s+|ещё\s+|еще\s+)?(?:требует\s+внимания|нового\s+по\s+(?:проектам|задачам|сервисам)|needs\s+attention)(\s|[?!.]|$)`)

// PraxisGrammars — one stage-0 rule per Praxis capability that free speech can
// reach. Wired after the agenda and feed rules and before the capture marker.
func PraxisGrammars() []Grammar {
	anything := regexp.MustCompile(`(?s)^(.*)$`)
	return []Grammar{
		{
			Name:    "praxis-lifecycle",
			Pattern: anything,
			Build: func(m []string) (Decision, bool) {
				c, ok := ParsePraxisLifecycle(m[1])
				if !ok {
					return Decision{}, false
				}
				return Decision{
					Stage:      0,
					Intent:     IntentAct,
					Confidence: 1.0,
					Slots:      Slots{Fn: c.Fn, HasFn: true, Value: c.Ref},
				}, true
			},
		},
		{
			Name:    "praxis-attention",
			Pattern: praxisAttentionPattern,
			Build: func([]string) (Decision, bool) {
				return Decision{
					Stage:      0,
					Intent:     IntentAct,
					Confidence: 1.0,
					Slots:      Slots{Fn: "list_attention", HasFn: true},
				}, true
			},
		},
		{
			Name:    "praxis-changes",
			Pattern: praxisChangesPattern,
			Build: func([]string) (Decision, bool) {
				return Decision{
					Stage:      0,
					Intent:     IntentAct,
					Confidence: 1.0,
					Slots:      Slots{Fn: "list_changes", HasFn: true},
				}, true
			},
		},
		{
			Name:   "praxis-service-attention",
			Decide: praxisServiceAttentionDecision,
		},
		{
			Name:    "praxis-entity-attention",
			Pattern: praxisEntityPattern,
			Build: func(m []string) (Decision, bool) {
				subject := strings.TrimSpace(m[1])
				if subject == "" {
					return Decision{}, false
				}
				return Decision{
					Stage:      0,
					Intent:     IntentAct,
					Confidence: 1.0,
					// Text, not Value: entityAttentionCapability reads Value
					// first and that slot means an item id everywhere else in
					// the Praxis dispatch.
					Slots: Slots{Fn: "entity_attention", HasFn: true, Text: subject},
				}, true
			},
		},
	}
}

// praxisServiceAttentionDecision recognises the colloquial operational frame
// "что там с X" only for the four services Maven's own architecture names.
// The generic frame is deliberately not claimed: "что там с погодой" belongs
// to weather and "что там с бэкапами" may need recall, network and attention.
// Arbitrary entity names remain Nexus's open-set responsibility.
func praxisServiceAttentionDecision(utterance string) (Decision, bool) {
	tokens := praxisTokens(strings.ToLower(strings.TrimSpace(utterance)))
	if len(tokens) != 4 || tokens[0] != "что" || tokens[1] != "там" ||
		(tokens[2] != "с" && tokens[2] != "со") ||
		!hasExactWord(lexicon.EcosystemServices(), tokens[3]) {
		return Decision{}, false
	}
	return Decision{
		Stage:      0,
		Intent:     IntentAct,
		Confidence: 1,
		Slots:      Slots{Fn: "entity_attention", HasFn: true, Text: tokens[3]},
	}, true
}

// praxisPosition reads which item in the list a sentence names, with -1 for the
// last one. Three tries per token, in this order:
//
//  1. the ordinals set, which lists the forms it lists;
//  2. the same set by lemma, because Russian has more cases than a data file
//     wants to spell out and "по первому пункту" is one of them;
//  3. the cardinals set, because "пункт три" is how a numbered list is read
//     aloud and it names a position rather than a count.
//
// Earliest token wins, so "первый и второй" answers the first consistently
// rather than by map order.
func praxisPosition(toks []string) (int, bool) {
	ords := lexicon.Ordinals()
	for _, t := range toks {
		if n, ok := lexicon.Ordinal(t); ok {
			return n, true
		}
		for _, o := range ords {
			if morph.SameWord(t, o.Word) {
				return o.N, true
			}
		}
		if n, ok := lexicon.Cardinal(t); ok && n > 0 {
			return n, true
		}
	}
	return 0, false
}

// praxisTokens splits an utterance into bare words. Tokenizing rather than
// substring-matching, because "готов" is a substring of "готовлю" — he is cooking,
// not resolving an item — and Go's \b would not have caught that either, being
// ASCII-only next to Cyrillic.
func praxisTokens(lower string) []string {
	return strings.FieldsFunc(lower, func(r rune) bool {
		return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' && r != '-'
	})
}

// praxisHasToken reports whether any token equals one of the forms exactly.
// Exact, not by lemma: morph.SameWord makes "закрой" and "закрыл" one word, and
// only one of them is an instruction (the same trap quiet_toggle.go documents).
func praxisHasToken(toks, forms []string) bool {
	for _, t := range toks {
		for _, f := range forms {
			if t == f {
				return true
			}
		}
	}
	return false
}

// praxisFiller — the words that can sit beside a bare command without giving it
// an object. Everything else is an object, and an imperative with an object is
// about that object.
var praxisFiller = []string{"пока", "уже", "давай", "это", "всё", "все", "ну", "и", "now", "then", "it"}

// praxisBareCommand reports whether the sentence is a command and nothing else:
// every token is either a lifecycle word or filler.
func praxisBareCommand(toks []string) bool {
	for _, t := range toks {
		if praxisHasToken([]string{t}, praxisFiller) || praxisHasToken([]string{t}, praxisMarkerVerbs) {
			continue
		}
		known := false
		for _, c := range praxisLifecycleVerbs {
			if praxisHasToken([]string{t}, c.imperative) || praxisHasLemma([]string{t}, c.stative) {
				known = true
				break
			}
		}
		if !known {
			return false
		}
	}
	return true
}

// praxisHasLemma is praxisHasToken by lemma, for the stative column only.
// "сделанный", "сделанным" and "сделано" are one word and mean one state, so the
// dictionary is the right test — and the imperative trap does not apply, because
// no form here is a command in the first place.
func praxisHasLemma(toks, forms []string) bool {
	for _, t := range toks {
		for _, f := range forms {
			if t == f || morph.SameWord(t, f) {
				return true
			}
		}
	}
	return false
}

// praxisNamesItem reports whether a token is the item noun. This one IS matched
// by lemma: "пункт" is a noun, so every case of it means the same thing.
func praxisNamesItem(toks []string) bool {
	for _, t := range toks {
		if morph.SameWord(t, "пункт") || t == "item" || t == "items" || t == "entry" {
			return true
		}
	}
	return false
}
