defmodule Microwaveprop.Propagation.Calibration do
  @moduledoc """
  Pure analysis functions for the algorithm-vs-ML-vs-reality
  comparison reports written by `mix prop.compare`.

  Every function here takes plain maps and lists, so the Mix task can
  load data however it wants and these helpers stay easy to test
  without a database.

  Sample shape (one map per QSO):

      %{
        algorithm_score: 0..100,    # composite score from Scorer
        ml_score: 0..100,           # ML model prediction (or nil if unavailable)
        distance_km: float(),       # actual contact distance — the empirical signal
        band: integer(),            # MHz
        # optional, copied through for outlier inspection:
        contact_id: term(), conditions: map()
      }
  """

  @score_buckets [
    {"0-20", 0, 20},
    {"20-40", 20, 40},
    {"40-60", 40, 60},
    {"60-80", 60, 80},
    {"80-100", 80, 101}
  ]

  @doc """
  Spearman rank correlation. Returns `nil` for fewer than 3 pairs.
  Tied ranks are averaged. Returns `0` (by convention) when one input
  has zero variance.
  """
  @spec spearman([number()], [number()]) :: float() | nil
  def spearman(xs, ys) when length(xs) != length(ys), do: nil
  def spearman(xs, _) when length(xs) < 3, do: nil

  def spearman(xs, ys) do
    n = length(xs)
    rx = ranks(xs)
    ry = ranks(ys)

    if zero_variance?(rx) or zero_variance?(ry) do
      0.0
    else
      sum_d_sq =
        rx
        |> Enum.zip(ry)
        |> Enum.reduce(0.0, fn {a, b}, acc ->
          d = a - b
          acc + d * d
        end)

      1.0 - 6.0 * sum_d_sq / (n * (n * n - 1))
    end
  end

  @doc "Root-mean-squared error between two equal-length numeric lists. Nil for empty."
  @spec rmse([number()], [number()]) :: float() | nil
  def rmse([], []), do: nil

  def rmse(xs, ys) when length(xs) == length(ys) do
    n = length(xs)

    sum_sq =
      xs
      |> Enum.zip(ys)
      |> Enum.reduce(0.0, fn {x, y}, acc ->
        d = x - y
        acc + d * d
      end)

    :math.sqrt(sum_sq / n)
  end

  @doc """
  Splits samples into the five fixed score buckets ([0-20, 20-40, ...,
  80-100]) and reports `n` + median observed distance for each. Always
  returns 5 entries, even when buckets are empty.

  A well-calibrated scorer shows monotonically increasing median
  distance across the buckets.
  """
  @spec bucket_by_score([map()], atom()) :: [%{bucket: String.t(), n: non_neg_integer(), median_distance_km: float() | nil}]
  def bucket_by_score(samples, score_key) do
    Enum.map(@score_buckets, fn {label, low, high} ->
      in_bucket =
        Enum.filter(samples, fn s ->
          score = Map.get(s, score_key)
          is_number(score) and score >= low and score < high
        end)

      %{
        bucket: label,
        n: length(in_bucket),
        median_distance_km: in_bucket |> Enum.map(& &1.distance_km) |> median()
      }
    end)
  end

  @doc """
  One-band summary: row count, mean scores, algorithm-vs-ML RMSE, and
  Spearman correlations of each score against actual contact distance.
  """
  @spec band_summary([map()]) :: %{
          n: non_neg_integer(),
          mean_algorithm_score: float() | nil,
          mean_ml_score: float() | nil,
          alg_ml_rmse: float() | nil,
          alg_distance_spearman: float() | nil,
          ml_distance_spearman: float() | nil
        }
  def band_summary([]) do
    %{
      n: 0,
      mean_algorithm_score: nil,
      mean_ml_score: nil,
      alg_ml_rmse: nil,
      alg_distance_spearman: nil,
      ml_distance_spearman: nil
    }
  end

  def band_summary(samples) do
    alg = Enum.map(samples, & &1.algorithm_score)
    ml = Enum.map(samples, & &1.ml_score)
    distances = Enum.map(samples, & &1.distance_km)

    %{
      n: length(samples),
      mean_algorithm_score: mean(alg),
      mean_ml_score: mean(ml),
      alg_ml_rmse: rmse(alg, ml),
      alg_distance_spearman: spearman(alg, distances),
      ml_distance_spearman: spearman(ml, distances)
    }
  end

  @doc "Top `n` samples by absolute (algorithm − ML) score difference, descending."
  @spec disagreements([map()], non_neg_integer()) :: [map()]
  def disagreements(samples, n) do
    samples
    |> Enum.map(fn s -> Map.put(s, :divergence, abs(s.algorithm_score - s.ml_score)) end)
    |> Enum.sort_by(& &1.divergence, :desc)
    |> Enum.take(n)
  end

  @doc """
  Drift detector. Two modes:

    * `:ml_drift` — passes the current alg-vs-ML RMSE; flags when it
      exceeds `:threshold` (default 8.0 score points).
    * `:algorithm_drift` — passes the current Spearman correlation
      between algorithm score and contact distance plus a `:history`
      list of past correlations; flags when current is more than
      `:margin` (default 0.10) below the history median.
  """
  @spec detect_drift(:ml_drift | :algorithm_drift, number() | nil, keyword()) ::
          :ok | {:drift, String.t()}
  def detect_drift(:ml_drift, current_rmse, opts) do
    threshold = Keyword.get(opts, :threshold, 8.0)

    cond do
      is_nil(current_rmse) -> :ok
      current_rmse > threshold ->
        {:drift, "ML model has drifted: alg-vs-ML RMSE = #{Float.round(current_rmse, 2)} > #{threshold}"}
      true -> :ok
    end
  end

  def detect_drift(:algorithm_drift, current_corr, opts) do
    history = Keyword.get(opts, :history, [])
    margin = Keyword.get(opts, :margin, 0.10)

    cond do
      is_nil(current_corr) -> :ok
      history == [] -> :ok
      current_corr < median(history) - margin ->
        {:drift,
         "Algorithm correlation dropped: current #{format(current_corr)} vs history median " <>
           "#{format(median(history))} (margin #{margin})"}

      true ->
        :ok
    end
  end

  ## ── helpers ──────────────────────────────────────────────────────

  defp ranks(values) do
    values
    |> Enum.with_index()
    |> Enum.sort_by(&elem(&1, 0))
    |> Enum.chunk_by(&elem(&1, 0))
    |> Enum.flat_map_reduce(1, fn group, start ->
      avg_rank = start + (length(group) - 1) / 2.0
      tagged = Enum.map(group, fn {_v, idx} -> {idx, avg_rank} end)
      {tagged, start + length(group)}
    end)
    |> elem(0)
    |> Enum.sort_by(&elem(&1, 0))
    |> Enum.map(&elem(&1, 1))
  end

  defp zero_variance?(list), do: list |> Enum.uniq() |> length() == 1

  defp mean([]), do: nil
  defp mean(list), do: Enum.sum(list) / length(list)

  defp median([]), do: nil

  defp median(list) do
    sorted = Enum.sort(list)
    n = length(sorted)
    mid = div(n, 2)

    if rem(n, 2) == 0 do
      (Enum.at(sorted, mid - 1) + Enum.at(sorted, mid)) / 2.0
    else
      Enum.at(sorted, mid) * 1.0
    end
  end

  defp format(nil), do: "n/a"
  defp format(n) when is_number(n), do: :erlang.float_to_binary(n * 1.0, decimals: 3)
end
