defmodule Microwaveprop.Beacons.RangeEstimate do
  @moduledoc """
  Estimates a beacon's reception pattern across the HRRR propagation grid.

  For every 0.125° grid cell within range of the beacon we solve a link budget

      Rx_dBm = EIRP_dBm + Rx_gain_dBi - FSPL(d, f) - atm_loss_per_km * d - score_adj_db

  where `d` is the great-circle distance between the beacon and the cell,
  `EIRP_dBm` comes from the beacon's stored `power_mw` (the field already
  represents EIRP), and `score_adj_db` is derived from the HRRR propagation
  score at the cell: score 50 = no adjustment, score 100 = -15 dB (ducting
  boost), score 0 = +15 dB (absorption / poor conditions). This yields a
  realistic per-cell reception map instead of idealised concentric circles.
  """

  alias Microwaveprop.Propagation
  alias Microwaveprop.Propagation.BandConfig

  # Signal-strength tiers and their RX sensitivity thresholds (dBm).
  @tiers [
    %{label: "Excellent", min_dbm: -100, color: "#059669"},
    %{label: "Good", min_dbm: -115, color: "#0d9488"},
    %{label: "Marginal", min_dbm: -125, color: "#ca8a04"},
    %{label: "Weak CW", min_dbm: -135, color: "#ea580c"},
    %{label: "Detection", min_dbm: -145, color: "#dc2626"}
  ]

  # Minimum received signal to render a cell.
  @detection_floor_dbm -145

  # Assume the receiving station is an average amateur microwave station.
  @rx_gain_dbi 20.0

  # HRRR grid step (degrees).
  @grid_step 0.125

  # Below this frequency free-space loss dominates over atmospheric
  # loss, so `rx_dbm` exceeds the detection floor across tens of
  # thousands of km². On a 1 W / 432 MHz beacon the estimate produced
  # ~126k cells — Jason-encoding that into the map's data attribute
  # locked up browsers in prod. Return an empty estimate below the
  # threshold so the coverage toggle (also gated at 5760 MHz in the
  # LiveView) never has a payload to render.
  @min_supported_mhz 5760

  @doc """
  Convert a power in milliwatts to dBm. Returns `-999.9` for non-positive input.
  """
  @spec mw_to_dbm(number()) :: float()
  def mw_to_dbm(mw) when is_number(mw) and mw > 0, do: 10.0 * :math.log10(mw)
  def mw_to_dbm(_), do: -999.9

  @doc """
  Returns the closest configured band frequency (in MHz) to the given beacon
  frequency. e.g. `nearest_band_mhz(10368.1) == 10_000`.
  """
  @spec nearest_band_mhz(number()) :: integer()
  def nearest_band_mhz(freq_mhz) when is_number(freq_mhz) do
    Enum.min_by(BandConfig.all_freqs(), fn b -> abs(b - freq_mhz) end)
  end

  @doc """
  Estimate a beacon's reception footprint as a list of per-HRRR-cell samples.

  Returns a map with band info, EIRP, the HRRR valid_time, the score at the
  beacon's own grid cell (for display), plus a `cells` list of all grid
  points within range where the estimated received signal exceeds the
  detection floor.
  """
  @spec estimate(struct()) :: map()
  def estimate(%{frequency_mhz: freq_mhz} = beacon) when freq_mhz < @min_supported_mhz do
    empty_estimate(beacon)
  end

  def estimate(beacon) do
    band_mhz = nearest_band_mhz(beacon.frequency_mhz)
    band_config = BandConfig.get(band_mhz)

    detail = Propagation.point_detail(band_mhz, beacon.lat, beacon.lon)
    center_score = (detail && detail.score) || 50
    valid_time = detail && detail.valid_time

    f_mhz = beacon.frequency_mhz * 1.0
    eirp_dbm = mw_to_dbm(beacon.power_mw || 0.0)
    atm_per_km = atm_loss_per_km(band_config)

    # Pull scores within a bounding box big enough to cover the weakest tier
    # under ideal conditions. Use the band's exceptional range * 1.5 with a
    # hard floor so small beacons still get a sensible area.
    max_range_km = max((band_config && band_config.exceptional_range_km * 1.5) || 600.0, 150.0)

    bounds = bbox(beacon.lat, beacon.lon, max_range_km)
    score_map = fetch_score_map(band_mhz, valid_time, bounds)

    cells =
      bounds
      |> grid_points()
      |> Enum.map(fn {lat, lon} ->
        key = {Float.round(lat, 3), Float.round(lon, 3)}
        score = Map.get(score_map, key, 50)
        d_km = haversine_km(beacon.lat, beacon.lon, lat, lon)
        rx_dbm = received_dbm(eirp_dbm, f_mhz, atm_per_km, d_km, score)

        {lat, lon, d_km, score, rx_dbm}
      end)
      |> Enum.filter(fn {_lat, _lon, _d, _score, rx_dbm} ->
        rx_dbm >= @detection_floor_dbm
      end)
      |> Enum.map(fn {lat, lon, d_km, score, rx_dbm} ->
        tier = tier_for(rx_dbm)

        %{
          lat: Float.round(lat, 3),
          lon: Float.round(lon, 3),
          distance_km: Float.round(d_km, 1),
          score: score,
          rx_dbm: round(rx_dbm),
          label: tier.label,
          color: tier.color
        }
      end)

    %{
      beacon_id: beacon.id,
      band_mhz: band_mhz,
      band_label: band_config && band_config.label,
      center_score: center_score,
      valid_time: valid_time,
      eirp_dbm: Float.round(eirp_dbm, 1),
      atm_per_km: Float.round(atm_per_km, 3),
      grid_step: @grid_step,
      max_range_km: Float.round(max_range_km, 0),
      cells: cells,
      tiers: @tiers
    }
  end

  defp empty_estimate(beacon) do
    band_mhz = nearest_band_mhz(beacon.frequency_mhz)
    band_config = BandConfig.get(band_mhz)

    %{
      beacon_id: beacon.id,
      band_mhz: band_mhz,
      band_label: band_config && band_config.label,
      center_score: 50,
      valid_time: nil,
      eirp_dbm: Float.round(mw_to_dbm(beacon.power_mw || 0.0), 1),
      atm_per_km: 0.0,
      grid_step: @grid_step,
      max_range_km: 0.0,
      cells: [],
      tiers: @tiers
    }
  end

  # --- path loss ------------------------------------------------------------

  defp received_dbm(_eirp, _f, _atm, +0.0, _score), do: 999.0

  defp received_dbm(eirp_dbm, f_mhz, atm_per_km, d_km, score) do
    fspl = 20.0 * :math.log10(d_km) + 20.0 * :math.log10(f_mhz) + 32.44
    atm = atm_per_km * d_km
    # Score 50 baseline, ±0.3 dB per score point away from 50.
    # score 100 → -15 dB (ducting), score 0 → +15 dB (poor).
    score_adj = (50 - score) * 0.3
    eirp_dbm + @rx_gain_dbi - fspl - atm - score_adj
  end

  defp tier_for(rx_dbm) do
    Enum.find(@tiers, fn t -> rx_dbm >= t.min_dbm end) || List.last(@tiers)
  end

  # --- atmosphere -----------------------------------------------------------

  # dB per km atmospheric attenuation from O2 + water vapor. The HRRR score
  # already captures humidity variability, so we use a fixed absolute humidity
  # of 10 g/m³ here to keep the physics layer clean.
  defp atm_loss_per_km(nil), do: 0.0

  defp atm_loss_per_km(band_config) do
    o2 = Map.get(band_config, :o2_db_km, 0.0)
    h2o_coeff = Map.get(band_config, :h2o_coeff, 0.0)
    o2 + h2o_coeff * 10.0
  end

  # --- geometry -------------------------------------------------------------

  @earth_radius_km 6371.0

  defp haversine_km(lat1, lon1, lat2, lon2) do
    dlat = :math.pi() * (lat2 - lat1) / 180.0
    dlon = :math.pi() * (lon2 - lon1) / 180.0
    rlat1 = :math.pi() * lat1 / 180.0
    rlat2 = :math.pi() * lat2 / 180.0

    a =
      :math.sin(dlat / 2) * :math.sin(dlat / 2) +
        :math.cos(rlat1) * :math.cos(rlat2) *
          :math.sin(dlon / 2) * :math.sin(dlon / 2)

    c = 2.0 * :math.atan2(:math.sqrt(a), :math.sqrt(1.0 - a))
    @earth_radius_km * c
  end

  defp bbox(lat, lon, range_km) do
    dlat = range_km / 111.0
    dlon = range_km / (111.0 * :math.cos(:math.pi() * lat / 180.0))

    %{
      "south" => lat - dlat,
      "north" => lat + dlat,
      "west" => lon - dlon,
      "east" => lon + dlon
    }
  end

  defp grid_points(%{"south" => s, "north" => n, "west" => w, "east" => e}) do
    # Snap bounds to the HRRR grid step so cells line up with propagation_scores rows.
    lat_start = Float.round(s / @grid_step) * @grid_step
    lon_start = Float.round(w / @grid_step) * @grid_step

    lat_count = max(round((n - lat_start) / @grid_step) + 1, 1)
    lon_count = max(round((e - lon_start) / @grid_step) + 1, 1)

    for i <- 0..(lat_count - 1),
        j <- 0..(lon_count - 1) do
      {Float.round(lat_start + i * @grid_step, 3), Float.round(lon_start + j * @grid_step, 3)}
    end
  end

  # --- score lookup ---------------------------------------------------------

  defp fetch_score_map(band_mhz, valid_time, bounds) do
    scores =
      if valid_time do
        Propagation.scores_at(band_mhz, valid_time, bounds)
      else
        Propagation.latest_scores(band_mhz, bounds)
      end

    Map.new(scores, fn s -> {{Float.round(s.lat, 3), Float.round(s.lon, 3)}, s.score} end)
  end
end
