defmodule Raxol.Sensor.Fusion do
  @moduledoc """
  Batches sensor readings and produces fused state.

  Receives `{:sensor_reading, reading}` messages from Feed processes,
  accumulates them in a batch, and on a configurable timer flushes
  the batch through a pure fusion function. Subscribers receive
  `{:fused_update, fused_state}` on each flush.
  """

  use GenServer

  require Logger

  alias Raxol.Sensor.Reading

  @default_batch_window_ms 100

  @type t :: %__MODULE__{
          feeds: %{atom() => pid()},
          batch: [Reading.t()],
          batch_window_ms: pos_integer(),
          fused_state: map(),
          subscribers: MapSet.t(pid()),
          batch_ref: reference() | nil,
          thresholds: map()
        }

  defstruct feeds: %{},
            batch: [],
            batch_window_ms: @default_batch_window_ms,
            fused_state: %{},
            subscribers: MapSet.new(),
            batch_ref: nil,
            thresholds: %{}

  # -- Public API --

  @spec start_link(keyword()) :: GenServer.on_start()
  def start_link(opts \\ []) do
    {name, opts} = Keyword.pop(opts, :name, __MODULE__)
    GenServer.start_link(__MODULE__, opts, name: name)
  end

  @spec register_feed(GenServer.server(), atom(), pid()) :: :ok
  def register_feed(server \\ __MODULE__, sensor_id, feed_pid) do
    GenServer.cast(server, {:register_feed, sensor_id, feed_pid})
  end

  @spec unregister_feed(GenServer.server(), atom()) :: :ok
  def unregister_feed(server \\ __MODULE__, sensor_id) do
    GenServer.cast(server, {:unregister_feed, sensor_id})
  end

  @spec get_fused_state(GenServer.server()) :: map()
  def get_fused_state(server \\ __MODULE__) do
    GenServer.call(server, :get_fused_state)
  end

  @spec subscribe(GenServer.server()) :: :ok
  def subscribe(server \\ __MODULE__) do
    GenServer.call(server, {:subscribe, self()})
  end

  # -- Callbacks --

  @impl true
  def init(opts) do
    batch_window = Keyword.get(opts, :batch_window_ms, @default_batch_window_ms)
    thresholds = Keyword.get(opts, :thresholds, %{})

    state = %__MODULE__{
      batch_window_ms: batch_window,
      thresholds: thresholds
    }

    {:ok, schedule_flush(state)}
  end

  @impl true
  def handle_call(:get_fused_state, _from, %__MODULE__{} = state) do
    {:reply, state.fused_state, state}
  end

  @impl true
  def handle_call({:subscribe, pid}, _from, %__MODULE__{} = state) do
    Process.monitor(pid)

    {:reply, :ok,
     %__MODULE__{state | subscribers: MapSet.put(state.subscribers, pid)}}
  end

  @impl true
  def handle_cast({:register_feed, sensor_id, feed_pid}, %__MODULE__{} = state) do
    Process.monitor(feed_pid)

    {:noreply,
     %__MODULE__{state | feeds: Map.put(state.feeds, sensor_id, feed_pid)}}
  end

  @impl true
  def handle_cast({:unregister_feed, sensor_id}, %__MODULE__{} = state) do
    {:noreply, %__MODULE__{state | feeds: Map.delete(state.feeds, sensor_id)}}
  end

  @impl true
  def handle_info({:sensor_reading, reading}, %__MODULE__{} = state) do
    {:noreply, %__MODULE__{state | batch: [reading | state.batch]}}
  end

  @impl true
  def handle_info(:flush_batch, %__MODULE__{batch: []} = state) do
    {:noreply, schedule_flush(state)}
  end

  @impl true
  def handle_info(:flush_batch, %__MODULE__{} = state) do
    fused = fuse_batch(Enum.reverse(state.batch), state.thresholds)

    Enum.each(state.subscribers, fn pid ->
      send(pid, {:fused_update, fused})
    end)

    state = %__MODULE__{state | batch: [], fused_state: fused}
    {:noreply, schedule_flush(state)}
  end

  @impl true
  def handle_info({:DOWN, _ref, :process, pid, _reason}, %__MODULE__{} = state) do
    subscribers = MapSet.delete(state.subscribers, pid)

    feeds =
      state.feeds
      |> Enum.reject(fn {_id, fpid} -> fpid == pid end)
      |> Map.new()

    {:noreply, %__MODULE__{state | subscribers: subscribers, feeds: feeds}}
  end

  @impl true
  def handle_info(msg, state) do
    Logger.debug("#{__MODULE__} received unexpected message: #{inspect(msg)}")
    {:noreply, state}
  end

  # -- Private --

  defp fuse_batch(readings, thresholds) do
    grouped = Enum.group_by(readings, & &1.sensor_id)

    sensors =
      Map.new(grouped, fn {sensor_id, sensor_readings} ->
        values = Enum.map(sensor_readings, & &1.values)
        qualities = Enum.map(sensor_readings, & &1.quality)
        latest = List.last(sensor_readings)

        fused_values = do_weighted_average(values, qualities)

        alerts =
          check_thresholds(
            fused_values,
            Map.get(thresholds, sensor_id, %{})
          )

        {sensor_id,
         %{
           values: fused_values,
           quality: Enum.sum(qualities) / length(qualities),
           latest_timestamp: latest.timestamp,
           reading_count: length(sensor_readings),
           alerts: alerts
         }}
      end)

    %{sensors: sensors, fused_at: System.monotonic_time(:millisecond)}
  end

  @compile {:no_warn_undefined, Raxol.Sensor.Fusion.NxBackend}

  defp do_weighted_average(values_list, qualities) do
    if Code.ensure_loaded?(Raxol.Sensor.Fusion.NxBackend) do
      Raxol.Sensor.Fusion.NxBackend.weighted_average(values_list, qualities)
    else
      weighted_average(values_list, qualities)
    end
  end

  @epsilon 1.0e-10

  defp weighted_average(values_list, qualities) do
    total_quality = Enum.sum(qualities)

    if abs(total_quality) < @epsilon do
      hd(values_list)
    else
      all_keys =
        values_list
        |> Enum.flat_map(&Map.keys/1)
        |> Enum.uniq()

      Map.new(all_keys, fn key ->
        weighted_sum =
          values_list
          |> Enum.zip(qualities)
          |> Enum.reduce(0.0, fn {vals, q}, acc ->
            case Map.get(vals, key) do
              v when is_number(v) -> acc + v * q
              _ -> acc
            end
          end)

        {key, weighted_sum / total_quality}
      end)
    end
  end

  defp check_thresholds(_values, thresholds) when map_size(thresholds) == 0 do
    []
  end

  defp check_thresholds(values, thresholds) do
    Enum.flat_map(thresholds, fn {key, {op, threshold}} ->
      case Map.get(values, key) do
        v when is_number(v) ->
          if threshold_violated?(v, op, threshold) do
            [%{key: key, value: v, op: op, threshold: threshold}]
          else
            []
          end

        _ ->
          []
      end
    end)
  end

  defp threshold_violated?(v, :gt, t), do: v > t
  defp threshold_violated?(v, :lt, t), do: v < t
  defp threshold_violated?(v, :gte, t), do: v >= t
  defp threshold_violated?(v, :lte, t), do: v <= t
  defp threshold_violated?(_v, _op, _t), do: false

  defp schedule_flush(%__MODULE__{} = state) do
    _ = cancel_timer(state.batch_ref)
    ref = Process.send_after(self(), :flush_batch, state.batch_window_ms)
    %__MODULE__{state | batch_ref: ref}
  end

  defp cancel_timer(nil), do: :ok
  defp cancel_timer(ref), do: Process.cancel_timer(ref)
end
