defmodule Microwaveprop.Pskr.CalibrationSamplerPropertyTest do
  @moduledoc """
  Properties of the producer side of the PSKR calibration pipeline.
  These pin down the contract that future refactors of
  `enqueue_missing_hrrr/2` (currently inlined in
  `Microwaveprop.Pskr.CalibrationSampler.build_for_hour/1`) cannot
  silently break:

    1. Cells with HRRR coverage never get re-enqueued (no churn on
       `hrrr_fetch_tasks`).
    2. Cells without HRRR coverage are *all* enqueued (or, for
       multiple bands sharing a midpoint, exactly one task per
       distinct rounded grid point).
    3. Calibration sample row count equals distinct cell count
       regardless of HRRR availability.
  """
  use Microwaveprop.DataCase, async: true
  use ExUnitProperties

  alias Microwaveprop.Pskr.CalibrationSample
  alias Microwaveprop.Pskr.CalibrationSampler
  alias Microwaveprop.Pskr.SpotHourly
  alias Microwaveprop.Repo
  alias Microwaveprop.Weather.HrrrProfile

  @hour ~U[2026-05-04 18:00:00Z]

  # ── Generators ──────────────────────────────────────────────────

  defp band_gen, do: member_of(["10000", "24000", "47000"])

  # CONUS-ish: 25-50N, 67-125W — within HRRR domain so the
  # round_to_hrrr_grid output is well-defined.
  defp lat_gen, do: float(min: 25.0, max: 49.5)
  defp lon_gen, do: float(min: -125.0, max: -67.0)

  # Per-band cell. Coordinates stay in `bind` so all spots within a
  # generated cell share the same midpoint (otherwise bucket_by_cell
  # would split them).
  defp cell_spec_gen do
    gen all(
          band <- band_gen(),
          lat <- lat_gen(),
          lon <- lon_gen(),
          n_spots <- integer(1..3)
        ) do
      %{band: band, lat: lat, lon: lon, n_spots: n_spots}
    end
  end

  defp insert_spot!(attrs) do
    {:ok, _} =
      %SpotHourly{}
      |> SpotHourly.changeset(
        Map.merge(
          %{
            hour_utc: @hour,
            band: "10000",
            sender_grid: "EM00aa",
            receiver_grid: "DM00bb",
            spot_count: 1,
            distance_km: 100.0,
            modes: ["FT8"]
          },
          attrs
        )
      )
      |> Repo.insert()
  end

  defp insert_hrrr!(lat, lon) do
    {:ok, _} =
      %HrrrProfile{}
      |> HrrrProfile.changeset(%{
        valid_time: @hour,
        lat: lat,
        lon: lon,
        run_time: @hour,
        surface_temp_c: 20.0,
        surface_dewpoint_c: 15.0,
        pwat_mm: 25.0,
        surface_pressure_mb: 1013.0,
        min_refractivity_gradient: -100.0,
        hpbl_m: 500.0,
        ducting_detected: false,
        profile: []
      })
      |> Repo.insert()
  end

  defp seed_spots!(specs, base_callsign) do
    specs
    |> Enum.with_index()
    |> Enum.each(fn {spec, idx} ->
      Enum.each(1..spec.n_spots, fn s_idx ->
        insert_spot!(%{
          band: spec.band,
          midpoint_lat: spec.lat,
          midpoint_lon: spec.lon,
          # Each spot needs a distinct (band, sender, receiver) so
          # the unique index doesn't collapse them.
          sender_grid: "S#{base_callsign}#{idx}#{s_idx}",
          receiver_grid: "R#{base_callsign}#{idx}#{s_idx}"
        })
      end)
    end)
  end

  defp task_count, do: Repo.aggregate(from(t in "hrrr_fetch_tasks"), :count)

  defp task_points do
    case Repo.one(from(t in "hrrr_fetch_tasks", select: t.points)) do
      nil -> []
      pts -> Enum.map(pts, fn %{"lat" => la, "lon" => lo} -> {la, lo} end)
    end
  end

  # 0.125° snap matches CalibrationSampler @grid_step_deg.
  defp snap_cell(lat), do: Float.round(lat / 0.125) * 0.125

  defp expected_unique_cells(specs) do
    specs
    |> Enum.map(fn s -> {s.band, snap_cell(s.lat), snap_cell(s.lon)} end)
    |> Enum.uniq()
  end

  # ── Properties ──────────────────────────────────────────────────

  property "no HRRR profile inserted: every distinct cell midpoint is enqueued exactly once" do
    check all(specs <- list_of(cell_spec_gen(), min_length: 1, max_length: 6), max_runs: 15) do
      Repo.delete_all(SpotHourly)
      Repo.delete_all(CalibrationSample)
      Repo.delete_all(HrrrProfile)
      Repo.query!("DELETE FROM hrrr_fetch_tasks")
      seed_spots!(specs, "A")

      CalibrationSampler.build_for_hour(@hour)

      # One row per (band, snapped_cell) goes into samples.
      assert Repo.aggregate(CalibrationSample, :count) == length(expected_unique_cells(specs))

      # Exactly one hrrr_fetch_tasks row (one valid_time = one row by
      # unique index). The point list inside it must equal the unique
      # set of cell midpoints rounded to the HRRR grid.
      assert task_count() == 1

      expected_pts =
        specs
        |> Enum.map(fn s -> {snap_cell(s.lat), snap_cell(s.lon)} end)
        |> Enum.map(fn {la, lo} -> {Float.round(la, 2), Float.round(lo, 2)} end)
        |> Enum.uniq()

      got = Enum.map(task_points(), fn {la, lo} -> {Float.round(la, 2), Float.round(lo, 2)} end)
      assert Enum.sort(got) == Enum.sort(expected_pts)
    end
  end

  property "every cell covered by HRRR: nothing is enqueued" do
    check all(specs <- list_of(cell_spec_gen(), min_length: 1, max_length: 5), max_runs: 12) do
      Repo.delete_all(SpotHourly)
      Repo.delete_all(CalibrationSample)
      Repo.delete_all(HrrrProfile)
      Repo.query!("DELETE FROM hrrr_fetch_tasks")
      seed_spots!(specs, "B")

      # Plant an HRRR profile near every snapped cell midpoint so
      # nearest_hrrr finds a match for all cells.
      specs
      |> Enum.map(fn s -> {snap_cell(s.lat), snap_cell(s.lon)} end)
      |> Enum.uniq()
      |> Enum.each(fn {la, lo} -> insert_hrrr!(la, lo) end)

      CalibrationSampler.build_for_hour(@hour)

      assert task_count() == 0
      # Samples still get written, just with non-NULL HRRR fields.
      assert Repo.aggregate(CalibrationSample, :count) == length(expected_unique_cells(specs))
    end
  end

  property "sample row count is invariant under HRRR availability" do
    check all(specs <- list_of(cell_spec_gen(), min_length: 1, max_length: 5), max_runs: 12) do
      # No HRRR
      Repo.delete_all(SpotHourly)
      Repo.delete_all(CalibrationSample)
      Repo.delete_all(HrrrProfile)
      Repo.query!("DELETE FROM hrrr_fetch_tasks")
      seed_spots!(specs, "C")
      CalibrationSampler.build_for_hour(@hour)
      no_hrrr_count = Repo.aggregate(CalibrationSample, :count)

      # Reset, plant HRRR, re-run.
      Repo.delete_all(SpotHourly)
      Repo.delete_all(CalibrationSample)
      Repo.query!("DELETE FROM hrrr_fetch_tasks")
      seed_spots!(specs, "D")

      specs
      |> Enum.map(fn s -> {snap_cell(s.lat), snap_cell(s.lon)} end)
      |> Enum.uniq()
      |> Enum.each(fn {la, lo} -> insert_hrrr!(la, lo) end)

      CalibrationSampler.build_for_hour(@hour)
      with_hrrr_count = Repo.aggregate(CalibrationSample, :count)

      assert no_hrrr_count == with_hrrr_count
    end
  end
end
