defmodule Microwaveprop.Propagation.ScorerPropertyTest do
  @moduledoc """
  Property tests for the pure scoring functions in
  `Microwaveprop.Propagation.Scorer`. Each property exercises ONE
  invariant — composite score bounds, per-factor bounds, monotonicity
  where expected, and the nil-handling contract.
  """

  use ExUnit.Case, async: true
  use ExUnitProperties

  alias Microwaveprop.Propagation.BandConfig
  alias Microwaveprop.Propagation.Scorer

  describe "temperature conversions" do
    property "f_to_c and c_to_f are inverses up to float epsilon" do
      check all(c <- float(min: -100.0, max: 150.0)) do
        round_tripped = c |> Scorer.c_to_f() |> Scorer.f_to_c()
        assert_in_delta round_tripped, c, 1.0e-9
      end
    end

    property "f_to_c and c_to_f propagate nil (scorer nil-pass contract)" do
      assert Scorer.f_to_c(nil) == nil
      assert Scorer.c_to_f(nil) == nil
    end

    property "f_to_c is linear (a F increase of ΔF maps to (5/9)·ΔF C)" do
      check all(
              base <- float(min: -50.0, max: 120.0),
              delta <- float(min: -30.0, max: 30.0)
            ) do
        d_c = Scorer.f_to_c(base + delta) - Scorer.f_to_c(base)
        assert_in_delta d_c, delta * 5.0 / 9.0, 1.0e-9
      end
    end
  end

  describe "wind_speed_kts/2" do
    property "is non-negative and propagates nil" do
      assert Scorer.wind_speed_kts(nil, 1.0) == nil
      assert Scorer.wind_speed_kts(1.0, nil) == nil

      check all(
              u <- float(min: -30.0, max: 30.0),
              v <- float(min: -30.0, max: 30.0)
            ) do
        kts = Scorer.wind_speed_kts(u, v)
        assert is_number(kts)
        assert kts >= 0.0
      end
    end

    property "magnitude equals sqrt(u²+v²) × m/s→kts conversion" do
      # Scorer uses the 5-digit 1.94384 factor (not the full-precision
      # 1.9438444924...). That's ~0.001% off, which tops out at a few
      # millimetres per hour on a 60 kt wind — too small to matter for
      # propagation scoring.
      check all(
              u <- float(min: -20.0, max: 20.0),
              v <- float(min: -20.0, max: 20.0)
            ) do
        speed_ms = :math.sqrt(u * u + v * v)
        expected = speed_ms * 1.94384
        assert_in_delta Scorer.wind_speed_kts(u, v), expected, 1.0e-6
      end
    end
  end

  describe "precip_to_rate_mmhr/1 and dbz_to_rain_rate_mmhr/1" do
    property "precip_to_rate_mmhr is non-negative and 0-valued for nil/0" do
      assert Scorer.precip_to_rate_mmhr(nil) == 0.0
      assert Scorer.precip_to_rate_mmhr(0.0) == 0.0
      assert Scorer.precip_to_rate_mmhr(-1.0) == 0.0

      check all(mm <- float(min: 0.01, max: 200.0)) do
        rate = Scorer.precip_to_rate_mmhr(mm)
        assert rate >= 0.0
        assert rate == mm
      end
    end

    property "dbz_to_rain_rate_mmhr is 0 below 5 dBZ and monotonic non-decreasing above" do
      check all(dbz <- float(min: -30.0, max: 4.99)) do
        assert Scorer.dbz_to_rain_rate_mmhr(dbz) == 0.0
      end

      assert Scorer.dbz_to_rain_rate_mmhr(nil) == 0.0

      check all(
              a <- float(min: 5.0, max: 60.0),
              delta <- float(min: 0.0, max: 30.0)
            ) do
        b = a + delta

        assert Scorer.dbz_to_rain_rate_mmhr(a) <= Scorer.dbz_to_rain_rate_mmhr(b) + 1.0e-9
      end
    end
  end

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

  # All configured bands — harmful (24+ GHz) and beneficial (10 GHz and
  # below) are both represented. A property that needs the opposite
  # humidity effect can filter after generating.
  defp band_config_gen do
    StreamData.member_of(BandConfig.all_bands())
  end

  defp beneficial_band_gen do
    StreamData.bind(band_config_gen(), fn band ->
      if band.humidity_effect == :beneficial do
        StreamData.constant(band)
      else
        StreamData.filter(band_config_gen(), &(&1.humidity_effect == :beneficial))
      end
    end)
  end

  defp harmful_band_gen do
    StreamData.bind(band_config_gen(), fn band ->
      if band.humidity_effect == :harmful do
        StreamData.constant(band)
      else
        StreamData.filter(band_config_gen(), &(&1.humidity_effect == :harmful))
      end
    end)
  end

  defp latitude_gen, do: StreamData.float(min: 25.0, max: 50.0)
  defp longitude_gen, do: StreamData.float(min: -125.0, max: -66.0)
  defp month_gen, do: StreamData.integer(1..12)
  defp hour_gen, do: StreamData.integer(0..23)
  defp minute_gen, do: StreamData.integer(0..59)

  # A self-consistent conditions map accepted by `composite_score/2`.
  # Dewpoint is bounded above by temperature so the T-Td depression
  # never goes negative.
  defp conditions_gen do
    gen all(
          temp_f <- StreamData.float(min: -20.0, max: 110.0),
          # 0..60°F spread
          depression <- StreamData.float(min: 0.0, max: 60.0),
          wind_ms <- StreamData.float(min: 0.0, max: 40.0),
          sky_pct <- StreamData.float(min: 0.0, max: 100.0),
          pressure_mb <- StreamData.float(min: 950.0, max: 1050.0),
          rain_mmhr <- StreamData.float(min: 0.0, max: 80.0),
          pwat_mm <- StreamData.float(min: 0.0, max: 60.0),
          grad <- StreamData.float(min: -400.0, max: 50.0),
          hpbl <- StreamData.float(min: 50.0, max: 3000.0),
          month <- month_gen(),
          hour <- hour_gen(),
          minute <- minute_gen(),
          lat <- latitude_gen(),
          lon <- longitude_gen()
        ) do
      dewpoint_f = temp_f - depression
      temp_c = (temp_f - 32) * 5 / 9
      dew_c = (dewpoint_f - 32) * 5 / 9

      %{
        abs_humidity: Scorer.absolute_humidity(temp_c, dew_c),
        temp_f: temp_f,
        dewpoint_f: dewpoint_f,
        wind_speed_kts: wind_ms * 1.94384,
        sky_cover_pct: sky_pct,
        utc_hour: hour,
        utc_minute: minute,
        month: month,
        latitude: lat,
        longitude: lon,
        pressure_mb: pressure_mb,
        prev_pressure_mb: nil,
        rain_rate_mmhr: rain_mmhr,
        min_refractivity_gradient: grad,
        bl_depth_m: hpbl,
        pwat_mm: pwat_mm
      }
    end
  end

  # ── score_humidity/2 ────────────────────────────────────────────

  property "score_humidity is always in 0..100 (beneficial bands)" do
    check all(
            ah <- StreamData.float(min: 0.0, max: 40.0),
            band <- beneficial_band_gen()
          ) do
      score = Scorer.score_humidity(ah, band)
      assert is_integer(score)
      assert score in 0..100
    end
  end

  property "score_humidity is always in 0..100 (harmful bands)" do
    check all(
            ah <- StreamData.float(min: 0.0, max: 40.0),
            band <- harmful_band_gen()
          ) do
      score = Scorer.score_humidity(ah, band)
      assert is_integer(score)
      assert score in 0..100
    end
  end

  property "score_humidity is monotonically non-increasing in humidity (harmful bands)" do
    # More absolute humidity → more absorption → never a higher score.
    check all(
            band <- harmful_band_gen(),
            ah_lo <- StreamData.float(min: 0.0, max: 20.0),
            delta <- StreamData.float(min: 0.0, max: 20.0)
          ) do
      ah_hi = ah_lo + delta
      assert Scorer.score_humidity(ah_hi, band) <= Scorer.score_humidity(ah_lo, band)
    end
  end

  # ── score_td_depression/3 ───────────────────────────────────────

  property "score_td_depression always in 0..100 for any band" do
    check all(
            temp_f <- StreamData.float(min: -40.0, max: 120.0),
            dewpoint_f <- StreamData.float(min: -50.0, max: 120.0),
            band <- band_config_gen()
          ) do
      score = Scorer.score_td_depression(temp_f, dewpoint_f, band)
      assert is_integer(score)
      assert score in 0..100
    end
  end

  property "score_td_depression is monotonically non-decreasing in depression (harmful bands)" do
    # Dry air (wide T-Td depression) reduces water-vapour absorption.
    check all(
            band <- harmful_band_gen(),
            temp_f <- StreamData.float(min: 20.0, max: 100.0),
            dep_lo <- StreamData.float(min: 0.0, max: 30.0),
            delta <- StreamData.float(min: 0.0, max: 20.0)
          ) do
      dep_hi = dep_lo + delta
      score_lo = Scorer.score_td_depression(temp_f, temp_f - dep_lo, band)
      score_hi = Scorer.score_td_depression(temp_f, temp_f - dep_hi, band)
      assert score_hi >= score_lo
    end
  end

  # ── score_refractivity/4,5 ──────────────────────────────────────

  property "score_refractivity returns 50 for nil gradient regardless of other inputs" do
    check all(
            bl <- StreamData.one_of([StreamData.constant(nil), StreamData.float(min: 0.0, max: 3000.0)]),
            band <- band_config_gen()
          ) do
      assert Scorer.score_refractivity(nil, bl, band) == 50
    end
  end

  property "score_refractivity is in 0..100 for any gradient / band / HPBL / duct / richardson" do
    check all(
            grad <- StreamData.float(min: -800.0, max: 200.0),
            bl <- StreamData.one_of([StreamData.constant(nil), StreamData.float(min: 0.0, max: 3000.0)]),
            duct_ghz <- StreamData.one_of([StreamData.constant(nil), StreamData.float(min: 0.0, max: 300.0)]),
            richardson <-
              StreamData.one_of([StreamData.constant(nil), StreamData.float(min: 0.0, max: 100.0)]),
            band <- band_config_gen()
          ) do
      score = Scorer.score_refractivity(grad, bl, duct_ghz, richardson, band)
      assert is_integer(score)
      assert score in 0..100
    end
  end

  # ── score_sky/1 ─────────────────────────────────────────────────

  property "score_sky always in 0..100 and monotonically non-increasing in cloudiness" do
    check all(
            pct_lo <- StreamData.float(min: 0.0, max: 100.0),
            delta <- StreamData.float(min: 0.0, max: 100.0)
          ) do
      pct_hi = min(100.0, pct_lo + delta)
      s_lo = Scorer.score_sky(pct_lo)
      s_hi = Scorer.score_sky(pct_hi)
      assert s_lo in 0..100
      assert s_hi in 0..100
      assert s_hi <= s_lo
    end
  end

  # ── score_wind/1 ────────────────────────────────────────────────

  property "score_wind always in 0..100 and monotonically non-increasing in wind speed" do
    check all(
            lo <- StreamData.float(min: 0.0, max: 60.0),
            delta <- StreamData.float(min: 0.0, max: 60.0)
          ) do
      hi = lo + delta
      s_lo = Scorer.score_wind(lo)
      s_hi = Scorer.score_wind(hi)
      assert s_lo in 0..100
      assert s_hi in 0..100
      assert s_hi <= s_lo
    end
  end

  # ── score_rain/2 ────────────────────────────────────────────────

  property "score_rain returns 100 for no rain (nil or 0) across every band" do
    check all(band <- band_config_gen()) do
      assert Scorer.score_rain(nil, band) == 100
      assert Scorer.score_rain(0, band) == 100
      assert Scorer.score_rain(0.0, band) == 100
    end
  end

  property "score_rain always in 0..100 and monotonically non-increasing in rain rate" do
    check all(
            band <- band_config_gen(),
            rate_lo <- StreamData.float(min: 0.0, max: 40.0),
            delta <- StreamData.float(min: 0.0, max: 40.0)
          ) do
      rate_hi = rate_lo + delta
      s_lo = Scorer.score_rain(rate_lo, band)
      s_hi = Scorer.score_rain(rate_hi, band)
      assert s_lo in 0..100
      assert s_hi in 0..100
      assert s_hi <= s_lo
    end
  end

  # ── score_pwat/2 ────────────────────────────────────────────────

  property "score_pwat always in 0..100 for any band" do
    check all(
            pwat <- StreamData.float(min: 0.0, max: 80.0),
            band <- band_config_gen()
          ) do
      score = Scorer.score_pwat(pwat, band)
      assert is_integer(score)
      assert score in 0..100
    end
  end

  property "score_pwat returns 60 for nil input on every band" do
    check all(band <- band_config_gen()) do
      assert Scorer.score_pwat(nil, band) == 60
    end
  end

  # ── score_pressure/2 ────────────────────────────────────────────

  property "score_pressure always in 0..100" do
    check all(
            current <- StreamData.one_of([StreamData.constant(nil), StreamData.float(min: 900.0, max: 1080.0)]),
            previous <- StreamData.one_of([StreamData.constant(nil), StreamData.float(min: 900.0, max: 1080.0)])
          ) do
      score = Scorer.score_pressure(current, previous)
      assert is_integer(score)
      assert score in 0..100
    end
  end

  # ── score_season/4 ──────────────────────────────────────────────

  property "score_season always in 0..100 for any band/month/point" do
    check all(
            month <- month_gen(),
            lat <- latitude_gen(),
            lon <- longitude_gen(),
            band <- band_config_gen()
          ) do
      score = Scorer.score_season(month, lat, lon, band)
      assert is_integer(score)
      assert score in 0..100
    end
  end

  property "score_season accepts nil lat/lon (no regional adjustment)" do
    check all(
            month <- month_gen(),
            band <- band_config_gen()
          ) do
      score = Scorer.score_season(month, nil, nil, band)
      assert is_integer(score)
      assert score in 0..100
    end
  end

  # ── score_time_of_day/4 ─────────────────────────────────────────

  property "score_time_of_day always in 0..100 and returns a string label" do
    check all(
            hour <- hour_gen(),
            minute <- minute_gen(),
            month <- month_gen(),
            lon <- longitude_gen()
          ) do
      {score, label} = Scorer.score_time_of_day(hour, minute, month, lon)
      assert score in 0..100
      assert is_binary(label)
      assert label != ""
    end
  end

  # ── composite_score/2 ───────────────────────────────────────────

  property "composite_score is integer in 0..100 with a factors map" do
    check all(
            conds <- conditions_gen(),
            band <- band_config_gen()
          ) do
      %{score: score, factors: factors} = Scorer.composite_score(conds, band)
      assert is_integer(score)
      assert score in 0..100
      assert is_map(factors)

      Enum.each(factors, fn {_factor, value} ->
        assert is_integer(value)
        assert value in 0..100
      end)
    end
  end

  property "composite_score agrees regardless of whether band invariants are pre-computed" do
    check all(
            conds <- conditions_gen(),
            band <- band_config_gen()
          ) do
      direct = Scorer.composite_score(conds, band)
      hoisted_conds = Map.merge(conds, Scorer.precompute_band_invariants(conds))
      via_hoist = Scorer.composite_score(hoisted_conds, band)

      assert direct.score == via_hoist.score
      assert direct.factors == via_hoist.factors
    end
  end

  # ── commercial_link_boost/2 ─────────────────────────────────────

  property "commercial_link_boost never decreases the score and stays in 0..100" do
    check all(
            base <- StreamData.integer(0..100),
            db <- StreamData.float(min: 0.0, max: 30.0),
            n_links <- StreamData.integer(0..10)
          ) do
      deg = %{degradation_db: db, n_links: n_links}
      boosted = Scorer.commercial_link_boost(base, deg)
      assert boosted in 0..100
      assert boosted >= base
    end
  end

  property "commercial_link_boost with nil or zero-link map is a no-op (clamped to 0..100)" do
    check all(base <- StreamData.integer(-50..150)) do
      expected = base |> max(0) |> min(100)
      assert Scorer.commercial_link_boost(base, nil) == expected
      assert Scorer.commercial_link_boost(base, %{n_links: 0}) == expected
    end
  end

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

  property "dbz_to_rain_rate_mmhr clips below 5 dBZ and caps at 150 mm/hr" do
    check all(dbz <- StreamData.float(min: -20.0, max: 80.0)) do
      rate = Scorer.dbz_to_rain_rate_mmhr(dbz)
      assert is_float(rate)
      assert rate >= 0.0
      assert rate <= 150.0
      if dbz < 5.0, do: assert(rate == 0.0)
    end
  end

  property "precip_to_rate_mmhr is 0 for nil / non-positive and passes through positives" do
    check all(mm <- StreamData.float(min: -10.0, max: 50.0)) do
      rate = Scorer.precip_to_rate_mmhr(mm)
      assert is_float(rate)
      assert rate >= 0.0

      if mm > 0 do
        assert rate == mm / 1
      else
        assert rate == 0.0
      end
    end
  end

  property "f_to_c and c_to_f are mutual inverses" do
    check all(f <- StreamData.float(min: -100.0, max: 200.0)) do
      roundtrip = f |> Scorer.f_to_c() |> Scorer.c_to_f()
      assert_in_delta roundtrip, f, 1.0e-9
    end
  end

  property "wind_speed_kts scales with Euclidean norm of (u, v)" do
    check all(
            u <- StreamData.float(min: -30.0, max: 30.0),
            v <- StreamData.float(min: -30.0, max: 30.0)
          ) do
      expected = :math.sqrt(u * u + v * v) * 1.94384
      assert_in_delta Scorer.wind_speed_kts(u, v), expected, 1.0e-9
    end
  end
end
