defmodule Mix.Tasks.PropagationTrain do
  @shortdoc "Train propagation ML model from month-balanced HRRR + algorithm scores"

  @moduledoc """
  Trains the propagation Nx/Axon model to predict the physical algorithm
  score from weather conditions alone.

  ## Why not use QSO outcomes as the target?

  An earlier version of this task fine-tuned on "within-band distance
  percentile" from the `contacts` table. That target is structurally
  biased: non-contest months (Feb, Mar, Nov) have almost no QSOs, so the
  model learned "these conditions → QSO happened → good" and implicitly
  "no QSO happened → bad." The result was a model that systematically
  under-predicted propagation during months when nobody is on the air,
  which is exactly the wrong thing for a forecast.

  This task instead:

    1. Samples `hrrr_profiles` stratified *uniformly by calendar month*
       — every month contributes the same number of rows regardless of
       contest-driven density. Contest months still exist in the data,
       they just don't drown out the quiet months.
    2. Joins each sample to `solar_indices` (same date) and the nearest
       `soundings` row (±6 h) for K-index / lifted-index features.
    3. Computes the target via `Scorer.composite_score/2` — the *same*
       physical scoring algorithm that `PropagationGridWorker` uses in
       production. Sky / wind / rain fall back to neutral defaults
       because `hrrr_profiles` doesn't store them; the model learns the
       weather-only residual and the missing factors remain constant.
    4. Trains on every band for every sampled profile, producing one
       training row per (profile, band) pair.

  ## Usage

      mix propagation_train
      mix propagation_train --samples-per-month 20000 --epochs 100
  """

  use Mix.Task

  alias Microwaveprop.Propagation.BandConfig
  alias Microwaveprop.Propagation.Model
  alias Microwaveprop.Propagation.Scorer
  alias Microwaveprop.Repo

  @query_timeout 600_000

  @impl Mix.Task
  def run(args) do
    {opts, _, _} =
      OptionParser.parse(args,
        strict: [
          samples_per_month: :integer,
          epochs: :integer,
          batch_size: :integer,
          learning_rate: :float
        ]
      )

    # Boot the app without starting Oban queues so training doesn't have
    # the HRRR / NARR / weather cron firing in the background against the
    # local DB we're reading from.
    Application.put_env(
      :microwaveprop,
      Oban,
      Keyword.put(Application.get_env(:microwaveprop, Oban, []), :queues, false)
    )

    Mix.Task.run("app.start")

    samples_per_month = Keyword.get(opts, :samples_per_month, 20_000)
    epochs = Keyword.get(opts, :epochs, 100)
    batch_size = Keyword.get(opts, :batch_size, 256)
    learning_rate = Keyword.get(opts, :learning_rate, 0.001)

    print_header(samples_per_month, epochs, batch_size, learning_rate)

    {features, targets, month_counts} = load_training_data(samples_per_month)

    n = elem(Nx.shape(features), 0)
    IO.puts("Loaded #{n} training rows (one per profile × band).")
    print_month_counts(month_counts)

    {features, targets} = shuffle(features, targets, n)
    {train_x, train_y, val_x, val_y, test_x, test_y} = split(features, targets, n)

    IO.puts("\nTraining for #{epochs} epochs (lr=#{learning_rate}, batch=#{batch_size})...")

    {trained_state, metrics} =
      Model.train(train_x, train_y,
        epochs: epochs,
        batch_size: batch_size,
        learning_rate: learning_rate
      )

    print_loss(metrics.final_loss)
    print_eval("Validation", Model.evaluate(trained_state, val_x, val_y))
    print_eval("Test", Model.evaluate(trained_state, test_x, test_y))

    IO.puts("\nSaving model to priv/models/propagation_v1.nx...")
    Model.save(trained_state)
    IO.puts("Done.")

    check_monthly_bias(trained_state)

    "-" |> String.duplicate(70) |> IO.puts()
    IO.puts("Finished: #{DateTime.to_string(DateTime.utc_now())}")
  end

  # ── Data loading ────────────────────────────────────────────────────

  # Returns {features_tensor, targets_tensor, %{month => count}}.
  # Stratifies by calendar month so every month contributes `per_month`
  # profile rows, then explodes each profile into one row per band.
  defp load_training_data(per_month) do
    IO.puts("Sampling hrrr_profiles stratified by month (#{per_month}/month)...")

    # The stratification is the whole point: random_row_in_month ranks each
    # profile within its (year, month) bucket by a random key, then we keep
    # the first `per_month` rows per month across all years. This is much
    # cheaper than ORDER BY random() over the whole 43M-row table because
    # the window function can stream.
    sql = """
    WITH ranked AS (
      SELECT
        h.valid_time,
        h.lat,
        h.lon,
        h.surface_temp_c,
        h.surface_dewpoint_c,
        h.surface_pressure_mb,
        h.min_refractivity_gradient,
        h.hpbl_m,
        h.pwat_mm,
        h.surface_refractivity,
        h.ducting_detected,
        extract(month from h.valid_time)::int AS month,
        extract(hour from h.valid_time)::int  AS utc_hour,
        ROW_NUMBER() OVER (
          PARTITION BY extract(month from h.valid_time)::int
          ORDER BY random()
        ) AS rn
      FROM hrrr_profiles h
      WHERE h.surface_temp_c IS NOT NULL
        AND h.surface_dewpoint_c IS NOT NULL
        AND h.surface_pressure_mb IS NOT NULL
    )
    SELECT
      r.valid_time, r.lat, r.lon,
      r.surface_temp_c, r.surface_dewpoint_c, r.surface_pressure_mb,
      r.min_refractivity_gradient, r.hpbl_m, r.pwat_mm,
      r.surface_refractivity, r.ducting_detected,
      r.month, r.utc_hour,
      si.sfi,
      COALESCE((SELECT MAX(v) FROM unnest(si.kp_values) AS v), 2.0) AS kp_max,
      snd.k_index,
      snd.lifted_index
    FROM ranked r
    LEFT JOIN solar_indices si ON si.date = r.valid_time::date
    LEFT JOIN LATERAL (
      SELECT s.k_index, s.lifted_index
      FROM soundings s
      WHERE s.observed_at BETWEEN r.valid_time - interval '6 hours'
                              AND r.valid_time + interval '6 hours'
      ORDER BY ABS(EXTRACT(EPOCH FROM s.observed_at - r.valid_time))
      LIMIT 1
    ) snd ON true
    WHERE r.rn <= $1
    """

    %{rows: rows} = Repo.query!(sql, [per_month], timeout: @query_timeout)
    IO.puts("  #{length(rows)} profile rows after stratified sample")

    month_counts = Enum.frequencies_by(rows, fn row -> Enum.at(row, 11) end)

    bands = BandConfig.all_freqs()

    # Each profile row turns into one training row per band.
    {feature_rows, target_rows} =
      rows
      |> Enum.flat_map(fn row -> encode_profile_rows(row, bands) end)
      |> Enum.unzip()

    {Nx.tensor(feature_rows, type: :f32), Nx.tensor(target_rows, type: :f32), month_counts}
  end

  # Builds one training row per (profile, band). Target is the algorithm
  # composite score normalized to [0, 1]; features are the ML model's
  # 20-element encoding of the same conditions.
  defp encode_profile_rows(row, bands) do
    [
      _valid_time,
      lat,
      lon,
      temp_c,
      dewpoint_c,
      pressure_mb,
      grad,
      hpbl_m,
      pwat_mm,
      refractivity,
      ducting,
      month,
      utc_hour,
      sfi,
      kp_max,
      k_index,
      lifted_index
    ] = row

    temp_c = to_float(temp_c)
    dewpoint_c = to_float(dewpoint_c)
    pressure_mb = to_float(pressure_mb)
    grad = to_float(grad)
    hpbl_m = to_float(hpbl_m)
    pwat_mm = to_float(pwat_mm)
    refractivity = to_float(refractivity)
    lat_f = to_float(lat)
    lon_f = to_float(lon)
    sfi_f = to_float(sfi)
    kp_f = to_float(kp_max)
    k_idx_f = to_float(k_index)
    li_f = to_float(lifted_index)

    scorer_conditions =
      build_scorer_conditions(
        temp_c,
        dewpoint_c,
        pressure_mb,
        grad,
        hpbl_m,
        pwat_mm,
        lat_f,
        lon_f,
        trunc(to_float(month)),
        trunc(to_float(utc_hour))
      )

    Enum.map(bands, fn freq_mhz ->
      band_config = BandConfig.get(freq_mhz)
      %{score: score} = Scorer.composite_score(scorer_conditions, band_config)

      features =
        Model.encode_features(%{
          surface_temp_c: temp_c,
          surface_dewpoint_c: dewpoint_c,
          surface_pressure_mb: pressure_mb,
          min_refractivity_gradient: grad,
          hpbl_m: hpbl_m,
          pwat_mm: pwat_mm,
          surface_refractivity: refractivity,
          latitude: lat_f,
          sfi: sfi_f,
          kp_max: kp_f,
          ducting_detected: ducting,
          k_index: k_idx_f,
          lifted_index: li_f,
          utc_hour: to_float(utc_hour),
          longitude: lon_f,
          month: trunc(to_float(month)),
          freq_mhz: freq_mhz
        })

      {features, [score / 100.0]}
    end)
  end

  # Builds a conditions map matching the shape Scorer.composite_score/2
  # expects. Sky / wind / rain default to nil → Scorer uses neutral
  # values (50, 50, 100). The ML model's feature set also omits those
  # channels, so the missing-factor contribution is constant across all
  # samples and the model learns the weather-derived residual cleanly.
  defp build_scorer_conditions(temp_c, dewpoint_c, pressure_mb, grad, hpbl_m, pwat_mm, lat, lon, month, utc_hour) do
    %{
      abs_humidity: Scorer.absolute_humidity(temp_c, dewpoint_c),
      temp_f: Scorer.c_to_f(temp_c),
      dewpoint_f: Scorer.c_to_f(dewpoint_c),
      pressure_mb: pressure_mb,
      prev_pressure_mb: nil,
      min_refractivity_gradient: grad,
      bl_depth_m: hpbl_m,
      pwat_mm: pwat_mm,
      sky_cover_pct: nil,
      wind_speed_kts: nil,
      rain_rate_mmhr: 0.0,
      utc_hour: utc_hour,
      utc_minute: 0,
      month: month,
      longitude: lon,
      latitude: lat,
      best_duct_band_ghz: nil
    }
  end

  # ── Post-training sanity check ──────────────────────────────────────

  # After training, evaluate the model on a synthetic set of
  # (temp, month) combinations at otherwise-identical conditions. The
  # goal is to confirm the model doesn't prefer / penalize specific
  # months independently of the weather they carry — i.e. that a
  # generic March day doesn't rank below a generic June day just
  # because the training data had fewer March contacts.
  defp check_monthly_bias(params) do
    IO.puts("\nMonthly sanity check at fixed conditions (10 GHz, 15°C, 10°C dewpoint)...")
    predict_fn = Model.compile_predict()

    for month <- 1..12 do
      conditions = %{
        surface_temp_c: 15.0,
        surface_dewpoint_c: 10.0,
        surface_pressure_mb: 1013.0,
        min_refractivity_gradient: -70.0,
        hpbl_m: 800.0,
        pwat_mm: 20.0,
        surface_refractivity: 320.0,
        latitude: 32.5,
        longitude: -97.0,
        sfi: 120.0,
        kp_max: 2.0,
        ducting_detected: false,
        k_index: 25.0,
        lifted_index: 0.0,
        utc_hour: 12,
        month: month,
        freq_mhz: 10_000
      }

      score = Model.predict_score(predict_fn, params, conditions)
      IO.puts("  month=#{month |> Integer.to_string() |> String.pad_leading(2)}: #{score}")
    end
  end

  # ── Helpers ──────────────────────────────────────────────────────────

  defp print_header(per_month, epochs, batch_size, lr) do
    "=" |> String.duplicate(70) |> IO.puts()
    IO.puts("PROPAGATION MODEL TRAINING — month-balanced HRRR → algorithm score")
    "=" |> String.duplicate(70) |> IO.puts()
    IO.puts("Samples per month: #{per_month}  epochs: #{epochs}  batch: #{batch_size}  lr: #{lr}")
    IO.puts("Started: #{DateTime.to_string(DateTime.utc_now())}\n")
  end

  defp print_month_counts(counts) do
    counts
    |> Enum.sort_by(fn {m, _} -> m end)
    |> Enum.each(fn {m, count} ->
      IO.puts("  month #{m |> Integer.to_string() |> String.pad_leading(2)}: #{count}")
    end)
  end

  defp shuffle(features, targets, n) do
    key = Nx.Random.key(System.os_time())
    {indices, _} = Nx.Random.shuffle(key, Nx.iota({n}))
    indices = Nx.as_type(indices, :s64)
    {Nx.take(features, indices), Nx.take(targets, indices)}
  end

  defp split(features, targets, n) do
    nc = elem(Nx.shape(features), 1)
    train_end = trunc(n * 0.8)
    val_end = trunc(n * 0.9)
    test_size = n - val_end

    IO.puts("  Split: train=#{train_end}, val=#{val_end - train_end}, test=#{test_size}")

    {
      Nx.slice(features, [0, 0], [train_end, nc]),
      Nx.slice(targets, [0, 0], [train_end, 1]),
      Nx.slice(features, [train_end, 0], [val_end - train_end, nc]),
      Nx.slice(targets, [train_end, 0], [val_end - train_end, 1]),
      Nx.slice(features, [val_end, 0], [test_size, nc]),
      Nx.slice(targets, [val_end, 0], [test_size, 1])
    }
  end

  defp print_loss(loss) do
    if is_float(loss) and loss == loss do
      IO.puts("  Final loss: #{Float.round(loss, 6)}")
    else
      IO.puts("  Final loss: NaN (training diverged)")
      System.halt(1)
    end
  end

  defp print_eval(label, metrics) do
    IO.puts("  #{label}: RMSE=#{Float.round(metrics.rmse * 100, 2)} pts, R²=#{Float.round(metrics.r_squared, 4)}")
  end

  defp to_float(nil), do: 0.0
  defp to_float(%Decimal{} = d), do: Decimal.to_float(d)
  defp to_float(v) when is_float(v), do: v
  defp to_float(v) when is_integer(v), do: v / 1
end
