// Package event is the unified intake envelope (Vikunja #283).
//
// # The problem it solves
//
// Things arrive at Maven from a lot of directions: a relayed Android
// notification (POST /api/ambient), a mail the reader extracted candidates
// from (ingest_mail), an RSS item, a changed page the crawler noticed, a
// zenmoney spend, a CalDAV event, a wg handshake that means he is home, a
// photo he sent, a meeting she was asked to record. Each of those grew its own
// shape, its own storage decision and its own log line. Nothing could answer
// "what came in today, from where" without reading eight packages.
//
// An Event is that answer: one flat, source-agnostic description of "something
// arrived". It is deliberately NOT a new storage layer and NOT a new transport.
// Every intake path keeps writing exactly what it wrote before — a fact, a
// note, a candidate task — and additionally describes what it did as an Event.
// The envelope is a VIEW over intake, not a replacement for it, which is why
// adopting it did not require touching eight callers.
//
// # What it is not
//
//   - Not a command. An Event is a report of something that happened; nothing
//     in Maven executes one. Digestion may read them; it may not be driven by
//     an event alone, because "a thing arrived" is not "a thing must be said".
//   - Not durable. The bus is a bounded in-memory ring. An event's durable
//     consequence is the fact/note/task the intake path already wrote; the
//     envelope is the recent-history window on top. A restart losing the ring
//     loses nothing that mattered.
//   - Not a secret store. Body carries what the intake path was already willing
//     to log or store. Nothing puts a mail body, an IMAP password or a
//     voiceprint in here, and callers must keep it that way.
package event

import (
	"encoding/json"
	"strings"
	"time"
)

// Event — one thing that arrived, normalized.
//
// The field set is the one recorded in the backlog, and it is intentionally
// small: anything source-specific goes in Payload, so adding a source never
// widens the struct and never breaks a reader.
type Event struct {
	// Source — provenance, in the facts vocabulary already used across the
	// repo: "ambient:notif", "caldav:personal", "poll:zenmoney", "rss:<feed>",
	// "crawl:<watch>", "email:<mailbox>", "infer:wg", "tap:voice". Same string
	// the fact or note was written under, so an event and its row can be
	// matched up by eye.
	Source string `json:"source"`

	// Kind — what sort of thing arrived, from the closed set below. This is the
	// field digestion switches on; Source is for provenance and display.
	Kind string `json:"kind"`

	// EntityIDs — Nexus entity ids this event is about, when the intake path
	// knew any. Usually empty: most intake happens before enrichment resolves a
	// subject to an entity.
	EntityIDs []string `json:"entity_ids,omitempty"`

	// Title — one short line, safe to show on a page. For a fact it is the key,
	// for a note the first line, for a task the task text.
	Title string `json:"title"`

	// Body — optional detail, already truncated by the caller.
	Body string `json:"body,omitempty"`

	// Priority — one of PriorityLow / PriorityNormal / PriorityHigh. It is a
	// hint about attention, not a delivery instruction: nothing here decides
	// whether Maven speaks. That stays with internal/loop and internal/delivery,
	// where the severity/presence routing table lives.
	Priority string `json:"priority"`

	// OccurredAt — when the thing happened, NOT when Maven noticed it. A wg
	// handshake carries the handshake instant; an RSS item carries its publish
	// time. Intake paths already make this distinction when writing facts, and
	// the envelope must not flatten it.
	OccurredAt time.Time `json:"occurred_at"`

	// NoticedAt — when Maven saw it. This is the ring's own ordering and the
	// one the page sorts and labels by.
	//
	// It exists because OccurredAt must stay truthful and therefore cannot be
	// an arrival order. A feed's first read returns twenty items spread over a
	// week and publishes them in feed order; the ambient relay writes a 09:00
	// notification about an 18:00 meeting. Ordering the journal by OccurredAt
	// while walking the ring backwards made the timestamp column run forwards
	// and backwards on the same page. Normalize fills it from now, always: a
	// caller does not get to say when Maven noticed something.
	NoticedAt time.Time `json:"noticed_at"`

	// Payload — source-specific extra, opaque here. Optional.
	Payload json.RawMessage `json:"payload,omitempty"`
}

// Kinds. Closed set: a reader may switch on these exhaustively. A new intake
// path picks the closest existing kind before it adds one — the point of the
// envelope is that digestion has a small stable input.
const (
	// KindFact — something was written to the facts table: a calendar read, a
	// zenmoney window, a presence probe, a crawler watermark.
	KindFact = "fact"

	// KindNote — something was written to the notes table: an RSS item, a
	// changed page, a meeting transcript, an image description.
	KindNote = "note"

	// KindTask — a candidate task was captured: the mail reader, the web form,
	// the voice path.
	KindTask = "task"

	// KindMessage — an inbound message on a reach channel. Nothing produces
	// this yet (telegram is send-only today); the kind exists so the bridge,
	// when it lands, is a constructor and not a schema change.
	KindMessage = "message"

	// KindHealth — a service or probe reported its own state.
	KindHealth = "health"
)

// Priorities.
const (
	PriorityLow    = "low"
	PriorityNormal = "normal"
	PriorityHigh   = "high"
)

// TitleMaxRunes / BodyMaxRunes bound what an envelope carries. The ring is
// in memory and served to a web page; a 40 KB crawled article has no business
// in either. Cut on a rune boundary — most of this text is Russian and half a
// cyrillic letter is a broken line.
const (
	TitleMaxRunes = 120
	BodyMaxRunes  = 400
)

// Normalize returns e with its fields put in range: whitespace collapsed out
// of Title, Title and Body truncated, an unknown or empty Priority forced to
// PriorityNormal, and a zero OccurredAt filled from now.
//
// It takes now as a parameter rather than reading the clock, so the whole
// package stays pure and the simulator (Vikunja #284) can replay intake against
// a scripted clock.
func (e Event) Normalize(now time.Time) Event {
	e.Title = truncateRunes(strings.Join(strings.Fields(e.Title), " "), TitleMaxRunes)
	e.Body = truncateRunes(strings.TrimSpace(e.Body), BodyMaxRunes)
	if !validPriority(e.Priority) {
		e.Priority = PriorityNormal
	}
	if e.Kind == "" {
		e.Kind = KindFact
	}
	if e.OccurredAt.IsZero() {
		e.OccurredAt = now
	}
	// Notice time is not a caller's to set: it is the instant the bus took it.
	e.NoticedAt = now
	return e
}

// Valid reports whether e carries the minimum a reader can rely on: a source,
// a known kind, a title and a time. The bus drops anything that fails — an
// envelope with no provenance is worse than no envelope, because it looks like
// evidence.
func (e Event) Valid() bool {
	return e.Source != "" && validKind(e.Kind) && e.Title != "" && !e.OccurredAt.IsZero()
}

func validKind(k string) bool {
	switch k {
	case KindFact, KindNote, KindTask, KindMessage, KindHealth:
		return true
	}
	return false
}

func validPriority(p string) bool {
	switch p {
	case PriorityLow, PriorityNormal, PriorityHigh:
		return true
	}
	return false
}

// truncateRunes cuts s to n runes, marking the cut.
func truncateRunes(s string, n int) string {
	r := []rune(s)
	if len(r) <= n {
		return s
	}
	return string(r[:n]) + "…"
}

// SourceKind guesses the Kind for a source string when the caller has not said
// otherwise. It exists so the one intake decorator in cmd/mavend does not need
// a switch per writer: the source prefix already tells you what arrived.
//
// Unknown prefixes get fallback, which is what the caller was going to write
// anyway (a WriteFact call knows it is a fact). There is deliberately no
// "email:" rule: the mail path constructs its task envelope itself, so mapping
// the prefix here would have journalled any future fact or note written under
// an email source as a captured task.
func SourceKind(source, fallback string) string {
	switch {
	case strings.HasPrefix(source, "rss:"), strings.HasPrefix(source, "crawl:"):
		return KindNote
	case strings.HasPrefix(source, "probe:"), strings.HasPrefix(source, "health:"):
		return KindHealth
	}
	return fallback
}
