src/lightspeed/presence.gleam

//// Deterministic presence tracker and diff model.

import gleam/int
import gleam/list
import gleam/option.{type Option, None, Some}

/// Presence metadata for one connection.
pub type Meta {
  Meta(ref: String, online_at_ms: Int)
}

/// Presence entry keyed by user identifier.
pub type Entry {
  Entry(key: String, metas: List(Meta))
}

/// Presence diff payload with joins and leaves.
pub type Diff {
  Diff(topic: String, joins: List(Entry), leaves: List(Entry))
}

type TopicEntries {
  TopicEntries(topic: String, entries_rev: List(Entry))
}

/// Presence tracker state.
pub opaque type Tracker {
  Tracker(topics_rev: List(TopicEntries))
}

/// New tracker.
pub fn new() -> Tracker {
  Tracker(topics_rev: [])
}

/// Track one join presence and return resulting diff.
pub fn join(
  tracker: Tracker,
  topic: String,
  key: String,
  meta: Meta,
) -> #(Tracker, Diff) {
  let #(topics_rev, joined_meta) =
    join_topic(tracker.topics_rev, topic, key, meta)
  let joins = case joined_meta {
    Some(joined) -> [Entry(key: key, metas: [joined])]
    None -> []
  }

  #(
    Tracker(topics_rev: topics_rev),
    Diff(topic: topic, joins: joins, leaves: []),
  )
}

/// Track one leave presence and return resulting diff.
pub fn leave(
  tracker: Tracker,
  topic: String,
  key: String,
  ref: String,
) -> #(Tracker, Diff) {
  let #(topics_rev, removed_meta) =
    leave_topic(tracker.topics_rev, topic, key, ref)
  let leaves = case removed_meta {
    Some(left) -> [Entry(key: key, metas: [left])]
    None -> []
  }

  #(
    Tracker(topics_rev: topics_rev),
    Diff(topic: topic, joins: [], leaves: leaves),
  )
}

/// List full presence state for one topic.
pub fn list_topic(tracker: Tracker, topic: String) -> List(Entry) {
  find_topic_entries(tracker.topics_rev, topic)
}

/// Get one key presence entry.
pub fn get(tracker: Tracker, topic: String, key: String) -> Option(Entry) {
  list_topic(tracker, topic)
  |> find_entry(key)
}

/// Number of tracked keys in one topic.
pub fn key_count(tracker: Tracker, topic: String) -> Int {
  list.length(list_topic(tracker, topic))
}

/// Number of tracked refs in one topic.
pub fn ref_count(tracker: Tracker, topic: String) -> Int {
  list_topic(tracker, topic)
  |> count_refs(0)
}

/// Stable topic labels for logs and tests.
pub fn topic_labels(tracker: Tracker) -> List(String) {
  tracker.topics_rev
  |> list.map(fn(topic_entries) {
    topic_entries.topic
    <> ":keys="
    <> int.to_string(list.length(topic_entries.entries_rev))
    <> ":refs="
    <> int.to_string(count_refs(topic_entries.entries_rev, 0))
  })
}

/// Diff topic.
pub fn diff_topic(diff: Diff) -> String {
  diff.topic
}

/// Joined entries.
pub fn joins(diff: Diff) -> List(Entry) {
  diff.joins
}

/// Left entries.
pub fn leaves(diff: Diff) -> List(Entry) {
  diff.leaves
}

/// Number of join entries in one diff.
pub fn join_count(diff: Diff) -> Int {
  list.length(diff.joins)
}

/// Number of leave entries in one diff.
pub fn leave_count(diff: Diff) -> Int {
  list.length(diff.leaves)
}

/// Stable diff label for fixtures.
pub fn diff_label(diff: Diff) -> String {
  diff.topic
  <> ":joins="
  <> int.to_string(join_count(diff))
  <> ":leaves="
  <> int.to_string(leave_count(diff))
}

/// Meta reference.
pub fn meta_ref(meta: Meta) -> String {
  meta.ref
}

/// Meta online timestamp.
pub fn meta_online_at_ms(meta: Meta) -> Int {
  meta.online_at_ms
}

fn join_topic(
  topics_rev: List(TopicEntries),
  topic: String,
  key: String,
  meta: Meta,
) -> #(List(TopicEntries), Option(Meta)) {
  case topics_rev {
    [] -> #(
      [
        TopicEntries(topic: topic, entries_rev: [Entry(key: key, metas: [meta])]),
      ],
      Some(meta),
    )

    [topic_entries, ..rest] ->
      case topic_entries.topic == topic {
        True -> {
          let #(entries_rev, joined) =
            join_entry(topic_entries.entries_rev, key, meta)
          #(
            [
              TopicEntries(topic: topic_entries.topic, entries_rev: entries_rev),
              ..rest
            ],
            joined,
          )
        }

        False -> {
          let #(updated_rest, joined) = join_topic(rest, topic, key, meta)
          #([topic_entries, ..updated_rest], joined)
        }
      }
  }
}

fn join_entry(
  entries_rev: List(Entry),
  key: String,
  meta: Meta,
) -> #(List(Entry), Option(Meta)) {
  case entries_rev {
    [] -> #([Entry(key: key, metas: [meta])], Some(meta))

    [entry, ..rest] ->
      case entry.key == key {
        True ->
          case has_ref(entry.metas, meta.ref) {
            True -> #([entry, ..rest], None)

            False -> {
              let updated = Entry(key: entry.key, metas: [meta, ..entry.metas])
              #([updated, ..rest], Some(meta))
            }
          }

        False -> {
          let #(updated_rest, joined) = join_entry(rest, key, meta)
          #([entry, ..updated_rest], joined)
        }
      }
  }
}

fn leave_topic(
  topics_rev: List(TopicEntries),
  topic: String,
  key: String,
  ref: String,
) -> #(List(TopicEntries), Option(Meta)) {
  case topics_rev {
    [] -> #([], None)

    [topic_entries, ..rest] ->
      case topic_entries.topic == topic {
        True -> {
          let #(entries_rev, removed_meta) =
            leave_entry(topic_entries.entries_rev, key, ref)
          case entries_rev {
            [] -> #(rest, removed_meta)
            _ -> #(
              [
                TopicEntries(
                  topic: topic_entries.topic,
                  entries_rev: entries_rev,
                ),
                ..rest
              ],
              removed_meta,
            )
          }
        }

        False -> {
          let #(updated_rest, removed_meta) = leave_topic(rest, topic, key, ref)
          #([topic_entries, ..updated_rest], removed_meta)
        }
      }
  }
}

fn leave_entry(
  entries_rev: List(Entry),
  key: String,
  ref: String,
) -> #(List(Entry), Option(Meta)) {
  case entries_rev {
    [] -> #([], None)

    [entry, ..rest] ->
      case entry.key == key {
        True -> {
          let #(metas, removed) = remove_meta(entry.metas, ref)
          case metas {
            [] -> #(rest, removed)
            _ -> #([Entry(key: entry.key, metas: metas), ..rest], removed)
          }
        }

        False -> {
          let #(updated_rest, removed) = leave_entry(rest, key, ref)
          #([entry, ..updated_rest], removed)
        }
      }
  }
}

fn remove_meta(metas: List(Meta), ref: String) -> #(List(Meta), Option(Meta)) {
  case metas {
    [] -> #([], None)

    [meta, ..rest] ->
      case meta.ref == ref {
        True -> #(rest, Some(meta))
        False -> {
          let #(updated_rest, removed) = remove_meta(rest, ref)
          #([meta, ..updated_rest], removed)
        }
      }
  }
}

fn has_ref(metas: List(Meta), ref: String) -> Bool {
  case metas {
    [] -> False
    [meta, ..rest] ->
      case meta.ref == ref {
        True -> True
        False -> has_ref(rest, ref)
      }
  }
}

fn find_topic_entries(
  topics_rev: List(TopicEntries),
  topic: String,
) -> List(Entry) {
  case topics_rev {
    [] -> []
    [topic_entries, ..rest] ->
      case topic_entries.topic == topic {
        True -> topic_entries.entries_rev
        False -> find_topic_entries(rest, topic)
      }
  }
}

fn find_entry(entries: List(Entry), key: String) -> Option(Entry) {
  case entries {
    [] -> None
    [entry, ..rest] ->
      case entry.key == key {
        True -> Some(entry)
        False -> find_entry(rest, key)
      }
  }
}

fn count_refs(entries: List(Entry), total: Int) -> Int {
  case entries {
    [] -> total
    [entry, ..rest] -> count_refs(rest, total + list.length(entry.metas))
  }
}