defmodule Microwaveprop.RoverPlanningTest do
  use Microwaveprop.DataCase, async: true

  alias Microwaveprop.AccountsFixtures
  alias Microwaveprop.Repo
  alias Microwaveprop.Rover
  alias Microwaveprop.RoverPlanning
  alias Microwaveprop.RoverPlanning.Mission
  alias Microwaveprop.Terrain.ElevationClient

  setup do
    # Oban runs inline in test, so RoverPathProfileWorker fires immediately
    # when the context enqueues jobs. Stub elevation lookups so the worker
    # can complete without hitting the real API.
    Req.Test.stub(ElevationClient, fn conn ->
      params = Plug.Conn.fetch_query_params(conn).query_params
      lat_count = params["latitude"] |> String.split(",") |> length()
      Req.Test.json(conn, %{"elevation" => List.duplicate(200.0, lat_count)})
    end)

    :ok
  end

  defp create_rover_location(attrs) do
    user = AccountsFixtures.user_fixture()

    {:ok, loc} =
      Rover.create_location(
        user,
        Map.merge(%{lat: 32.5, lon: -97.5, status: :good}, attrs)
      )

    loc
  end

  defp valid_attrs(extra \\ %{}) do
    Map.merge(
      %{
        "name" => "Test mission",
        "band_mhz" => 10_000,
        "only_known_good" => true,
        "rover_height_ft" => 8.0,
        "station_height_ft" => 30.0,
        "stations" => %{
          "0" => %{"input" => "EM12kp", "position" => 0}
        }
      },
      extra
    )
  end

  describe "create_mission/2" do
    test "inserts a mission with stations and enqueues a path per (rover × station) pair" do
      user = AccountsFixtures.user_fixture()
      _ideal_loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
      _off_limits = create_rover_location(%{lat: 33.0, lon: -98.0, status: :bad})

      assert {:ok, mission} = RoverPlanning.create_mission(user, valid_attrs())
      assert mission.user_id == user.id
      assert [station] = mission.stations
      assert station.grid == "EM12KP"
      assert is_float(station.lat)
      assert is_float(station.lon)

      paths = RoverPlanning.list_paths(mission)
      # only_known_good=true → only the :good location pairs with the station.
      # Oban runs inline in test so the worker has already completed.
      assert length(paths) == 1
      assert hd(paths).status in [:complete, :failed]
    end

    test "completed path caches the link-budget loss components" do
      user = AccountsFixtures.user_fixture()
      _loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})

      assert {:ok, mission} = RoverPlanning.create_mission(user, valid_attrs())
      [path] = RoverPlanning.list_paths(mission)
      assert path.status == :complete

      # The result map MUST carry the cached loss-budget so the show
      # page (and any future caller) can render link math without
      # re-running the computation. Values come from the band's
      # absorption coefficients × distance + free-space path loss + the
      # already-cached terrain diffraction. Stored as floats.
      assert is_float(path.result["free_space_loss_db"])
      assert is_float(path.result["oxygen_loss_db"])
      assert is_float(path.result["humidity_loss_db_baseline"])
      assert is_float(path.result["total_baseline_loss_db"])
      # Free-space loss for a ~140 km, 10 GHz path is ~155 dB; sanity
      # check it's in the right order of magnitude (not zero, not 1000).
      assert path.result["free_space_loss_db"] > 100
      assert path.result["free_space_loss_db"] < 200
    end

    test "completed path stores a propagation_score key (may be nil without scores file)" do
      user = AccountsFixtures.user_fixture()
      _loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})

      assert {:ok, mission} = RoverPlanning.create_mission(user, valid_attrs())
      [path] = RoverPlanning.list_paths(mission)
      # The worker is the gate: status only flips to :complete after the
      # path-calculator pipeline (terrain + propagation lookup) has run.
      # The score itself is nil in the test env (no .prop file on disk),
      # but the key MUST be present so the show-page can render "—"
      # without a key-existence check at every cell.
      assert path.status == :complete
      assert Map.has_key?(path.result, "propagation_score")
    end

    test "backfill_paths/0 re-runs failed paths across all missions" do
      user = AccountsFixtures.user_fixture()
      _loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})

      assert {:ok, mission} = RoverPlanning.create_mission(user, valid_attrs())
      [path] = RoverPlanning.list_paths(mission)
      assert path.status == :complete

      # Simulate a path that was stuck in :failed (e.g. transient
      # elevation API error during the original computation).
      {:ok, _} =
        path
        |> Ecto.Changeset.change(%{status: :failed, error: "transient", result: nil})
        |> Repo.update()

      assert :ok = RoverPlanning.backfill_paths()

      # Oban inline → backfill enqueues a job, the worker reruns
      # immediately, and the path flips back to :complete.
      [reloaded] = RoverPlanning.list_paths(mission)
      assert reloaded.status == :complete
      assert is_map(reloaded.result)
    end

    test "backfill_paths/0 inserts missing rows for under-populated missions" do
      user = AccountsFixtures.user_fixture()
      _loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})

      assert {:ok, mission} = RoverPlanning.create_mission(user, valid_attrs())
      # Simulate a half-broken mission: paths were never persisted (e.g.
      # the original create_mission Multi succeeded but the Oban insert
      # was lost due to a crash). Backfill must repopulate the matrix.
      Repo.delete_all(Ecto.Query.from(p in RoverPlanning.Path, where: p.mission_id == ^mission.id))
      assert RoverPlanning.list_paths(mission) == []

      assert :ok = RoverPlanning.backfill_paths()

      assert [path] = RoverPlanning.list_paths(mission)
      assert path.status == :complete
    end

    test "multi-band mission inserts one path per (rover, station, band)" do
      user = AccountsFixtures.user_fixture()
      _loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})

      attrs =
        valid_attrs(%{
          "bands_mhz" => [10_000, 24_000, 47_000],
          # Form may still include band_mhz (legacy primary band); normalize
          # should overwrite it from bands_mhz in any case.
          "band_mhz" => 10_000
        })

      assert {:ok, mission} = RoverPlanning.create_mission(user, attrs)
      assert mission.bands_mhz == [10_000, 24_000, 47_000]

      paths = RoverPlanning.list_paths(mission)
      assert length(paths) == 3
      assert Enum.sort(Enum.map(paths, & &1.band_mhz)) == [10_000, 24_000, 47_000]
    end

    test "reconcile drops paths whose band is no longer requested" do
      user = AccountsFixtures.user_fixture()
      _loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})

      attrs = valid_attrs(%{"bands_mhz" => [10_000, 24_000]})
      assert {:ok, mission} = RoverPlanning.create_mission(user, attrs)
      assert length(RoverPlanning.list_paths(mission)) == 2

      # User narrows the mission to a single band — reconcile should
      # remove the now-unused 24 GHz row, keep the 10 GHz row.
      mission =
        mission
        |> Mission.changeset(%{"bands_mhz" => [10_000]})
        |> Repo.update!()
        |> Repo.preload([:user, :stations], force: true)

      :ok = RoverPlanning.replace_mission_paths(mission)

      paths = RoverPlanning.list_paths(mission)
      assert length(paths) == 1
      assert hd(paths).band_mhz == 10_000
    end

    test "with only_known_good=false, all rover-locations get a path entry" do
      user = AccountsFixtures.user_fixture()
      _ideal = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
      _off = create_rover_location(%{lat: 33.0, lon: -98.0, status: :bad})

      attrs = valid_attrs(%{"only_known_good" => false})
      assert {:ok, mission} = RoverPlanning.create_mission(user, attrs)
      assert length(RoverPlanning.list_paths(mission)) == 2
    end

    test "missing stations returns a changeset error" do
      user = AccountsFixtures.user_fixture()
      attrs = Map.delete(valid_attrs(), "stations")

      assert {:error, %Ecto.Changeset{} = cs} = RoverPlanning.create_mission(user, attrs)
      refute cs.valid?
      assert {_, _} = cs.errors[:stations] || hd(cs.errors)
    end
  end

  describe "update_mission/3" do
    test "owner can update a mission and paths get rebuilt" do
      user = AccountsFixtures.user_fixture()
      _ = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
      {:ok, mission} = RoverPlanning.create_mission(user, valid_attrs())

      [original] = RoverPlanning.list_paths(mission)

      attrs = valid_attrs(%{"name" => "Updated"})
      assert {:ok, updated} = RoverPlanning.update_mission(user, mission.id, attrs)
      assert updated.name == "Updated"

      # Old path was deleted by replace, fresh row inserted with new id.
      [fresh] = RoverPlanning.list_paths(updated)
      refute fresh.id == original.id
    end

    test "non-owner update is denied" do
      owner = AccountsFixtures.user_fixture()
      other = AccountsFixtures.user_fixture()
      _ = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
      {:ok, mission} = RoverPlanning.create_mission(owner, valid_attrs())

      assert {:error, :not_found} =
               RoverPlanning.update_mission(other, mission.id, valid_attrs())
    end

    test "admin can update any mission" do
      owner = AccountsFixtures.user_fixture()
      admin = AccountsFixtures.user_fixture()
      {:ok, admin} = Microwaveprop.Accounts.admin_update_user(admin, %{is_admin: true})
      _ = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
      {:ok, mission} = RoverPlanning.create_mission(owner, valid_attrs())

      assert {:ok, updated} =
               RoverPlanning.update_mission(admin, mission.id, valid_attrs(%{"name" => "By admin"}))

      assert updated.name == "By admin"
    end
  end

  describe "delete_mission/2" do
    test "owner can delete; non-owner cannot" do
      owner = AccountsFixtures.user_fixture()
      other = AccountsFixtures.user_fixture()
      {:ok, mission} = RoverPlanning.create_mission(owner, valid_attrs())

      assert {:error, :not_found} = RoverPlanning.delete_mission(other, mission.id)
      assert {:ok, _} = RoverPlanning.delete_mission(owner, mission.id)
      refute Repo.get(Mission, mission.id)
    end
  end
end
