defmodule Oban.Met.Cronitor do
  @moduledoc false

  use GenServer

  alias __MODULE__, as: State
  alias Oban.Notifier

  require Logger

  defstruct [
    :conf,
    :name,
    :timer,
    crontabs: %{},
    interval: :timer.seconds(15),
    ttl: :timer.seconds(60)
  ]

  @spec child_spec(Keyword.t()) :: Supervisor.child_spec()
  def child_spec(opts) do
    name = Keyword.get(opts, :name, __MODULE__)

    opts
    |> super()
    |> Map.put(:id, name)
  end

  @spec start_link(Keyword.t()) :: GenServer.on_start()
  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: opts[:name])
  end

  def merged_crontab(name) do
    GenServer.call(name, :merged_crontab)
  end

  # Callbacks

  @impl GenServer
  def init(opts) do
    Process.flag(:trap_exit, true)

    state = struct!(State, opts)

    Notifier.listen(state.conf.name, [:cronitor])

    {:ok, state, {:continue, :start}}
  end

  @impl GenServer
  def handle_continue(:start, %State{} = state) do
    handle_info(:share, state)
  end

  @impl GenServer
  def terminate(_reason, state) do
    if is_reference(state.timer), do: Process.cancel_timer(state.timer)

    :ok
  end

  @impl GenServer
  def handle_call(:share, _from, %State{} = state) do
    {:noreply, state} = handle_info(:share, state)

    {:reply, :ok, state}
  end

  def handle_call(:merged_crontab, _from, %State{} = state) do
    merged_crontab =
      for {_key, {_ts, crontab}} <- state.crontabs, entry <- crontab, uniq: true, do: entry

    {:reply, merged_crontab, state}
  end

  @impl GenServer
  def handle_info(:share, %State{} = state) do
    %{name: name, node: node, plugins: plugins} = state.conf

    crontab =
      plugins
      |> Keyword.get(Oban.Plugins.Cron, [])
      |> Keyword.get(:crontab, [])
      |> Enum.map(fn
        {expr, work} -> {expr, inspect(work), %{}}
        {expr, work, opts} -> {expr, inspect(work), Map.new(opts)}
      end)

    payload = %{crontab: crontab, name: inspect(name), node: node}

    Notifier.notify(state.conf, :cronitor, payload)

    state =
      state
      |> purge_stale()
      |> schedule_share()

    {:noreply, state}
  end

  def handle_info({:notification, :cronitor, payload}, %State{} = state) do
    %{"crontab" => crontab, "name" => name, "node" => node} = payload

    ts = System.system_time(:millisecond)
    crontab = Enum.map(crontab, &List.to_tuple/1)
    crontabs = Map.put(state.crontabs, {node, name}, {ts, crontab})

    {:noreply, %{state | crontabs: crontabs}}
  end

  def handle_info(message, state) do
    Logger.warning(
      message: "Received unexpected message: #{inspect(message)}",
      source: :oban_met,
      module: __MODULE__
    )

    {:noreply, state}
  end

  # Helpers

  defp purge_stale(%State{} = state) do
    expires = System.system_time(:millisecond) - state.ttl
    crontabs = Map.reject(state.crontabs, fn {_key, {ts, _tab}} -> ts < expires end)

    %{state | crontabs: crontabs}
  end

  defp schedule_share(%State{} = state) do
    %{state | timer: Process.send_after(self(), :share, state.interval)}
  end
end
