defmodule Microwaveprop.Propagation.ScoresFileTest do
  # async: false — tests mutate the global Application env to redirect
  # the scores dir to a private tmp tree. Running these in parallel
  # would have concurrent tests trample each other's directory setting.
  use ExUnit.Case, async: false

  alias Microwaveprop.Propagation.Grid
  alias Microwaveprop.Propagation.ScoresFile

  setup do
    dir =
      Path.join(
        System.tmp_dir!(),
        "scores_file_test_#{System.unique_integer([:positive])}"
      )

    File.mkdir_p!(dir)

    prev = Application.get_env(:microwaveprop, :propagation_scores_dir)
    Application.put_env(:microwaveprop, :propagation_scores_dir, dir)

    on_exit(fn ->
      File.rm_rf!(dir)

      if prev do
        Application.put_env(:microwaveprop, :propagation_scores_dir, prev)
      else
        Application.delete_env(:microwaveprop, :propagation_scores_dir)
      end
    end)

    %{dir: dir}
  end

  describe "path_for/2" do
    test "nests files under base_dir/{band_mhz}/ with an ISO-8601 valid_time", %{dir: dir} do
      path = ScoresFile.path_for(10_000, ~U[2026-04-14 18:00:00Z])
      assert path == Path.join([dir, "10000", "2026-04-14T18:00:00Z.ntms"])
    end
  end

  describe "write!/3 + read/2 round trip" do
    test "preserves the score for each scored grid point", %{dir: _dir} do
      valid_time = ~U[2026-04-14 18:00:00Z]

      scores = [
        %{lat: 25.0, lon: -125.0, score: 10},
        %{lat: 25.0, lon: -124.875, score: 20},
        %{lat: 25.125, lon: -125.0, score: 30}
      ]

      ScoresFile.write!(10_000, valid_time, scores)

      assert {:ok, payload} = ScoresFile.read(10_000, valid_time)
      assert payload.band_mhz == 10_000
      assert DateTime.compare(payload.valid_time, valid_time) == :eq
      assert payload.lat_min == Grid.bounds().lat_min
      assert payload.lon_min == Grid.bounds().lon_min
      assert payload.step == Grid.step()

      # The three scored cells we wrote come back at their grid positions.
      assert score_at(payload, 25.0, -125.0) == 10
      assert score_at(payload, 25.0, -124.875) == 20
      assert score_at(payload, 25.125, -125.0) == 30
    end

    test "unscored cells read back as 255 (no-data sentinel)" do
      valid_time = ~U[2026-04-14 18:00:00Z]
      ScoresFile.write!(10_000, valid_time, [%{lat: 25.0, lon: -125.0, score: 42}])

      {:ok, payload} = ScoresFile.read(10_000, valid_time)
      assert score_at(payload, 25.0, -125.0) == 42
      # Any cell we didn't write should be the no-data sentinel.
      assert score_at(payload, 26.5, -100.0) == 255
    end
  end

  describe "read/2 errors" do
    test "returns {:error, :enoent} when no file exists for the band/time" do
      assert {:error, :enoent} = ScoresFile.read(10_000, ~U[2026-04-14 18:00:00Z])
    end

    test "returns {:error, :invalid_format} for a file that isn't a score grid" do
      path = ScoresFile.path_for(10_000, ~U[2026-04-14 18:00:00Z])
      File.mkdir_p!(Path.dirname(path))
      File.write!(path, "not a score file")

      assert {:error, :invalid_format} = ScoresFile.read(10_000, ~U[2026-04-14 18:00:00Z])
    end
  end

  describe "atomic write" do
    test "leaves no .tmp files behind after a successful write", %{dir: dir} do
      ScoresFile.write!(10_000, ~U[2026-04-14 18:00:00Z], [%{lat: 25.0, lon: -125.0, score: 1}])
      tmp_files = find_files_matching(dir, ~r/\.tmp\./)
      assert tmp_files == []
    end
  end

  describe "list_valid_times/1" do
    test "returns files sorted ascending for a band" do
      ScoresFile.write!(10_000, ~U[2026-04-14 18:00:00Z], [])
      ScoresFile.write!(10_000, ~U[2026-04-14 16:00:00Z], [])
      ScoresFile.write!(10_000, ~U[2026-04-14 17:00:00Z], [])
      # Unrelated band's files shouldn't leak in.
      ScoresFile.write!(24_000, ~U[2026-04-14 20:00:00Z], [])

      assert ScoresFile.list_valid_times(10_000) == [
               ~U[2026-04-14 16:00:00Z],
               ~U[2026-04-14 17:00:00Z],
               ~U[2026-04-14 18:00:00Z]
             ]
    end

    test "returns an empty list when the band dir doesn't exist" do
      assert ScoresFile.list_valid_times(75_000) == []
    end
  end

  describe "latest_valid_time/0" do
    test "returns the max valid_time across every band on disk" do
      ScoresFile.write!(10_000, ~U[2026-04-14 16:00:00Z], [])
      ScoresFile.write!(24_000, ~U[2026-04-14 18:00:00Z], [])
      ScoresFile.write!(10_000, ~U[2026-04-14 17:00:00Z], [])

      assert ScoresFile.latest_valid_time() == ~U[2026-04-14 18:00:00Z]
    end

    test "returns nil on an empty tree" do
      assert ScoresFile.latest_valid_time() == nil
    end
  end

  describe "read_bounds/3" do
    test "returns every scored cell (no-data excluded) when bounds is nil" do
      ScoresFile.write!(10_000, ~U[2026-04-14 18:00:00Z], [
        %{lat: 25.0, lon: -125.0, score: 10},
        %{lat: 25.125, lon: -125.0, score: 20}
      ])

      scores = ScoresFile.read_bounds(10_000, ~U[2026-04-14 18:00:00Z])

      assert [
               %{lat: 25.0, lon: -125.0, score: 10},
               %{lat: 25.125, lon: -125.0, score: 20}
             ] = Enum.sort_by(scores, & &1.lat)
    end

    test "restricts results to the bounding box when provided" do
      ScoresFile.write!(10_000, ~U[2026-04-14 18:00:00Z], [
        %{lat: 25.0, lon: -125.0, score: 10},
        %{lat: 49.875, lon: -66.0, score: 90}
      ])

      bounds = %{"south" => 24.0, "north" => 26.0, "west" => -126.0, "east" => -124.0}
      scores = ScoresFile.read_bounds(10_000, ~U[2026-04-14 18:00:00Z], bounds)

      assert scores == [%{lat: 25.0, lon: -125.0, score: 10}]
    end

    test "returns an empty list when the file doesn't exist" do
      assert ScoresFile.read_bounds(10_000, ~U[2026-04-14 18:00:00Z]) == []
    end
  end

  describe "read_point/4" do
    test "returns the score for a grid cell by (lat, lon) lookup" do
      ScoresFile.write!(10_000, ~U[2026-04-14 18:00:00Z], [
        %{lat: 25.0, lon: -125.0, score: 42}
      ])

      assert ScoresFile.read_point(10_000, ~U[2026-04-14 18:00:00Z], 25.0, -125.0) == 42
    end

    test "returns nil for cells that weren't scored" do
      ScoresFile.write!(10_000, ~U[2026-04-14 18:00:00Z], [
        %{lat: 25.0, lon: -125.0, score: 42}
      ])

      assert ScoresFile.read_point(10_000, ~U[2026-04-14 18:00:00Z], 40.0, -100.0) == nil
    end

    test "returns nil when the file is missing" do
      assert ScoresFile.read_point(10_000, ~U[2026-04-14 18:00:00Z], 25.0, -125.0) == nil
    end
  end

  describe "retain_window/2" do
    test "deletes files outside [run_time, run_time + hours] across every band" do
      run_time = ~U[2026-04-14 16:00:00Z]

      ScoresFile.write!(10_000, ~U[2026-04-14 15:00:00Z], [])
      ScoresFile.write!(10_000, ~U[2026-04-14 16:00:00Z], [])
      ScoresFile.write!(10_000, ~U[2026-04-14 17:00:00Z], [])
      ScoresFile.write!(24_000, ~U[2026-04-14 14:00:00Z], [])
      ScoresFile.write!(24_000, ~U[2026-04-14 16:00:00Z], [])
      ScoresFile.write!(24_000, ~U[2026-04-15 10:00:00Z], [])
      ScoresFile.write!(24_000, ~U[2026-04-15 11:00:00Z], [])

      # Window: 16:00 -> 10:00 next day (18 hours forward).
      assert ScoresFile.retain_window(run_time, 18) == 3

      assert ScoresFile.list_valid_times(10_000) == [
               ~U[2026-04-14 16:00:00Z],
               ~U[2026-04-14 17:00:00Z]
             ]

      assert ScoresFile.list_valid_times(24_000) == [
               ~U[2026-04-14 16:00:00Z],
               ~U[2026-04-15 10:00:00Z]
             ]
    end

    test "returns 0 and leaves foreign files alone", %{dir: dir} do
      run_time = ~U[2026-04-14 16:00:00Z]
      stray = Path.join(dir, "some_other_file.txt")
      File.write!(stray, "keep me")

      assert ScoresFile.retain_window(run_time, 18) == 0
      assert File.exists?(stray)
    end
  end

  describe "prune_older_than/1" do
    test "deletes files whose valid_time is strictly before the cutoff", %{dir: _dir} do
      old = ~U[2026-04-14 10:00:00Z]
      fresh = ~U[2026-04-14 18:00:00Z]

      ScoresFile.write!(10_000, old, [%{lat: 25.0, lon: -125.0, score: 1}])
      ScoresFile.write!(10_000, fresh, [%{lat: 25.0, lon: -125.0, score: 2}])

      cutoff = ~U[2026-04-14 15:00:00Z]
      assert ScoresFile.prune_older_than(cutoff) == 1

      assert {:error, :enoent} = ScoresFile.read(10_000, old)
      assert {:ok, _} = ScoresFile.read(10_000, fresh)
    end

    test "leaves foreign files in the scores dir alone", %{dir: dir} do
      stray = Path.join(dir, "some_other_file.txt")
      File.write!(stray, "do not touch")

      assert ScoresFile.prune_older_than(~U[2099-01-01 00:00:00Z]) == 0
      assert File.exists?(stray)
    end
  end

  # Look up the score for (lat, lon) in the binary returned by read/2.
  defp score_at(payload, lat, lon) do
    row = round((lat - payload.lat_min) / payload.step)
    col = round((lon - payload.lon_min) / payload.step)
    idx = row * payload.n_cols + col
    :binary.at(payload.scores, idx)
  end

  defp find_files_matching(dir, pattern) do
    dir
    |> Path.join("**/*")
    |> Path.wildcard()
    |> Enum.filter(fn path -> File.regular?(path) and Regex.match?(pattern, path) end)
  end
end
