defmodule Mix.Tasks.Prop.Compare do
  @shortdoc "Compare algorithm scoring vs ML model vs actual contact distances"

  @moduledoc """
  Runs the static weighted algorithm and the ML model against a recent
  contact sample, then measures both against the empirical signal —
  achieved contact distance.

  Three things land on disk every run:

    * `priv/calibration/latest.json` — full breakdown of the most
      recent run (overwritten each time).
    * `priv/calibration/history.jsonl` — single-line summary appended
      so trends show up over weeks/months.
    * `priv/calibration/recommendation.txt` — human-readable next
      action, written only when drift is detected.

  ## Why this is the feedback loop

  The ML model was trained to predict the algorithm's composite score.
  The algorithm itself is a static weighted blend that was last
  recalibrated on a snapshot of the contact corpus. Both can drift:

    * **ML drift** — atmospheric data shifts past the training
      distribution and the ML model starts diverging from the
      algorithm. Surfaced by alg-vs-ML RMSE.
    * **Algorithm drift** — physical conditions change (new
      stations, new bands in heavy use, climatology shifts) and the
      Spearman correlation between algorithm score and observed
      contact distance starts dropping.

  Each comparison run logs both metrics to `history.jsonl`. When the
  rolling history shows degradation, this task emits a recommendation
  pointing at the right remediation:

      mix recalibrate_scorer    # algorithm drift → refit weights
      mix propagation_train     # ML drift → retrain model

  Run on a cron (weekly is plenty) and the loop converges as long as
  the underlying contact data keeps flowing.

  ## Usage

      mix prop.compare
      mix prop.compare --days 30 --samples 5000
      mix prop.compare --output priv/calibration/

  ## Options

    * `--days N` — only consider contacts from the last N days
      (default 365). Contact density varies a lot: tighten this to
      a few weeks once the corpus is dense again.
    * `--samples N` — cap the sampled pairs to N rows (default 5000).
    * `--output DIR` — directory for report files (default `priv/calibration`).
  """

  use Mix.Task

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

  require Logger

  @bands [10_000, 24_000, 47_000, 75_000]
  @query_timeout 600_000

  @impl Mix.Task
  def run(args) do
    {opts, _, _} =
      OptionParser.parse(args,
        strict: [days: :integer, samples: :integer, output: :string]
      )

    days = Keyword.get(opts, :days, 365)
    samples_cap = Keyword.get(opts, :samples, 5_000)
    output_dir = Keyword.get(opts, :output, Path.join(["priv", "calibration"]))

    Application.put_env(
      :microwaveprop,
      Oban,
      Keyword.put(Application.get_env(:microwaveprop, Oban, []), :queues, false)
    )

    Mix.Task.run("app.start")
    File.mkdir_p!(output_dir)

    print_header(days, samples_cap)

    rows = load_dataset(days, samples_cap)
    IO.puts("Loaded #{length(rows)} contact-profile pairs from the last #{days} days.\n")

    if rows == [] do
      IO.puts("No samples — nothing to compare.")
      System.halt(0)
    end

    {predict_fn, params} = load_ml_or_halt()

    samples = Enum.map(rows, &score_sample(&1, predict_fn, params))

    summaries = summarize(samples)
    print_summaries(summaries)

    history = read_history(output_dir)
    recommendations = build_recommendations(summaries, history)

    write_latest(output_dir, samples, summaries, recommendations)
    append_history(output_dir, summaries)
    write_recommendations(output_dir, recommendations)

    print_recommendations(recommendations)

    "-" |> String.duplicate(80) |> IO.puts()
    IO.puts("Report files:")
    IO.puts("  #{Path.join(output_dir, "latest.json")}")
    IO.puts("  #{Path.join(output_dir, "history.jsonl")}")

    if recommendations != [] do
      IO.puts("  #{Path.join(output_dir, "recommendation.txt")}")
    end
  end

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

  defp load_dataset(days, samples_cap) do
    sql = """
    SELECT
      q.id,
      q.band::int AS band,
      q.distance_km::float AS distance_km,
      q.qso_timestamp,
      EXTRACT(MONTH FROM q.qso_timestamp)::int AS month,
      EXTRACT(HOUR FROM q.qso_timestamp)::int AS utc_hour,
      ((q.pos1->>'lat')::float + (q.pos2->>'lat')::float) / 2.0 AS lat,
      ((q.pos1->>'lon')::float + (q.pos2->>'lon')::float) / 2.0 AS 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
    FROM contacts q
    INNER JOIN hrrr_profiles h
      ON h.lat = ROUND(((q.pos1->>'lat')::numeric + (q.pos2->>'lat')::numeric) / 2 * 8) / 8
     AND h.lon = ROUND(((q.pos1->>'lon')::numeric + (q.pos2->>'lon')::numeric) / 2 * 8) / 8
     AND h.valid_time = date_trunc('hour', q.qso_timestamp)
    WHERE q.distance_km > 0
      AND q.distance_km < 3000
      AND q.qso_timestamp >= NOW() - ($1::int || ' days')::interval
      AND q.flagged_invalid IS NOT TRUE
      AND q.pos1 IS NOT NULL
      AND q.pos2 IS NOT NULL
      AND q.band::int = ANY($2::int[])
    ORDER BY q.qso_timestamp DESC
    LIMIT $3
    """

    %{rows: rows, columns: cols} =
      Repo.query!(sql, [days, @bands, samples_cap], timeout: @query_timeout)

    Enum.map(rows, fn row ->
      cols
      |> Enum.zip(row)
      |> Map.new(fn {k, v} -> {String.to_atom(k), to_native(k, v)} end)
    end)
  end

  defp to_native("id", <<_::binary-size(16)>> = bin), do: Ecto.UUID.cast!(bin)
  defp to_native(_, %Decimal{} = d), do: Decimal.to_float(d)
  defp to_native(_, other), do: other

  ## ── Scoring ──────────────────────────────────────────────────────

  defp score_sample(row, predict_fn, params) do
    band_config = BandConfig.get(row.band)
    conditions = build_scorer_conditions(row)

    %{score: alg_score} = Scorer.composite_score(conditions, band_config)

    ml_score = Model.predict_score(predict_fn, params, ml_features(row))

    %{
      contact_id: row.id,
      band: row.band,
      distance_km: row.distance_km,
      algorithm_score: alg_score,
      ml_score: ml_score,
      conditions: %{
        temp_c: row.surface_temp_c,
        dewpoint_c: row.surface_dewpoint_c,
        pressure_mb: row.surface_pressure_mb,
        gradient: row.min_refractivity_gradient,
        hpbl_m: row.hpbl_m,
        pwat_mm: row.pwat_mm
      }
    }
  end

  defp build_scorer_conditions(row) do
    %{
      abs_humidity: Scorer.absolute_humidity(safe_float(row.surface_temp_c), safe_float(row.surface_dewpoint_c)),
      temp_f: row.surface_temp_c |> safe_float() |> Scorer.c_to_f(),
      dewpoint_f: row.surface_dewpoint_c |> safe_float() |> Scorer.c_to_f(),
      pressure_mb: safe_float(row.surface_pressure_mb),
      prev_pressure_mb: nil,
      min_refractivity_gradient: safe_float(row.min_refractivity_gradient),
      bl_depth_m: safe_float(row.hpbl_m),
      pwat_mm: safe_float(row.pwat_mm),
      sky_cover_pct: nil,
      wind_speed_kts: nil,
      rain_rate_mmhr: 0.0,
      utc_hour: row.utc_hour,
      utc_minute: 0,
      month: row.month,
      longitude: row.lon,
      latitude: row.lat,
      best_duct_band_ghz: nil
    }
  end

  defp ml_features(row) do
    %{
      surface_temp_c: safe_float(row.surface_temp_c, 20.0),
      surface_dewpoint_c: safe_float(row.surface_dewpoint_c, 10.0),
      surface_pressure_mb: safe_float(row.surface_pressure_mb, 1013.0),
      min_refractivity_gradient: safe_float(row.min_refractivity_gradient, -70.0),
      hpbl_m: safe_float(row.hpbl_m, 500.0),
      pwat_mm: safe_float(row.pwat_mm, 20.0),
      surface_refractivity: safe_float(row.surface_refractivity, 320.0),
      ducting_detected: row.ducting_detected || false,
      latitude: row.lat,
      longitude: row.lon,
      utc_hour: row.utc_hour,
      month: row.month,
      freq_mhz: row.band
    }
  end

  defp safe_float(nil), do: 0.0
  defp safe_float(%Decimal{} = d), do: Decimal.to_float(d)
  defp safe_float(v) when is_number(v), do: v * 1.0
  defp safe_float(_), do: 0.0

  defp safe_float(nil, default), do: default
  defp safe_float(%Decimal{} = d, _default), do: Decimal.to_float(d)
  defp safe_float(v, _default) when is_number(v), do: v * 1.0
  defp safe_float(_, default), do: default

  ## ── Summaries ────────────────────────────────────────────────────

  defp summarize(samples) do
    overall = Calibration.band_summary(samples)
    overall_buckets = Calibration.bucket_by_score(samples, :algorithm_score)

    by_band =
      @bands
      |> Map.new(fn band ->
        band_samples = Enum.filter(samples, &(&1.band == band))
        {band, Calibration.band_summary(band_samples)}
      end)

    top_disagreements = Calibration.disagreements(samples, 10)

    %{
      n_samples: length(samples),
      overall: overall,
      overall_buckets: overall_buckets,
      by_band: by_band,
      top_disagreements: top_disagreements
    }
  end

  ## ── Drift detection + recommendations ────────────────────────────

  defp build_recommendations(summaries, history) do
    ml_drift =
      Calibration.detect_drift(:ml_drift, summaries.overall.alg_ml_rmse, threshold: 8.0)

    history_corrs =
      Enum.flat_map(history, fn entry ->
        case entry["overall"]["alg_distance_spearman"] do
          n when is_number(n) -> [n]
          _ -> []
        end
      end)

    algorithm_drift =
      Calibration.detect_drift(
        :algorithm_drift,
        summaries.overall.alg_distance_spearman,
        history: history_corrs,
        margin: 0.10
      )

    [
      to_recommendation(:ml_drift, ml_drift, "mix propagation_train"),
      to_recommendation(:algorithm_drift, algorithm_drift, "mix recalibrate_scorer")
    ]
    |> Enum.reject(&is_nil/1)
  end

  defp to_recommendation(_kind, :ok, _action), do: nil
  defp to_recommendation(kind, {:drift, msg}, action), do: %{kind: kind, reason: msg, action: action}

  ## ── File output ──────────────────────────────────────────────────

  defp write_latest(dir, samples, summaries, recommendations) do
    payload = %{
      generated_at: DateTime.utc_now() |> DateTime.to_iso8601(),
      n_samples: length(samples),
      overall: jsonable(summaries.overall),
      overall_buckets: summaries.overall_buckets,
      by_band:
        Map.new(summaries.by_band, fn {band, summary} -> {Integer.to_string(band), jsonable(summary)} end),
      top_disagreements:
        Enum.map(summaries.top_disagreements, fn s ->
          %{
            contact_id: s.contact_id,
            band: s.band,
            distance_km: s.distance_km,
            algorithm_score: s.algorithm_score,
            ml_score: s.ml_score,
            divergence: s.divergence,
            conditions: s.conditions
          }
        end),
      recommendations: recommendations
    }

    File.write!(Path.join(dir, "latest.json"), Jason.encode_to_iodata!(payload, pretty: true))
  end

  defp append_history(dir, summaries) do
    line =
      Jason.encode!(%{
        date: DateTime.utc_now() |> DateTime.to_iso8601(),
        n_samples: summaries.n_samples,
        overall: jsonable(summaries.overall),
        by_band:
          Map.new(summaries.by_band, fn {band, summary} ->
            {Integer.to_string(band), jsonable(summary)}
          end)
      })

    File.write!(Path.join(dir, "history.jsonl"), line <> "\n", [:append])
  end

  defp read_history(dir) do
    path = Path.join(dir, "history.jsonl")

    case File.read(path) do
      {:ok, content} ->
        content
        |> String.split("\n", trim: true)
        |> Enum.flat_map(fn line ->
          case Jason.decode(line) do
            {:ok, entry} -> [entry]
            {:error, _} -> []
          end
        end)

      {:error, _} ->
        []
    end
  end

  defp write_recommendations(_dir, []), do: :ok

  defp write_recommendations(dir, recs) do
    body =
      [
        "Calibration drift detected at #{DateTime.utc_now() |> DateTime.to_iso8601()}.",
        "",
        "Recommended actions (run in order):",
        ""
        | Enum.flat_map(recs, fn rec -> ["  $ #{rec.action}", "    reason: #{rec.reason}", ""] end)
      ]
      |> Enum.join("\n")

    File.write!(Path.join(dir, "recommendation.txt"), body)
  end

  ## ── Console output ───────────────────────────────────────────────

  defp print_header(days, samples_cap) do
    "=" |> String.duplicate(80) |> IO.puts()
    IO.puts("PROPAGATION CALIBRATION — algorithm vs ML vs empirical contact distance")
    "=" |> String.duplicate(80) |> IO.puts()
    IO.puts("Window: last #{days} days   Sample cap: #{samples_cap}")
    IO.puts("Started: #{DateTime.to_string(DateTime.utc_now())}\n")
  end

  defp print_summaries(summaries) do
    IO.puts(String.duplicate("-", 80))
    IO.puts("OVERALL (n=#{summaries.overall.n})")
    IO.puts(String.duplicate("-", 80))
    print_summary_row(summaries.overall)

    IO.puts("\nAlgorithm-score buckets vs achieved distance:")
    IO.puts("  #{pad("bucket", 10)} #{pad("n", 8)} #{pad("median km", 12)}")
    IO.puts("  " <> String.duplicate("-", 30))

    Enum.each(summaries.overall_buckets, fn b ->
      IO.puts(
        "  #{pad(b.bucket, 10)} #{pad(Integer.to_string(b.n), 8)} " <>
          pad(format_num(b.median_distance_km, 1), 12)
      )
    end)

    IO.puts("\n" <> String.duplicate("-", 80))
    IO.puts("PER BAND")
    IO.puts(String.duplicate("-", 80))

    IO.puts(
      "  #{pad("band", 8)} #{pad("n", 6)} #{pad("mean(alg)", 11)} #{pad("mean(ml)", 11)} " <>
        "#{pad("alg-ml RMSE", 13)} #{pad("alg↔dist ρ", 13)} #{pad("ml↔dist ρ", 12)}"
    )

    IO.puts("  " <> String.duplicate("-", 78))

    Enum.each(@bands, fn band ->
      summary = summaries.by_band[band]
      label = format_band(band)

      IO.puts(
        "  #{pad(label, 8)} #{pad(Integer.to_string(summary.n), 6)} " <>
          "#{pad(format_num(summary.mean_algorithm_score, 1), 11)} " <>
          "#{pad(format_num(summary.mean_ml_score, 1), 11)} " <>
          "#{pad(format_num(summary.alg_ml_rmse, 2), 13)} " <>
          "#{pad(format_num(summary.alg_distance_spearman, 3), 13)} " <>
          "#{pad(format_num(summary.ml_distance_spearman, 3), 12)}"
      )
    end)

    IO.puts("\n" <> String.duplicate("-", 80))
    IO.puts("TOP DISAGREEMENTS — biggest |algorithm − ML| score gaps")
    IO.puts(String.duplicate("-", 80))

    IO.puts(
      "  #{pad("band", 7)} #{pad("dist km", 10)} #{pad("alg", 6)} " <>
        "#{pad("ml", 6)} #{pad("Δ", 6)}  notable conditions"
    )

    IO.puts("  " <> String.duplicate("-", 70))

    summaries.top_disagreements
    |> Enum.take(10)
    |> Enum.each(fn s ->
      grad = s.conditions[:gradient]
      pwat = s.conditions[:pwat_mm]

      IO.puts(
        "  #{pad(format_band(s.band), 7)} " <>
          "#{pad(format_num(s.distance_km, 1), 10)} " <>
          "#{pad(Integer.to_string(s.algorithm_score), 6)} " <>
          "#{pad(Integer.to_string(s.ml_score), 6)} " <>
          "#{pad(Integer.to_string(s.divergence), 6)}  " <>
          "grad=#{format_num(grad, 0)} pwat=#{format_num(pwat, 1)}"
      )
    end)

    IO.puts("")
  end

  defp print_summary_row(s) do
    IO.puts(
      "  mean alg=#{format_num(s.mean_algorithm_score, 2)} " <>
        "mean ml=#{format_num(s.mean_ml_score, 2)} " <>
        "alg-ml RMSE=#{format_num(s.alg_ml_rmse, 2)} " <>
        "alg↔dist ρ=#{format_num(s.alg_distance_spearman, 3)} " <>
        "ml↔dist ρ=#{format_num(s.ml_distance_spearman, 3)}"
    )
  end

  defp print_recommendations([]) do
    IO.puts("\n✓ No drift detected — algorithm + ML are in sync with reality.")
    IO.puts("  Re-run periodically (weekly is plenty) to keep watching.")
  end

  defp print_recommendations(recs) do
    IO.puts("\n" <> String.duplicate("=", 80))
    IO.puts("⚠ DRIFT DETECTED — recommended actions:")
    IO.puts(String.duplicate("=", 80))

    Enum.each(recs, fn rec ->
      IO.puts("\n  $ #{rec.action}")
      IO.puts("    " <> rec.reason)
    end)

    IO.puts("")
  end

  ## ── Misc ─────────────────────────────────────────────────────────

  defp load_ml_or_halt do
    case Model.load() do
      {:ok, params} ->
        {Model.compile_predict(), params}

      :error ->
        IO.puts(:stderr, "ERROR: no trained ML model at #{Model.default_path()}.")
        IO.puts(:stderr, "Run `mix propagation_train` first.")
        System.halt(1)
    end
  end

  defp jsonable(map) when is_map(map) do
    Map.new(map, fn {k, v} ->
      {k,
       case v do
         n when is_float(n) -> Float.round(n, 6)
         other -> other
       end}
    end)
  end

  defp pad(s, n), do: String.pad_trailing(to_string(s), n)

  defp format_num(nil, _), do: "n/a"
  defp format_num(n, decimals) when is_number(n), do: :erlang.float_to_binary(n * 1.0, decimals: decimals)
  defp format_num(_, _), do: "n/a"

  defp format_band(10_000), do: "10G"
  defp format_band(24_000), do: "24G"
  defp format_band(47_000), do: "47G"
  defp format_band(75_000), do: "75G"
  defp format_band(b), do: "#{b}MHz"
end
