// Package morning is maven's morning routine engine — item #3 off the
// 2026-07-20 backlog (Vikunja #280).
//
// A Routine is NOT four independent reminder timers. It's a checklist for a
// daily window: several Items, each evidenced by a fact key, completed in
// any order, checked once near the end of the window. Maven should be able
// to answer "what's still missing" at any point (Evaluate), and should nag
// AT MOST once per day per routine when something got skipped (Due) — never
// fire four separate item timers that stack into noise.
//
// This package is pure, like internal/loop and internal/routine: no store,
// no clock of its own. The daemon's tick driver owns the impurity (reads
// facts under the store lock, holds the last-nudge map across ticks) and
// calls Due each tick, exactly as it does for loop.Rule and routine.Routine.
package morning

import (
	"fmt"
	"time"

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

// Item — one checklist entry. FactKey is the fact whose latest non-voided
// value, if timestamped within today's window, counts as completion
// evidence — manual (voice-tapped "выпил воды") and inferred (another
// daemon writing the same key) are indistinguishable and both count, per
// the backlog's "manual and inferred completion evidence" requirement.
type Item struct {
	Key     string
	FactKey string
	Label   string // RU text surfaced when this item is still missing.
	// Optional — a missing one is not worth a nudge on its own.
	//
	// Every item was implicitly required until 04-08-2026, because there was
	// no field, so a skipped stretch read exactly like skipped medication and
	// #280's first behaviour could not hold (Vikunja #473). A checklist where
	// everything is mandatory is a checklist he learns to ignore.
	//
	// It changes two things and nothing else: an all-optional routine never
	// nudges, and a nudge that does fire names the optional stragglers after
	// the required ones, in softer words. Evidence, the window and the day
	// plan treat both kinds alike — a missing optional item is still missing.
	Optional bool
}

// Routine — one daily checklist. WindowStart/WindowEnd are "HH:MM" local
// time and must not cross midnight (a morning routine doesn't span days).
// NudgeAt is when the engine checks for stragglers and nags once if
// anything's missing; empty defaults to WindowEnd (nag right as the window
// closes, not the moment it opens). Weekdays scopes which days this routine
// applies to — empty means every day; set it twice under different names
// for weekday/weekend variants.
type Routine struct {
	Name        string
	Weekdays    []time.Weekday
	WindowStart string
	WindowEnd   string
	NudgeAt     string
	Severity    int
	Items       []Item
}

// Status — the checklist's state right now. Active is false when the
// routine doesn't apply today (weekday) or `now` falls outside its window;
// Missing/Completed are meaningless in that case.
type Status struct {
	RoutineName string
	Active      bool
	Missing     []Item
	Completed   []Item
}

// Candidate — a routine that's due for its one-per-day nag: the window has
// reached NudgeAt and at least one REQUIRED item is still unevidenced. Missing
// carries the optional stragglers too, so the one message she is allowed per
// day per routine can mention them; they never cause it.
type Candidate struct {
	Routine Routine
	Missing []Item
}

// Required reports the missing items that are not optional. The nudge fires on
// these; the rest ride along.
func Required(missing []Item) []Item {
	var out []Item
	for _, it := range missing {
		if !it.Optional {
			out = append(out, it)
		}
	}
	return out
}

// OptionalOnly is the other half of Required.
func OptionalOnly(missing []Item) []Item {
	var out []Item
	for _, it := range missing {
		if it.Optional {
			out = append(out, it)
		}
	}
	return out
}

// Validate reports the first structural problem with a routine set: missing
// name/items, an unparseable HH:MM, an inverted window, a duplicate item key
// within a routine, or an out-of-range weekday. Called at config load so a
// typo surfaces at startup, not as a silently-broken checklist at runtime.
func Validate(routines []Routine) error {
	for _, r := range routines {
		if r.Name == "" {
			return fmt.Errorf("morning: name is required")
		}
		if len(r.Items) == 0 {
			return fmt.Errorf("morning routine %q: at least one item is required", r.Name)
		}
		startH, startM, ok := parseHHMM(r.WindowStart)
		if !ok {
			return fmt.Errorf("morning routine %q: bad window_start %q", r.Name, r.WindowStart)
		}
		endH, endM, ok := parseHHMM(r.WindowEnd)
		if !ok {
			return fmt.Errorf("morning routine %q: bad window_end %q", r.Name, r.WindowEnd)
		}
		if startH*60+startM >= endH*60+endM {
			return fmt.Errorf("morning routine %q: window_start must be before window_end", r.Name)
		}
		if r.NudgeAt != "" {
			if _, _, ok := parseHHMM(r.NudgeAt); !ok {
				return fmt.Errorf("morning routine %q: bad nudge_at %q", r.Name, r.NudgeAt)
			}
		}
		for _, w := range r.Weekdays {
			if w < time.Sunday || w > time.Saturday {
				return fmt.Errorf("morning routine %q: bad weekday %d", r.Name, w)
			}
		}
		seen := make(map[string]bool, len(r.Items))
		for _, it := range r.Items {
			if it.Key == "" {
				return fmt.Errorf("morning routine %q: item key is required", r.Name)
			}
			if it.FactKey == "" {
				return fmt.Errorf("morning routine %q item %q: fact_key is required", r.Name, it.Key)
			}
			if seen[it.Key] {
				return fmt.Errorf("morning routine %q: duplicate item key %q", r.Name, it.Key)
			}
			seen[it.Key] = true
		}
	}
	return nil
}

// Evaluate reports the routine's current checklist state, pure over the
// given facts snapshot and clock reading. Callable any time — the "what's
// still missing" query path — not just at nudge time.
func Evaluate(r Routine, facts map[string]store.Fact, now time.Time) Status {
	st := Status{RoutineName: r.Name}
	if !appliesToday(r, now) {
		return st
	}
	start, ok1 := todayAt(r.WindowStart, now)
	end, ok2 := todayAt(r.WindowEnd, now)
	if !ok1 || !ok2 || now.Before(start) || !now.Before(end) {
		return st
	}
	st.Active = true
	for _, it := range r.Items {
		if evidenced(it, facts, start, now) {
			st.Completed = append(st.Completed, it)
		} else {
			st.Missing = append(st.Missing, it)
		}
	}
	return st
}

// Outstanding reports the items of a routine that today has no evidence for,
// whether or not the window is still open. Evaluate answers "what is missing
// right now" and goes silent the moment the window closes; the day plan asks a
// different question, "what did today still not get done", and a skipped
// routine is exactly what it is worth telling him. Nothing before the window
// opens is outstanding yet, so the morning routine is not a complaint at 06:00.
func Outstanding(r Routine, facts map[string]store.Fact, now time.Time) []Item {
	if !appliesToday(r, now) {
		return nil
	}
	start, ok := todayAt(r.WindowStart, now)
	if !ok || now.Before(start) {
		return nil
	}
	var missing []Item
	for _, it := range r.Items {
		if !evidenced(it, facts, start, now) {
			missing = append(missing, it)
		}
	}
	return missing
}

// Due returns the routines that have reached their nudge time today with at
// least one item still missing, and records `now` in `last` for each one
// returned so it fires at most once per calendar day. The caller owns
// `last` (the tick driver holds it across ticks, mirroring routine.Due).
func Due(routines []Routine, facts map[string]store.Fact, last map[string]time.Time, now time.Time) []Candidate {
	var out []Candidate
	for _, r := range routines {
		if !appliesToday(r, now) {
			continue
		}
		start, ok := todayAt(r.WindowStart, now)
		if !ok {
			continue
		}
		nudgeAtStr := r.NudgeAt
		if nudgeAtStr == "" {
			nudgeAtStr = r.WindowEnd
		}
		nudgeAt, ok := todayAt(nudgeAtStr, now)
		if !ok || now.Before(nudgeAt) {
			continue
		}
		var missing []Item
		for _, it := range r.Items {
			if !evidenced(it, facts, start, now) {
				missing = append(missing, it)
			}
		}
		// A day where only the optional items were skipped is a fine day, and
		// nagging about it is what teaches him to stop listening (Vikunja
		// #473). The optional ones still travel in Missing so the message can
		// mention them when it is being sent anyway.
		if len(Required(missing)) == 0 {
			continue
		}
		if prev, seen := last[r.Name]; seen && sameDay(prev, now) {
			continue
		}
		last[r.Name] = now
		out = append(out, Candidate{Routine: r, Missing: missing})
	}
	return out
}

// evidenced reports whether item has a non-voided fact timestamped within
// [windowStart, now] — evidence from before the window opened (e.g.
// yesterday's dose) doesn't count; evidence from the future can't exist.
func evidenced(it Item, facts map[string]store.Fact, windowStart, now time.Time) bool {
	f, ok := facts[it.FactKey]
	if !ok || f.Ts.IsZero() {
		return false
	}
	return !f.Ts.Before(windowStart) && !f.Ts.After(now)
}

func appliesToday(r Routine, now time.Time) bool {
	if len(r.Weekdays) == 0 {
		return true
	}
	for _, w := range r.Weekdays {
		if w == now.Weekday() {
			return true
		}
	}
	return false
}

// todayAt resolves an "HH:MM" clock reading against now's calendar date and
// location.
func todayAt(hhmm string, now time.Time) (time.Time, bool) {
	h, m, ok := parseHHMM(hhmm)
	if !ok {
		return time.Time{}, false
	}
	y, mo, d := now.Date()
	return time.Date(y, mo, d, h, m, 0, 0, now.Location()), true
}

func sameDay(a, b time.Time) bool {
	ay, am, ad := a.Date()
	by, bm, bd := b.Date()
	return ay == by && am == bm && ad == bd
}

func parseHHMM(s string) (hour, min int, ok bool) {
	if len(s) != 5 || s[2] != ':' {
		return 0, 0, false
	}
	h := int(s[0]-'0')*10 + int(s[1]-'0')
	m := int(s[3]-'0')*10 + int(s[4]-'0')
	if h < 0 || h > 23 || m < 0 || m > 59 {
		return 0, 0, false
	}
	return h, m, true
}
