package phraser

// Hand-written Russian nudges instead of generated ones.
//
// Why: on a nudge there is nothing to be creative about. Measured over many
// runs, Qwen3.5-0.8B breaks the persona (formal "вы", plural imperatives,
// masculine self-reference) and invents facts and units — it once told him to
// boil an egg for "90-95 секунд". A nudge is five words of known content, so
// wording it with a model buys nothing and risks the persona every time.
//
// The wording lives in nudges_ru_v1.json so it can be edited without touching
// Go. This file only picks one and fills in the values.

import (
	"context"
	_ "embed"
	"encoding/json"
	"fmt"
	"math/rand"
	"regexp"

	"github.com/kami/maven/internal/lexicon"
	"strings"
	"sync"
	"time"
	"unicode"

	"github.com/kami/maven/internal/delivery"
	"github.com/kami/maven/internal/loop"
)

//go:embed nudges_ru_v1.json
var nudgeTemplateJSON []byte

// NudgeTemplateSchemaVersion — the version this code understands.
const NudgeTemplateSchemaVersion = 1

type nudgeRuleSet struct {
	Mood     string   `json:"mood"`
	Variants []string `json:"variants"`
}

type nudgeTemplateFile struct {
	SchemaVersion int                     `json:"schema_version"`
	Name          string                  `json:"name"`
	Notes         []string                `json:"notes"`
	Rules         map[string]nudgeRuleSet `json:"rules"`
}

// NudgeTemplates picks a hand-written Russian nudge for a candidate.
//
// Safe for concurrent use. Random, but never the same variant twice in a row
// for the same rule — being nagged with identical words is what makes a nudge
// easy to tune out.
type NudgeTemplates struct {
	mu   sync.Mutex
	rnd  *rand.Rand
	last map[string]string // rule family -> the text used last time
	file nudgeTemplateFile
}

// NewNudgeTemplates loads the embedded template file. Pass a source to make the
// picking reproducible in tests; nil means seed from the clock.
func NewNudgeTemplates(src rand.Source) (*NudgeTemplates, error) {
	var f nudgeTemplateFile
	if err := json.Unmarshal(nudgeTemplateJSON, &f); err != nil {
		return nil, fmt.Errorf("nudge templates: parse: %w", err)
	}
	if f.SchemaVersion != NudgeTemplateSchemaVersion {
		return nil, fmt.Errorf("nudge templates: schema_version %d, want %d",
			f.SchemaVersion, NudgeTemplateSchemaVersion)
	}
	if len(f.Rules) == 0 {
		return nil, fmt.Errorf("nudge templates: no rules")
	}
	if src == nil {
		src = rand.NewSource(time.Now().UnixNano())
	}
	return &NudgeTemplates{
		rnd:  rand.New(src),
		last: map[string]string{},
		file: f,
	}, nil
}

// PhraseNudge implements the nudge half of the Phraser interface, so the
// templates can be scored by the same harness as the model.
func (t *NudgeTemplates) PhraseNudge(_ context.Context, c loop.Candidate) (delivery.PhrasedNudge, error) {
	body, mood := t.Nudge(c)
	return delivery.PhrasedNudge{Candidate: c, Body: body, Summary: body, Mood: mood}, nil
}

// Nudge returns the text and the mood for one candidate. Never fails: if no
// template fits it uses the plain per-rule fallback.
func (t *NudgeTemplates) Nudge(c loop.Candidate) (body, mood string) {
	rule := c.Rule.Name
	family := t.pluralFamily(t.family(rule), c)
	set, ok := t.file.Rules[family]
	if !ok {
		return fallbackNudge(c), "neutral"
	}
	vals := nudgeValues(c)

	// Only variants whose placeholders all have a value.
	usable := make([]string, 0, len(set.Variants))
	for _, v := range set.Variants {
		if text, ok := fillTemplate(v, vals); ok {
			usable = append(usable, text)
		}
	}
	if len(usable) == 0 {
		return fallbackNudge(c), "neutral"
	}

	mood = set.Mood
	if mood == "" {
		mood = "neutral"
	}
	return t.pick(family, usable), mood
}

// pick chooses at random, skipping whatever this rule said last time.
func (t *NudgeTemplates) pick(family string, usable []string) string {
	t.mu.Lock()
	defer t.mu.Unlock()

	choices := usable
	if len(usable) > 1 {
		choices = make([]string, 0, len(usable))
		for _, v := range usable {
			if v != t.last[family] {
				choices = append(choices, v)
			}
		}
		if len(choices) == 0 { // every variant equals the last one
			choices = usable
		}
	}
	got := choices[t.rnd.Intn(len(choices))]
	t.last[family] = got
	return got
}

// family maps a rule name to a block in the template file: an exact match
// first, then the prefix of "routine:зарядка" / "morning:утро", then "default".
func (t *NudgeTemplates) family(rule string) string {
	if _, ok := t.file.Rules[rule]; ok {
		return rule
	}
	if i := strings.IndexByte(rule, ':'); i > 0 {
		if _, ok := t.file.Rules[rule[:i]]; ok {
			return rule[:i]
		}
	}
	return "default"
}

// pluralFamily swaps in the plural wording when {service} will hold a list.
// Russian agrees the verb with the subject, so one set of templates cannot
// serve both: "Сервис paperless не отвечает" and "Сервисы nginx, paperless не
// отвечают" differ in the noun, the verb and the adjective. Filling a list into
// the singular text is the kind of near-miss that reads as machine-written.
//
// Only service_down has a plural form today. A family with no "_many" set in
// the file is returned unchanged, so adding one is a data change.
func (t *NudgeTemplates) pluralFamily(family string, c loop.Candidate) string {
	if len(loop.DownServices(c.State)) < 2 {
		return family
	}
	many := family + "_many"
	if _, ok := t.file.Rules[many]; ok {
		return many
	}
	return family
}

// placeholderRE — the {name} slots a template may use.
var placeholderRE = regexp.MustCompile(`\{([a-z]+)\}`)

// nudgeValues collects what this candidate can fill in. A key missing here
// means every template needing it is skipped, so nothing half-filled is ever
// spoken.
func nudgeValues(c loop.Candidate) map[string]string {
	vals := map[string]string{}
	rule := c.Rule.Name

	// {service} — one fact per kuma monitor, keyed "service_down:<name>", so
	// the name lives in the key SUFFIX and there is no fact called plain
	// "service_down" to read. loop.DownServices is the same helper the rule
	// fired on, which is what stops the message naming a service that is up.
	// This used to read c.State.Fact(rule) — the pre-per-monitor aggregate —
	// and so never filled, leaving the one nameless variant as the only
	// fillable template every time (Vikunja #534).
	if down := loop.DownServices(c.State); len(down) > 0 {
		vals["service"] = strings.Join(down, ", ")
	}
	// {since} — only at hour scale. Below an hour the phrase would be minutes,
	// and none of the templates read well with "сорок минут". service_down has
	// no {since} to offer: its facts are keyed by monitor, and the rule is
	// edge-triggered, so it fires on the transition rather than hours later.
	if d, ok := c.State.Since(rule); ok && d >= time.Hour {
		if s := ruSinceWords(d); s != "" {
			vals["since"] = s
		}
	}
	// {what} — the Russian suffix of "routine:таблетки" / "morning:утро".
	if i := strings.IndexByte(rule, ':'); i > 0 && i+1 < len(rule) {
		vals["what"] = rule[i+1:]
	}
	return vals
}

// fillTemplate substitutes the placeholders. Returns false when a value is
// missing, so a raw "{since}" can never reach the text-to-speech voice.
func fillTemplate(tmpl string, vals map[string]string) (string, bool) {
	missing := false
	out := placeholderRE.ReplaceAllStringFunc(tmpl, func(m string) string {
		name := m[1 : len(m)-1]
		v, ok := vals[name]
		if !ok || v == "" {
			missing = true
			return m
		}
		return v
	})
	if missing || strings.ContainsAny(out, "{}%") {
		return "", false
	}
	return capitalizeFirst(out), true
}

// capitalizeFirst — a placeholder can start the sentence, and "полтора часа без
// перерыва" should be spoken as a sentence, not a fragment.
func capitalizeFirst(s string) string {
	for i, r := range s {
		return string(unicode.ToUpper(r)) + s[i+len(string(r)):]
	}
	return s
}

// hourWords — hours spelled out. "3 ч" is fine on a screen and wrong in a
// Russian voice, so the number goes out as words. Closed set, indexed by the
// hour, kept in internal/lexicon (Vikunja #525).
func hourWord(h int) string { return lexicon.HourSpoken(h) }

const hoursSpoken = 24

// hourPlural — час / часа / часов by Russian counting rules.
func hourPlural(h int) string {
	if h%100 >= 11 && h%100 <= 14 {
		return "часов"
	}
	switch h % 10 {
	case 1:
		return "час"
	case 2, 3, 4:
		return "часа"
	default:
		return "часов"
	}
}

// ruSinceWords — "полтора часа", "два с половиной часа", "семь часов".
// Empty string means "do not say it" (under an hour, or over a day).
func ruSinceWords(d time.Duration) string {
	if d < time.Hour {
		return ""
	}
	h := int(d.Hours())
	m := int(d.Minutes()) % 60
	if m >= 45 {
		h++
		m = 0
	}
	if h >= hoursSpoken {
		return "больше суток"
	}
	if h == 1 {
		if m >= 15 {
			return "полтора часа"
		}
		return "час"
	}
	if m >= 15 {
		return hourWord(h) + " с половиной часа"
	}
	return hourWord(h) + " " + hourPlural(h)
}
