defmodule Berrypod.Analytics.Buffer do
  @moduledoc """
  ETS-backed event buffer for analytics.

  Events are written to ETS via `record/1` (fast, no DB round trip) and
  flushed to SQLite every 10 seconds in a single `Repo.insert_all` call.
  This prevents write contention on SQLite from individual event inserts.

  Also tracks active sessions: events from the same visitor within 30 minutes
  get the same `session_hash`. After 30 min of inactivity, a new session starts.
  """

  use GenServer

  alias Berrypod.Repo
  alias Berrypod.Analytics.Event

  @flush_interval_ms 10_000
  @session_timeout_ms 30 * 60 * 1_000

  @event_fields [
    :id,
    :name,
    :pathname,
    :visitor_hash,
    :session_hash,
    :referrer,
    :referrer_source,
    :utm_source,
    :utm_medium,
    :utm_campaign,
    :country_code,
    :screen_size,
    :browser,
    :os,
    :revenue,
    :inserted_at
  ]
  @event_defaults Map.new(@event_fields, &{&1, nil})

  # ── Public API ──

  def start_link(opts \\ []) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @doc """
  Buffers an analytics event for batch writing.

  Accepts a map of event attributes (must include `:visitor_hash` and `:pathname`).
  Assigns a `session_hash` based on the visitor's active session.
  Returns `:ok` immediately.
  """
  def record(attrs) when is_map(attrs) do
    GenServer.cast(__MODULE__, {:record, attrs})
  end

  @doc """
  Removes the Plug's initial pageview so the hook can replace it.

  Looks in the ETS buffer first. If the event was already flushed to the DB
  (the 10s timer can fire between Plug record and hook connect), deletes it
  from there instead. Either way, the hook then records its own event with
  full data (screen_size etc.).

  If JS never connects, the Plug's event stays and flushes normally.
  """
  def supersede(ref) when is_binary(ref) do
    GenServer.cast(__MODULE__, {:supersede, ref})
  end

  # ── GenServer callbacks ──

  @impl true
  def init(_opts) do
    table = :ets.new(:analytics_buffer, [:set, :private])
    schedule_flush()

    {:ok, %{table: table, counter: 0, sessions: %{}}}
  end

  @impl true
  def handle_cast({:record, attrs}, state) do
    now = DateTime.utc_now()
    visitor_hash = Map.fetch!(attrs, :visitor_hash)

    {session_hash, sessions} = resolve_session(visitor_hash, now, state.sessions)

    event =
      attrs
      |> Map.put(:session_hash, session_hash)
      |> Map.put_new(:id, Ecto.UUID.generate())
      |> Map.put(:inserted_at, DateTime.truncate(now, :second))

    counter = state.counter + 1
    :ets.insert(state.table, {counter, event})

    {:noreply, %{state | counter: counter, sessions: sessions}}
  end

  @impl true
  def handle_cast({:supersede, ref}, state) do
    found_in_ets =
      state.table
      |> :ets.tab2list()
      |> Enum.reduce(false, fn {key, event}, found ->
        if Map.get(event, :id) == ref do
          :ets.delete(state.table, key)
          true
        else
          found
        end
      end)

    # If the 10s flush already moved it to the DB, delete it there
    unless found_in_ets do
      import Ecto.Query
      Repo.delete_all(from(e in Event, where: e.id == ^ref))
    end

    {:noreply, state}
  end

  @impl true
  def handle_info(:flush, state) do
    state = flush_events(state)
    schedule_flush()
    {:noreply, state}
  end

  @impl true
  def terminate(_reason, state) do
    flush_events(state)
    :ok
  end

  # ── Private ──

  defp resolve_session(visitor_hash, now, sessions) do
    now_ms = DateTime.to_unix(now, :millisecond)

    case Map.get(sessions, visitor_hash) do
      %{hash: hash, last_at: last_at} when now_ms - last_at < @session_timeout_ms ->
        sessions = Map.put(sessions, visitor_hash, %{hash: hash, last_at: now_ms})
        {hash, sessions}

      _ ->
        hash = :crypto.strong_rand_bytes(8)
        sessions = Map.put(sessions, visitor_hash, %{hash: hash, last_at: now_ms})
        {hash, sessions}
    end
  end

  defp flush_events(state) do
    events = drain_ets(state.table)

    if events != [] do
      rows =
        Enum.map(events, fn {_key, event} ->
          @event_defaults
          |> Map.merge(Map.take(event, @event_fields))
          |> Enum.into([])
        end)

      Repo.insert_all(Event, rows)
    end

    # Prune expired sessions
    now_ms = DateTime.to_unix(DateTime.utc_now(), :millisecond)

    sessions =
      Map.filter(state.sessions, fn {_hash, %{last_at: last_at}} ->
        now_ms - last_at < @session_timeout_ms
      end)

    %{state | counter: 0, sessions: sessions}
  end

  defp drain_ets(table) do
    events = :ets.tab2list(table)
    :ets.delete_all_objects(table)
    events
  end

  defp schedule_flush do
    Process.send_after(self(), :flush, @flush_interval_ms)
  end
end
