defmodule Microwaveprop.Radio.BandResolverPropertyTest do
  use ExUnit.Case, async: true
  use ExUnitProperties

  alias Microwaveprop.Radio.BandResolver

  describe "resolve/1 output contract" do
    property "result is always nil or a member of allowed_bands/0 (strings)" do
      check all(s <- string(:printable)) do
        case BandResolver.resolve(s) do
          nil -> :ok
          mhz -> assert mhz in BandResolver.allowed_bands()
        end
      end
    end

    property "result is always nil or a member of allowed_bands/0 (integers)" do
      check all(i <- integer(-1_000..500_000)) do
        case BandResolver.resolve(i) do
          nil -> :ok
          mhz -> assert mhz in BandResolver.allowed_bands()
        end
      end
    end

    property "result is always nil or a member of allowed_bands/0 (floats)" do
      check all(f <- float(min: -10.0, max: 500_000.0)) do
        case BandResolver.resolve(f) do
          nil -> :ok
          mhz -> assert mhz in BandResolver.allowed_bands()
        end
      end
    end

    property "never crashes on arbitrary term input" do
      check all(
              term <-
                one_of([
                  constant(nil),
                  constant(""),
                  constant(:atom),
                  constant([]),
                  constant(%{}),
                  string(:printable),
                  integer(),
                  float()
                ])
            ) do
        result = BandResolver.resolve(term)
        assert is_nil(result) or is_integer(result)
      end
    end
  end

  describe "resolve/1 is idempotent for canonical bands" do
    property "every allowed band resolves to itself" do
      check all(mhz <- member_of(BandResolver.allowed_bands())) do
        assert BandResolver.resolve(mhz) == mhz
        assert BandResolver.resolve(Integer.to_string(mhz)) == mhz
      end
    end

    property "resolve_as_string/1 round-trips through Integer.to_string" do
      check all(mhz <- member_of(BandResolver.allowed_bands())) do
        assert BandResolver.resolve_as_string(mhz) == Integer.to_string(mhz)
      end
    end
  end

  describe "resolve/1 snap-to-nearest" do
    property "frequencies within a small neighborhood of a microwave band resolve to that band" do
      # The >= 900 MHz branch uses nearest-neighbor snapping. A small
      # perturbation of a canonical band value must land on the same
      # band — but we clamp the perturbed input to stay ≥ 900 so we
      # don't cross the resolver's lower cutoff for 902 (the smallest
      # microwave band).
      bands_900_plus = Enum.filter(BandResolver.allowed_bands(), &(&1 >= 900))

      check all(
              mhz <- member_of(bands_900_plus),
              delta_pct <- float(min: -0.001, max: 0.001)
            ) do
        perturbed = max(900.01, mhz * (1.0 + delta_pct))
        # Only assert when the perturbed value is still unambiguously
        # closer to `mhz` than to any other allowed band.
        nearest_other =
          bands_900_plus
          |> Enum.reject(&(&1 == mhz))
          |> Enum.min_by(&abs(&1 - perturbed))

        if abs(perturbed - mhz) < abs(perturbed - nearest_other) do
          assert BandResolver.resolve(perturbed) == mhz
        end
      end
    end
  end
end
