defmodule MicrowavepropWeb.ScoresController do
  @moduledoc """
  HTTP endpoint serving propagation score grids in a compact binary
  cell-pack format. Replaces the old `update_scores` LiveView push for
  the /map overlay so the client can fetch directly (cacheable, doesn't
  block the LiveView channel) and decode via DataView + typed-array
  views without a JSON parse.
  """
  use MicrowavepropWeb, :controller

  alias Microwaveprop.Propagation

  @spec cells(Plug.Conn.t(), map()) :: Plug.Conn.t()
  def cells(conn, %{"band" => band_param} = params) do
    with {band_mhz, ""} <- Integer.parse(band_param),
         {:ok, valid_time} <- parse_optional_time(params["time"]),
         {:ok, bounds} <- parse_bounds(params) do
      scores =
        if valid_time do
          Propagation.scores_at(band_mhz, valid_time, bounds)
        else
          Propagation.latest_scores(band_mhz, bounds)
        end

      conn
      |> put_resp_header("content-type", "application/octet-stream")
      |> put_resp_header("cache-control", "public, max-age=60")
      |> send_resp(200, encode_binary(scores))
    else
      _ -> bad_request(conn)
    end
  end

  def cells(conn, _params), do: bad_request(conn)

  defp bad_request(conn) do
    conn
    |> put_status(400)
    |> json(%{error: "invalid params"})
  end

  defp parse_optional_time(nil), do: {:ok, nil}

  defp parse_optional_time(value) when is_binary(value) do
    case DateTime.from_iso8601(value) do
      {:ok, dt, _offset} -> {:ok, dt}
      _ -> :error
    end
  end

  defp parse_optional_time(_), do: :error

  defp parse_bounds(%{"south" => s, "north" => n, "west" => w, "east" => e}) do
    with {:ok, south} <- parse_float(s),
         {:ok, north} <- parse_float(n),
         {:ok, west} <- parse_float(w),
         {:ok, east} <- parse_float(e) do
      {:ok, %{"south" => south, "north" => north, "west" => west, "east" => east}}
    end
  end

  defp parse_bounds(_), do: {:ok, nil}

  defp parse_float(value) when is_binary(value) do
    case Float.parse(value) do
      {f, ""} -> {:ok, f}
      _ -> :error
    end
  end

  defp parse_float(_), do: :error

  # Binary cell-pack format. Each entry is a 0-100 score at a (lat, lon).
  # Layout chosen so the f32 lat/lon arrays land on 4-byte boundaries
  # for typed-array decoding in JS — DataView reads tolerate
  # misalignment but Float32Array views over an ArrayBuffer don't.
  #
  #   "PSCR" (4 bytes)        — magic
  #   u8                      — version (1)
  #   u8 × 3                  — reserved padding (zero)
  #   u32 LE                  — cell count
  #   f32 LE × cell_count     — latitudes
  #   f32 LE × cell_count     — longitudes
  #   u8  × cell_count        — scores (0..100)
  defp encode_binary(scores) do
    cell_count = length(scores)

    header = <<"PSCR", 1::8, 0::8, 0::8, 0::8, cell_count::little-32>>

    lats = scores |> Enum.map(&<<&1.lat * 1.0::little-float-32>>) |> IO.iodata_to_binary()
    lons = scores |> Enum.map(&<<&1.lon * 1.0::little-float-32>>) |> IO.iodata_to_binary()
    score_bytes = scores |> Enum.map(&<<clamp_score(&1.score)::8>>) |> IO.iodata_to_binary()

    <<header::binary, lats::binary, lons::binary, score_bytes::binary>>
  end

  defp clamp_score(s) when is_integer(s) and s < 0, do: 0
  defp clamp_score(s) when is_integer(s) and s > 100, do: 100
  defp clamp_score(s) when is_integer(s), do: s
  defp clamp_score(s) when is_float(s), do: s |> round() |> clamp_score()
  defp clamp_score(_), do: 0
end
