// Package calendar is the one calendar data model the rest of maven shares:
// an Event, the iCal text it is parsed from and rendered to, and the fact
// encoding that puts it in the store.
//
// It exists because three separate features read or write the same events and
// must agree on their shape: the CalDAV read side (cmd/mavcaldav, Vikunja
// #126/#127), the write-only render target that publishes maven's own
// reminders as a calendar (#127), and the day plan that recites them (#128).
// Before this package the parse lived inline in cmd/mavcaldav and the fact key
// format was a Sprintf in two places.
//
// The package is pure: no HTTP, no store, no clock of its own. Callers own the
// impurity, the way internal/morning and internal/loop do.
package calendar

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

// Fact sources. A calendar event reaches the store as a
// `facts (kind=env, key=calendar_event_..., source=<one of these>)` row, and
// the source is the whole provenance story:
//
//   - SourcePersonal — maven's own Radicale, read AND rendered to. Canonical
//     state stays in sqlite; the calendar is a render target (#127).
//   - SourceWork — a work calendar, read-only by definition (#126). Nothing in
//     maven ever writes to it: no code path pairs this source with a PUT.
//   - SourceAmbient — inferred from an Android notification-listener relay
//     rather than read from a server (#126). Confidence is below 1.0 because a
//     notification is a signal about an event, not the event.
const (
	SourcePersonal = "poll:caldav"
	SourceWork     = "poll:caldav:work"
	SourceAmbient  = "ambient:notif"
)

// AmbientConfidence — the confidence a notification-derived event is stored
// with. A parsed notification line is evidence, not a reading of the calendar,
// so it must never be indistinguishable from one (#126).
const AmbientConfidence = 0.6

// Sources lists every source a calendar event may legitimately carry, for the
// store query that reads the calendar back out. Ordered from most to least
// trusted.
func Sources() []string {
	return []string{SourcePersonal, SourceWork, SourceAmbient}
}

// ReadOnlySource reports whether events from this source may never be written
// back. The work calendar is read-only by definition — see #126: maven holding
// a credential that can write to an employer's calendar is the thing the task
// exists to avoid.
func ReadOnlySource(source string) bool {
	return source == SourceWork || source == SourceAmbient
}

// Event — one calendar entry. UID is the iCal UID when the event was parsed
// from a server and the identity maven renders under when it publishes one;
// Start/End are instants. All-day events are not modelled: the busy gate and
// the day plan both need a time of day, and an all-day marker answers neither.
type Event struct {
	UID     string
	Summary string
	Start   time.Time
	End     time.Time
}

// FactKey is the store key for an event: one key per day per summary, stable
// across polls so re-reading an unchanged calendar rewrites nothing.
//
// The date prefix is load-bearing — store.CalendarEvents selects a day range
// by key prefix, not by a timestamp column — and it is the OWNER's day, taken
// on the box clock. An event carries the zone its server stated it in, so
// keying off the event's own location would file a 21:00 Moscow meeting under
// a different date than the day plan asks for.
func FactKey(e Event) string { return FactKeyIn(e, time.Local) }

// FactKeyIn is FactKey against an explicit location.
func FactKeyIn(e Event, loc *time.Location) string {
	return fmt.Sprintf("calendar_event_%s_%s", e.Start.In(loc).Format("20060102"), safeKey(e.Summary))
}

// FactValue is the human-readable rendering stored as the fact value, and the
// string the day plan and the query path read back. Times are the owner's wall
// clock, for the same reason the key date is.
func FactValue(e Event) string { return FactValueIn(e, time.Local) }

// FactValueIn is FactValue against an explicit location.
func FactValueIn(e Event, loc *time.Location) string {
	return fmt.Sprintf("%s @ %s-%s", e.Summary, e.Start.In(loc).Format("15:04"), e.End.In(loc).Format("15:04"))
}

// FactSummary strips the "@ HH:MM-HH:MM" tail FactValue appends, for a caller
// that prints the time itself. The day plan does: without this it renders
// "14:00 — Standup @ 14:00-14:30" and says the hour twice.
func FactSummary(value string) string {
	i := strings.LastIndex(value, " @ ")
	if i < 0 {
		return value
	}
	tail := value[i+len(" @ "):]
	if len(tail) != len("15:04-15:04") {
		return value
	}
	for j, r := range tail {
		switch j {
		case 2, 8:
			if r != ':' {
				return value
			}
		case 5:
			if r != '-' {
				return value
			}
		default:
			if r < '0' || r > '9' {
				return value
			}
		}
	}
	return value[:i]
}

// EventKeyPrefix — every calendar event fact starts with this. The loop scans
// the family to work out whether a meeting covers right now (Vikunja #513).
const EventKeyPrefix = "calendar_event_"

// FactSpan reads an event fact back into the instants it covers, against loc.
// The day comes from the key and the two clock readings from the value's
// "@ HH:MM-HH:MM" tail, which is everything FactValue wrote.
//
// ok is false for anything that does not parse. A fact whose span cannot be
// read tells you nothing about now, and guessing a span is how a signal that
// was meant to suppress one nudge starts suppressing all of them.
//
// An end at or before the start is read as crossing midnight, so a 23:30-00:15
// meeting covers the quarter hour it actually covers.
func FactSpan(key, value string, loc *time.Location) (start, end time.Time, ok bool) {
	if !strings.HasPrefix(key, EventKeyPrefix) {
		return time.Time{}, time.Time{}, false
	}
	rest := key[len(EventKeyPrefix):]
	if len(rest) < 8 {
		return time.Time{}, time.Time{}, false
	}
	day, err := time.ParseInLocation("20060102", rest[:8], loc)
	if err != nil {
		return time.Time{}, time.Time{}, false
	}
	// SetValue stores a string fact JSON-encoded, so the value comes back
	// quoted. Reading the tail off the quote is how this returned false for
	// every real event the first time it ran.
	if unq, err := strconv.Unquote(value); err == nil {
		value = unq
	}
	i := strings.LastIndex(value, " @ ")
	if i < 0 {
		return time.Time{}, time.Time{}, false
	}
	tail := value[i+len(" @ "):]
	from, to, found := strings.Cut(tail, "-")
	if !found {
		return time.Time{}, time.Time{}, false
	}
	sh, sm, ok1 := parseHM(strings.TrimSpace(from))
	eh, em, ok2 := parseHM(strings.TrimSpace(to))
	if !ok1 || !ok2 {
		return time.Time{}, time.Time{}, false
	}
	start = day.Add(time.Duration(sh)*time.Hour + time.Duration(sm)*time.Minute)
	end = day.Add(time.Duration(eh)*time.Hour + time.Duration(em)*time.Minute)
	if !end.After(start) {
		end = end.Add(24 * time.Hour)
	}
	return start, end, true
}

// parseHM reads "15:04" and nothing else.
func parseHM(s string) (h, m int, ok bool) {
	t, err := time.Parse("15:04", s)
	if err != nil {
		return 0, 0, false
	}
	return t.Hour(), t.Minute(), true
}

// KeyPrefixForDay is the fact-key prefix covering one calendar day. The store
// range-scans between two of these.
func KeyPrefixForDay(day time.Time) string {
	return fmt.Sprintf("calendar_event_%s", day.Format("20060102"))
}

// Busy reports whether any event covers the instant now — the read the loop
// gate uses to suppress nudges during a meeting.
func Busy(events []Event, now time.Time) bool {
	for _, e := range events {
		if !now.Before(e.Start) && now.Before(e.End) {
			return true
		}
	}
	return false
}

// Overlapping returns the events intersecting [from, to), sorted by start.
func Overlapping(events []Event, from, to time.Time) []Event {
	var out []Event
	for _, e := range events {
		if e.End.After(from) && e.Start.Before(to) {
			out = append(out, e)
		}
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Start.Before(out[j].Start) })
	return out
}

// safeKey makes a summary safe to use inside a fact key: letters and digits in
// any script, plus dashes, with space and underscore folded to a dash.
//
// It kept ASCII only until 04-08-2026, and dropped everything else. His
// calendar is Russian, so "Встреча с Аней" and "Обед с мамой" both reduced to
// "--" and produced the same key on the same day — the second event of the day
// silently overwrote the first (Vikunja #443). Letting the letters through is
// what makes the key identify the event. Migration #18 drops the keys written
// under the old rule; they are re-derived on the next poll.
func safeKey(s string) string {
	var b strings.Builder
	for _, r := range s {
		switch {
		case unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-':
			b.WriteRune(r)
		case r == ' ' || r == '_':
			b.WriteRune('-')
		}
	}
	return b.String()
}
