defmodule Microwaveprop.Propagation.BandConfigPropertyTest do
  @moduledoc """
  Property tests for `Microwaveprop.Propagation.BandConfig` —
  exercises the data-driven invariants that the scorer relies on
  (every band has the right shape; weights sum to 1.0; seasonal
  tables cover all 12 months).
  """

  use ExUnit.Case, async: true
  use ExUnitProperties

  alias Microwaveprop.Propagation.BandConfig

  defp band_config_gen, do: StreamData.member_of(BandConfig.all_bands())
  defp freq_gen, do: StreamData.member_of(BandConfig.all_freqs())
  defp month_gen, do: StreamData.integer(1..12)

  property "get/1 returns a well-formed struct for every supported frequency" do
    check all(freq <- freq_gen()) do
      band = BandConfig.get(freq)
      assert is_map(band)
      assert band.freq_mhz == freq
      assert is_binary(band.label)
      assert band.humidity_effect in [:beneficial, :harmful]
      assert is_map(band.seasonal_base)
      assert is_map(band.seasonal_adj)
    end
  end

  property "every band's seasonal_base covers all 12 months with 0..100 integers" do
    check all(band <- band_config_gen()) do
      Enum.each(1..12, fn month ->
        value = Map.fetch!(band.seasonal_base, month)
        assert is_integer(value)
        assert value in 0..100
      end)
    end
  end

  property "weights/1 returns a map whose values sum to ~1.0" do
    check all(band <- band_config_gen()) do
      weights = BandConfig.weights(band)
      total = weights |> Map.values() |> Enum.sum()
      assert_in_delta total, 1.0, 1.0e-3
    end
  end

  property "weights/1 covers exactly the ten scoring factors" do
    # The scorer reduces over a factors map with these keys; if a band
    # ever dropped one, `Map.fetch!` in `composite_score/2` would crash.
    expected = MapSet.new(~w(humidity time_of_day td_depression refractivity sky season wind rain pressure pwat)a)

    check all(band <- band_config_gen()) do
      keys = band |> BandConfig.weights() |> Map.keys() |> MapSet.new()
      assert keys == expected
    end
  end

  property "weights/1 accepts nil and maps without override, returning the global defaults" do
    defaults = BandConfig.weights()

    check all(band <- band_config_gen()) do
      # A band without its own `:weights` falls through to defaults.
      if Map.has_key?(band, :weights) do
        assert is_map(BandConfig.weights(band))
      else
        assert BandConfig.weights(band) == defaults
      end
    end

    assert BandConfig.weights(nil) == defaults
  end

  property "unknown frequencies return nil from get/1" do
    known = MapSet.new(BandConfig.all_freqs())

    check all(freq <- StreamData.integer(1..1_000_000), freq not in known) do
      assert BandConfig.get(freq) == nil
    end
  end

  property "sunrise_table returns 12 numeric entries in a plausible hour range" do
    table = BandConfig.sunrise_table()
    assert length(table) == 12

    check all(idx <- StreamData.integer(0..11)) do
      entry = Enum.at(table, idx)
      assert is_number(entry)
      assert entry >= 4.0 and entry <= 9.0
    end
  end

  property "humidity_beneficial_thresholds are strictly increasing in the threshold value" do
    thresholds = BandConfig.humidity_beneficial_thresholds()
    cutoffs = Enum.map(thresholds, fn {cutoff, _score} -> cutoff end)
    assert cutoffs == Enum.sort(cutoffs)
    assert length(Enum.uniq(cutoffs)) == length(cutoffs)
  end

  property "refractivity_thresholds are strictly decreasing (more negative gradient first)" do
    # `find_refractivity_threshold/3` walks the list and picks the
    # first cutoff greater than the observed gradient — the ordering
    # is what makes that correct.
    cutoffs =
      Enum.map(BandConfig.refractivity_thresholds(), fn {cutoff, _b, _h} -> cutoff end)

    assert cutoffs == Enum.sort(cutoffs)
    assert length(Enum.uniq(cutoffs)) == length(cutoffs)
  end

  property "tiers are ordered descending by min_score and cover 0..100" do
    tiers = BandConfig.tiers()
    min_scores = Enum.map(tiers, & &1.min_score)
    assert min_scores == Enum.sort(min_scores, :desc)
    assert List.last(min_scores) == 0
    assert hd(min_scores) <= 100

    check all(s <- StreamData.integer(0..100)) do
      # Every score in 0..100 matches at least one tier.
      assert Enum.any?(tiers, fn tier -> s >= tier.min_score end)
    end
  end

  property "band_options pairs each band label with its stringified freq_mhz" do
    options = BandConfig.band_options()
    assert length(options) == length(BandConfig.all_bands())

    check all({label, value} <- StreamData.member_of(options)) do
      assert is_binary(label)
      assert is_binary(value)
      {freq, ""} = Integer.parse(value)
      band = BandConfig.get(freq)
      assert band.label == label
    end
  end

  property "every month has a non-negative seasonal_adj value for every band" do
    check all(band <- band_config_gen(), month <- month_gen()) do
      # The adjustment map is sparse — nil for missing entries is fine,
      # but any explicit entry must be a number.
      case Map.get(band.seasonal_adj, month) do
        nil -> :ok
        value -> assert is_number(value)
      end
    end
  end
end
