defmodule Microwaveprop.Weather.Grib2.SectionTest do
  use ExUnit.Case, async: true
  use ExUnitProperties

  alias Microwaveprop.Weather.Grib2.Section

  describe "parse_message/1" do
    test "parses a synthetic GRIB2 message" do
      msg = build_synthetic_grib2()
      assert {:ok, parsed} = Section.parse_message(msg)

      # Grid params from Section 3
      assert parsed.grid_params.nx == 1799
      assert parsed.grid_params.ny == 1059
      assert_in_delta parsed.grid_params.la1, 21.138123, 0.000001
      assert_in_delta parsed.grid_params.lo1, 237.280472, 0.000001
      assert_in_delta parsed.grid_params.dx, 3000.0, 0.1
      assert_in_delta parsed.grid_params.dy, 3000.0, 0.1
      assert_in_delta parsed.grid_params.latin1, 38.5, 0.000001
      assert_in_delta parsed.grid_params.latin2, 38.5, 0.000001
      assert parsed.grid_params.scan_mode == 64

      # Product from Section 4
      assert parsed.product.var == "TMP"
      assert parsed.product.level == "2 m above ground"

      # Packing params from Section 5
      assert parsed.packing_params.num_data_points == 100
      assert parsed.packing_params.bits_per_value == 16

      # Data from Section 7
      assert is_binary(parsed.data)

      # Bitmap indicator
      assert parsed.bitmap == :none
    end

    test "rejects non-GRIB2 binary" do
      assert {:error, _} = Section.parse_message(<<"NOT_GRIB", 0::64-big>>)
    end

    test "rejects GRIB edition 1" do
      indicator = <<"GRIB", 0::16, 0::8, 1::8, 100::64-big>>
      assert {:error, _} = Section.parse_message(indicator)
    end

    test "parses message with trailing padding bytes instead of 7777" do
      msg = build_synthetic_grib2_with_trailing_padding()
      assert {:ok, parsed} = Section.parse_message(msg)
      assert parsed.product.var == "TMP"
      assert parsed.bitmap == :none
    end

    test "parses message with truncated trailing section" do
      msg = build_synthetic_grib2_with_truncated_section()
      assert {:ok, parsed} = Section.parse_message(msg)
      assert parsed.product.var == "TMP"
      assert parsed.bitmap == :none
    end
  end

  describe "product identification" do
    test "identifies all supported variables" do
      # discipline 0 (meteorological)
      assert Section.identify_var(0, 0, 0) == "TMP"
      assert Section.identify_var(0, 0, 6) == "DPT"
      assert Section.identify_var(0, 3, 0) == "PRES"
      assert Section.identify_var(0, 3, 5) == "HGT"
      assert Section.identify_var(0, 3, 18) == "HPBL"
      assert Section.identify_var(0, 1, 3) == "PWAT"
    end

    test "returns category:number for unknown variables" do
      assert Section.identify_var(0, 99, 99) == "99:99"
    end

    test "covers the full set of known variables" do
      # Pin the complete lookup set so adding a new variable without
      # a matching clause surfaces as a test failure, not a silent
      # fallback to "cat:num".
      known = [
        {{0, 0, 0}, "TMP"},
        {{0, 0, 6}, "DPT"},
        {{0, 1, 0}, "SPFH"},
        {{0, 1, 3}, "PWAT"},
        {{0, 1, 8}, "APCP"},
        {{0, 2, 2}, "UGRD"},
        {{0, 2, 3}, "VGRD"},
        {{0, 3, 0}, "PRES"},
        {{0, 3, 5}, "HGT"},
        {{0, 3, 18}, "HPBL"},
        {{0, 6, 1}, "TCDC"},
        {{0, 19, 11}, "TKE"}
      ]

      for {{d, c, n}, name} <- known do
        assert Section.identify_var(d, c, n) == name
      end
    end

    property "falls back to `cat:num` for every unknown (discipline, cat, num) triple" do
      known_cat_nums =
        MapSet.new([{0, 0}, {0, 6}, {1, 0}, {1, 3}, {1, 8}, {2, 2}, {2, 3}, {3, 0}, {3, 5}, {3, 18}, {6, 1}, {19, 11}])

      check all(
              discipline <- integer(0..50),
              cat <- integer(0..50),
              num <- integer(0..50),
              not MapSet.member?(known_cat_nums, {cat, num})
            ) do
        assert Section.identify_var(discipline, cat, num) == "#{cat}:#{num}"
      end
    end
  end

  describe "level identification" do
    test "surface" do
      assert Section.identify_level(1, 0) == "surface"
    end

    test "pressure level in mb" do
      # Stored in Pa, displayed in mb
      assert Section.identify_level(100, 100_000) == "1000 mb"
      assert Section.identify_level(100, 85_000) == "850 mb"
    end

    test "meters above ground" do
      assert Section.identify_level(103, 2) == "2 m above ground"
      assert Section.identify_level(103, 10) == "10 m above ground"
    end

    test "entire atmosphere" do
      assert Section.identify_level(200, 0) ==
               "entire atmosphere (considered as a single layer)"
    end

    test "unknown level type" do
      assert Section.identify_level(255, 0) == "unknown:255:0"
    end

    test "entire atmosphere (type 10)" do
      assert Section.identify_level(10, 0) == "entire atmosphere"
    end

    test "hybrid level (type 105) carries the level index through" do
      assert Section.identify_level(105, 1) == "1 hybrid level"
      assert Section.identify_level(105, 50) == "50 hybrid level"
    end

    property "pressure levels always render as `<mb> mb` where mb = value_pa div 100" do
      check all(value_pa <- integer(0..110_000)) do
        assert Section.identify_level(100, value_pa) == "#{div(value_pa, 100)} mb"
      end
    end

    property "unknown surface type always renders as `unknown:<type>:<value>`" do
      # Pre-compute the unknown-type domain so the generator doesn't
      # fight a 97% rejection rate trying to sample 6 known types out
      # of 256.
      known = MapSet.new([1, 10, 100, 103, 105, 200])
      unknown_types = Enum.reject(0..255, &MapSet.member?(known, &1))

      check all(
              type <- member_of(unknown_types),
              value <- integer(0..1_000_000)
            ) do
        assert Section.identify_level(type, value) == "unknown:#{type}:#{value}"
      end
    end
  end

  describe "sign_magnitude_16/1" do
    test "positive value" do
      assert Section.sign_magnitude_16(<<0::1, 5::15>>) == 5
    end

    test "negative value" do
      assert Section.sign_magnitude_16(<<1::1, 5::15>>) == -5
    end

    test "zero" do
      assert Section.sign_magnitude_16(<<0::16>>) == 0
    end

    property "round-trips any |n| in 0..32_767 back to n through |magnitude| packing" do
      # The 16-bit field has one sign bit and 15 magnitude bits, so
      # the representable range is -32_767..32_767.
      check all(n <- integer(-32_767..32_767)) do
        mag = abs(n)

        sign_bit =
          cond do
            n < 0 -> 1
            n == 0 -> 0
            true -> 0
          end

        packed = <<sign_bit::1, mag::15>>
        decoded = Section.sign_magnitude_16(packed)

        # Note: +0 and -0 both decode to 0 (sign bit is irrelevant
        # when magnitude is zero) — that's the expected contract.
        if n == 0, do: assert(decoded == 0), else: assert(decoded == n)
      end
    end

    property "magnitude packing is monotonic in the magnitude bits (same sign)" do
      check all(
              sign_bit <- integer(0..1),
              a <- integer(0..32_766),
              delta <- integer(0..(32_767 - a))
            ) do
        b = a + delta
        packed_a = <<sign_bit::1, a::15>>
        packed_b = <<sign_bit::1, b::15>>

        if sign_bit == 0 do
          assert Section.sign_magnitude_16(packed_a) <= Section.sign_magnitude_16(packed_b)
        else
          # Negative magnitudes grow more negative as the magnitude bits grow.
          assert Section.sign_magnitude_16(packed_a) >= Section.sign_magnitude_16(packed_b)
        end
      end
    end
  end

  # --- Helpers ---

  defp build_synthetic_grib2 do
    # Section 1 (Identification) - minimal
    sec1_body = <<
      # Originating center, subcenter, etc.
      0::16-big,
      0::16-big,
      # Master/local tables
      2::8,
      1::8,
      # Significance of reference time
      1::8,
      # Year, month, day, hour, minute, second
      2026::16-big,
      3::8,
      28::8,
      18::8,
      0::8,
      0::8,
      # Production status, type
      0::8,
      1::8
    >>

    sec1 = section_wrap(1, sec1_body)

    # Section 3 (Grid Definition) - Template 3.30 (Lambert Conformal)
    sec3_body = <<
      # Source of grid definition
      0::8,
      # Number of data points
      1_905_141::32-big,
      # Number of octets for optional list
      0::8,
      # Interpretation
      0::8,
      # Grid Definition Template Number = 30 (Lambert Conformal)
      30::16-big,
      # Shape of earth (6 = spherical, R=6371229)
      6::8,
      # Scale factor/value of radius (not used for shape 6)
      0::8,
      0::32-big,
      # Scale factor/value of major axis
      0::8,
      0::32-big,
      # Scale factor/value of minor axis
      0::8,
      0::32-big,
      # Nx, Ny
      1799::32-big,
      1059::32-big,
      # La1 in microdegrees
      21_138_123::32-big,
      # Lo1 in microdegrees
      237_280_472::32-big,
      # Resolution and component flags
      0b00111000::8,
      # LaD in microdegrees
      38_500_000::32-big,
      # LoV in microdegrees
      262_500_000::32-big,
      # Dx in millimetres
      3_000_000::32-big,
      # Dy in millimetres
      3_000_000::32-big,
      # Projection centre flag
      0::8,
      # Scanning mode
      64::8,
      # Latin1 in microdegrees
      38_500_000::32-big,
      # Latin2 in microdegrees
      38_500_000::32-big,
      # Latitude/longitude of southern pole
      0::32-big,
      0::32-big
    >>

    sec3 = section_wrap(3, sec3_body)

    # Section 4 (Product Definition) - Template 4.0
    sec4_body = <<
      # Number of coordinate values
      0::16-big,
      # Product Definition Template Number = 0
      0::16-big,
      # Parameter category (0 = Temperature)
      0::8,
      # Parameter number (0 = Temperature)
      0::8,
      # Type of generating process
      2::8,
      # Background + forecast generating process
      0::8,
      0::8,
      # Hours/minutes of observational data cutoff
      0::16-big,
      0::8,
      # Indicator of unit of time range (1=hour)
      1::8,
      # Forecast time
      0::32-big,
      # Type of first fixed surface (103 = above ground)
      103::8,
      # Scale factor and scaled value of first fixed surface
      0::8,
      2::32-big,
      # Type of second fixed surface (255 = missing)
      255::8,
      0::8,
      0::32-big
    >>

    sec4 = section_wrap(4, sec4_body)

    # Section 5 (Data Representation) - Template 5.0 (Simple packing)
    # Reference value as IEEE 754 float32
    ref_val = <<0.0::float-32-big>>

    sec5_body = <<
      # Number of data points
      100::32-big,
      # Data Representation Template Number = 0
      0::16-big,
      # Reference value (IEEE 754)
      ref_val::binary,
      # Binary scale factor (sign-magnitude 16-bit)
      0::16-big,
      # Decimal scale factor (sign-magnitude 16-bit)
      0::16-big,
      # Number of bits per value
      16::8
    >>

    sec5 = section_wrap(5, sec5_body)

    # Section 6 (Bitmap) - no bitmap
    sec6_body = <<255::8>>
    sec6 = section_wrap(6, sec6_body)

    # Section 7 (Data) - 100 values of 16 bits = 200 bytes
    data_bytes = :binary.copy(<<42::16-big>>, 100)
    sec7 = section_wrap(7, data_bytes)

    # Section 8 (End)
    sec8 = "7777"

    body = sec1 <> sec3 <> sec4 <> sec5 <> sec6 <> sec7 <> sec8
    total_length = 16 + byte_size(body)

    # Section 0 (Indicator)
    sec0 = <<"GRIB", 0::16, 0::8, 2::8, total_length::64-big>>

    sec0 <> body
  end

  defp section_wrap(section_number, body) do
    length = 5 + byte_size(body)
    <<length::32-big, section_number::8, body::binary>>
  end

  # Like build_synthetic_grib2 but with 3 padding bytes instead of "7777"
  defp build_synthetic_grib2_with_trailing_padding do
    body = build_sections_body() <> <<0, 0, 0>>
    total_length = 16 + byte_size(body)
    sec0 = <<"GRIB", 0::16, 0::8, 2::8, total_length::64-big>>
    sec0 <> body
  end

  # Like build_synthetic_grib2 but total_length includes extra bytes after "7777"
  # that look like a truncated section header
  defp build_synthetic_grib2_with_truncated_section do
    # All valid sections + "7777" + a truncated next section (length says 500 but only 10 bytes)
    truncated = <<500::32-big, 4::8, 0, 0, 0, 0, 0>>
    body = build_sections_body() <> "7777" <> truncated
    total_length = 16 + byte_size(body)
    sec0 = <<"GRIB", 0::16, 0::8, 2::8, total_length::64-big>>
    sec0 <> body
  end

  defp build_sections_body do
    sec1_body = <<
      0::16-big,
      0::16-big,
      2::8,
      1::8,
      1::8,
      2026::16-big,
      3::8,
      28::8,
      18::8,
      0::8,
      0::8,
      0::8,
      1::8
    >>

    sec3_body = <<
      0::8,
      1_905_141::32-big,
      0::8,
      0::8,
      30::16-big,
      6::8,
      0::8,
      0::32-big,
      0::8,
      0::32-big,
      0::8,
      0::32-big,
      1799::32-big,
      1059::32-big,
      21_138_123::32-big,
      237_280_472::32-big,
      0b00111000::8,
      38_500_000::32-big,
      262_500_000::32-big,
      3_000_000::32-big,
      3_000_000::32-big,
      0::8,
      64::8,
      38_500_000::32-big,
      38_500_000::32-big,
      0::32-big,
      0::32-big
    >>

    sec4_body = <<
      0::16-big,
      0::16-big,
      0::8,
      0::8,
      2::8,
      0::8,
      0::8,
      0::16-big,
      0::8,
      1::8,
      0::32-big,
      103::8,
      0::8,
      2::32-big,
      255::8,
      0::8,
      0::32-big
    >>

    ref_val = <<0.0::float-32-big>>

    sec5_body = <<
      100::32-big,
      0::16-big,
      ref_val::binary,
      0::16-big,
      0::16-big,
      16::8
    >>

    sec6_body = <<255::8>>
    data_bytes = :binary.copy(<<42::16-big>>, 100)

    section_wrap(1, sec1_body) <>
      section_wrap(3, sec3_body) <>
      section_wrap(4, sec4_body) <>
      section_wrap(5, sec5_body) <>
      section_wrap(6, sec6_body) <>
      section_wrap(7, data_bytes)
  end
end
