defmodule MicrowavepropWeb.WeatherTileController do
  use MicrowavepropWeb, :controller

  alias Microwaveprop.Weather

  # Layer fields the binary cell-pack can carry. Used both as the
  # whitelist for ?layers=... and as the default if the param is absent.
  @cell_fields ~w(
    temperature dewpoint_depression surface_rh surface_pressure_mb
    surface_refractivity refractivity_gradient bl_height pwat
    temp_850mb dewpoint_850mb temp_700mb dewpoint_700mb
    lapse_rate mid_lapse_rate inversion_strength inversion_base_m
    ducting duct_base_m duct_strength
  )a

  @spec cells(Plug.Conn.t(), map()) :: Plug.Conn.t()
  def cells(conn, %{"time" => time_param} = params) do
    with {:ok, valid_time, _offset} <- DateTime.from_iso8601(time_param),
         {:ok, bounds} <- parse_bounds(params),
         {:ok, layers} <- parse_layers(params["layers"]) do
      rows = read_rows(valid_time, bounds, params["source"])

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

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

  defp read_rows(valid_time, bounds, "hrdps"), do: Weather.weather_grid_hrdps_at(valid_time, bounds)
  defp read_rows(valid_time, bounds, _other), do: Weather.weather_grid_at(valid_time, bounds)

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

  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: :error

  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

  defp parse_layers(nil), do: {:ok, @cell_fields}

  defp parse_layers(value) when is_binary(value) do
    fields =
      value
      |> String.split(",", trim: true)
      |> Enum.map(&String.trim/1)
      |> Enum.map(&safe_atom/1)

    cond do
      Enum.any?(fields, &is_nil/1) -> :error
      Enum.empty?(fields) -> {:ok, @cell_fields}
      true -> {:ok, fields}
    end
  end

  defp parse_layers(_), do: :error

  defp safe_atom(name) when is_binary(name) do
    if name in Enum.map(@cell_fields, &Atom.to_string/1) do
      String.to_existing_atom(name)
    end
  end

  # Custom binary cell-pack format. Designed for cheap parsing in JS via
  # DataView + Float32Array (no library, no JSON parse). The header is
  # padded so all f32 arrays land on 4-byte boundaries — DataView reads
  # tolerate misalignment but typed-array views over an ArrayBuffer
  # don't.
  #
  #   "WCEL" (4 bytes)        — magic
  #   u8                      — version (1)
  #   u32 LE                  — cell count
  #   u8                      — layer count
  #   for each layer:
  #     u8                    — name length
  #     u8 × name_length      — name (UTF-8)
  #   u8 × N                  — zero padding to next 4-byte boundary
  #   f32 LE × cell_count     — latitudes
  #   f32 LE × cell_count     — longitudes
  #   for each layer:
  #     f32 LE × cell_count   — values (NaN = null; booleans 0.0/1.0)
  defp encode_binary(rows, layers) do
    cell_count = length(rows)
    layer_names = Enum.map(layers, &Atom.to_string/1)

    header =
      <<"WCEL", 1::8, cell_count::little-32, length(layer_names)::8>> <>
        Enum.map_join(layer_names, fn n -> <<byte_size(n)::8>> <> n end)

    pad_size = padding_to_align(byte_size(header), 4)
    header_padded = header <> :binary.copy(<<0>>, pad_size)

    lats = rows |> Enum.map(&encode_f32(&1.lat)) |> IO.iodata_to_binary()
    lons = rows |> Enum.map(&encode_f32(&1.lon)) |> IO.iodata_to_binary()

    values =
      layers
      |> Enum.map(fn field ->
        Enum.map(rows, fn row -> encode_value_f32(Map.get(row, field)) end)
      end)
      |> IO.iodata_to_binary()

    <<header_padded::binary, lats::binary, lons::binary, values::binary>>
  end

  defp padding_to_align(size, align) do
    rem = rem(size, align)
    if rem == 0, do: 0, else: align - rem
  end

  # f32 quiet NaN bit pattern in little-endian — used for null values so
  # the JS side can check Number.isNaN on a Float32Array entry.
  @f32_nan <<0::8, 0::8, 0xC0::8, 0x7F::8>>

  defp encode_value_f32(nil), do: @f32_nan
  defp encode_value_f32(true), do: <<1.0::little-float-32>>
  defp encode_value_f32(false), do: <<0.0::little-float-32>>
  defp encode_value_f32(v) when is_number(v), do: <<v * 1.0::little-float-32>>
  defp encode_value_f32(_), do: @f32_nan

  defp encode_f32(v) when is_number(v), do: <<v * 1.0::little-float-32>>
  defp encode_f32(_), do: @f32_nan
end
