defmodule Mix.Tasks.Calibrate.Aprs144 do
  @shortdoc "Fit 144 MHz scoring weights from aprs.me's last 24h of verified RF hops"
  @moduledoc """
  Pulls recent position-bearing APRS packets from aprs.me via
  `Microwaveprop.AprsRepo`, parses TNC2 paths into verified RF hops via
  `Microwaveprop.Aprs.PathParser`, computes a 10-element propagation factor
  vector at each hop's midpoint via `Microwaveprop.Propagation.Recalibrator`,
  and runs gradient descent against random-baseline negatives to fit new
  144 MHz weights.

  This is a dry-run: the proposed weights are printed but `band_config.ex`
  is NOT modified. Operators copy the weights into the 144 MHz block
  manually after reviewing the train/val loss numbers.

  ## Usage

      mix calibrate.aprs_144
      mix calibrate.aprs_144 --since-hours 6 --epochs 3000 --lr 0.005

  ## Options

    * `--since-hours` — packet window in hours (default: 24, max recent
      retention in aprs.me prod)
    * `--epochs` — gradient-descent iterations (default: 2000)
    * `--lr` — learning rate (default: 0.01)
    * `--max-packets` — cap on packets pulled from aprs.me (default: 50_000)
  """
  use Mix.Task

  alias Microwaveprop.Aprs
  alias Microwaveprop.Aprs.PathParser
  alias Microwaveprop.Backtest
  alias Microwaveprop.Propagation.BandConfig
  alias Microwaveprop.Propagation.Recalibrator
  alias Microwaveprop.Weather

  @factor_keys ~w(humidity time_of_day td_depression refractivity sky season wind rain pwat pressure)a
  @min_samples 50
  @band_mhz 144

  @impl Mix.Task
  def run(argv) do
    Mix.Task.run("app.start")

    # Guard runs BEFORE the Oban pause so a misconfig exits without
    # leaving queues paused. Subsequent failures are inside try/after.
    if BandConfig.get(@band_mhz) == nil do
      Mix.raise("BandConfig has no #{@band_mhz} MHz entry; cannot calibrate.")
    end

    _ = Oban.pause_all_queues(Oban)

    try do
      do_run(argv)
    after
      # Allow `iex -S mix` workflows to keep using Oban after the task
      # returns; for one-shot Mix invocations the BEAM exits and this is
      # a no-op.
      _ = Oban.resume_all_queues(Oban)
    end
  end

  defp do_run(argv) do
    {opts, _, _} =
      OptionParser.parse(argv,
        switches: [
          since_hours: :integer,
          epochs: :integer,
          lr: :float,
          max_packets: :integer
        ]
      )

    since_hours = Keyword.get(opts, :since_hours, 24)
    epochs = Keyword.get(opts, :epochs, 2000)
    learning_rate = Keyword.get(opts, :lr, 0.01)
    max_packets = Keyword.get(opts, :max_packets, 50_000)

    Mix.shell().info("APRS-144 calibration")
    Mix.shell().info("  since_hours:   #{since_hours}")
    Mix.shell().info("  epochs:        #{epochs}")
    Mix.shell().info("  learning_rate: #{learning_rate}")
    Mix.shell().info("  max_packets:   #{max_packets}")
    Mix.shell().info("")

    since = DateTime.add(DateTime.utc_now(), -since_hours * 3600, :second)
    packets = Aprs.recent_packets_with_paths(since: since, limit: max_packets)
    Mix.shell().info("Pulled #{length(packets)} packets from aprs.me")

    callsigns = collect_callsigns(packets)
    positions = Aprs.station_positions(callsigns)
    Mix.shell().info("Resolved #{map_size(positions)} / #{length(callsigns)} digi positions")

    lookup = build_lookup(positions)
    hops = parse_all_hops(packets, lookup)
    Mix.shell().info("Parsed #{length(hops)} verified RF hops")

    positives = compute_positive_factors(hops)

    Mix.shell().info(
      "Computed #{length(positives)} / #{length(hops)} positive factor vectors " <>
        "(#{length(hops) - length(positives)} hops dropped for missing HRRR coverage)"
    )

    if length(positives) < @min_samples do
      Mix.shell().info("")
      Mix.shell().info("Refusing to fit: only #{length(positives)} positive samples (need >= #{@min_samples})")
    else
      {negatives, attempted} = compute_negative_factors(length(positives))

      Mix.shell().info(
        "Computed #{length(negatives)} / #{attempted} negative factor vectors " <>
          "(#{attempted - length(negatives)} samples dropped for missing HRRR coverage)"
      )

      if length(negatives) < @min_samples do
        Mix.shell().info("")
        Mix.shell().info("Refusing to fit: only #{length(negatives)} negative samples (need >= #{@min_samples})")
      else
        result = Recalibrator.train(positives, negatives, learning_rate: learning_rate, epochs: epochs)
        print_results(result, positives, negatives)
      end
    end
  end

  # ── Private ──────────────────────────────────────────────────────

  defp collect_callsigns(packets) do
    packets
    |> Enum.flat_map(fn %{path: path} ->
      path
      |> String.split(",", trim: true)
      |> Enum.map(&String.trim/1)
      |> Enum.map(&String.trim_trailing(&1, "*"))
    end)
    |> Enum.filter(&PathParser.valid_callsign?/1)
    |> Enum.uniq()
  end

  defp build_lookup(positions) do
    fn callsign ->
      case Map.get(positions, callsign) do
        nil -> nil
        {lat, lon, _heard_at} -> {lat, lon}
      end
    end
  end

  defp parse_all_hops(packets, lookup) do
    Enum.flat_map(packets, fn pkt ->
      PathParser.parse_hops(pkt.sender, {pkt.lat, pkt.lon}, pkt.path, pkt.received_at, lookup)
    end)
  end

  defp compute_positive_factors(hops) do
    Enum.flat_map(hops, fn hop ->
      {src_lat, src_lon} = hop.src_pos
      {dst_lat, dst_lon} = hop.dst_pos
      mid_lat = (src_lat + dst_lat) / 2.0
      mid_lon = (src_lon + dst_lon) / 2.0

      case Weather.find_nearest_hrrr(mid_lat, mid_lon, hop.heard_at) do
        nil -> []
        profile -> [Recalibrator.compute_factors(profile, hop.heard_at, @band_mhz)]
      end
    end)
  end

  defp compute_negative_factors(n) do
    # Use a wide contact-pool draw (5_000 minimum) so the random baseline
    # samples don't cluster geographically when n is small. Backtest's
    # default :sample_size is 5_000.
    #
    # v1 limitation: negatives sample from the full contacts table (all
    # bands, dominantly 10 GHz tropo). For a 144 MHz fit this means the
    # trainer separates "verified VHF receive" from "anywhere a 10 GHz
    # contact happened with timestamp jitter". A future iteration should
    # draw negatives from APRS coverage where no `*` digi handled the
    # frame in the same window — true band-matched negatives.
    baselines = Backtest.random_baseline(n, sample_size: max(n, 5_000))

    factors =
      Enum.flat_map(baselines, fn {lat, lon, time} ->
        case Weather.find_nearest_hrrr(lat, lon, time) do
          nil -> []
          profile -> [Recalibrator.compute_factors(profile, time, @band_mhz)]
        end
      end)

    {factors, length(baselines)}
  end

  defp print_results(result, positives, negatives) do
    Mix.shell().info("")
    Mix.shell().info("APRS-144 calibration result")
    Mix.shell().info("  positives:    #{length(positives)} hops with HRRR coverage")
    Mix.shell().info("  negatives:    #{length(negatives)} random-baseline samples")
    Mix.shell().info("  train loss:   #{format_float(result.train_loss)}")
    Mix.shell().info("  val loss:     #{format_float(result.val_loss)}")
    Mix.shell().info("  initial loss: #{format_float(result.initial_loss)}")
    Mix.shell().info("")

    current_weights = BandConfig.weights()

    Mix.shell().info("Current 144 MHz weights (BandConfig defaults — no per-band override):")
    print_weights(current_weights)
    Mix.shell().info("")

    Mix.shell().info("Proposed 144 MHz weights (this fit):")
    print_weights(result.weights)
  end

  defp print_weights(weights) do
    Enum.each(@factor_keys, fn key ->
      value = Map.get(weights, key, 0.0)
      label = key |> Atom.to_string() |> Kernel.<>(":")
      formatted = "~-13s ~.4f" |> :io_lib.format([label, value * 1.0]) |> IO.iodata_to_binary()
      Mix.shell().info("  " <> formatted)
    end)
  end

  defp format_float(f) when is_float(f), do: :erlang.float_to_binary(f, decimals: 4)
  defp format_float(f), do: to_string(f)
end
