src/automata/event/builtin/body.gleam

import automata/event.{type Event}
import automata/event/metadata
import automata/event/source.{type Source, type SourceKind}
import automata/fsevent/ast as fs_ast
import automata/fsevent/event.{type WatchEvent} as fs_event
import automata/fsevent/op as fs_op
import automata/schedule/ast.{type ValidDateTime}
import gleam/dict.{type Dict}
import gleam/list
import gleam/option.{type Option}
import gleam/string

/// Built-in body sum used by the `BuiltinEvent` alias.
///
/// Closed for known kinds plus a `Custom` escape hatch. The
/// `FileSystem` variant carries an `automata/fsevent` `WatchEvent`
/// so the full set of ops (`Create`, `Write`, `Remove`, `Rename`,
/// `Chmod`) and the rename's old path can travel together with the
/// event without callers having to invent ad-hoc body kinds.
pub type EventBody {
  Scheduled(
    plan_id: String,
    fired_at: ValidDateTime,
    schedule_kind: ScheduleKind,
  )
  FileSystem(event: WatchEvent)
  Manual(reason: Option(String), actor: Option(String))
  Custom(kind: String, attributes: Dict(String, String))
}

/// Distinguishes cron and RRULE plans inside `Scheduled` so a worker
/// can branch without inspecting the producing module.
pub type ScheduleKind {
  CronSchedule
  RRuleSchedule
}

/// Convenience alias used by ecosystem-wide layers (worker, runtime).
pub type BuiltinEvent =
  Event(EventBody)

/// Stable string label for routing tables, metrics, and structured logs.
///
/// `FileSystem(_)` becomes `"file_system:create"` for a single-op
/// event, `"file_system:create+write"` when several ops are present
/// (lowercase ops joined by `+` in canonical order). `Custom("kind", _)`
/// becomes `"custom:kind"`.
pub fn kind(body: EventBody) -> String {
  case body {
    Scheduled(_, _, _) -> "scheduled"
    FileSystem(event) -> "file_system:" <> file_system_op_label(event)
    Manual(_, _) -> "manual"
    Custom(name, _) -> "custom:" <> name
  }
}

pub fn scheduled(
  plan_id plan_id: String,
  fired_at fired_at: ValidDateTime,
  schedule_kind schedule_kind: ScheduleKind,
) -> EventBody {
  Scheduled(plan_id: plan_id, fired_at: fired_at, schedule_kind: schedule_kind)
}

/// Wrap an `automata/fsevent` `WatchEvent` in the canonical body.
pub fn file_system(event event: WatchEvent) -> EventBody {
  FileSystem(event: event)
}

pub fn manual(
  reason reason: Option(String),
  actor actor: Option(String),
) -> EventBody {
  Manual(reason: reason, actor: actor)
}

pub fn custom(
  kind kind: String,
  attributes attributes: Dict(String, String),
) -> EventBody {
  Custom(kind: kind, attributes: attributes)
}

/// Construct a `BuiltinEvent` whose `source.kind` is derived from
/// `body`. This is the only sanctioned constructor for `BuiltinEvent`
/// because it makes source/body misclassification unrepresentable —
/// `Scheduled` always pairs with `ScheduleSource`, `FileSystem(_)` with
/// `FileSystemSource`, `Custom("vendor.kind", _)` with
/// `CustomSource("vendor.kind")`, etc.
pub fn new(
  id id: String,
  occurred_at occurred_at: ValidDateTime,
  source_id source_id: String,
  body body: EventBody,
) -> BuiltinEvent {
  event.Event(
    id: id,
    occurred_at: occurred_at,
    source: derived_source(body, source_id),
    body: body,
    metadata: metadata.empty(),
  )
}

/// Build a `Scheduled` `BuiltinEvent` from a single `(id, plan_id, at,
/// schedule_kind)` tuple.
///
/// Equivalent to:
///
/// ```gleam
/// new(
///   id: id,
///   occurred_at: at,
///   source_id: plan_id,
///   body: scheduled(plan_id: plan_id, fired_at: at, schedule_kind: kind),
/// )
/// ```
///
/// In the common case `source_id` equals `plan_id` and `occurred_at`
/// equals `fired_at`, so the verbose form forces the caller to type the
/// same value twice. This smart constructor halves the surface and
/// removes the "are these intentionally different?" cognitive load.
/// Reach for `new/4` only when the two values genuinely differ
/// (delayed dispatch recorded after the fact, fan-out under a
/// different `source_id`, etc.).
pub fn scheduled_event(
  id id: String,
  plan_id plan_id: String,
  at at: ValidDateTime,
  schedule_kind schedule_kind: ScheduleKind,
) -> BuiltinEvent {
  new(
    id: id,
    occurred_at: at,
    source_id: plan_id,
    body: scheduled(
      plan_id: plan_id,
      fired_at: at,
      schedule_kind: schedule_kind,
    ),
  )
}

/// Derive the canonical `Source` for an `EventBody`. Exposed so that
/// callers building events via `event.new` (for advanced cases like
/// attaching a source name) can stay consistent with `body.new`.
pub fn derived_source(body: EventBody, source_id: String) -> Source {
  source.new(kind: derived_source_kind(body), id: source_id)
}

/// Map an `EventBody` to its canonical `SourceKind`.
pub fn derived_source_kind(body: EventBody) -> SourceKind {
  case body {
    Scheduled(_, _, _) -> source.ScheduleSource
    FileSystem(_) -> source.FileSystemSource
    Manual(_, _) -> source.ManualSource
    Custom(name, _) -> source.CustomSource(name)
  }
}

fn file_system_op_label(event: WatchEvent) -> String {
  event
  |> fs_event.event_ops
  |> fs_op.to_list
  |> list.map(op_label)
  |> string.join("+")
}

fn op_label(op: fs_ast.Op) -> String {
  fs_op.op_to_string(op)
  |> string.lowercase
}