defmodule Microwaveprop.Terrain.ViewshedPropertyTest do
  @moduledoc """
  StreamData property tests for the pure-math portions of
  `Microwaveprop.Terrain.Viewshed`.

  Each property exercises one physical invariant of the viewshed's
  math helpers without touching SRTM tiles or `Task.async_stream` — the
  scenarios are constructed so the expected bound is physically
  meaningful (reach never exceeds max range, flat terrain is always
  visible, destination_point is a no-op for zero distance, and the
  BLOCKED ducting-vs-terrain `max/2` is monotonic in score).
  """

  use ExUnit.Case, async: true
  use ExUnitProperties

  alias Microwaveprop.Terrain.Viewshed

  describe "find_reach_km/2" do
    property "never returns a value greater than max_range_km" do
      check all(
              n <- integer(2..20),
              max_range <- float(min: 1.0, max: 500.0),
              obstruction_idx <- integer(0..25)
            ) do
        points =
          for i <- 0..n do
            %{obstructed: i == obstruction_idx and i > 0 and i < n, dist_km: i / n * max_range}
          end

        reach = Viewshed.find_reach_km(points, max_range)
        assert reach <= max_range
        assert reach >= 0.0
      end
    end

    property "all-clear profiles always return max_range_km" do
      check all(
              n <- integer(2..30),
              max_range <- float(min: 0.1, max: 1_000.0)
            ) do
        points =
          for i <- 0..n do
            %{obstructed: false, dist_km: i / n * max_range}
          end

        assert Viewshed.find_reach_km(points, max_range) == max_range
      end
    end
  end

  describe "destination_point/4" do
    property "zero distance always returns (approximately) the origin" do
      check all(
              lat <- float(min: -80.0, max: 80.0),
              lon <- float(min: -180.0, max: 180.0),
              bearing <- float(min: 0.0, max: 359.9)
            ) do
        {lat2, lon2} = Viewshed.destination_point(lat, lon, bearing, 0.0)
        assert_in_delta lat2, lat, 1.0e-9
        assert_in_delta lon2, lon, 1.0e-9
      end
    end

    property "north/south bearings preserve longitude and move latitude in the right sign" do
      # `destination_point/4` is great-circle: due-east travel at non-zero
      # latitude slightly bends a tiny bit toward the pole/equator, so
      # we only assert the exact-meridian preservation for N/S bearings,
      # which is a pure math identity regardless of latitude.
      check all(
              lat <- float(min: -60.0, max: 60.0),
              lon <- float(min: -170.0, max: 170.0),
              dist_km <- float(min: 1.0, max: 500.0)
            ) do
        {lat_n, lon_n} = Viewshed.destination_point(lat, lon, 0.0, dist_km)
        {lat_s, lon_s} = Viewshed.destination_point(lat, lon, 180.0, dist_km)

        assert_in_delta lon_n, lon, 1.0e-6
        assert_in_delta lon_s, lon, 1.0e-6
        # Far enough from the poles (|lat| ≤ 60) no wraparound occurs at 500 km.
        assert lat_n > lat
        assert lat_s < lat
      end
    end
  end

  describe "effective_reach_km/3" do
    property "BLOCKED verdicts: reach is non-decreasing as score rises" do
      # terrain_reach_factor is constant in score, so max(terrain, ducting)
      # can only rise as ducting_reach_factor rises. ducting_reach_factor
      # is a non-decreasing step function of score.
      check all(
              dif_db <- float(min: 0.0, max: 60.0),
              max_range <- float(min: 1.0, max: 500.0),
              score_a <- integer(0..100),
              bump <- integer(0..50)
            ) do
        score_b = min(100, score_a + bump)
        analysis = %{verdict: "BLOCKED", diffraction_db: dif_db}
        r_a = Viewshed.effective_reach_km(analysis, max_range, score_a)
        r_b = Viewshed.effective_reach_km(analysis, max_range, score_b)
        assert r_b >= r_a
      end
    end

    property "non-BLOCKED verdicts return deterministic fractions of max_range" do
      check all(max_range <- float(min: 0.0, max: 1_000.0), score <- integer(0..100)) do
        clear = %{verdict: "CLEAR", diffraction_db: 0.0}
        minor = %{verdict: "FRESNEL_MINOR", diffraction_db: 1.0}
        partial = %{verdict: "FRESNEL_PARTIAL", diffraction_db: 4.0}

        assert Viewshed.effective_reach_km(clear, max_range, score) == max_range
        assert_in_delta Viewshed.effective_reach_km(minor, max_range, score), max_range * 0.9, 1.0e-9
        assert_in_delta Viewshed.effective_reach_km(partial, max_range, score), max_range * 0.7, 1.0e-9
      end
    end
  end

  describe "destination_point/4 round-trip distance" do
    property "the generated point is ~dist_km from the origin via the haversine" do
      # Regardless of bearing, the great-circle distance between the
      # origin and destination_point's result must match the requested
      # distance. Allow a loose absolute tolerance for earth-radius
      # rounding at longer distances (the module uses 6371 km exactly).
      check all(
              lat <- float(min: -60.0, max: 60.0),
              lon <- float(min: -170.0, max: 170.0),
              bearing <- float(min: 0.0, max: 359.999),
              dist_km <- float(min: 1.0, max: 500.0)
            ) do
        {lat2, lon2} = Viewshed.destination_point(lat, lon, bearing, dist_km)

        measured = Microwaveprop.Geo.haversine_km(lat, lon, lat2, lon2)
        assert_in_delta measured, dist_km, 0.5
      end
    end
  end

  describe "analyse_ray/5 over generated flat profiles" do
    property "flat terrain with high antennas always reaches the full distance" do
      # Antenna floor chosen to beat the worst-case (max dist, max freq)
      # earth-bulge + first-Fresnel clearance with margin, matching the
      # pattern used in TerrainAnalysis property tests.
      ant_h = 150.0

      check all(
              n_segs <- integer(4..16),
              dist_km <- float(min: 2.0, max: 40.0),
              freq_ghz <- float(min: 5.0, max: 50.0)
            ) do
        profile =
          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

        result = Viewshed.analyse_ray(profile, dist_km, freq_ghz, ant_h, ant_h)

        assert result.reach_km == dist_km
        assert result.verdict in ["CLEAR", "FRESNEL_MINOR"]
      end
    end
  end
end
