package pattern

import (
	"fmt"
	"math"
	"sort"
	"strings"
)

// ProposedRoutine is a detected recurring pattern that the system wants to
// suggest as a reminder. Returned by Detect when intervals are stable.
type ProposedRoutine struct {
	Action       string
	Object       string
	IntervalDays float64 // median of the on-pattern intervals, in days
	N            int     // number of events used
}

// MaxIntervalRatio — how far an interval may sit from the median and still
// count as on-pattern. 1.5 means a 7-day rhythm accepts gaps between ~4.7 and
// ~10.5 days.
//
// It is applied per interval against the MEDIAN, not to the longest/shortest
// pair. The old extremes test asked "is every gap similar to every other gap",
// which is a different and much more brittle question: 7, 7, 7, 7, 20 is four
// clean weeks and one holiday, and max/min = 2.9 threw the whole thing away.
// One missed week should not erase a habit.
const MaxIntervalRatio = 1.5

// MinOnPatternFraction — how much of the history must sit inside the band
// before a rhythm is a rhythm. A strict majority: with the median as the
// centre, half the intervals are inside it by construction, so anything at or
// below 0.5 would accept noise. 5, 8, 10, 3 has a median of 6.5 and only two
// of four gaps in band, so it stays what it is — irregular, no routine.
//
// At the MinEvents floor (three intervals) 0.7 demands all three, which is
// right: four events is already the cheapest bar and there is no room in it to
// also forgive an outlier. Tolerance starts at five intervals, where 4/5 passes.
const MinOnPatternFraction = 0.7

// MinEvents is the minimum number of events needed to detect a pattern.
// With N events there are N-1 intervals, so 4 events means 3 intervals.
//
// This used to be 3 (two intervals), which is not a pattern — it is a
// coincidence with a mean. Two gaps of similar length happen constantly:
// water the plants on a Sunday, again the next Sunday, once more the Sunday
// after, and a detector with a ±50% band calls that a weekly routine. The
// cost of being wrong is asymmetric now that the digestion tick scans all of
// history on its own schedule and can announce what it finds: a false
// positive is something the owner has to read and dismiss, and a dismissal
// is permanent, so one bad guess burns that action+object pair forever.
// Three intervals is the cheapest bar that makes a run distinguishable from
// a repeat. False negatives cost one more observation and nothing else.
const MinEvents = 4

// MinIntervalDays — the fastest rhythm that may be called a routine. Two
// hours.
//
// Without a floor, four taps of the same key minutes apart give intervals near
// 0.002 days. They all sit inside the ±50% band by construction, so the
// detector proposed a routine and PhraseRoutine worded it as "каждый день"
// (Vikunja #468). The damage outlives the mistake: UNIQUE(action, object)
// means dismissing the bogus proposal burns that pair permanently, so the real
// routine behind it can never be proposed again.
//
// Two hours rather than a day, because a genuine habit can run several times a
// day — meals, water, a break. Anything faster than that is not a habit she
// should be proposing to remind him about; the loop rules already cover that
// range, and they are rules, not guesses. It is checked against the median, so
// one quick repeat inside a real rhythm still counts.
//
// The other half of this is that hand-QA of the detector was unsafe: seeding a
// pattern the obvious way, four chat turns in a row, poisoned the very pair
// being tested.
const MinIntervalDays = 2.0 / 24.0

// Detect checks whether a sequence of events for the same action+object
// forms a stable recurring pattern. Returns a ProposedRoutine when:
//   - At least MinEvents events exist (≥3 intervals)
//   - The median interval is at least MinIntervalDays
//   - At least MinOnPatternFraction of the intervals sit within
//     MaxIntervalRatio of the median interval
//
// The reported IntervalDays is the median of the ON-PATTERN intervals only.
// Outliers are excluded from the number as well as from the test, so a habit
// interrupted by a two-week holiday is still reported as weekly rather than as
// "every 9.6 days" — a figure that describes neither the habit nor the gap.
//
// Returns nil when there aren't enough events or the intervals are too
// irregular — false negatives are harmless. The only dangerous mistake
// is a false positive, and this detector makes none: the confirmation
// gate (voice park or web page) catches any we do produce.
func Detect(events []Event) (*ProposedRoutine, error) {
	if len(events) < MinEvents {
		return nil, nil // not enough data
	}

	nIntervals := len(events) - 1
	intervals := make([]float64, nIntervals)

	for i := 0; i < nIntervals; i++ {
		diff := events[i+1].Ts.Sub(events[i].Ts)
		days := diff.Hours() / 24.0
		if days <= 0 {
			// Two events at the same timestamp — can't compute a meaningful
			// interval. Skip this candidate silently.
			return nil, nil
		}
		intervals[i] = days
	}

	center := medianFloat(intervals)
	if center <= 0 || center < MinIntervalDays {
		return nil, nil // a burst, not a rhythm — see MinIntervalDays
	}

	// Keep the intervals that sit inside the band around the median. The
	// bound is symmetric in ratio terms, not in days: half the median below,
	// the median times the ratio above.
	var onPattern []float64
	for _, d := range intervals {
		if d <= center*MaxIntervalRatio && d >= center/MaxIntervalRatio {
			onPattern = append(onPattern, d)
		}
	}
	if float64(len(onPattern))/float64(nIntervals) < MinOnPatternFraction {
		return nil, nil // too irregular
	}

	return &ProposedRoutine{
		Action:       events[0].Action,
		Object:       events[0].Object,
		IntervalDays: math.Round(medianFloat(onPattern)*10) / 10, // round to 1 decimal
		N:            len(events),
	}, nil
}

// medianFloat — the middle value, averaging the two middles on an even count.
// Sorts a copy: the caller's interval order is the event order and stays that
// way.
func medianFloat(xs []float64) float64 {
	if len(xs) == 0 {
		return 0
	}
	s := make([]float64, len(xs))
	copy(s, xs)
	sort.Float64s(s)
	mid := len(s) / 2
	if len(s)%2 == 1 {
		return s[mid]
	}
	return (s[mid-1] + s[mid]) / 2
}

// PhraseRoutine generates a human-readable suggestion string for a
// detected routine. Returns a Russian phrase like
// "ты заправляешь поилку раз в 7 дней — напоминать?"
func PhraseRoutine(p *ProposedRoutine) string {
	actionWord := p.Action
	objectWord := p.Object

	days := int(math.Round(p.IntervalDays))
	// Russian grammatical gender/hardcoded — matches maven's existing persona.
	var intervalPhrase string
	switch {
	case days < 1:
		intervalPhrase = "каждый день"
	case days == 1:
		intervalPhrase = "каждый день"
	case days < 7:
		intervalPhrase = fmt.Sprintf("раз в %d дня", days)
		if days%10 == 1 && days%100 != 11 {
			intervalPhrase = fmt.Sprintf("раз в %d день", days)
		}
	case days == 7:
		intervalPhrase = "раз в неделю"
	case days%7 == 0:
		intervalPhrase = fmt.Sprintf("раз в %d недели", days/7)
		if (days/7)%10 == 1 && (days/7)%100 != 11 {
			intervalPhrase = fmt.Sprintf("раз в %d неделю", days/7)
		}
	case days < 30:
		intervalPhrase = fmt.Sprintf("раз в %d дней", days)
	default:
		intervalPhrase = fmt.Sprintf("каждые %d дней", days)
	}

	objectDisplay := strings.ReplaceAll(objectWord, "_", " ")
	return fmt.Sprintf("ты %s %s %s — напоминать?", actionVerb(actionWord), objectDisplay, intervalPhrase)
}

// actionVerb returns a conjugated Russian verb form for "you do" (ты-form).
func actionVerb(action string) string {
	switch action {
	case "refill":
		return "заправляешь"
	case "feed":
		return "кормишь"
	case "change":
		return "меняешь"
	case "clean":
		return "чистишь"
	case "take":
		return "принимаешь"
	case "walk":
		return "выгуливаешь"
	case "water":
		return "поливаешь"
	default:
		return action + " (делаешь)"
	}
}
