package memory

import (
	"fmt"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/kami/maven/internal/say"
)

// Behavioural memory — "what do I usually do?" (Vikunja #254).
//
// The profile is COUNTED, not generated. docs/plans/09-behavioral-memory.md
// asks for an LLM to write a behaviour profile daily and store it as a fact;
// this does not do that, on purpose. A 1.7B asked to summarise a year of habits
// will produce fluent claims about the owner's life that no row in the store
// supports, and a wrong claim about him is the most expensive kind of wrong
// maven can be. Counting distinct days per weekday is verifiable, cheap enough
// to run on the question, and cannot invent a habit he does not have.
//
// Recomputed on read rather than cached as a fact for the same reason the store
// is append-only: a cached profile can disagree with the rows it came from, and
// then there are two truths. The plan's step 5 ("profile updates on fact write")
// exists to keep a cache fresh; there is no cache, so a new fact is already in
// the next answer.
//
// It is also read-only and unprompted-free. The plan's step 4 — a morning
// dispatcher nudge proposing the day — is deliberately NOT here: maven is not a
// nag, and proposing plans at 08:00 every day is the definition of one. Pattern
// inference that leads to a routine the owner accepts already exists in
// internal/pattern with the proposal queue on /routines; that is the sanctioned
// path from "she noticed" to "she acts", and it goes through him.

// Observation — one thing the owner was recorded doing, reduced to what a habit
// needs: when, and what. Facts arrive as store/ipc rows; the caller maps them
// so this package stays free of both.
type Observation struct {
	At   time.Time
	Key  string
	Kind string // "self" | "env" | "config"
}

// Activity — one recurring thing, as counted. Days is the number of DISTINCT
// days it was observed on, which is the number that decides whether something
// is a habit; Count can be inflated by one busy day.
//
// TypicalAt is the median time of day it happens at, rounded to the minute — a
// median and not a mean, so one 03:00 outlier does not move "он обычно пьёт
// воду утром" into the night. It is a CIRCULAR median: the clock wraps, and a
// plain median of minutes-since-midnight reports 12:00 for a man who goes to
// bed at 23:50.
//
// HasTypical is false when the times are spread too widely for any of them to
// be typical (see maxTypicalSpread). She then names the habit without a time
// instead of naming a time she cannot support.
type Activity struct {
	Key        string
	Days       int
	Count      int
	TypicalAt  time.Duration
	HasTypical bool
}

// Profile — the counted behaviour model.
//
// Weekly holds only the activities that DISTINGUISH a weekday: things he does
// on Tuesdays and not on most other days. Everyday holds the ones that recur
// across the week, and All holds both. The split exists because the two answer
// different questions, and conflating them produced the failure that named
// this: asked what he does on Saturdays, maven replied "ты пьёшь воду".
type Profile struct {
	Since    time.Time
	Until    time.Time
	Weekly   map[time.Weekday][]Activity
	Everyday []Activity
	All      []Activity
}

// EverydaySpan — the number of weekdays an activity must be a habit on before
// it stops counting as characteristic of any one of them. Six of seven, not
// five: a weekday-only rhythm spans exactly five, and "по будням ты
// тренируешься" is a real answer about Tuesday. Six days a week is not.
const EverydaySpan = 6

// MinHabitDays — how many distinct days an activity must appear on before maven
// will call it usual. Two is the smallest number that can distinguish a habit
// from a one-off; below that she says she does not know yet, which is true.
const MinHabitDays = 2

// nonBehaviouralKeyPrefixes — keys that are machinery or one-shot records, not
// behaviour. Calendar events carry the day in the key so they can never repeat;
// cooldown and quiet rows are maven's own tuning state, not his habits.
// quiet is listed with its separators rather than bare: as a five-letter
// prefix it would also swallow any future self-fact key that merely starts
// with those letters.
var nonBehaviouralKeyPrefixes = []string{
	"calendar_event_",
	"cooldown:",
	"quiet_",
	"quiet:",
	"behavior_profile",
}

// nonBehaviouralKeys — exact keys, for the ones with no separator to anchor on.
var nonBehaviouralKeys = map[string]bool{"quiet": true}

// BuildProfile counts habits out of observations. now bounds the window's upper
// end and supplies the location every day boundary is taken in — a habit is
// "on Tuesdays" in the owner's timezone or it is nothing.
//
// Only self-facts count. An env row is the world (weather, a relayed meeting),
// and a config row is maven's own state; neither says anything about what he
// usually does.
func BuildProfile(obs []Observation, now time.Time) Profile {
	loc := now.Location()
	p := Profile{Until: now, Weekly: map[time.Weekday][]Activity{}}

	type bucket struct {
		days  map[string]struct{}
		count int
		mins  []int
	}
	// key → bucket, and (weekday, key) → bucket.
	all := map[string]*bucket{}
	weekly := map[time.Weekday]map[string]*bucket{}

	for _, o := range obs {
		if o.Kind != "self" {
			continue
		}
		key := canonicalize(o.Key)
		if key == "" || nonBehavioural(key) {
			continue
		}
		at := o.At.In(loc)
		if at.IsZero() || at.After(now) {
			continue
		}
		if p.Since.IsZero() || at.Before(p.Since) {
			p.Since = at
		}
		day := at.Format("2006-01-02")
		minute := at.Hour()*60 + at.Minute()

		bump := func(m map[string]*bucket) {
			b := m[key]
			if b == nil {
				b = &bucket{days: map[string]struct{}{}}
				m[key] = b
			}
			b.days[day] = struct{}{}
			b.count++
			b.mins = append(b.mins, minute)
		}
		bump(all)
		wd := at.Weekday()
		if weekly[wd] == nil {
			weekly[wd] = map[string]*bucket{}
		}
		bump(weekly[wd])
	}

	harvest := func(m map[string]*bucket) []Activity {
		var out []Activity
		for key, b := range m {
			if len(b.days) < MinHabitDays {
				continue
			}
			mid, known := circularMedianMinutes(b.mins)
			out = append(out, Activity{
				Key:        key,
				Days:       len(b.days),
				Count:      b.count,
				TypicalAt:  time.Duration(mid) * time.Minute,
				HasTypical: known,
			})
		}
		// Most-established first, then earliest in the day, then by key so the
		// same history always reads back the same way.
		sort.Slice(out, func(i, j int) bool {
			if out[i].Days != out[j].Days {
				return out[i].Days > out[j].Days
			}
			if out[i].TypicalAt != out[j].TypicalAt {
				return out[i].TypicalAt < out[j].TypicalAt
			}
			return out[i].Key < out[j].Key
		})
		return out
	}

	p.All = harvest(all)

	// How many weekdays each key is a habit on. An activity that recurs on
	// most days of the week is a daily habit, and naming it as an answer to
	// "что я обычно делаю по субботам?" is a non-answer: "ты пьёшь воду" is
	// true of Saturday and of every other day, so it says nothing about
	// Saturday. Those are held in Everyday and read back separately.
	span := map[string]int{}
	harvested := map[time.Weekday][]Activity{}
	for wd, m := range weekly {
		acts := harvest(m)
		harvested[wd] = acts
		for _, a := range acts {
			span[a.Key]++
		}
	}
	for wd, acts := range harvested {
		var distinct []Activity
		for _, a := range acts {
			if span[a.Key] >= EverydaySpan {
				continue
			}
			distinct = append(distinct, a)
		}
		if len(distinct) > 0 {
			p.Weekly[wd] = distinct
		}
	}
	for _, a := range p.All {
		if span[a.Key] >= EverydaySpan {
			p.Everyday = append(p.Everyday, a)
		}
	}
	return p
}

// canonicalize maps a fact key onto the key the profile counts it under.
// Lowercased, trimmed, and separators folded to "_" before the alias lookup,
// so "Выпил воды", "выпил-воды" and "выпил_воды" are one habit and not three.
// An unlisted key counts as itself.
func canonicalize(key string) string {
	k := strings.ToLower(strings.TrimSpace(key))
	k = strings.NewReplacer(" ", "_", "-", "_").Replace(k)
	k = strings.Trim(k, "_")
	if c, ok := canonicalKey[k]; ok {
		return c
	}
	return k
}

func nonBehavioural(key string) bool {
	if nonBehaviouralKeys[key] {
		return true
	}
	for _, p := range nonBehaviouralKeyPrefixes {
		if strings.HasPrefix(key, p) {
			return true
		}
	}
	return false
}

// minutesPerDay — the modulus every clock time is taken in.
const minutesPerDay = 24 * 60

// maxTypicalSpread — how far apart the observations of one activity may sit,
// once rotated onto the shortest arc, before "usually at X" stops being a
// claim about anything. Half a day: wider than that and the values cover the
// clock, so no point on it is typical.
const maxTypicalSpread = minutesPerDay / 2

// circularMedianMinutes — the median time of day, on a clock rather than on a
// number line. Reports (0, false) when the values are too spread out to have a
// middle.
//
// A plain median of minutes-since-midnight is wrong for anything that straddles
// midnight, which is exactly the activity most likely to: bedtimes of 23:40,
// 23:50, 00:10 and 00:20 average out to 720 minutes, and she says "обычно ты
// спишь около 12:00". The fix is to find the rotation of the sorted values with
// the shortest span — the arc the observations actually occupy — take the
// ordinary median inside it, and wrap the answer back into the day.
func circularMedianMinutes(xs []int) (int, bool) {
	if len(xs) == 0 {
		return 0, false
	}
	s := make([]int, len(xs))
	for i, x := range xs {
		s[i] = ((x % minutesPerDay) + minutesPerDay) % minutesPerDay
	}
	sort.Ints(s)

	// Each rotation cuts the day at one observation and unwraps the values
	// before the cut onto the following day. The cut with the smallest span is
	// the one where no observation is on the far side of midnight from the rest.
	best, bestSpan := 0, minutesPerDay+1
	for i := range s {
		span := s[(i+len(s)-1)%len(s)] - s[i]
		if i > 0 {
			span += minutesPerDay
		}
		if span < bestSpan {
			best, bestSpan = i, span
		}
	}
	if bestSpan > maxTypicalSpread {
		return 0, false
	}
	rot := make([]int, 0, len(s))
	for i := 0; i < len(s); i++ {
		v := s[(best+i)%len(s)]
		if best+i >= len(s) {
			v += minutesPerDay
		}
		rot = append(rot, v)
	}
	m := medianInt(rot) % minutesPerDay
	return m, true
}

// medianInt — the middle value, averaging the two middles on an even count.
func medianInt(xs []int) int {
	if len(xs) == 0 {
		return 0
	}
	s := make([]int, len(xs))
	copy(s, xs)
	sort.Ints(s)
	mid := len(s) / 2
	if len(s)%2 == 1 {
		return s[mid]
	}
	return (s[mid-1] + s[mid]) / 2
}

// weekdayRU and activityRU are loaded from the embedded behavior_ru.json;
// see behavior_ru.go.

// FormatWeekdayRU reads back what DISTINGUISHES a given weekday.
// Second person singular and informal, as she speaks TO him.
//
// When nothing distinguishes it, she says so and names the daily habits as
// daily habits instead of passing them off as an answer about that day. The
// previous version had no such distinction and answered "что я делаю по
// субботам?" with "ты пьёшь воду" — true, useless, and phrased as if Saturday
// were the reason.
func (p Profile) FormatWeekdayRU(wd time.Weekday) string {
	day := weekdayRU[int(wd)%7]
	acts := p.Weekly[wd]
	if len(acts) > 0 {
		return say.S(say.HabitWeekday, map[string]string{"day": day, "items": joinActivities(acts)})
	}
	if len(p.Everyday) > 0 {
		return say.S(say.HabitWeekdaySame, map[string]string{
			"day": day, "items": joinActivities(p.Everyday),
		})
	}
	return say.S(say.HabitWeekdayNone, map[string]string{"day": day})
}

// FormatWeekendRU reads back what distinguishes Saturday and Sunday.
//
// The two days are answered separately rather than pooled: "по выходным" is a
// question about both, and a habit he has on Saturdays only is the interesting
// half of the answer, not noise to average away.
func (p Profile) FormatWeekendRU() string {
	sat, sun := p.Weekly[time.Saturday], p.Weekly[time.Sunday]
	switch {
	case len(sat) > 0 && len(sun) > 0:
		return say.S(say.HabitWeekendBoth, map[string]string{
			"items_sat": joinActivities(sat), "items_sun": joinActivities(sun),
		})
	case len(sat) > 0:
		return say.S(say.HabitWeekendSat, map[string]string{"items": joinActivities(sat)})
	case len(sun) > 0:
		return say.S(say.HabitWeekendSun, map[string]string{"items": joinActivities(sun)})
	case len(p.Everyday) > 0:
		return say.S(say.HabitWeekendSame, map[string]string{"items": joinActivities(p.Everyday)})
	}
	return say.S(say.HabitWeekendNone, nil)
}

// FormatOverallRU reads back the habits that hold across the whole week, and
// says over what stretch of records it is claiming them.
//
// The period is spoken because "обычно" without one is an unfalsifiable claim
// about his life: the same sentence comes out of three days of taps and out of
// a year of them, and only one of those is worth believing.
func (p Profile) FormatOverallRU() string {
	if len(p.All) == 0 {
		return say.S(say.HabitOverallNone, nil)
	}
	return say.S(say.HabitOverall, map[string]string{
		"items": joinActivities(p.All), "span": p.spanRU(),
	})
}

// spanRU — "по записям за последние N дней", or a vaguer phrase when the window
// is too short to name in days.
func (p Profile) spanRU() string {
	if p.Since.IsZero() || !p.Until.After(p.Since) {
		return say.S(say.HabitSpanToday, nil)
	}
	days := int(p.Until.Sub(p.Since).Hours()/24) + 1
	return say.S(say.HabitSpanDays, map[string]string{
		"n": strconv.Itoa(days), "word": say.Days(days),
	})
}

// maxRecited bounds a spoken profile. A list of fifteen habits read aloud is
// not an answer; the most established few are.
const maxRecited = 5

func joinActivities(acts []Activity) string {
	if len(acts) > maxRecited {
		acts = acts[:maxRecited]
	}
	parts := make([]string, len(acts))
	for i, a := range acts {
		gloss, ok := activityRU[a.Key]
		if !ok {
			// No gloss: quote the key instead of reading it as a verb. The keys
			// come from the model, so an unglossed one is as likely to be
			// "выпил_воды" as a noun, and "обычно ты выпил_воды около 09:00" is
			// not a sentence.
			gloss = say.S(say.HabitUnglossed, map[string]string{
				"key": strings.ReplaceAll(a.Key, "_", " "),
			})
		}
		if !a.HasTypical {
			parts[i] = gloss
			continue
		}
		parts[i] = say.S(say.HabitAt, map[string]string{
			"gloss": gloss,
			"time":  fmt.Sprintf("%02d:%02d", int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60),
		})
	}
	if len(parts) == 1 {
		return parts[0]
	}
	return strings.Join(parts[:len(parts)-1], ", ") + " и " + parts[len(parts)-1]
}
