defmodule Microwaveprop.Terrain.TerrainAnalysisPropertyTest do
  @moduledoc """
  StreamData property tests for ITU-R P.526-16 diffraction math.

  Each property encodes a single invariant of the pure-math core of
  `Microwaveprop.Terrain.TerrainAnalysis`. The scenarios are
  constructed so the expected bound is physically meaningful — e.g.
  knife-edge loss is never negative regardless of ν, diffraction loss
  vanishes on an unobstructed path, fresnel radius is symmetric in its
  distance arguments, and the P.526 piecewise threshold at ν = −0.78
  is a true zero floor.
  """
  use ExUnit.Case, async: true
  use ExUnitProperties

  alias Microwaveprop.Terrain.TerrainAnalysis

  describe "knife_edge_loss/1" do
    property "is non-negative for every real ν" do
      check all(nu <- float(min: -50.0, max: 50.0)) do
        assert TerrainAnalysis.knife_edge_loss(nu) >= 0
      end
    end

    property "is exactly 0 for ν ≤ −0.78 (P.526 piecewise branch)" do
      check all(nu <- float(min: -50.0, max: -0.78)) do
        assert TerrainAnalysis.knife_edge_loss(nu) == 0
      end
    end

    property "is monotonically non-decreasing in ν above the cutoff" do
      check all(
              a <- float(min: -0.7, max: 20.0),
              delta <- float(min: 0.0001, max: 5.0)
            ) do
        loss_a = TerrainAnalysis.knife_edge_loss(a)
        loss_b = TerrainAnalysis.knife_edge_loss(a + delta)
        assert loss_b >= loss_a
      end
    end
  end

  describe "diffraction_param/4" do
    property "sign of ν matches sign of h when distances and λ are positive" do
      check all(
              h <- float(min: -500.0, max: 500.0),
              d1 <- float(min: 1.0, max: 100_000.0),
              d2 <- float(min: 1.0, max: 100_000.0),
              lambda <- float(min: 0.001, max: 1.0)
            ) do
        nu = TerrainAnalysis.diffraction_param(h, d1, d2, lambda)

        cond do
          h > 0 -> assert nu > 0
          h < 0 -> assert nu < 0
          true -> assert nu == 0.0
        end
      end
    end

    property "degenerate inputs (d ≤ 0 or λ ≤ 0) return 0.0" do
      check all(
              h <- float(min: -500.0, max: 500.0),
              bad <- float(min: -10.0, max: 0.0),
              good <- float(min: 1.0, max: 1_000.0),
              lambda <- float(min: 0.001, max: 1.0)
            ) do
        assert TerrainAnalysis.diffraction_param(h, bad, good, lambda) == 0.0
        assert TerrainAnalysis.diffraction_param(h, good, bad, lambda) == 0.0
        assert TerrainAnalysis.diffraction_param(h, good, good, bad) == 0.0
      end
    end
  end

  describe "fresnel_radius/3" do
    property "is non-negative and symmetric in d1 and d2" do
      check all(
              d1 <- float(min: 1.0, max: 100_000.0),
              d2 <- float(min: 1.0, max: 100_000.0),
              lambda <- float(min: 0.001, max: 1.0)
            ) do
        r1 = TerrainAnalysis.fresnel_radius(d1, d2, lambda)
        r2 = TerrainAnalysis.fresnel_radius(d2, d1, lambda)
        assert r1 >= 0
        assert_in_delta r1, r2, 1.0e-9
      end
    end

    property "returns 0 when either distance is non-positive" do
      check all(
              bad <- float(min: -10.0, max: 0.0),
              good <- float(min: 1.0, max: 1_000.0),
              lambda <- float(min: 0.001, max: 1.0)
            ) do
        assert TerrainAnalysis.fresnel_radius(bad, good, lambda) == 0
        assert TerrainAnalysis.fresnel_radius(good, bad, lambda) == 0
      end
    end
  end

  describe "earth_bulge/3" do
    property "is non-negative for fractions in [0, 1] and positive k" do
      check all(
              frac <- float(min: 0.0, max: 1.0),
              dist_km <- float(min: 0.0, max: 500.0),
              k <- float(min: 0.5, max: 10.0)
            ) do
        assert TerrainAnalysis.earth_bulge(frac, dist_km, k) >= 0
      end
    end

    property "is zero at both endpoints regardless of distance or k" do
      check all(
              dist_km <- float(min: 0.0, max: 500.0),
              k <- float(min: 0.5, max: 10.0)
            ) do
        assert TerrainAnalysis.earth_bulge(0.0, dist_km, k) == 0.0
        assert TerrainAnalysis.earth_bulge(1.0, dist_km, k) == 0.0
      end
    end

    property "is symmetric around the midpoint" do
      check all(
              frac <- float(min: 0.0, max: 0.5),
              dist_km <- float(min: 1.0, max: 500.0),
              k <- float(min: 0.5, max: 10.0)
            ) do
        left = TerrainAnalysis.earth_bulge(frac, dist_km, k)
        right = TerrainAnalysis.earth_bulge(1.0 - frac, dist_km, k)
        assert_in_delta left, right, 1.0e-9
      end
    end
  end

  describe "k_factor/1" do
    property "returns a positive value for any dN/dh" do
      check all(dn_dh <- float(min: -500.0, max: 500.0)) do
        assert TerrainAnalysis.k_factor(dn_dh) > 0
      end
    end

    property "sub-refractive gradients (dN/dh > 0) yield k < 1" do
      check all(dn_dh <- float(min: 1.0, max: 500.0)) do
        assert TerrainAnalysis.k_factor(dn_dh) < 1.0
      end
    end

    property "super-refractive gradients (dN/dh < 0, above ducting cap) yield k > 1" do
      # Stay well above the -157 ducting threshold so the 100.0 cap doesn't kick in.
      check all(dn_dh <- float(min: -100.0, max: -1.0)) do
        assert TerrainAnalysis.k_factor(dn_dh) > 1.0
      end
    end
  end

  describe "analyse/4 invariants" do
    # Flat sea-level terrain with both antennas raised high enough to
    # clear the midpoint earth bulge *and* the first Fresnel zone: the
    # beam must be unobstructed and produce zero diffraction loss.
    # Antenna floor is chosen from the worst-case (max dist, max freq)
    # so every generated case is guaranteed physically clear.
    property "clear LoS (flat terrain, high antennas) produces 0 diffraction loss" do
      max_dist_km = 50.0
      min_freq_ghz = 5.0
      # Midpoint earth bulge + first-Fresnel clearance with margin.
      # bulge(0.5, 50km) ≈ 36.8m; r1(25km, 25km, 0.06m) ≈ 27.4m → ~64m.
      ant_floor_m = 120.0

      check all(
              n_segs <- integer(4..20),
              dist_km <- float(min: 5.0, max: max_dist_km),
              freq_ghz <- float(min: min_freq_ghz, max: 50.0),
              extra_h <- float(min: 0.0, max: 400.0)
            ) do
        profile = flat_profile(n_segs, dist_km)
        ant_h = ant_floor_m + extra_h

        result =
          TerrainAnalysis.analyse(profile, dist_km, freq_ghz, ant_ht_a: ant_h, ant_ht_b: ant_h)

        assert result.diffraction_db == 0
        assert result.obstructed_count == 0
        assert result.verdict in ["CLEAR", "FRESNEL_MINOR"]
      end
    end

    property "diffraction loss is always non-negative" do
      check all(
              n_segs <- integer(4..12),
              dist_km <- float(min: 5.0, max: 100.0),
              freq_ghz <- float(min: 1.0, max: 50.0),
              peak_elev <- float(min: 0.0, max: 2_000.0)
            ) do
        profile = ridge_profile(n_segs, dist_km, peak_elev)
        result = TerrainAnalysis.analyse(profile, dist_km, freq_ghz)
        assert result.diffraction_db >= 0
      end
    end

    # Deygout degenerate case: when only one interior point rises above
    # the direct ray, the three-edge method collapses to a single
    # knife-edge and the total loss must equal the principal edge's loss.
    property "single-obstacle path: total loss equals the principal knife-edge loss" do
      check all(
              dist_km <- float(min: 20.0, max: 80.0),
              freq_ghz <- float(min: 2.0, max: 24.0),
              peak_elev <- float(min: 200.0, max: 1_500.0)
            ) do
        # 3-segment profile: endpoints at 0, single interior peak.
        profile = [
          %{lat: 32.9, lon: -97.0, d: 0.0, elev: 0.0, dist_km: 0.0},
          %{lat: 33.0, lon: -97.0, d: 0.5, elev: peak_elev, dist_km: dist_km * 0.5},
          %{lat: 33.1, lon: -97.0, d: 1.0, elev: 0.0, dist_km: dist_km}
        ]

        result = TerrainAnalysis.analyse(profile, dist_km, freq_ghz)

        # Hand-compute the knife-edge loss for the single interior point.
        lambda_m = 0.3 / freq_ghz
        d1_m = dist_km * 0.5 * 1000
        d2_m = dist_km * 0.5 * 1000
        # h = peak above direct ray = peak_elev + earth_bulge - 0 (endpoints)
        bulge = TerrainAnalysis.earth_bulge(0.5, dist_km)
        h = peak_elev + bulge
        nu = TerrainAnalysis.diffraction_param(h, d1_m, d2_m, lambda_m)
        expected = TerrainAnalysis.knife_edge_loss(nu)

        assert_in_delta result.diffraction_db, expected, 0.001
      end
    end
  end

  # ── helpers ────────────────────────────────────────────────────

  defp flat_profile(n_segs, dist_km) do
    for i <- 0..n_segs do
      f = i / n_segs
      %{lat: 32.9 + f * 0.1, lon: -97.0, d: f, elev: 0.0, dist_km: f * dist_km}
    end
  end

  defp ridge_profile(n_segs, dist_km, peak_elev) do
    mid = div(n_segs, 2)

    for i <- 0..n_segs do
      f = i / n_segs
      elev = if i == mid, do: peak_elev, else: 0.0
      %{lat: 32.9 + f * 0.1, lon: -97.0, d: f, elev: elev, dist_km: f * dist_km}
    end
  end
end
