defmodule Microwaveprop.Rover.Compute do
  @moduledoc """
  End-to-end Calculate pipeline for the rover planner.

  Given a home QTH, a list of selected fixed stations, a band/time/mode,
  and drive/elevation constraints, returns the per-cell quality scores
  plus the top 5 candidate parking spots.
  """

  alias Microwaveprop.Buildings.Index, as: BuildingsIndex
  alias Microwaveprop.Buildings.Loader, as: BuildingsLoader
  alias Microwaveprop.Canopy
  alias Microwaveprop.Propagation
  alias Microwaveprop.Radio.Maidenhead
  alias Microwaveprop.Rover.Aggregator
  alias Microwaveprop.Rover.DriveTime
  alias Microwaveprop.Rover.Elevation
  alias Microwaveprop.Rover.Hilltop
  alias Microwaveprop.Rover.LinkMargin
  alias Microwaveprop.Rover.PathTerrain
  alias Microwaveprop.Rover.Prominence
  alias Microwaveprop.Rover.RoadProximity

  require Logger

  @avg_speed_kmh 65.0
  @drive_penalty_db_per_hour 2.0
  @top_n 5

  # Per-link terrain advantage (dB) is `clearance_m / @clearance_m_per_db`,
  # clamped to [-@clearance_cap_db, +@clearance_cap_db]. ~30 m matches a
  # mature tree canopy so a rover one canopy-height above the worst
  # mid-path terrain earns +1 dB.
  @clearance_m_per_db 30.0
  @clearance_cap_db 10.0

  # Per-cell prominence bonus (broad-hilltop preference). Capped low
  # so it only acts as a tiebreaker between cells with similar link
  # margins — clearance to stations is the primary signal.
  @prominence_m_per_db 30.0
  @prominence_cap_db 4.0

  # Per-cell road-proximity penalty: cells inside @road_free_km of a road
  # take no penalty, then 0.5 dB / km up to @road_penalty_cap_db. Cells
  # far from any road are still surfaced (just down-ranked) so wilderness
  # spots aren't impossible to find.
  @road_free_km 0.5
  @road_penalty_db_per_km 0.5
  @road_penalty_cap_db 6.0

  # Per-cell building-clutter penalty: tallest building within
  # @building_clutter_radius_m of the cell scaled at 1 dB / 5 m, capped low so
  # urban canyons get down-ranked without dominating link-margin signal. The
  # path-clearance step already accounts for buildings ON the link path; this
  # penalty captures "surrounded by stuff that scatters/blocks every direction".
  @building_clutter_radius_m 75
  @building_clutter_m_per_db 5.0
  @building_clutter_cap_db 6.0

  # Per-cell tree-canopy clutter penalty. Same shape as the building
  # clutter penalty but scaled gentler (1 dB / 8 m, capped at 4 dB) —
  # forests are common away from roads so the penalty is a tiebreaker,
  # not a hard exclusion. Path-clearance already drops cells whose
  # link to a station is blocked by trees; this penalty surfaces the
  # "I'm in a forest, every direction is foliage" signal.
  @canopy_clutter_m_per_db 8.0
  @canopy_clutter_cap_db 4.0

  @tier_excellent 10.0
  @tier_good 3.0
  @tier_marginal 0.0

  @color_excellent "#16a34a"
  @color_good "#eab308"
  @color_marginal "#f97316"

  @type run_args :: %{
          home: %{lat: float(), lon: float(), elev_m: integer() | nil},
          stations: [%{callsign: String.t(), lat: float(), lon: float(), selected: boolean()}],
          band_mhz: non_neg_integer(),
          valid_time: DateTime.t(),
          mode: atom(),
          max_distance_km: float(),
          min_elev_gain: integer()
        }

  @spec run(run_args(), keyword()) :: %{
          cells: [map()],
          top_candidates: [map()],
          warnings: [String.t()]
        }
  def run(args, deps \\ []) do
    scores_at = Keyword.get(deps, :scores_at, &Propagation.scores_at/3)
    elev_lookup = Keyword.get(deps, :elev_lookup, &Elevation.lookup_many/1)
    clearance_lookup = Keyword.get(deps, :clearance_lookup, &PathTerrain.clearance_map/2)
    prominence_lookup = Keyword.get(deps, :prominence_lookup, &Prominence.prominence_map/1)
    road_lookup = Keyword.get(deps, :road_lookup, &RoadProximity.road_distances/2)

    buildings_clutter_lookup =
      Keyword.get(deps, :buildings_clutter_lookup, &default_building_clutter/1)

    canopy_clutter_lookup =
      Keyword.get(deps, :canopy_clutter_lookup, &default_canopy_clutter/1)

    hilltop_snap = Keyword.get(deps, :hilltop_snap, &Hilltop.snap/1)
    progress = Keyword.get(deps, :progress, fn _, _, _ -> :ok end)

    %{
      home: home,
      stations: stations,
      band_mhz: band_mhz,
      valid_time: valid_time,
      mode: mode,
      max_distance_km: max_distance_km,
      min_elev_gain: min_elev_gain
    } = args

    selected_stations = Enum.filter(stations, & &1.selected)
    radius_km = max_distance_km * 1.0
    bbox = bbox_around(home, radius_km)

    road_enabled? = Application.get_env(:microwaveprop, :rover_road_proximity_enabled, true)
    total_steps = if road_enabled?, do: 9, else: 8
    counter = :counters.new(1, [:atomics])

    step = fn label ->
      :counters.add(counter, 1, 1)
      progress.(label, :counters.get(counter, 1), total_steps)
    end

    step.("Loading propagation grid")
    raw_cells = time_step("scores_at", fn -> scores_at.(band_mhz, valid_time, bbox) end)

    good_locations = Map.get(args, :good_locations, nil)

    candidate_cells =
      case good_locations do
        nil -> raw_cells
        [] -> raw_cells
        locations -> snap_cells_to_locations(locations, raw_cells)
      end

    in_radius =
      Enum.filter(candidate_cells, fn cell ->
        DriveTime.haversine_km({home.lat, home.lon}, {cell.lat, cell.lon}) <= radius_km
      end)

    Logger.info(
      "rover compute: radius=#{radius_km}km cells=#{length(raw_cells)} in_radius=#{length(in_radius)} stations=#{length(selected_stations)}"
    )

    points = Enum.map(in_radius, &{&1.lat, &1.lon})
    step.("Looking up elevation")
    elev_map = time_step("elev_lookup", fn -> elev_lookup.(points) end)
    step.("Loading building footprints")
    _ = time_step("buildings_load", fn -> BuildingsLoader.ensure_loaded_for_bbox(bbox) end)
    step.("Computing path clearance")
    clearance_map = time_step("clearance", fn -> clearance_lookup.(in_radius, selected_stations) end)
    step.("Measuring terrain prominence")
    prominence_map = time_step("prominence", fn -> prominence_lookup.(in_radius) end)

    road_map =
      if road_enabled? do
        step.("Checking road access")
        time_step("road_proximity", fn -> fetch_road_map(road_lookup, in_radius, bbox) end)
      else
        %{}
      end

    step.("Scanning building clutter")
    clutter_map = time_step("building_clutter", fn -> buildings_clutter_lookup.(in_radius) end)
    step.("Scanning tree canopy")
    canopy_map = time_step("canopy_clutter", fn -> canopy_clutter_lookup.(in_radius) end)
    step.("Scoring cells")

    home_elev = home.elev_m || 0

    cell_maps = %{
      elev: elev_map,
      clearance: clearance_map,
      prominence: prominence_map,
      road: road_map,
      clutter: clutter_map,
      canopy: canopy_map
    }

    cells =
      in_radius
      |> Enum.map(fn cell -> annotate_cell(cell, cell_maps, home, mode, selected_stations) end)
      |> Enum.filter(&keep_cell?(&1, home_elev, min_elev_gain))

    top_candidates =
      cells
      |> Enum.sort_by(& &1.score, :desc)
      |> Enum.take(@top_n)
      |> Enum.map(&candidate_payload(&1, home, hilltop_snap))

    warnings = build_warnings(raw_cells, in_radius, elev_map, cells)

    %{cells: cells, top_candidates: top_candidates, warnings: warnings}
  end

  defp build_warnings(raw_cells, in_radius, elev_map, cells) do
    []
    |> add_warning_if(raw_cells == [], "No HRRR score grid available for this band/time.")
    |> add_warning_if(in_radius == [], "No score grid cells within the drive radius.")
    |> add_warning_if(
      in_radius != [] and elev_map_all_nil?(elev_map),
      "Elevation tiles unavailable (SRTM not mounted); ranking ignores elevation."
    )
    |> add_warning_if(
      in_radius != [] and cells == [],
      "All cells filtered out by score/elevation thresholds."
    )
  end

  defp add_warning_if(list, true, msg), do: list ++ [msg]
  defp add_warning_if(list, false, _msg), do: list

  defp elev_map_all_nil?(elev_map) do
    elev_map != %{} and Enum.all?(elev_map, fn {_k, v} -> is_nil(v) end)
  end

  defp keep_cell?(nil, _home_elev, _min_gain), do: false

  defp keep_cell?(%{score: score, elev_m: elev_m}, home_elev, min_gain) do
    # Cells with unknown elevation (no SRTM tile) are kept; elev gain
    # filter only applies when we actually know the cell elevation.
    elev_ok? = is_nil(elev_m) or elev_m - home_elev >= min_gain
    elev_ok? and score >= 0
  end

  defp keep_cell?(_, _, _), do: false

  defp annotate_cell(cell, cell_maps, home, mode, stations) do
    %{
      elev: elev_map,
      clearance: clearance_map,
      prominence: prominence_map,
      road: road_map,
      clutter: clutter_map,
      canopy: canopy_map
    } = cell_maps

    elev_m = Map.get(elev_map, {cell.lat, cell.lon})
    prominence_m = Map.get(prominence_map, {cell.lat, cell.lon})
    road_km = Map.get(road_map, {cell.lat, cell.lon})
    building_height_m = Map.get(clutter_map, {cell.lat, cell.lon})
    canopy_height_m = Map.get(canopy_map, {cell.lat, cell.lon})
    base_margin = LinkMargin.link_margin_from_score(cell.score, mode)

    margins =
      Enum.map(stations, fn s ->
        clearance_db =
          terrain_db(Map.get(clearance_map, {{cell.lat, cell.lon}, {s.lat, s.lon}}))

        base_margin + clearance_db
      end)

    case Aggregator.cell_margin_db(margins) do
      nil ->
        nil

      agg_db ->
        dist_km = DriveTime.haversine_km({home.lat, home.lon}, {cell.lat, cell.lon})

        score =
          agg_db + prominence_db(prominence_m) - drive_penalty(dist_km) -
            road_penalty_db(road_km) - building_clutter_db(building_height_m) -
            canopy_clutter_db(canopy_height_m)

        %{
          lat: cell.lat,
          lon: cell.lon,
          elev_m: elev_m,
          prominence_m: prominence_m,
          road_km: road_km,
          building_height_m: building_height_m,
          canopy_height_m: canopy_height_m,
          score: score,
          distance_km: dist_km,
          tier_color: tier_color(score)
        }
    end
  end

  defp time_step(label, fun) do
    {us, result} = :timer.tc(fun)
    Logger.info("rover compute: #{label} took #{div(us, 1000)} ms")
    result
  end

  defp fetch_road_map(road_lookup, cells, bbox) do
    case road_lookup.(cells, bbox) do
      {:ok, map} -> map
      {:error, _} -> %{}
    end
  end

  defp road_penalty_db(nil), do: 0.0

  defp road_penalty_db(km) when is_number(km) do
    excess = max(km - @road_free_km, 0.0)
    db = excess * @road_penalty_db_per_km
    min(db, @road_penalty_cap_db)
  end

  defp building_clutter_db(nil), do: 0.0

  defp building_clutter_db(height_m) when is_number(height_m) and height_m <= 0, do: 0.0

  defp building_clutter_db(height_m) when is_number(height_m) do
    db = height_m / @building_clutter_m_per_db
    min(db, @building_clutter_cap_db)
  end

  defp default_building_clutter(cells) do
    Map.new(cells, fn cell ->
      {{cell.lat, cell.lon}, BuildingsIndex.max_height_near(cell.lat, cell.lon, @building_clutter_radius_m)}
    end)
  end

  defp canopy_clutter_db(nil), do: 0.0
  defp canopy_clutter_db(h) when is_number(h) and h <= 0, do: 0.0

  defp canopy_clutter_db(h) when is_number(h) do
    db = h / @canopy_clutter_m_per_db
    min(db, @canopy_clutter_cap_db)
  end

  defp default_canopy_clutter(cells) do
    cells
    |> Enum.map(fn cell -> {cell.lat, cell.lon} end)
    |> Canopy.lookup_many()
  end

  # When the user constrains candidates to known good locations, build
  # synthetic cells at each location's exact lat/lon, inheriting the
  # propagation score from the nearest grid cell (so atmospheric forecast
  # still factors in even though the spot itself isn't a grid centroid).
  defp snap_cells_to_locations(locations, raw_cells) do
    Enum.flat_map(locations, fn loc ->
      case nearest_grid_cell(raw_cells, loc) do
        nil -> []
        cell -> [%{lat: loc.lat, lon: loc.lon, score: cell.score}]
      end
    end)
  end

  defp nearest_grid_cell([], _loc), do: nil

  defp nearest_grid_cell(cells, loc) do
    Enum.min_by(cells, fn c ->
      DriveTime.haversine_km({c.lat, c.lon}, {loc.lat, loc.lon})
    end)
  end

  defp terrain_db(nil), do: 0.0

  defp terrain_db(clearance_m) do
    db = clearance_m / @clearance_m_per_db
    db |> max(-@clearance_cap_db) |> min(@clearance_cap_db)
  end

  defp prominence_db(nil), do: 0.0

  defp prominence_db(prominence_m) do
    db = prominence_m / @prominence_m_per_db
    db |> max(-@prominence_cap_db) |> min(@prominence_cap_db)
  end

  defp candidate_payload(cell, home, hilltop_snap) do
    {lat, lon, elev_m} =
      case hilltop_snap.({cell.lat, cell.lon}) do
        {hl_lat, hl_lon, hl_elev} -> {hl_lat, hl_lon, hl_elev}
        _ -> {cell.lat, cell.lon, cell.elev_m}
      end

    distance_km = DriveTime.haversine_km({home.lat, home.lon}, {lat, lon})
    drive_min = DriveTime.drive_min(distance_km)
    bearing = DriveTime.bearing_compass({home.lat, home.lon}, {lat, lon})
    grid = Maidenhead.from_latlon(lat, lon, 10)

    %{
      grid: grid,
      lat: lat,
      lon: lon,
      elev_m: elev_m,
      prominence_m: cell.prominence_m,
      road_km: cell.road_km,
      drive_min: drive_min,
      score: cell.score,
      tier_color: cell.tier_color,
      distance_km: distance_km,
      bearing_compass: bearing,
      name: "#{grid} — #{round(distance_km)} km #{bearing} of home"
    }
  end

  defp drive_penalty(dist_km), do: dist_km / @avg_speed_kmh * @drive_penalty_db_per_hour

  defp tier_color(score) do
    cond do
      score >= @tier_excellent -> @color_excellent
      score >= @tier_good -> @color_good
      score >= @tier_marginal -> @color_marginal
      true -> @color_marginal
    end
  end

  # Approximate bounding box. 1 deg lat ≈ 111 km; 1 deg lon ≈ 111·cos(lat) km.
  defp bbox_around(%{lat: lat, lon: lon}, radius_km) do
    dlat = radius_km / 111.0
    dlon = radius_km / (111.0 * max(:math.cos(lat * :math.pi() / 180.0), 0.1))

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