package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"strconv"
	"strings"
	"time"

	"github.com/kami/maven/internal/dialogue"
	"github.com/kami/maven/internal/ipc"
	"github.com/kami/maven/internal/lexicon"
	"github.com/kami/maven/internal/morph"
	"github.com/kami/maven/internal/router"
	"github.com/kami/maven/internal/store"
)

// reminderCancelRequest exists to make the parser's contract explicit: a hit
// proves only that the turn is an addressed imperative naming the reminder
// store. Subject and time are resolved separately after that safety boundary.
type reminderCancelRequest struct{}

var reminderCancelVerbs = func() map[string]bool {
	out := make(map[string]bool)
	for _, word := range lexicon.ReminderCancelVerbs() {
		out[strings.ToLower(word)] = true
	}
	return out
}()

var reminderCancelFrame = func() map[string]bool {
	out := make(map[string]bool)
	for _, word := range lexicon.ReminderCancelFrame() {
		out[strings.ToLower(word)] = true
	}
	return out
}()

// isReminderCancelTarget is deliberately a noun test, not a substring test.
// A committed reminder must be named, otherwise "убери со стола" would reach
// the reminder store. Russian cases are grammar and go through morph; the
// English singular/plural forms are closed command vocabulary.
func isReminderCancelTarget(tok string) bool {
	if morph.SameWord(tok, "напоминание") || morph.SameWord(tok, "будильник") {
		return true
	}
	switch tok {
	case "reminder", "reminders", "alarm", "alarms":
		return true
	default:
		return false
	}
}

// reminderCancelLead reports which words may precede the imperative without
// becoming a subject of their own. Filler/politeness vocabulary already has
// one home in the lexicon; Maven's name is an address, not a Russian class.
func reminderCancelLead(tok string) bool {
	return lexicon.IsFillerParticle(tok) || tok == "мавен" || tok == "maven"
}

// parseReminderCancelRequest recognizes an exact cancel imperative at the
// start of the addressed command plus an explicit reminder noun. Both are
// whole tokens. Requiring command position is the safety boundary: infinitive
// questions ("как отменить ..."), reported speech ("он сказал: отмени ...")
// and past-tense remarks never reach the reminder store. A relative clause
// after a real command remains valid even though it may contain a question
// pronoun, so this is stronger and more precise than a punctuation test.
func parseReminderCancelRequest(text string) (reminderCancelRequest, bool) {
	tokens := turnTokens(text)
	verbAt := -1
	for i, tok := range tokens {
		if reminderCancelVerbs[tok] {
			verbAt = i
			break
		}
	}
	if verbAt < 0 {
		return reminderCancelRequest{}, false
	}
	for _, tok := range tokens[:verbAt] {
		if !reminderCancelLead(tok) {
			return reminderCancelRequest{}, false
		}
	}
	for _, tok := range tokens[verbAt+1:] {
		if isReminderCancelTarget(tok) {
			return reminderCancelRequest{}, true
		}
	}
	return reminderCancelRequest{}, false
}

func reminderCancelNegation(tok string) bool {
	switch tok {
	case "не", "ни", "not", "no", "don't", "dont":
		return true
	default:
		return false
	}
}

func reminderCancelTimeLead(tok string) bool {
	switch tok {
	case "в", "во", "на", "к", "ко", "через", "спустя",
		"at", "in", "by", "until", "after", "before":
		return true
	default:
		return false
	}
}

func reminderCancelTimeUnit(tok string) bool {
	if lexicon.IsHourUnit(tok) || lexicon.IsMinuteUnit(tok) {
		return true
	}
	for _, part := range lexicon.PartsOfDay() {
		if tok == part {
			return true
		}
	}
	return tok == "утра" || tok == "дня" || tok == "вечера" || tok == "ночи" ||
		tok == "am" || tok == "pm" || tok == "noon" || tok == "midnight"
}

func reminderCancelNumeral(tok string) (int, bool) {
	if n, ok := lexicon.Cardinal(tok); ok {
		return n, true
	}
	if n, ok := lexicon.Ordinal(tok); ok && n > 0 {
		return n, true
	}
	n, err := strconv.Atoi(tok)
	return n, err == nil
}

// reminderClockTokenBudget records the numeric pieces that came from a written
// clock. turnTokens deliberately splits 21:30 into 21 and 30, so a small
// multiset lets subject extraction ignore exactly those occurrences without
// discarding the same number when it also belongs to the reminder text.
func reminderClockTokenBudget(text string) map[string]int {
	out := make(map[string]int)
	for _, field := range strings.Fields(strings.ToLower(text)) {
		field = strings.Trim(field, ".,!?;()[]{}«»\"'")
		hour, minute, ok := strings.Cut(field, ":")
		if !ok || len(minute) != 2 {
			continue
		}
		h, herr := strconv.Atoi(hour)
		m, merr := strconv.Atoi(minute)
		if herr != nil || merr != nil || h < 0 || h > 23 || m < 0 || m > 59 {
			continue
		}
		out[hour]++
		out[minute]++
	}
	return out
}

// reminderCancellationTerms keeps identity-bearing words, including negation
// and quantities. The old ownContent shortcut erased both, so "не звонить" and
// "звонить", or "одну таблетку" and "две таблетки", could select the same
// row. Time framing is removed only after the shared parser proved that this
// turn actually carries a readable time; numerals are removed only in a clock
// position, never merely because they are numbers.
func reminderCancellationTerms(text string, hasTime bool) []string {
	tokens := turnTokens(text)
	clockBudget := reminderClockTokenBudget(text)
	out := make([]string, 0, len(tokens))
	for i, tok := range tokens {
		if reminderCancelVerbs[tok] || isReminderCancelTarget(tok) ||
			reminderCancelFrame[tok] || lexicon.IsFillerParticle(tok) {
			continue
		}
		if !hasTime || reminderCancelNegation(tok) {
			out = append(out, tok)
			continue
		}
		if clockBudget[tok] > 0 {
			clockBudget[tok]--
			continue
		}
		if _, numeric := reminderCancelNumeral(tok); numeric {
			prevTime := i > 0 && reminderCancelTimeLead(tokens[i-1])
			nextTime := i+1 < len(tokens) && reminderCancelTimeUnit(tokens[i+1])
			if prevTime || nextTime {
				continue
			}
		}
		// frameWords is assembled exclusively from the closed time/grammar
		// lexicons. At this point a time was parsed, and negation has already
		// been preserved above, so these words identify the time rather than
		// the stored reminder body.
		if frameWords[tok] {
			continue
		}
		out = append(out, tok)
	}
	return out
}

// reminderCancellationTime applies the same parse and resolved-hour gate as a
// newly created reminder. A time expression that is present but unread is not
// silently discarded: the caller asks for a clearer time instead of cancelling
// whichever row happens to match the remaining words.
func (h *reactiveHandler) reminderCancellationTime(ctx context.Context, text string) (time.Time, bool) {
	if slots := h.extractor.Extract(ctx, router.IntentReminder, text, h.now()); slots.HasTime {
		return slots.Time, true
	}
	if h.timeParser == nil {
		return time.Time{}, false
	}
	parsed, ok, err := h.timeParser.Parse(ctx, text, h.now())
	if err != nil || !ok || !router.ResolvedTheHour(text, parsed) {
		return time.Time{}, false
	}
	return parsed, true
}

func reminderNextFire(r ipc.Reminder) time.Time {
	if !r.NextFireTs.IsZero() {
		return r.NextFireTs
	}
	return r.FireTs
}

// reminderTimeMatches lets state disambiguate a clock when the day was not
// named. "На девять" can therefore select the sole 09:00/21:00 reminder, but
// if both exist they both remain candidates and Maven asks. A named day or an
// interval denotes an absolute minute and must match that minute exactly.
func reminderTimeMatches(text string, parsed, fire time.Time) bool {
	local := fire.In(parsed.Location())
	if router.NamesADay(text) || router.NamesAnInterval(text) {
		return local.Truncate(time.Minute).Equal(parsed.Truncate(time.Minute))
	}
	if router.HourIsAmbiguous(text) {
		return local.Minute() == parsed.Minute() && local.Hour()%12 == parsed.Hour()%12
	}
	return local.Hour() == parsed.Hour() && local.Minute() == parsed.Minute()
}

func reminderTextMatchesTerms(r ipc.Reminder, terms []string) bool {
	if len(terms) == 0 {
		return true
	}
	words := turnTokens(store.ReminderText(r.Payload))
	used := make([]bool, len(words))
	for _, term := range terms {
		found := false
		for i, word := range words {
			if used[i] {
				continue
			}
			tn, tok := reminderCancelNumeral(term)
			wn, wok := reminderCancelNumeral(word)
			if term == word || morph.SameWord(term, word) || (tok && wok && tn == wn) {
				used[i] = true
				found = true
				break
			}
		}
		if !found {
			return false
		}
	}
	return true
}

func reminderCancellationLabel(r ipc.Reminder, now time.Time) string {
	fire := reminderNextFire(r).In(now.Location())
	when := dayPrefix(now, fire)
	if when == "это" {
		when = fmt.Sprintf("%d %s", fire.Day(), lexicon.MonthGenitive(int(fire.Month())))
	}
	return fmt.Sprintf("%s в %s — %s", when, fire.Format("15:04"), store.ReminderText(r.Payload))
}

// offerReminderCancellations binds exactly the rows Maven names, in that order.
// An ordinal on the next turn therefore points at the spoken list, never at a
// fresh query whose order may have changed in between.
func (h *reactiveHandler) offerReminderCancellations(ctx context.Context, text string, matches []ipc.Reminder) string {
	const maxSpoken = 5
	truncated := len(matches) > maxSpoken
	if len(matches) > maxSpoken {
		matches = matches[:maxSpoken]
	}
	candidates := make([]dialogue.Candidate, 0, len(matches))
	parts := make([]string, 0, len(matches))
	for i, r := range matches {
		label := reminderCancellationLabel(r, h.now())
		candidates = append(candidates, dialogue.Candidate{Kind: "reminder-cancel", Ref: r.ID, Label: label})
		parts = append(parts, fmt.Sprintf("%d: %s", i+1, label))
	}

	if h.dialogueSessions == nil {
		return "нашла несколько подходящих напоминаний — уточни текст или время."
	}
	id, now := dialogueIDOf(ctx), h.now()
	// This command is its own turn. Reusing an older session would keep stale
	// intent/slots alive after the choice and let the next utterance inherit
	// unrelated state, so the offered list gets a fresh system session.
	h.dialogueSessions.Put(id, &dialogue.Session{
		Intent: dialogue.IntentSystem, Utterance: text, Timestamp: now,
		Candidates: candidates,
	})
	prefix := "нашла несколько подходящих. какое отменить? "
	if truncated {
		prefix = "нашла больше пяти подходящих; называю первые пять. если нужного здесь нет, уточни текст или время. какое отменить? "
	}
	return prefix + strings.Join(parts, "; ") + ". ответь одним порядковым словом, например «второе»."
}

func (h *reactiveHandler) clearReminderCandidates(ctx context.Context) {
	if h.dialogueSessions != nil {
		h.dialogueSessions.SetCandidates(dialogueIDOf(ctx), h.now(), nil)
	}
}

func (h *reactiveHandler) cancelReminderChoice(ctx context.Context, id int64, label string) string {
	if err := h.api.CancelReminder(ctx, id); err != nil {
		switch {
		case errors.Is(err, ipc.ErrReminderNotFound), errors.Is(err, ipc.ErrReminderState):
			h.clearReminderCandidates(ctx)
			return "это напоминание уже не ожидает отправки."
		case errors.Is(err, ipc.ErrReminderInFlight):
			h.clearReminderCandidates(ctx)
			return "я уже начала отправлять это напоминание — надёжно отменить его уже нельзя."
		default:
			log.Printf("voice: cancel reminder %d: %v", id, err)
			return "не получилось отменить напоминание."
		}
	}
	h.clearReminderCandidates(ctx)
	log.Printf("voice: cancelled reminder %d (%q)", id, label)
	return "отменила напоминание: " + label + "."
}

// resolveReminderCancellation is the stateful pre-route resolver for a
// committed reminder. It claims only the explicit structural command above,
// resolves against every pending row, and never ranks an ambiguous set down to
// one. One match cancels; more than one is an offered, ordinal-bound question.
func (h *reactiveHandler) resolveReminderCancellation(ctx context.Context, text string) (string, bool) {
	_, ok := parseReminderCancelRequest(text)
	if !ok {
		return "", false
	}
	rows, err := h.api.ListPendingReminders(ctx, 0)
	if err != nil {
		log.Printf("voice: list reminders for cancellation: %v", err)
		return "не получилось посмотреть напоминания.", true
	}
	if len(rows) == 0 {
		return "ожидающих напоминаний нет.", true
	}

	parsed, hasTime := h.reminderCancellationTime(ctx, text)
	if router.MentionsTime(text) && !hasTime {
		return "не смогла разобрать время напоминания — уточни его.", true
	}
	terms := reminderCancellationTerms(text, hasTime)
	matches := make([]ipc.Reminder, 0, len(rows))
	for _, r := range rows {
		if !reminderTextMatchesTerms(r, terms) {
			continue
		}
		if hasTime && !reminderTimeMatches(text, parsed, reminderNextFire(r)) {
			continue
		}
		matches = append(matches, r)
	}

	switch len(matches) {
	case 0:
		return "не нашла такого ожидающего напоминания.", true
	case 1:
		label := reminderCancellationLabel(matches[0], h.now())
		return h.cancelReminderChoice(ctx, matches[0].ID, label), true
	default:
		return h.offerReminderCancellations(ctx, text, matches), true
	}
}
