// phraser/replier.go — reactive reply phrasing, the confirmation he hears
// after every fact, note and reminder.
//
// It lived in cmd/mavend as package main until Vikunja #396, which meant the
// most frequently heard sentence Maven says was the one path the phrasing eval
// could not import, let alone score. Nothing here talks to the daemon: the
// caller supplies the completer and the context block, and cmd/mavend keeps the
// stub fallback so a model error still answers.
package phraser

import (
	"context"
	"strings"
	"time"

	"github.com/kami/maven/internal/llm"
	"github.com/kami/maven/internal/persona"
	"github.com/kami/maven/internal/router"
)

// Completer is the model seam for the replier, a subset of router.Completer.
// *llm.Client satisfies it.
type Completer interface {
	Complete(ctx context.Context, r llm.Req) (string, error)
}

// replyTimeout bounds one reply. Generous because the resident model on the CPU
// floor is slow and the caller has a deterministic fallback anyway.
const replyTimeout = 60 * time.Second

// ReplySystemPrompt — the reactive confirmation contract: one short Russian
// sentence, feminine self-reference, informal address, no question.
//
// The example is deliberately contentless. It used to be "Записала, что ты
// выпил стакан воды.", and the model copied the glass into a real reply about
// water he never described that way (V-592). An example carrying a plausible
// completion of the input is an invitation to reuse it.
const ReplySystemPrompt = `Ты — Maven, домашняя ассистентка (о себе — в женском роде). Владелец — мужчина, говоришь с ним на "ты", в единственном числе; никогда не "вы"/"ваш" и не "он"/"его". Подтверди действие РОВНО ОДНИМ коротким предложением (≤120 символов), по-русски, спокойно и без официальных формулировок. Не задавай вопросов, не повторяй слова, не добавляй ничего после точки. Отвечай ТОЛЬКО одним объектом JSON с полями "response" (текст) и "mood" (ровно одно из: neutral, happy, thinking, tired, confused).
Пример: {"response": "Хорошо, напомню.", "mood": "neutral"}
Никогда не пиши "..." в поле response.`

// Replier phrases reactive confirmations with the resident model. It has no
// fallback of its own: an error is returned, and the daemon answers from the
// deterministic stub. That is also what makes it scorable — a dead server shows
// up as an error rather than as bad phrasing.
type Replier struct {
	c Completer

	// block renders the shared context block per turn (who he is, the time).
	// nil ⇒ the prompt stands alone.
	block func() string
}

// NewReplier builds a replier over c. block may be nil.
func NewReplier(c Completer, block func() string) *Replier {
	return &Replier{c: c, block: block}
}

// PhraseReply returns the confirmation for one decision. An empty string with a
// nil error means the model produced nothing usable, which the caller must
// treat exactly like an error.
func (r *Replier) PhraseReply(ctx context.Context, d router.Decision) (string, error) {
	ctx, cancel := context.WithTimeout(ctx, replyTimeout)
	defer cancel()
	out, err := r.c.Complete(ctx, llm.Req{
		System:        persona.Prepend(r.block, ReplySystemPrompt),
		User:          replyContext(d),
		Grammar:       ResponseGrammar,
		MaxTokens:     512,
		RepeatPenalty: 1.3,
	})
	if err != nil {
		return "", err
	}
	out = stripThink(out)
	if response, _, perr := parseResponseMood(out); perr != nil {
		return "", perr
	} else if response != "" {
		return response, nil
	}
	// fallback: the model answered in bare prose, which is fine here.
	return firstSentence(out), nil
}

// firstSentence trims the model's output to a single clean confirmation: first
// line, first sentence, whitespace-normalized — the last-line defense against a
// small model that rambles past the first period despite the prompt + stop.
func firstSentence(s string) string {
	s = strings.TrimSpace(s)
	if i := strings.IndexByte(s, '\n'); i >= 0 {
		s = s[:i]
	}
	// keep up to and including the first sentence-ending punctuation.
	if i := strings.IndexAny(s, ".!?"); i >= 0 {
		s = s[:i+1]
	}
	return strings.TrimSpace(s)
}

// replyContext renders the decision into a compact RU description for the model.
func replyContext(d router.Decision) string {
	switch d.Intent {
	case router.IntentFact:
		return "записала факт: " + d.Slots.Key + " " + d.Slots.Value
	case router.IntentNote:
		return "сохранила заметку: " + d.Slots.Text
	case router.IntentReminder:
		return "поставила напоминание: " + d.Slots.Text
	default:
		return string(d.Intent) + ": " + d.Slots.Text
	}
}

// StripThink removes the <think> block a Thinking-variant model emits before its
// answer. Exported for the daemon's own model callers, which parse output that
// never passes through a phraser method.
func StripThink(s string) string { return stripThink(s) }
