// Package tasks ranks captured work (Vikunja #129).
//
// The ordering is COMPUTED, not generated. Asking a 1.7B model which of his
// tasks matters most would produce a fluent opinion about his life with no
// basis in anything, and a confidently wrong priority is worse than no
// priority at all — the same reasoning as the behaviour profile in
// internal/memory, which counts instead of summarising.
//
// So: four signals, all of them things he told her, and a reason string naming
// the one that decided each row. Nothing here invents urgency. A task with no
// due date and no weight scores nothing and sits where its age puts it, which
// is the honest answer to "which of these matters?" when he never said.
//
// Ranking is a READ. It sorts and renders; it never writes, schedules or
// announces. Maven is not a nag: a task rising to the top of this list is not a
// reason to speak, only the order she recites in when asked.
package tasks

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

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

// Status values, mirroring internal/store so a caller can rank ipc.Task rows
// without importing the store.
const (
	StatusCandidate = "candidate"
	StatusOpen      = "open"
)

// Item — one task to rank. The subset of a task that ranking depends on;
// callers map their own row type onto it.
type Item struct {
	ID      int64
	Text    string
	Status  string
	Created time.Time
	Due     *time.Time
	Weight  int
}

// Ranked — one task with its score and the reason that decided it.
type Ranked struct {
	Item
	Score float64
	// Reason — the dominant signal, in Russian, for the page and the spoken
	// list. Empty when nothing distinguished this task: no due date, no
	// weight, not old. Saying "потому что" about a task he never prioritised
	// would be making something up.
	Reason string
}

// Scoring weights. Deliberately coarse round numbers: this is a knob, not
// math, and the only property that has to hold is the ordering between classes
// (overdue beats today beats this week beats undated).
const (
	scoreOverdue     = 100 // he already missed it
	scoreOverduePer  = 5   // per further day late, capped
	scoreOverdueCap  = 40
	scoreDueToday    = 60
	scoreDueTomorrow = 40
	scoreDueWeek     = 20
	// Above scoreAgeCap on purpose: a dated task must outrank an undated one
	// however long the undated one has sat, or the class ordering this block
	// claims is inverted by age alone.
	scoreDueLater   = 12
	scorePerWeight  = 15 // "срочно" / "важно" / the web form's select
	scorePerWeekOld = 1  // so nothing rots at the bottom forever
	scoreAgeCap     = 10
	// MaxWeight — the highest importance hint capture accepts. Three rungs is
	// as many as anyone can rank by hand honestly.
	MaxWeight = 3
)

// Rank scores every item and returns them ordered: confirmed work first, then
// candidates, each by score descending, oldest first on a tie.
//
// Candidates never outrank open work, whatever their due date. A task Maven
// derived from something she read is a suggestion until he confirms it, and
// putting her guess above his own stated work would be reading his priorities
// back to him wrong.
func Rank(items []Item, now time.Time) []Ranked {
	out := make([]Ranked, 0, len(items))
	for _, it := range items {
		score, reason := score(it, now)
		out = append(out, Ranked{Item: it, Score: score, Reason: reason})
	}
	sort.SliceStable(out, func(i, j int) bool {
		ci, cj := out[i].Status == StatusCandidate, out[j].Status == StatusCandidate
		if ci != cj {
			return !ci // open before candidate
		}
		if out[i].Score != out[j].Score {
			return out[i].Score > out[j].Score
		}
		return out[i].Created.Before(out[j].Created) // oldest first, FIFO
	})
	return out
}

// score — the per-item scoring function. Returns the score and the dominant
// reason. Deadline beats weight when both are present: a date is a fact about
// the world, a weight is how he felt when he filed it.
func score(it Item, now time.Time) (float64, string) {
	var total float64
	reason := ""

	if it.Due != nil {
		days := dayDelta(*it.Due, now)
		switch {
		case days < 0:
			late := -days
			bonus := float64(late * scoreOverduePer)
			if bonus > scoreOverdueCap {
				bonus = scoreOverdueCap
			}
			total += scoreOverdue + bonus
			reason = say.S(say.ReasonOverdue, nil)
			if late > 0 {
				// One day needs no arm of its own: «просрочено на 1 день»
				// falls out of the count helper like every other number.
				reason = say.S(say.ReasonOverdueDays, map[string]string{
					"n": strconv.Itoa(late), "word": say.Days(late),
				})
			}
		case days == 0:
			total += scoreDueToday
			reason = say.S(say.ReasonToday, nil)
		case days == 1:
			total += scoreDueTomorrow
			reason = say.S(say.ReasonTomorrow, nil)
		case days <= 7:
			total += scoreDueWeek
			reason = say.S(say.ReasonInDays, map[string]string{
				"n": strconv.Itoa(days), "word": say.Days(days),
			})
		default:
			total += scoreDueLater
		}
	}

	w := it.Weight
	if w > MaxWeight {
		w = MaxWeight
	}
	if w > 0 {
		total += float64(w * scorePerWeight)
		if reason == "" {
			// The rungs get their own words. The reason string is the one place
			// the ranking explains itself, and reading "важно" back at a task
			// he flagged "срочно" reports a word he did not say.
			reason = say.S(say.ReasonImportant, nil)
			if w >= MaxWeight {
				reason = say.S(say.ReasonUrgent, nil)
			}
		}
	}

	if !it.Created.IsZero() {
		weeks := int(now.Sub(it.Created).Hours() / (24 * 7))
		if weeks > 0 {
			age := float64(weeks * scorePerWeekOld)
			if age > scoreAgeCap {
				age = scoreAgeCap
			}
			total += age
			if reason == "" && weeks >= 2 {
				reason = say.S(say.ReasonStale, nil)
			}
		}
	}
	return total, reason
}

// dayDelta — calendar days from now to due, in NOW's location. Whole days, not
// hours: a task due today is due today whether it is 09:00 or 23:00, and an
// hours-based comparison would call this evening's task "overdue" all afternoon.
//
// The location has to come from now. A due date read back from the store is a
// UTC instant (store.scanTask ends in time.UnixMilli(...).UTC()), so taking the
// location from it compared calendar days in UTC while the page rendered the
// same date in local time. East of Greenwich that is off by one all morning: a
// task due tomorrow read "сегодня", and on its due date it read "просрочено на
// день" and scored 105 instead of 60, one table cell away from a due column
// that said otherwise.
func dayDelta(due, now time.Time) int {
	loc := now.Location()
	d := due.In(loc)
	dd := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc)
	nn := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
	return int(dd.Sub(nn).Hours() / 24)
}

// SpokenLimit — how many tasks the spoken list names before it summarises the
// rest. A recital of twenty items is noise; five is a list he can hold.
const SpokenLimit = 5

// FormatRU renders a ranked list the way Maven says it. Confirmed work first,
// with the reason attached where there is one; candidates named as
// unconfirmed, never recited as his work.
//
// One renderer for the voice reply and the web page, for the same reason
// DayPlan.Spoken is built core-side: two formatters drift, and then she says
// one order and shows another.
func FormatRU(ranked []Ranked) string {
	open, cands := split(ranked)
	if len(open) == 0 && len(cands) == 0 {
		return say.S(say.TasksNone, nil)
	}

	var b strings.Builder
	if len(open) > 0 {
		b.WriteString(say.S(say.TasksFirst, map[string]string{
			"items": joinRU(open, SpokenLimit, true),
		}))
	}
	if len(cands) > 0 {
		// Two sentences, and the first one ends on a joined list that carries
		// whatever punctuation its last task had — usually none. So the break
		// is the caller's to make, not the line file's (Vikunja #521).
		if b.Len() > 0 {
			if !strings.HasSuffix(b.String(), ".") {
				b.WriteString(".")
			}
			b.WriteString(" ")
		}
		b.WriteString(say.S(say.TasksCandidates, map[string]string{
			"items": joinRU(cands, SpokenLimit, false),
		}))
	}
	return b.String()
}

// split separates confirmed work from candidates, preserving Rank's order
// within each half. FormatRU and Spoken have to agree on where the line falls,
// so they read it from one place.
func split(ranked []Ranked) (open, cands []Ranked) {
	for _, r := range ranked {
		if r.Status == StatusCandidate {
			cands = append(cands, r)
		} else {
			open = append(open, r)
		}
	}
	return open, cands
}

// joinRU lists up to limit tasks, then says how many are left. withReasons
// attaches the parenthesised reason — candidates are listed bare, since their
// due dates are Maven's reading of a mail and not something he stated.
func joinRU(rs []Ranked, limit int, withReasons bool) string {
	shown := rs
	rest := 0
	if len(rs) > limit {
		shown, rest = rs[:limit], len(rs)-limit
	}
	parts := make([]string, 0, len(shown))
	for _, r := range shown {
		if withReasons && r.Reason != "" {
			parts = append(parts, r.Text+" ("+r.Reason+")")
		} else {
			parts = append(parts, r.Text)
		}
	}
	s := strings.Join(parts, "; ")
	if rest > 0 {
		// With the noun. Spoken, a bare number trails off mid-sentence.
		s += fmt.Sprintf("; и ещё %d %s", rest, say.CountWord(rest, "задача", "задачи", "задач"))
	}
	return s
}

// Spoken — the tasks FormatRU actually named, in the order it named them
// (Vikunja #448). "второй" has to mean the second thing she said, so the list
// an ordinal resolves against is built here and not by a caller guessing how
// the renderer split and truncated it.
func Spoken(ranked []Ranked) []Ranked {
	open, cands := split(ranked)
	out := make([]Ranked, 0, 2*SpokenLimit)
	for _, group := range [][]Ranked{open, cands} {
		if len(group) > SpokenLimit {
			group = group[:SpokenLimit]
		}
		out = append(out, group...)
	}
	return out
}
