defmodule Microwaveprop.Propagation.ShadowComparator do
  @moduledoc """
  Shadow-mode validator for the Rust `prop-grid-rs` port.

  During Phase 1 shadow mode the Rust worker writes score-grid files to
  `/data/scores_shadow/<band>/<iso>.ntms` while Elixir keeps writing to
  `/data/scores/`. This module compares the two files and logs the
  per-band delta distribution so drift is visible before cutover.

  Callable from IEx on demand or from a one-shot Mix task; not started
  in the supervision tree (this is temporary instrumentation).
  """

  alias Microwaveprop.Propagation.ScoresFile

  require Logger

  @default_prod_dir "/data/scores"
  @default_shadow_dir "/data/scores_shadow"

  @type diff :: %{
          band_mhz: non_neg_integer(),
          valid_time: DateTime.t(),
          total_cells: non_neg_integer(),
          matching_cells: non_neg_integer(),
          missing_shadow_cells: non_neg_integer(),
          max_abs_delta: non_neg_integer(),
          mean_abs_delta: float()
        }

  @spec compare(non_neg_integer(), DateTime.t(), keyword()) ::
          {:ok, diff()} | {:error, term()}
  def compare(band_mhz, %DateTime{} = valid_time, opts \\ []) do
    prod_dir = Keyword.get(opts, :prod_dir, @default_prod_dir)
    shadow_dir = Keyword.get(opts, :shadow_dir, @default_shadow_dir)

    with {:ok, prod} <- read_file(prod_dir, band_mhz, valid_time),
         {:ok, shadow} <- read_file(shadow_dir, band_mhz, valid_time) do
      {:ok, diff_payloads(band_mhz, valid_time, prod, shadow)}
    end
  end

  defp read_file(dir, band_mhz, valid_time) do
    path = band_mhz |> ScoresFile.path_for(valid_time) |> swap_base(dir)

    case File.read(path) do
      {:ok, binary} -> decode_scores_file(binary)
      {:error, reason} -> {:error, {:read, path, reason}}
    end
  end

  defp swap_base(path, new_base) do
    suffix = Path.relative_to(path, ScoresFile.base_dir())
    Path.join(new_base, suffix)
  end

  # Inline parse of the v1 scores-file on-disk format so this module
  # doesn't reach into `ScoresFile`'s private `decode/1`. The format is
  # documented in `ScoresFile`'s moduledoc; only the body size and
  # dimensions matter for a diff, so we intentionally keep this minimal.
  # The literal "NTMS" magic is the file-header marker already on disk.
  defp decode_scores_file(
         <<"NTMS", 1::unsigned-8, _band_mhz::unsigned-little-32, _valid_time_unix::signed-little-64,
           _lat_min::float-little-32, _lon_min::float-little-32, _step::float-little-32, n_rows::unsigned-little-16,
           n_cols::unsigned-little-16, body::binary>>
       ) do
    expected = n_rows * n_cols

    if byte_size(body) == expected do
      {:ok, %{n_rows: n_rows, n_cols: n_cols, scores: body}}
    else
      {:error, :invalid_format}
    end
  end

  defp decode_scores_file(_), do: {:error, :invalid_format}

  defp diff_payloads(band_mhz, valid_time, prod, shadow) do
    %{scores: p_body, n_cols: n_cols, n_rows: n_rows} = prod
    %{scores: s_body} = shadow

    {total, matching, missing, max_abs, sum_abs} =
      for row <- 0..(n_rows - 1),
          col <- 0..(n_cols - 1),
          reduce: {0, 0, 0, 0, 0} do
        {t, m, miss, max_d, sum_d} ->
          idx = row * n_cols + col
          p = :binary.at(p_body, idx)
          s = :binary.at(s_body, idx)

          cond do
            p == 255 and s == 255 ->
              {t, m, miss, max_d, sum_d}

            p != 255 and s == 255 ->
              {t + 1, m, miss + 1, max_d, sum_d}

            p == s ->
              {t + 1, m + 1, miss, max_d, sum_d}

            true ->
              d = abs(p - s)
              {t + 1, m, miss, max(max_d, d), sum_d + d}
          end
      end

    mean = if total > 0, do: sum_abs / total, else: 0.0

    %{
      band_mhz: band_mhz,
      valid_time: valid_time,
      total_cells: total,
      matching_cells: matching,
      missing_shadow_cells: missing,
      max_abs_delta: max_abs,
      mean_abs_delta: mean
    }
  end
end
