defmodule AprsmeWeb.Live.Shared.ParamUtilsTest do
  use ExUnit.Case, async: true
  use ExUnitProperties

  alias AprsmeWeb.Live.Shared.ParamUtils

  describe "parse_float_in_range/4" do
    test "parses valid float inside range" do
      assert ParamUtils.parse_float_in_range("30.5", 0.0, -90.0, 90.0) == 30.5
    end

    test "returns default when input is outside range" do
      assert ParamUtils.parse_float_in_range("200", 0.0, -90.0, 90.0) == 0.0
      assert ParamUtils.parse_float_in_range("-200", 0.0, -90.0, 90.0) == 0.0
    end

    test "returns default for unparseable input" do
      assert ParamUtils.parse_float_in_range("abc", 0.0, -90.0, 90.0) == 0.0
      assert ParamUtils.parse_float_in_range("", 0.0, -90.0, 90.0) == 0.0
    end

    test "returns default for non-binary input" do
      assert ParamUtils.parse_float_in_range(nil, 1.5, -90.0, 90.0) == 1.5
      assert ParamUtils.parse_float_in_range(42, 1.5, -90.0, 90.0) == 1.5
    end

    test "accepts negative values in range" do
      assert ParamUtils.parse_float_in_range("-45.25", 0.0, -90.0, 90.0) == -45.25
    end

    test "accepts values at exact range boundaries" do
      assert ParamUtils.parse_float_in_range("90.0", 0.0, -90.0, 90.0) == 90.0
      assert ParamUtils.parse_float_in_range("-90.0", 0.0, -90.0, 90.0) == -90.0
    end

    test "accepts input with trailing non-numeric characters if still in range" do
      # This mirrors documented behavior in the implementation.
      assert ParamUtils.parse_float_in_range("30.5abc", 0.0, -90.0, 90.0) == 30.5
    end
  end

  describe "parse_int_in_range/4" do
    test "parses valid integer inside range" do
      assert ParamUtils.parse_int_in_range("10", 5, 1, 20) == 10
    end

    test "returns default for out-of-range value" do
      assert ParamUtils.parse_int_in_range("100", 5, 1, 20) == 5
      assert ParamUtils.parse_int_in_range("0", 5, 1, 20) == 5
    end

    test "returns default for unparseable value" do
      assert ParamUtils.parse_int_in_range("foo", 5, 1, 20) == 5
      assert ParamUtils.parse_int_in_range(nil, 5, 1, 20) == 5
    end

    test "accepts boundary values" do
      assert ParamUtils.parse_int_in_range("1", 5, 1, 20) == 1
      assert ParamUtils.parse_int_in_range("20", 5, 1, 20) == 20
    end
  end

  describe "sanitize_numeric_string/1" do
    test "trims whitespace" do
      assert ParamUtils.sanitize_numeric_string("  42  ") == "42"
    end

    test "strips non-numeric characters" do
      assert ParamUtils.sanitize_numeric_string("1<script>2") == "12"
    end

    test "keeps scientific notation characters" do
      assert ParamUtils.sanitize_numeric_string("1e5") == "1e5"
      assert ParamUtils.sanitize_numeric_string("1.5E-3") == "1.5E-3"
    end

    test "keeps decimals and signs" do
      assert ParamUtils.sanitize_numeric_string("-3.14") == "-3.14"
    end

    test "limits length to 20 characters" do
      long = String.duplicate("1", 50)
      assert byte_size(ParamUtils.sanitize_numeric_string(long)) == 20
    end

    test "returns empty string for non-binary" do
      assert ParamUtils.sanitize_numeric_string(nil) == ""
      assert ParamUtils.sanitize_numeric_string(123) == ""
    end
  end

  describe "limit_string_length/2" do
    test "truncates strings longer than limit" do
      assert ParamUtils.limit_string_length("hello world", 5) == "hello"
    end

    test "leaves short strings alone" do
      assert ParamUtils.limit_string_length("hi", 10) == "hi"
    end

    test "handles strings exactly at the limit" do
      assert ParamUtils.limit_string_length("abcde", 5) == "abcde"
    end
  end

  describe "finite?/1" do
    test "returns true for floats" do
      assert ParamUtils.finite?(1.0)
      assert ParamUtils.finite?(-999.9)
      assert ParamUtils.finite?(0.0)
    end

    test "returns false for non-floats" do
      refute ParamUtils.finite?(1)
      refute ParamUtils.finite?("1.0")
      refute ParamUtils.finite?(nil)
    end
  end

  describe "safe_parse_coordinate/4" do
    test "parses strings and clamps" do
      assert ParamUtils.safe_parse_coordinate("45.0", 0.0, -90.0, 90.0) == 45.0
    end

    test "passes through numbers in range" do
      assert ParamUtils.safe_parse_coordinate(12.5, 0.0, -90.0, 90.0) == 12.5
    end

    test "clamps numbers above max" do
      assert ParamUtils.safe_parse_coordinate(500, 0.0, -90.0, 90.0) == 90.0
    end

    test "clamps numbers below min" do
      assert ParamUtils.safe_parse_coordinate(-500, 0.0, -90.0, 90.0) == -90.0
    end

    test "returns default for invalid string (out-of-range)" do
      assert ParamUtils.safe_parse_coordinate("200", 1.5, -90.0, 90.0) == 1.5
    end

    test "returns default for non-string, non-number input" do
      assert ParamUtils.safe_parse_coordinate(nil, 1.5, -90.0, 90.0) == 1.5
      assert ParamUtils.safe_parse_coordinate(%{}, 1.5, -90.0, 90.0) == 1.5
    end
  end

  describe "clamp_coordinate/3" do
    test "keeps values in range" do
      assert ParamUtils.clamp_coordinate(45.0, -90.0, 90.0) == 45.0
    end

    test "clamps above max" do
      assert ParamUtils.clamp_coordinate(91.0, -90.0, 90.0) == 90.0
    end

    test "clamps below min" do
      assert ParamUtils.clamp_coordinate(-91.0, -90.0, 90.0) == -90.0
    end

    test "returns 0.0 for non-numeric" do
      assert ParamUtils.clamp_coordinate(nil, -90.0, 90.0) == 0.0
    end
  end

  describe "clamp_zoom/1" do
    test "parses binary zoom" do
      assert ParamUtils.clamp_zoom("10") == 10
    end

    test "clamps integer outside range" do
      assert ParamUtils.clamp_zoom(50) == 20
      assert ParamUtils.clamp_zoom(-5) == 1
    end

    test "truncates float" do
      assert ParamUtils.clamp_zoom(8.9) == 8
    end

    test "returns default for invalid input" do
      assert ParamUtils.clamp_zoom(nil) == 5
      assert ParamUtils.clamp_zoom(%{}) == 5
      assert ParamUtils.clamp_zoom("garbage") == 5
    end
  end

  describe "valid_callsign?/1" do
    test "accepts standard callsigns" do
      assert ParamUtils.valid_callsign?("K5ABC")
      assert ParamUtils.valid_callsign?("W1XYZ-9")
    end

    test "rejects lowercase? actually the regex is case-insensitive" do
      assert ParamUtils.valid_callsign?("k5abc")
    end

    test "rejects obvious garbage" do
      refute ParamUtils.valid_callsign?("")
      refute ParamUtils.valid_callsign?("HELLO WORLD")
      refute ParamUtils.valid_callsign?("K5ABC#")
    end

    test "rejects non-binary input" do
      refute ParamUtils.valid_callsign?(nil)
      refute ParamUtils.valid_callsign?(123)
    end
  end

  describe "sanitize_path_string/1" do
    test "keeps allowed characters" do
      assert ParamUtils.sanitize_path_string("WIDE1-1,WIDE2-2*") == "WIDE1-1,WIDE2-2*"
    end

    test "strips disallowed characters" do
      assert ParamUtils.sanitize_path_string("A<script>B") == "AscriptB"
    end

    test "trims whitespace" do
      assert ParamUtils.sanitize_path_string("  ABC  ") == "ABC"
    end

    test "limits length" do
      long = String.duplicate("A", 300)
      assert byte_size(ParamUtils.sanitize_path_string(long)) == 200
    end

    test "returns empty for non-binary" do
      assert ParamUtils.sanitize_path_string(nil) == ""
    end
  end

  describe "property: parse_float_in_range never returns a value outside the range when input was valid" do
    property "result is always default or within [min, max]" do
      check all(
              value <- float(min: -1000.0, max: 1000.0),
              min <- float(min: -500.0, max: 0.0),
              max <- float(min: 0.0, max: 500.0),
              min < max
            ) do
        default = min
        result = ParamUtils.parse_float_in_range(to_string(value), default, min, max)
        # credo:disable-for-next-line Jump.CredoChecks.ConditionalAssertion
        assert result == default or (result >= min and result <= max)
        # parse_float_in_range returns default on parse failure or clamped value
      end
    end
  end

  describe "property: clamp_coordinate never exceeds bounds" do
    property "clamp_coordinate(v, min, max) is always in [min, max]" do
      check all(
              v <- one_of([float(), integer()]),
              min <- float(min: -1000.0, max: 0.0),
              max <- float(min: 0.0, max: 1000.0),
              min < max
            ) do
        result = ParamUtils.clamp_coordinate(v, min, max)
        assert result >= min and result <= max
      end
    end
  end

  describe "property: clamp_zoom always returns an integer in [1, 20]" do
    property "clamp_zoom enforces invariants" do
      check all(zoom <- one_of([integer(), float(), string(:alphanumeric), constant(nil)])) do
        result = ParamUtils.clamp_zoom(zoom)
        assert is_integer(result)
        assert result >= 1 and result <= 20
      end
    end
  end

  describe "property: sanitize_numeric_string output is always printable and short" do
    property "output length <= 20 and contains only safe characters" do
      check all(s <- string(:printable)) do
        result = ParamUtils.sanitize_numeric_string(s)
        assert byte_size(result) <= 20
        assert Regex.match?(~r/^[\d\.\-eE]*$/, result)
      end
    end
  end
end
