package calendar

import (
	"strings"
	"time"
	"unicode"

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

// Ambient events — the work calendar read (Vikunja #126).
//
// The work calendar is not read by holding a work credential. A corp mail or
// calendar session living on the homelab ties the box's blast radius to the
// employer's data, which is the thing the task exists to refuse. What maven
// reads instead is the SIGNAL: an Android notification-listener on the owner's
// phone relays meeting notifications over wg/LAN, and maven turns the ones that
// clearly describe a meeting into calendar events.
//
// That makes the provenance honest. A notification is evidence about an event,
// not a reading of the calendar, so it is stored under SourceAmbient at
// AmbientConfidence — never indistinguishable from a real CalDAV read, and the
// query path hedges when it recites one.
//
// The parse is deliberately conservative. A notification with no recognisable
// clock reading produces nothing at all: maven is not a guesser-of-truth, and a
// mailbox full of noise turned into invented events is worse than a gap. Mail
// as a notification signal, not a mailbox.

// Notification — one relayed Android notification. Package is the posting app
// (for the log and for the owner to see where a wrong event came from), Title
// and Text are the notification's two text lines, Posted is when the phone
// showed it. Nothing else off the notification is kept.
type Notification struct {
	Package string    `json:"package"`
	Title   string    `json:"title"`
	Text    string    `json:"text"`
	Posted  time.Time `json:"posted_at"`
}

// ambientPastGrace — how far before the notification a derived start may sit
// before the event is refused.
//
// The date is not in the clock reading, so it is inferred, and the inference is
// only safe while the event is still roughly now. A 21:00 reminder reading
// "Tomorrow at 09:00" would otherwise land at 09:00 TODAY, twelve hours in the
// past, and FactKey would file that wrong meeting under today's date. Storing a
// wrong meeting is the one outcome this file exists to avoid, so anything this
// stale is dropped instead. The grace covers the ordinary case of a phone
// reposting a notification for a meeting already under way.
const ambientPastGrace = 2 * time.Hour

// EventFromNotification turns a notification into the event it describes, or
// reports false when it does not clearly describe one.
//
// It needs two things: a clock reading, and a summary that is not just that
// clock reading. The date comes from Posted's day, shifted by an explicit day
// word ("завтра", "tomorrow") when the notification carries one, and the result
// is refused if it lands more than ambientPastGrace in the past. A bare start
// time gets DefaultReminderDuration.
//
// The clock reading is read in the daemon's zone (Vikunja #482). Posted is an
// instant and carries an offset; "созвон в 14:30" is a wall clock and carries
// none, so the zone has to come from somewhere else. A relay that posts
// "2026-08-02T09:00:00Z" used to make that 14:30 UTC, which stored an 18:30
// meeting on a UTC+4 box — wrong by the deploy's own offset, and invisible on a
// UTC box. The owner's phone and the box share a zone, so the box's zone is the
// honest reading of a bare wall clock.
//
// See EventFromNotificationIn, which takes the zone explicitly.
func EventFromNotification(n Notification) (Event, bool) {
	return EventFromNotificationIn(n, time.Local)
}

// EventFromNotificationIn is EventFromNotification against an explicit zone.
//
// "созвон в 14:30" carries no zone, and nobody writing a phone notification
// means 14:30Z. The wall clock used to be resolved against Posted's own zone,
// and a phone posts an RFC 3339 instant ending in Z, so every ambient meeting
// on this box landed four hours late. The size of the error is the deploy's UTC
// offset, which is why it is invisible on a UTC box and wrong everywhere else.
//
// Posted stays an instant in its own zone: it says when the phone showed the
// notification, and the past-grace check compares instants. Only the day and
// the wall clock are read in loc.
func EventFromNotificationIn(n Notification, loc *time.Location) (Event, bool) {
	if loc == nil {
		loc = time.Local
	}
	if n.Posted.IsZero() {
		return Event{}, false
	}
	line := strings.TrimSpace(n.Title + " " + n.Text)
	start, end, ok := parseTimeRange(line)
	if !ok {
		return Event{}, false
	}
	summary := notificationSummary(n)
	if summary == "" {
		return Event{}, false
	}

	// The day is Posted's LOCAL day: a notification posted at 23:30Z saying
	// "завтра в 09:00" is already tomorrow where he is standing.
	y, m, d := n.Posted.In(loc).AddDate(0, 0, dayOffset(line)).Date()
	s := time.Date(y, m, d, start.hour, start.min, 0, 0, loc)
	// Too far in the past to be the meeting this notification is about. The day
	// was inferred, so the honest reading is that the inference was wrong.
	if s.Before(n.Posted.Add(-ambientPastGrace)) {
		return Event{}, false
	}
	var e time.Time
	if end != nil {
		e = time.Date(y, m, d, end.hour, end.min, 0, 0, loc)
		// A range that ends before it starts crossed midnight.
		if !e.After(s) {
			e = e.AddDate(0, 0, 1)
		}
	} else {
		e = s.Add(DefaultReminderDuration)
	}
	return Event{Summary: summary, Start: s, End: e}, true
}

// dayOffset reports how many days off Posted's day the notification puts the
// event. Only explicit words move it: an offset is a claim about which day, and
// guessing which day is exactly the guess this parse refuses to make.
//
// The words are a closed class and live complete in internal/lexicon (Vikunja
// #525), which is how "позавчера" resolves here now — it never did while the map
// was inline. Matched whole, so "послезавтра" is not read as "завтра".
func dayOffset(line string) int {
	for _, f := range strings.Fields(strings.ToLower(line)) {
		f = strings.Trim(f, ".,;:!?—–-()\"'«»")
		if off, ok := lexicon.DayOffset(f); ok {
			return off
		}
	}
	return 0
}

// stripDayWords removes the day word from a summary candidate. It named the
// date, which now lives in Start, and leaving it in makes "Завтра Планёрка"
// the name of the meeting.
func stripDayWords(s string) string {
	out := make([]string, 0, 8)
	for _, f := range strings.Fields(s) {
		if _, ok := lexicon.DayOffset(strings.Trim(f, ".,;:!?—–-()\"'«»")); ok {
			continue
		}
		out = append(out, f)
	}
	return strings.Join(out, " ")
}

// notificationSummary picks the text that names the meeting: the title when it
// carries words, otherwise the body. The clock reading is stripped out — it
// already lives in the times, and FactValue renders it again.
func notificationSummary(n Notification) string {
	for _, cand := range []string{n.Title, n.Text} {
		s := strings.TrimSpace(stripDayWords(stripClock(cand)))
		s = strings.Trim(s, " \t-–—,;:@|·")
		s = strings.Join(strings.Fields(s), " ")
		if hasLetters(s) {
			return s
		}
	}
	return ""
}

type clock struct{ hour, min int }

// parseTimeRange finds the first clock reading in s, and a second one if the
// text spells a range. Accepted separators between hours and minutes are ":"
// and "."; between the two ends of a range, "-", "–", "—" or "до".
//
// Bare hours ("в 14") are NOT accepted. Loose digits in a notification are far
// more often a count, a date or an unread badge than a meeting time, and an
// invented event is worse than no event.
func parseTimeRange(s string) (start clock, end *clock, ok bool) {
	first, _, firstEnd, ok := nextClock(s, 0)
	if !ok {
		return clock{}, nil, false
	}
	sep := strings.TrimLeft(s[firstEnd:], " \t")
	for _, p := range []string{"-", "–", "—", "до "} {
		if !strings.HasPrefix(sep, p) {
			continue
		}
		if second, _, _, ok2 := nextClock(strings.TrimPrefix(sep, p), 0); ok2 {
			return first, &second, true
		}
		break
	}
	return first, nil, true
}

// nextClock scans s from byte offset `from` for the first HH:MM (or HH.MM) and
// returns it with the byte range it occupied. Digits and separators are ASCII,
// so byte offsets are safe over Cyrillic text.
func nextClock(s string, from int) (c clock, start, end int, ok bool) {
	for i := from; i < len(s); i++ {
		if !isDigit(s[i]) {
			continue
		}
		j := i
		for j < len(s) && isDigit(s[j]) {
			j++
		}
		// A run longer than two digits is a year, an id or an unread count.
		if j-i > 2 {
			i = j
			continue
		}
		if j >= len(s) || (s[j] != ':' && s[j] != '.') {
			i = j
			continue
		}
		k := j + 1
		for k < len(s) && isDigit(s[k]) {
			k++
		}
		if k-(j+1) != 2 {
			i = j
			continue
		}
		// Reject a group that is a link in a longer dotted or colon chain:
		// "2026.08.15" would otherwise offer "08.15" as 08:15, and a deadline
		// date invented as a meeting time is exactly the wrong kind of guess.
		// A trailing ":ss" is fine — that is a time with seconds.
		if i > 0 && (s[i-1] == '.' || s[i-1] == ':' || isDigit(s[i-1])) {
			i = k
			continue
		}
		if k < len(s) && s[k] == '.' && k+1 < len(s) && isDigit(s[k+1]) {
			i = k
			continue
		}
		hour, min := atoi(s[i:j]), atoi(s[j+1:k])
		if hour > 23 || min > 59 {
			i = k
			continue
		}
		return clock{hour, min}, i, k, true
	}
	return clock{}, 0, 0, false
}

func isDigit(b byte) bool { return b >= '0' && b <= '9' }

func atoi(s string) int {
	n := 0
	for i := 0; i < len(s); i++ {
		n = n*10 + int(s[i]-'0')
	}
	return n
}

// stripClock removes every clock reading from a summary candidate, along with
// the preposition or separator that introduced it.
func stripClock(s string) string {
	for {
		_, start, end, ok := nextClock(s, 0)
		if !ok {
			return s
		}
		head := trimTrailingPreposition(strings.TrimRight(s[:start], "0123456789:.-–— \t"))
		s = strings.TrimSpace(strings.TrimSpace(head) + " " + strings.TrimSpace(s[end:]))
	}
}

// trimTrailingPreposition drops the word that introduced a clock reading, so
// "Встреча в 14:00" becomes "Встреча" and "с 11:30 до 12:15 Созвон" does not
// keep a dangling "с". It repeats, because a range has two of them.
func trimTrailingPreposition(s string) string {
	preps := []string{"в", "с", "до", "от", "at", "from", "to"}
	for again := true; again; {
		again = false
		s = strings.TrimRight(s, " \t")
		for _, p := range preps {
			if s == p {
				return ""
			}
			if strings.HasSuffix(s, " "+p) {
				s = s[:len(s)-len(p)-1]
				again = true
				break
			}
		}
	}
	return s
}

func hasLetters(s string) bool {
	for _, r := range s {
		if unicode.IsLetter(r) {
			return true
		}
	}
	return false
}
