defmodule Microwaveprop.Weather.Grib2.Wgrib2Test do
  @moduledoc """
  Characterization tests for `Microwaveprop.Weather.Grib2.Wgrib2`.

  These exercise the real `wgrib2` binary against checked-in HRRR GRIB2
  fixtures. Tests that require the binary will check `available?()` and
  fall back to a documented assertion if it's missing — the dev and CI
  environments are expected to have `wgrib2` installed.

  Covered:
    * Public entry points: `extract_grid/3`, `extract_grid_from_file/3`,
      `extract_grid_from_file_mapped/4`, `extract_grid_messages/3`,
      `extract_grid_messages_from_file/3`, `extract_points_from_file/3`.
    * Round-trip of longitude between -180..180 input and output.
    * Reducer callback plumbing in the `_mapped` variant.
    * Undefined-value filtering (9.999e20 sentinel) at the edge of the
      HRRR CONUS mask.
    * Date parsing via the inventory (DateTime truncation + UTC tag).
    * Error shape when wgrib2 exits non-zero (invalid input file).
    * Empty-match and empty-points corner cases.

  Deferred (intentional):
    * Stubbing `System.cmd/3` output strings — the module calls the
      binary directly with no injection seam. The parser helpers
      (`parse_wgrib2_inventory`, `parse_wgrib2_date_digits`,
      `parse_lon_val_segment`) are covered indirectly via the real
      binary round-trip. Mocking would require introducing DI we don't
      want as part of characterization.
    * `{:error, :wgrib2_not_available}` branch — `available?/0` is
      computed from `System.find_executable/1` with no seam. With
      wgrib2 present, we document the branch in the moduledoc and
      cover the helper returning `true`; exercising the `false` arm
      would require mocking `System.find_executable/1`.
    * `{:error, :enoent}` -> `{:ok, %{}}` branch in `extract_file_*`
      (requires wgrib2 to succeed yet produce no output file — never
      seen in practice and wgrib2's behavior on empty matches is the
      same whether run on binary or file).
  """
  use ExUnit.Case, async: true

  alias Microwaveprop.Weather.Grib2.Wgrib2

  @single_fixture "test/fixtures/grib2/hrrr_tmp_2m.grib2"
  @multi_fixture "test/fixtures/grib2/hrrr_multi.grib2"

  # Small bbox around Dallas, TX that we know is inside the HRRR domain.
  @dallas_grid %{
    lon_start: -96.8,
    lon_count: 2,
    lon_step: 0.1,
    lat_start: 32.78,
    lat_count: 2,
    lat_step: 0.1
  }

  describe "available?/0" do
    test "returns a boolean" do
      assert is_boolean(Wgrib2.available?())
    end

    test "reflects System.find_executable/1 result" do
      expected = System.find_executable("wgrib2") != nil
      assert Wgrib2.available?() == expected
    end
  end

  describe "extract_grid/3 — real HRRR fixture" do
    @describetag :slow
    @describetag timeout: 60_000

    test "extracts TMP/DPT values for every grid cell in bbox" do
      fixture = File.read!(@multi_fixture)
      assert {:ok, result} = Wgrib2.extract_grid(fixture, ":(TMP|DPT):", @dallas_grid)

      # 2x2 grid of points — all should resolve inside HRRR CONUS.
      assert map_size(result) == 4

      for {{lat, lon}, cell} <- result do
        assert lat in [32.78, 32.88]
        assert lon in [-96.8, -96.7]
        assert Map.has_key?(cell, "TMP:2 m above ground")
        assert Map.has_key?(cell, "DPT:2 m above ground")

        tmp = cell["TMP:2 m above ground"]
        dpt = cell["DPT:2 m above ground"]

        assert tmp > 200.0 and tmp < 340.0
        assert dpt > 200.0 and dpt < 340.0
        # Dew point must be <= dry-bulb temp physically.
        assert dpt <= tmp
      end
    end

    test "returns coordinates in -180..180 longitude convention" do
      fixture = File.read!(@single_fixture)
      {:ok, result} = Wgrib2.extract_grid(fixture, ":TMP:", @dallas_grid)

      for {{_lat, lon}, _cell} <- result do
        # Input was negative — denormalize_lon must round-trip to negative.
        assert lon < 0
        assert lon > -180
      end
    end

    test "non-matching regex yields empty map" do
      fixture = File.read!(@single_fixture)
      # wgrib2 writes no output file when no messages match, which the
      # module handles as {:ok, %{}}.
      assert {:ok, result} = Wgrib2.extract_grid(fixture, ":NOPE_NO_SUCH_VAR:", @dallas_grid)
      assert result == %{}
    end
  end

  describe "extract_grid_from_file/3 — real HRRR fixture" do
    @describetag :slow
    @describetag timeout: 60_000

    test "produces the same result as extract_grid/3 for the same inputs" do
      fixture = File.read!(@multi_fixture)

      {:ok, from_bin} = Wgrib2.extract_grid(fixture, ":(TMP|DPT):", @dallas_grid)
      {:ok, from_file} = Wgrib2.extract_grid_from_file(@multi_fixture, ":(TMP|DPT):", @dallas_grid)

      assert from_bin |> Map.keys() |> Enum.sort() ==
               from_file |> Map.keys() |> Enum.sort()

      for point <- Map.keys(from_bin) do
        for {key, v_bin} <- from_bin[point] do
          assert_in_delta from_file[point][key], v_bin, 0.001
        end
      end
    end

    test "returns error tuple for a nonexistent file" do
      assert {:error, msg} =
               Wgrib2.extract_grid_from_file(
                 "/tmp/definitely-not-a-grib2-file.grib2",
                 ":TMP:",
                 @dallas_grid
               )

      assert is_binary(msg)
      assert msg =~ "wgrib2 failed"
    end
  end

  describe "extract_grid_from_file_mapped/4 — real HRRR fixture" do
    @describetag :slow
    @describetag timeout: 60_000

    test "reducer sees per-cell map and output is keyed by {lat,lon}" do
      # Reducer extracts just the TMP value as a scalar.
      reducer = fn cell -> Map.get(cell, "TMP:2 m above ground") end

      {:ok, result} =
        Wgrib2.extract_grid_from_file_mapped(
          @multi_fixture,
          ":(TMP|DPT):",
          @dallas_grid,
          reducer
        )

      assert map_size(result) == 4

      for {{lat, lon}, scalar} <- result do
        assert lat in [32.78, 32.88]
        assert lon in [-96.8, -96.7]
        assert is_float(scalar)
        assert scalar > 200.0 and scalar < 340.0
      end
    end

    test "reducer return value flows through unchanged to output map" do
      reducer = fn _cell -> :reduced end

      {:ok, result} =
        Wgrib2.extract_grid_from_file_mapped(@multi_fixture, ":TMP:", @dallas_grid, reducer)

      assert map_size(result) == 4
      assert Enum.all?(Map.values(result), &(&1 == :reduced))
    end
  end

  describe "extract_grid_messages/3 — real HRRR fixture" do
    @describetag :slow
    @describetag timeout: 60_000

    test "returns one entry per matched message with real physical values" do
      fixture = File.read!(@multi_fixture)

      {:ok, messages} = Wgrib2.extract_grid_messages(fixture, ":(TMP|DPT):", @dallas_grid)
      {:ok, grid} = Wgrib2.extract_grid(fixture, ":(TMP|DPT):", @dallas_grid)

      # hrrr_multi.grib2 contains exactly TMP:2m and DPT:2m.
      assert length(messages) == 2

      vars = messages |> Enum.map(& &1.var) |> Enum.sort()
      assert vars == ["DPT", "TMP"]

      # Physical-value assertions per message — verifies the Fortran record
      # overhead (8 bytes/message) is accounted for in the parser.
      tmp_msg = Enum.find(messages, &(&1.var == "TMP"))
      dpt_msg = Enum.find(messages, &(&1.var == "DPT"))

      for msg <- messages do
        assert msg.level == "2 m above ground"
        assert %DateTime{} = msg.datetime
        assert msg.datetime == DateTime.truncate(msg.datetime, :second)
        assert msg.datetime.time_zone == "Etc/UTC"

        assert is_map(msg.values)
        assert map_size(msg.values) == 4

        for {{lat, lon}, v} <- msg.values do
          assert lat in [32.78, 32.88]
          assert lon in [-96.8, -96.7]
          assert is_float(v)
          assert v > 200.0 and v < 340.0, "#{msg.var} value #{v} outside physical range"
        end
      end

      # Dew point must be <= dry-bulb temp per point.
      for {point, tmp} <- tmp_msg.values do
        dpt = Map.fetch!(dpt_msg.values, point)
        assert dpt <= tmp, "DPT #{dpt} > TMP #{tmp} at #{inspect(point)}"
      end

      # Cross-check: same inputs as extract_grid/3 — values must agree within 0.01 K.
      for {point, tmp} <- tmp_msg.values do
        assert_in_delta grid[point]["TMP:2 m above ground"], tmp, 0.01
      end

      for {point, dpt} <- dpt_msg.values do
        assert_in_delta grid[point]["DPT:2 m above ground"], dpt, 0.01
      end
    end
  end

  describe "extract_grid_messages_from_file/3 — real HRRR fixture" do
    @describetag :slow
    @describetag timeout: 60_000

    # Both the binary and file variants funnel into `build_messages_per_message`,
    # so they must produce identical output for identical inputs.
    test "mirrors extract_grid_messages/3 on the same data" do
      fixture = File.read!(@multi_fixture)
      {:ok, from_bin} = Wgrib2.extract_grid_messages(fixture, ":(TMP|DPT):", @dallas_grid)

      {:ok, from_file} =
        Wgrib2.extract_grid_messages_from_file(@multi_fixture, ":(TMP|DPT):", @dallas_grid)

      assert length(from_bin) == length(from_file)

      sorted_bin = Enum.sort_by(from_bin, & &1.var)
      sorted_file = Enum.sort_by(from_file, & &1.var)

      for {a, b} <- Enum.zip(sorted_bin, sorted_file) do
        assert a.var == b.var
        assert a.level == b.level
        assert a.datetime == b.datetime

        assert a.values |> Map.keys() |> Enum.sort() ==
                 b.values |> Map.keys() |> Enum.sort()

        for {point, v} <- a.values do
          assert_in_delta b.values[point], v, 0.001
        end
      end
    end
  end

  describe "extract_points_from_file/3 — real HRRR fixture" do
    @describetag :slow
    @describetag timeout: 60_000

    test "extracts values at requested points, snapping wgrib2 nearest coords back" do
      points = [{32.78, -96.8}, {40.71, -74.01}]

      {:ok, result} = Wgrib2.extract_points_from_file(@multi_fixture, ":(TMP|DPT):", points)

      for point <- points do
        assert Map.has_key?(result, point), "Missing point #{inspect(point)}"
        assert Map.has_key?(result[point], "TMP:2 m above ground")
        assert Map.has_key?(result[point], "DPT:2 m above ground")

        tmp = result[point]["TMP:2 m above ground"]
        assert tmp > 200.0 and tmp < 340.0
      end
    end

    test "accepts an empty point list" do
      assert {:ok, result} = Wgrib2.extract_points_from_file(@single_fixture, ":TMP:", [])
      assert result == %{}
    end

    test "returns error for a nonexistent file" do
      assert {:error, msg} =
               Wgrib2.extract_points_from_file(
                 "/tmp/definitely-not-a-grib2-file.grib2",
                 ":TMP:",
                 [{32.78, -96.8}]
               )

      assert is_binary(msg)
      assert msg =~ "wgrib2 failed"
    end
  end

  describe "bbox edge cases" do
    @describetag :slow
    @describetag timeout: 60_000

    test "1x1 grid returns a single point" do
      spec = %{
        lon_start: -96.8,
        lon_count: 1,
        lon_step: 0.1,
        lat_start: 32.78,
        lat_count: 1,
        lat_step: 0.1
      }

      fixture = File.read!(@single_fixture)
      {:ok, result} = Wgrib2.extract_grid(fixture, ":TMP:", spec)

      assert map_size(result) == 1
      [{lat, lon}] = Map.keys(result)
      assert_in_delta lat, 32.78, 0.001
      assert_in_delta lon, -96.8, 0.001
    end

    test "undefined cells (HRRR mask) do not leak the 9.999e20 sentinel" do
      # Middle of the Pacific, outside HRRR CONUS.
      spec = %{
        lon_start: -170.0,
        lon_count: 2,
        lon_step: 0.1,
        lat_start: 20.0,
        lat_count: 2,
        lat_step: 0.1
      }

      fixture = File.read!(@single_fixture)
      {:ok, result} = Wgrib2.extract_grid(fixture, ":TMP:", spec)

      for {_point, cell} <- result do
        for {_key, v} <- cell do
          assert v < 1.0e10, "sentinel leaked: #{v}"
        end
      end
    end
  end
end
