defmodule Microwaveprop.Propagation.ScoresFilePropertyTest do
  @moduledoc """
  Property tests for `Microwaveprop.Propagation.ScoresFile.extract_points/2`.

  No IO: we construct the decoded-payload map directly (matching the
  shape `decode/1` returns) so these tests stay in the pure regime.
  """

  use ExUnit.Case, async: true
  use ExUnitProperties

  alias Microwaveprop.Propagation.ScoresFile

  @no_data 255

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

  defp grid_gen do
    gen all(
          lat_min <- float(min: 20.0, max: 30.0),
          lon_min <- float(min: -130.0, max: -100.0),
          step <- member_of([0.125, 0.25, 0.5]),
          n_rows <- integer(2..8),
          n_cols <- integer(2..8)
        ) do
      %{lat_min: lat_min, lon_min: lon_min, step: step, n_rows: n_rows, n_cols: n_cols}
    end
  end

  # Score bytes include the no-data sentinel (255) so `extract_points`
  # has something to skip.
  defp scores_body_gen(n_bytes) do
    list_of(integer(0..255), length: n_bytes)
  end

  defp payload_gen do
    gen all(
          grid <- grid_gen(),
          body_bytes <- scores_body_gen(grid.n_rows * grid.n_cols)
        ) do
      body = :erlang.list_to_binary(body_bytes)

      %{
        band_mhz: 10_000,
        valid_time: ~U[2026-04-14 18:00:00Z],
        lat_min: grid.lat_min,
        lon_min: grid.lon_min,
        step: grid.step,
        n_rows: grid.n_rows,
        n_cols: grid.n_cols,
        scores: body
      }
    end
  end

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

  property "extract_points returns every non-sentinel cell when bounds are nil" do
    check all(payload <- payload_gen()) do
      points = ScoresFile.extract_points(payload, nil)
      expected_count = payload.scores |> :binary.bin_to_list() |> Enum.count(&(&1 != @no_data))
      assert length(points) == expected_count
    end
  end

  property "every returned point has an integer score in 0..100 and lands on the grid" do
    check all(payload <- payload_gen()) do
      points = ScoresFile.extract_points(payload, nil)

      Enum.each(points, fn %{lat: lat, lon: lon, score: score} ->
        assert is_integer(score)
        # Bodies include 0..254 (we filter 255); 101..254 are permitted
        # bytes even if the scoring pipeline never writes them. What we
        # care about for extraction is that bytes round-trip unchanged.
        assert score in 0..254

        # lat/lon fall on the declared grid.
        row = round((lat - payload.lat_min) / payload.step)
        col = round((lon - payload.lon_min) / payload.step)
        assert row in 0..(payload.n_rows - 1)
        assert col in 0..(payload.n_cols - 1)
      end)
    end
  end

  property "extract_points is idempotent under lat/lon round-tripping back into byte offsets" do
    check all(payload <- payload_gen()) do
      points = ScoresFile.extract_points(payload, nil)

      Enum.each(points, fn %{lat: lat, lon: lon, score: score} ->
        row = round((lat - payload.lat_min) / payload.step)
        col = round((lon - payload.lon_min) / payload.step)
        offset = row * payload.n_cols + col
        assert :binary.at(payload.scores, offset) == score
      end)
    end
  end

  property "bounds-filtered output is always a subset of the unbounded output" do
    check all(
            payload <- payload_gen(),
            south_idx <- integer(0..7),
            north_idx <- integer(0..7),
            west_idx <- integer(0..7),
            east_idx <- integer(0..7)
          ) do
      south = payload.lat_min + south_idx * payload.step
      north = payload.lat_min + north_idx * payload.step
      west = payload.lon_min + west_idx * payload.step
      east = payload.lon_min + east_idx * payload.step

      bounds = %{south: south, north: north, west: west, east: east}
      all_points = ScoresFile.extract_points(payload, nil)
      sub_points = ScoresFile.extract_points(payload, bounds)

      all_set = MapSet.new(all_points)
      sub_set = MapSet.new(sub_points)

      assert MapSet.subset?(sub_set, all_set)
    end
  end

  property "bounds with string keys behave identically to atom keys" do
    check all(payload <- payload_gen()) do
      south = payload.lat_min
      north = payload.lat_min + (payload.n_rows - 1) * payload.step
      west = payload.lon_min
      east = payload.lon_min + (payload.n_cols - 1) * payload.step

      atom_bounds = %{south: south, north: north, west: west, east: east}
      string_bounds = %{"south" => south, "north" => north, "west" => west, "east" => east}

      assert MapSet.new(ScoresFile.extract_points(payload, atom_bounds)) ==
               MapSet.new(ScoresFile.extract_points(payload, string_bounds))
    end
  end

  property "a grid whose body is entirely no-data yields an empty point list" do
    check all(grid <- grid_gen()) do
      empty_body = :binary.copy(<<@no_data>>, grid.n_rows * grid.n_cols)

      payload = %{
        band_mhz: 10_000,
        valid_time: ~U[2026-04-14 18:00:00Z],
        lat_min: grid.lat_min,
        lon_min: grid.lon_min,
        step: grid.step,
        n_rows: grid.n_rows,
        n_cols: grid.n_cols,
        scores: empty_body
      }

      assert ScoresFile.extract_points(payload, nil) == []
    end
  end
end
