defmodule Microwaveprop.Propagation.PathCompute do
  @moduledoc """
  Pure(ish) path-calculator engine extracted from `MicrowavepropWeb.PathLive`.

  Given a source / destination / band / station-params tuple, runs the
  same pipeline `/path` does: terrain analysis (ITU-R P.526), HRRR
  profile lookup at 9 evenly-spaced points, sounding & ionosphere
  readouts, native HRRR duct info, composite scoring, loss + power
  budgets, and the 18-hour propagation forecast at the path midpoint.

  Used by both:
    * the live `MicrowavepropWeb.PathLive` page (live recompute)
    * the rover-planning `RoverPathProfileWorker` (background cache —
      result map is stored on the matching `RoverPlanning.Path`).
  """

  import Microwaveprop.Geo, only: [haversine_km: 4, bearing_deg: 4]

  alias Microwaveprop.Geo
  alias Microwaveprop.Ionosphere
  alias Microwaveprop.Propagation
  alias Microwaveprop.Propagation.BandConfig
  alias Microwaveprop.Propagation.ProfilesFile
  alias Microwaveprop.Propagation.Scorer
  alias Microwaveprop.Propagation.SporadicE
  alias Microwaveprop.Repo
  alias Microwaveprop.Terrain.ElevationClient
  alias Microwaveprop.Terrain.TerrainAnalysis
  alias Microwaveprop.Weather
  alias Microwaveprop.Weather.Station

  require Logger

  @type station_params :: %{
          required(:src_height_m) => float(),
          required(:dst_height_m) => float(),
          required(:tx_power_dbm) => float(),
          required(:src_gain_dbi) => float(),
          required(:dst_gain_dbi) => float(),
          optional(:src_height_ft) => float(),
          optional(:dst_height_ft) => float(),
          optional(:tx_power_mw) => float()
        }

  @type result :: %{
          source: map(),
          destination: map(),
          station_params: station_params(),
          band_config: map(),
          band_mhz: integer(),
          freq_ghz: float(),
          dist_km: float(),
          bearing: float(),
          terrain: map() | nil,
          conditions: map() | nil,
          scoring: map() | nil,
          loss_budget: map(),
          power_budget: map(),
          forecast: list(),
          hrrr_count: integer(),
          hrrr_points: list(),
          ionosphere: map() | nil,
          sounding: map() | nil
        }

  @doc """
  Run the full path-calculator pipeline. Returns `{:ok, result}` on
  success, `{:error, reason}` when either endpoint fails to resolve
  (callsign / grid / coords).
  """
  @spec compute(String.t(), String.t(), integer(), station_params()) ::
          {:ok, result()} | {:error, term()}
  def compute(source, dest, band_mhz, station_params) do
    with {:ok, src} <- resolve_location(source),
         {:ok, dst} <- resolve_location(dest) do
      band_config = BandConfig.get(band_mhz) || BandConfig.get(10_000)
      freq_ghz = band_mhz / 1000

      dist_km = haversine_km(src.lat, src.lon, dst.lat, dst.lon)
      bearing = bearing_deg(src.lat, src.lon, dst.lat, dst.lon)

      terrain_result = compute_terrain(src, dst, dist_km, freq_ghz, station_params)

      now = DateTime.utc_now()
      midlat = (src.lat + dst.lat) / 2
      midlon = (src.lon + dst.lon) / 2

      profile_valid_time = latest_profile_valid_time(now)
      profile_grid = profile_grid_for(profile_valid_time)
      sample_points = path_sample_points(src, dst, 9)

      {grid_hits, misses} = partition_grid_hits(sample_points, profile_grid, profile_valid_time)
      fallback_hits = fallback_hits(misses, now)

      hrrr_points = Enum.reverse(grid_hits, fallback_hits)
      hrrr_profiles = Enum.map(hrrr_points, & &1.profile)

      sounding = build_sounding_readout(midlat, midlon, now)

      native_duct =
        case Weather.nearest_native_duct_info(midlat, midlon, now) do
          {:ok, info} -> info
          {:error, :not_found} -> %{best_duct_band_ghz: nil, bulk_richardson: nil}
        end

      {conditions, scoring} =
        build_scoring(hrrr_profiles, src, dst, now, band_config, native_duct)

      loss_budget = compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions)
      power_budget = compute_power_budget(station_params, loss_budget)

      forecast = Propagation.point_forecast(band_mhz, midlat, midlon)

      ionosphere = build_ionosphere_readout(band_mhz, midlat, midlon, dist_km)

      {:ok,
       %{
         source: src,
         destination: dst,
         station_params: station_params,
         band_config: band_config,
         band_mhz: band_mhz,
         freq_ghz: freq_ghz,
         dist_km: dist_km,
         bearing: bearing,
         terrain: terrain_result,
         conditions: conditions,
         scoring: scoring,
         loss_budget: loss_budget,
         power_budget: power_budget,
         forecast: forecast,
         hrrr_count: length(hrrr_profiles),
         hrrr_points: hrrr_points,
         ionosphere: ionosphere,
         sounding: sounding
       }}
    end
  end

  defp compute_terrain(src, dst, dist_km, freq_ghz, station_params) do
    case ElevationClient.fetch_elevation_profile(src.lat, src.lon, dst.lat, dst.lon, 64, download: true) do
      {:ok, profile} ->
        analysis =
          TerrainAnalysis.analyse(profile, dist_km, freq_ghz,
            ant_ht_a: station_params.src_height_m,
            ant_ht_b: station_params.dst_height_m
          )

        %{profile: profile, analysis: analysis}

      {:error, reason} ->
        Logger.warning(
          "PathCompute terrain profile load failed: src=#{src.lat},#{src.lon} dst=#{dst.lat},#{dst.lon} reason=#{inspect(reason)}"
        )

        nil
    end
  end

  defp fallback_hits(misses, now) do
    misses
    |> Enum.reverse()
    |> Task.async_stream(&fallback_hrrr_point(&1, now),
      max_concurrency: 4,
      timeout: 5_000,
      on_timeout: :kill_task
    )
    |> Enum.zip(Enum.reverse(misses))
    |> Enum.flat_map(fn
      {{:ok, nil}, _} ->
        []

      {{:ok, point}, _} ->
        [point]

      {{:exit, reason}, {label, lat, lon}} ->
        Logger.error(
          "PathCompute HRRR fallback lookup failed: label=#{inspect(label)} lat=#{lat} lon=#{lon} reason=#{inspect(reason)}"
        )

        []
    end)
  end

  @doc "Public for PathLive's `path_forecast_detail` event."
  @spec resolve_location(String.t()) :: {:ok, map()} | {:error, String.t()}
  def resolve_location(input) do
    case MicrowavepropWeb.LocationResolver.resolve(input) do
      :empty -> {:error, "Location is required"}
      other -> other
    end
  end

  @doc "Public for PathLive's `path_forecast_detail` event."
  @spec build_ionosphere_readout(integer(), float(), float(), float()) :: map() | nil
  def build_ionosphere_readout(band_mhz, midlat, midlon, dist_km) when band_mhz in [50, 144, 222, 432] do
    case Ionosphere.nearest_foes(midlat, midlon) do
      {:ok, obs} ->
        es_score = SporadicE.es_score(obs.fo_es_mhz, band_mhz, dist_km)
        muf = SporadicE.single_hop_muf(obs.fo_es_mhz, dist_km)

        %{
          station_code: obs.station_code,
          valid_time: obs.valid_time,
          fo_es_mhz: obs.fo_es_mhz,
          fo_f2_mhz: obs.fo_f2_mhz,
          mufd_mhz: obs.mufd_mhz,
          es_score: es_score,
          es_muf_mhz: muf,
          es_in_range?: dist_km >= 500 and dist_km <= 2500
        }

      {:error, _reason} ->
        nil
    end
  end

  def build_ionosphere_readout(_band_mhz, _lat, _lon, _dist), do: nil

  defp profile_grid_for(nil), do: nil

  defp profile_grid_for(valid_time) do
    case ProfilesFile.read(valid_time) do
      {:ok, grid} ->
        grid

      {:error, reason} ->
        Logger.warning(
          "PathCompute ProfilesFile.read failed: valid_time=#{inspect(valid_time)} reason=#{inspect(reason)}"
        )

        nil

      other ->
        Logger.warning(
          "PathCompute ProfilesFile.read unexpected: valid_time=#{inspect(valid_time)} got=#{inspect(other)}"
        )

        nil
    end
  end

  defp lookup_profile_grid(grid, lat, lon) do
    {snapped_lat, snapped_lon} = ProfilesFile.snap(lat, lon)
    Map.get(grid, {snapped_lat, snapped_lon})
  end

  defp partition_grid_hits(sample_points, profile_grid, profile_valid_time) do
    Enum.reduce(sample_points, {[], []}, fn pt, acc ->
      classify_grid_hit(pt, profile_grid, profile_valid_time, acc)
    end)
  end

  defp classify_grid_hit(pt, nil, _vt, {hits, mss}), do: {hits, [pt | mss]}

  defp classify_grid_hit({label, lat, lon} = pt, grid, vt, {hits, mss}) do
    case lookup_profile_grid(grid, lat, lon) do
      nil -> {hits, [pt | mss]}
      cell -> {[%{label: label, profile: profile_from_cell(cell, lat, lon, vt)} | hits], mss}
    end
  end

  defp fallback_hrrr_point({label, lat, lon}, now) do
    case Weather.find_nearest_hrrr(lat, lon, now) do
      nil -> nil
      profile -> %{label: label, profile: profile}
    end
  end

  defp latest_profile_valid_time(now) do
    case ProfilesFile.list_valid_times() do
      [] -> nil
      times -> pick_latest_at_or_before(times, now)
    end
  end

  defp pick_latest_at_or_before(times, now) do
    case Enum.filter(times, fn t -> DateTime.compare(t, now) != :gt end) do
      [] -> List.first(times)
      past -> Enum.max(past, DateTime)
    end
  end

  defp profile_from_cell(cell, lat, lon, valid_time) do
    cell
    |> Map.put_new(:lat, lat)
    |> Map.put_new(:lon, lon)
    |> Map.put_new(:valid_time, valid_time)
    |> Map.put_new(:min_refractivity_gradient, Map.get(cell, :native_min_gradient))
    |> Map.put_new(:surface_refractivity, nil)
    |> Map.put_new(:ducting_detected, Map.get(cell, :duct_count, 0) > 0)
    |> Map.put_new(:duct_characteristics, nil)
  end

  defp path_sample_points(src, dst, count) when count >= 2 do
    Enum.map(1..count, fn i ->
      t = (i - 1) / (count - 1)
      lat = src.lat + (dst.lat - src.lat) * t
      lon = src.lon + (dst.lon - src.lon) * t

      label =
        cond do
          i == 1 -> "Source"
          i == count -> "Destination"
          i * 2 == count + 1 -> "Midpoint"
          true -> "#{round(t * 100)}%"
        end

      {label, lat, lon}
    end)
  end

  defp build_sounding_readout(midlat, midlon, now) do
    case Weather.nearest_sounding_to(midlat, midlon, now) do
      {:ok, sounding} ->
        station = if Ecto.assoc_loaded?(sounding.station), do: sounding.station
        station = station || Repo.get(Station, sounding.station_id)

        distance_km =
          if station, do: haversine_km(midlat, midlon, station.lat, station.lon)

        %{
          station_code: station && station.station_code,
          station_name: station && station.name,
          observed_at: sounding.observed_at,
          ducting_detected: sounding.ducting_detected,
          min_refractivity_gradient: sounding.min_refractivity_gradient,
          boundary_layer_depth_m: sounding.boundary_layer_depth_m,
          precipitable_water_mm: sounding.precipitable_water_mm,
          distance_km: distance_km
        }

      {:error, :not_found} ->
        nil
    end
  end

  defp build_scoring([], _src, _dst, _now, _band_config, _native_duct), do: {nil, nil}

  defp build_scoring(profiles, src, dst, now, band_config, native_duct) do
    temps = profiles |> Enum.map(& &1.surface_temp_c) |> Enum.reject(&is_nil/1)
    dewpoints = profiles |> Enum.map(& &1.surface_dewpoint_c) |> Enum.reject(&is_nil/1)

    if temps == [] or dewpoints == [] do
      {nil, nil}
    else
      avg_temp_c = Enum.sum(temps) / length(temps)
      avg_dewpoint_c = Enum.sum(dewpoints) / length(dewpoints)

      pressures = profiles |> Enum.map(& &1.surface_pressure_mb) |> Enum.reject(&is_nil/1)
      gradients = profiles |> Enum.map(& &1.min_refractivity_gradient) |> Enum.reject(&is_nil/1)
      bl_depths = profiles |> Enum.map(& &1.hpbl_m) |> Enum.reject(&is_nil/1)
      pwats = profiles |> Enum.map(& &1.pwat_mm) |> Enum.reject(&is_nil/1)

      conditions = %{
        abs_humidity: Scorer.absolute_humidity(avg_temp_c, avg_dewpoint_c),
        temp_f: Scorer.c_to_f(avg_temp_c),
        dewpoint_f: Scorer.c_to_f(avg_dewpoint_c),
        temp_c: avg_temp_c,
        dewpoint_c: avg_dewpoint_c,
        wind_speed_kts: nil,
        sky_cover_pct: nil,
        utc_hour: now.hour,
        utc_minute: now.minute,
        month: now.month,
        latitude: (src.lat + dst.lat) / 2,
        longitude: src.lon,
        pressure_mb: if(pressures != [], do: Enum.min(pressures)),
        prev_pressure_mb: nil,
        rain_rate_mmhr: 0.0,
        min_refractivity_gradient: if(gradients != [], do: Enum.min(gradients)),
        bl_depth_m: if(bl_depths != [], do: Enum.sum(bl_depths) / length(bl_depths)),
        pwat_mm: if(pwats != [], do: Enum.sum(pwats) / length(pwats)),
        best_duct_band_ghz: native_duct[:best_duct_band_ghz],
        bulk_richardson: native_duct[:bulk_richardson]
      }

      scoring = Scorer.composite_score(conditions, band_config)
      {conditions, scoring}
    end
  end

  defp compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions) do
    freq_mhz = freq_ghz * 1000
    fspl = 20 * :math.log10(max(dist_km, 0.001)) + 20 * :math.log10(freq_mhz) + 32.44

    o2_loss = band_config.o2_db_km * dist_km
    h2o_coeff = band_config.h2o_coeff

    abs_humidity =
      if conditions do
        conditions.abs_humidity
      else
        7.5
      end

    h2o_loss = h2o_coeff * abs_humidity * dist_km

    rain_loss =
      if conditions && conditions.rain_rate_mmhr > 0 do
        gamma = band_config.rain_k * :math.pow(conditions.rain_rate_mmhr, band_config.rain_alpha)
        gamma * dist_km
      else
        0.0
      end

    diffraction_loss =
      if terrain_result do
        terrain_result.analysis.diffraction_db * 1.0
      else
        0.0
      end

    total = fspl + o2_loss + h2o_loss + rain_loss + diffraction_loss

    %{
      fspl: Float.round(fspl, 1),
      o2: Float.round(o2_loss, 2),
      h2o: Float.round(h2o_loss, 2),
      rain: Float.round(rain_loss, 2),
      diffraction: Float.round(diffraction_loss, 1),
      total: Float.round(total, 1)
    }
  end

  defp compute_power_budget(station_params, loss_budget) do
    tx_power_dbm = station_params.tx_power_dbm
    eirp_dbm = tx_power_dbm + station_params.src_gain_dbi
    rx_power_dbm = eirp_dbm - loss_budget.total + station_params.dst_gain_dbi

    rx_sensitivity_cw = -140.0
    rx_sensitivity_ssb = -130.0
    margin_cw = rx_power_dbm - rx_sensitivity_cw
    margin_ssb = rx_power_dbm - rx_sensitivity_ssb

    %{
      tx_power_dbm: Float.round(tx_power_dbm, 1),
      eirp_dbm: Float.round(eirp_dbm, 1),
      rx_power_dbm: Float.round(rx_power_dbm, 1),
      margin_cw: Float.round(margin_cw, 1),
      margin_ssb: Float.round(margin_ssb, 1)
    }
  end

  # Avoid unused-alias warnings for the import.
  @compile {:no_warn_undefined, Geo}
end
