defmodule MicrowavepropWeb.MapLiveTest do
  use MicrowavepropWeb.ConnCase, async: false

  import Phoenix.LiveViewTest

  alias Microwaveprop.Propagation
  alias Microwaveprop.Propagation.ScoreCache
  alias Phoenix.LiveView.Utils

  describe "mount" do
    test "renders full-page map", %{conn: conn} do
      {:ok, _lv, html} = live(conn, ~p"/map")
      assert html =~ ~s(id="propagation-map")
      assert html =~ ~s(phx-hook="PropagationMap")
    end

    test "renders band dropdown with all bands", %{conn: conn} do
      {:ok, _lv, html} = live(conn, ~p"/map")
      assert html =~ "10 GHz"
      assert html =~ "24 GHz"
      assert html =~ "47 GHz"
      assert html =~ "75 GHz"
      assert html =~ "241 GHz"
    end

    test "defaults to 10 GHz selected", %{conn: conn} do
      {:ok, _lv, html} = live(conn, ~p"/map")
      assert html =~ ~s(class="active")
    end

    test "includes data-scores attribute", %{conn: conn} do
      {:ok, _lv, html} = live(conn, ~p"/map")
      assert html =~ "data-scores"
    end

    test "renders grid toggle", %{conn: conn} do
      {:ok, _lv, html} = live(conn, ~p"/map")
      assert html =~ "Grid squares"
      assert html =~ ~s(phx-click="toggle_grid")
    end

    test "renders scoring algorithm link", %{conn: conn} do
      {:ok, _lv, html} = live(conn, ~p"/map")
      assert html =~ "Scoring Algorithm"
      assert html =~ ~s(/algo)
    end

    test "renders submit QSO link", %{conn: conn} do
      {:ok, _lv, html} = live(conn, ~p"/map")
      assert html =~ "Submit a Contact"
      assert html =~ ~s(/submit)
    end
  end

  describe "CONUS-only banner" do
    @banner_copy "This data set currently covers the continental United States only."

    test "is hidden when no CF lat/lon in session", %{conn: conn} do
      {:ok, _lv, html} = live(conn, ~p"/map")
      refute html =~ ~s(id="conus-banner")
      refute html =~ @banner_copy
    end

    test "is hidden when CF lat/lon is inside CONUS (DFW)", %{conn: conn} do
      conn = Phoenix.ConnTest.init_test_session(conn, %{cf_lat: 32.9, cf_lon: -97.0})
      {:ok, _lv, html} = live(conn, ~p"/map")
      refute html =~ ~s(id="conus-banner")
      refute html =~ @banner_copy
    end

    test "is visible when CF lat/lon is outside CONUS (London)", %{conn: conn} do
      conn = Phoenix.ConnTest.init_test_session(conn, %{cf_lat: 51.5, cf_lon: -0.1})
      {:ok, _lv, html} = live(conn, ~p"/map")
      assert html =~ ~s(id="conus-banner")
      assert html =~ @banner_copy
    end
  end

  describe "select_band" do
    test "updates selected band on event", %{conn: conn} do
      {:ok, lv, _html} = live(conn, ~p"/map")

      html = render_click(lv, "select_band", %{"value" => 24_000})

      assert html =~ "24 GHz"
    end
  end

  describe "compute_viewshed" do
    test "accepts compute_viewshed event without crashing", %{conn: conn} do
      {:ok, lv, _html} = live(conn, ~p"/map")
      # Event should be accepted; async computation may fail (no SRTM in test)
      # but the LiveView should not crash
      render_click(lv, "compute_viewshed", %{"lat" => 32.9, "lon" => -97.0})
      # If we get here without crash, the event handler exists
      assert render(lv) =~ "propagation-map"
    end
  end

  describe "toggle_grid" do
    test "toggles grid visibility", %{conn: conn} do
      {:ok, lv, html} = live(conn, ~p"/map")
      refute html =~ "checked"

      html = render_click(lv, "toggle_grid")
      assert html =~ "checked"

      html = render_click(lv, "toggle_grid")
      refute html =~ "checked"
    end
  end

  describe "daily outlook strip" do
    test "no longer renders on the main map", %{conn: conn} do
      {:ok, _lv, html} = live(conn, ~p"/map")
      refute html =~ "daily-outlook"
      refute html =~ "7-day outlook"
      refute html =~ "Extended outlook"
    end
  end

  describe "data timestamp indicator" do
    test "renders a 'Data from …' line on both mobile and desktop sidebars", %{conn: conn} do
      {:ok, _lv, html} = live(conn, ~p"/map")
      assert html =~ ~s(id="data-timestamp-mobile")
      assert html =~ ~s(id="data-timestamp-desktop")
      # Clean test DB has no scores; the indicator falls back to "No data".
      assert html =~ "No data"
    end
  end

  describe "URL params" do
    test "band URL param selects the requested band on mount", %{conn: conn} do
      {:ok, _lv, html} = live(conn, ~p"/map?band=24000")
      assert html =~ ~s(data-selected-band="24000")
    end

    test "lat/lon/zoom URL params override the default map center/zoom", %{conn: conn} do
      {:ok, _lv, html} = live(conn, ~p"/map?lat=40.0&lon=-100.0&zoom=5")
      assert html =~ ~s(data-center-lat="40.0")
      assert html =~ ~s(data-center-lon="-100.0")
      assert html =~ ~s(data-zoom="5")
    end

    test "invalid band falls back to the default", %{conn: conn} do
      {:ok, _lv, html} = live(conn, ~p"/map?band=notanumber")
      assert html =~ ~s(data-selected-band="10000")
    end

    test "unknown band MHz falls back to the default", %{conn: conn} do
      {:ok, _lv, html} = live(conn, ~p"/map?band=99999")
      assert html =~ ~s(data-selected-band="10000")
    end

    test "malformed lat/lon/zoom are ignored", %{conn: conn} do
      {:ok, _lv, html} = live(conn, ~p"/map?lat=banana&lon=apple&zoom=over9000")
      assert html =~ ~s(data-zoom="7")
      refute html =~ ~s(data-center-lat="banana")
    end

    test "out-of-range lat/lon are ignored", %{conn: conn} do
      {:ok, _lv, html} = live(conn, ~p"/map?lat=500&lon=-999")
      refute html =~ ~s(data-center-lat="500)
    end

    test "out-of-range zoom falls back to default", %{conn: conn} do
      {:ok, _lv, html} = live(conn, ~p"/map?zoom=42")
      assert html =~ ~s(data-zoom="7")
    end

    test "unparseable time is ignored", %{conn: conn} do
      {:ok, lv, _html} = live(conn, ~p"/map?t=not-a-date")
      # Page renders normally without crashing.
      assert render(lv) =~ "data-selected-time"
    end

    test "ISO8601 time outside the current forecast window falls back", %{conn: conn} do
      # Year 2000 is well before any valid HRRR data; page should still
      # mount and fall back to the normal 'now' cursor rather than try
      # to fetch scores for an empty time.
      {:ok, _lv, _html} = live(conn, ~p"/map?t=2000-01-01T00:00:00Z")
    end

    test "every param garbage at once still mounts cleanly", %{conn: conn} do
      {:ok, _lv, html} =
        live(conn, ~p"/map?band=xyz&t=nope&lat=abc&lon=def&zoom=foo")

      assert html =~ ~s(data-selected-band="10000")
      assert html =~ ~s(data-zoom="7")
    end
  end

  describe "cursor_for_now/2" do
    alias MicrowavepropWeb.MapLive

    test "returns nil for an empty list" do
      assert MapLive.cursor_for_now([], DateTime.utc_now()) == nil
    end

    test "picks the latest time at or before now — never future" do
      # Valid times at 18:00, 19:00, 20:00 UTC. Wall clock 19:35.
      # Nearest-by-abs would round up to 20:00 (25 min future);
      # floor picks 19:00 (35 min past).
      times = [~U[2026-04-18 18:00:00Z], ~U[2026-04-18 19:00:00Z], ~U[2026-04-18 20:00:00Z]]
      now = ~U[2026-04-18 19:35:00Z]

      assert MapLive.cursor_for_now(times, now) == ~U[2026-04-18 19:00:00Z]
    end

    test "matches exact hour when now is on the hour" do
      times = [~U[2026-04-18 18:00:00Z], ~U[2026-04-18 19:00:00Z]]
      assert MapLive.cursor_for_now(times, ~U[2026-04-18 19:00:00Z]) == ~U[2026-04-18 19:00:00Z]
    end

    test "falls back to the earliest time when every slot is in the future" do
      times = [~U[2026-04-18 20:00:00Z], ~U[2026-04-18 21:00:00Z]]
      now = ~U[2026-04-18 19:00:00Z]
      assert MapLive.cursor_for_now(times, now) == ~U[2026-04-18 20:00:00Z]
    end

    test "ignores list order" do
      times = [~U[2026-04-18 20:00:00Z], ~U[2026-04-18 18:00:00Z], ~U[2026-04-18 19:00:00Z]]
      now = ~U[2026-04-18 19:35:00Z]
      assert MapLive.cursor_for_now(times, now) == ~U[2026-04-18 19:00:00Z]
    end
  end

  describe "tracking_now?/2" do
    alias MicrowavepropWeb.MapLive

    test "true when selected time equals the latest at-or-before-now slot" do
      times = [~U[2026-04-18 18:00:00Z], ~U[2026-04-18 19:00:00Z], ~U[2026-04-18 20:00:00Z]]
      now = ~U[2026-04-18 19:35:00Z]
      # closest_to_now at 19:35 with these slots is 19:00.
      assert MapLive.tracking_now?(~U[2026-04-18 19:00:00Z], times, now) == true
    end

    test "false when selected time is an earlier historical slot" do
      times = [~U[2026-04-18 18:00:00Z], ~U[2026-04-18 19:00:00Z], ~U[2026-04-18 20:00:00Z]]
      now = ~U[2026-04-18 19:35:00Z]
      assert MapLive.tracking_now?(~U[2026-04-18 18:00:00Z], times, now) == false
    end

    test "false when selected time is a future forecast slot" do
      times = [~U[2026-04-18 18:00:00Z], ~U[2026-04-18 19:00:00Z], ~U[2026-04-18 20:00:00Z]]
      now = ~U[2026-04-18 19:35:00Z]
      assert MapLive.tracking_now?(~U[2026-04-18 20:00:00Z], times, now) == false
    end

    test "false when valid_times is empty" do
      assert MapLive.tracking_now?(~U[2026-04-18 19:00:00Z], [], ~U[2026-04-18 19:00:00Z]) == false
    end

    test "false when selected_time is nil" do
      times = [~U[2026-04-18 19:00:00Z]]
      assert MapLive.tracking_now?(nil, times, ~U[2026-04-18 19:00:00Z]) == false
    end
  end

  describe "advance_now_cursor handler" do
    setup do
      ScoreCache.clear()
      on_exit(fn -> ScoreCache.clear() end)
      :ok
    end

    test "advances selected_time to the newly-past hour when tracking now", %{conn: conn} do
      # Seed two adjacent hours so there's somewhere to advance to. The
      # later of the two is the one we want the cursor to snap to once
      # the clock ticks past it.
      previous_hour =
        DateTime.utc_now() |> DateTime.truncate(:second) |> Map.merge(%{minute: 0, second: 0}) |> DateTime.add(-1, :hour)

      current_hour = DateTime.add(previous_hour, 3600, :second)

      for t <- [previous_hour, current_hour] do
        Propagation.replace_scores(
          [%{lat: 33.0, lon: -97.0, valid_time: t, band_mhz: 10_000, score: 50, factors: nil}],
          t
        )
      end

      {:ok, lv, _html} = live(conn, ~p"/map")

      # Force the LV onto the previous hour and mark it as tracking_now?
      # so the tick handler has a clear advance opportunity.
      :sys.replace_state(lv.pid, fn state ->
        socket =
          state.socket
          |> Utils.assign(:selected_time, previous_hour)
          |> Utils.assign(:tracking_now?, true)

        %{state | socket: socket}
      end)

      send(lv.pid, :advance_now_cursor)
      _ = render(lv)

      assigns = :sys.get_state(lv.pid).socket.assigns
      assert assigns.selected_time == current_hour
    end

    test "leaves selected_time alone when the user is on a specific hour", %{conn: conn} do
      previous_hour =
        DateTime.utc_now() |> DateTime.truncate(:second) |> Map.merge(%{minute: 0, second: 0}) |> DateTime.add(-1, :hour)

      current_hour = DateTime.add(previous_hour, 3600, :second)

      for t <- [previous_hour, current_hour] do
        Propagation.replace_scores(
          [%{lat: 33.0, lon: -97.0, valid_time: t, band_mhz: 10_000, score: 50, factors: nil}],
          t
        )
      end

      {:ok, lv, _html} = live(conn, ~p"/map")

      :sys.replace_state(lv.pid, fn state ->
        socket =
          state.socket
          |> Utils.assign(:selected_time, previous_hour)
          |> Utils.assign(:tracking_now?, false)

        %{state | socket: socket}
      end)

      send(lv.pid, :advance_now_cursor)
      _ = render(lv)

      assigns = :sys.get_state(lv.pid).socket.assigns
      assert assigns.selected_time == previous_hour
    end
  end

  describe "format_data_timestamp/1" do
    alias MicrowavepropWeb.MapLive

    test "returns 'No data' for nil" do
      assert MapLive.format_data_timestamp(nil) == "No data"
    end

    test "renders the valid_time and a 'now' suffix when within ±30 minutes" do
      now = DateTime.utc_now()
      label = MapLive.format_data_timestamp(now)
      assert label =~ "UTC"
      assert label =~ "now"
    end

    test "renders a '+Nh' suffix when the valid_time is in the future" do
      future = DateTime.add(DateTime.utc_now(), 3 * 3600, :second)
      assert MapLive.format_data_timestamp(future) =~ "+3h"
    end

    test "renders a 'Nh ago' suffix when the valid_time is in the past" do
      past = DateTime.add(DateTime.utc_now(), -4 * 3600, :second)
      assert MapLive.format_data_timestamp(past) =~ "4h ago"
    end
  end

  describe "propagation_updated handler" do
    setup do
      ScoreCache.clear()

      on_exit(fn ->
        ScoreCache.clear()
      end)

      :ok
    end

    test "pushes freshly-written scores when the cache still holds stale ones", %{conn: conn} do
      # Use a valid_time close enough to now that available_valid_times
      # keeps it in the timeline.
      valid_time = DateTime.utc_now() |> DateTime.truncate(:second) |> Map.put(:minute, 0) |> Map.put(:second, 0)

      # Seed the on-disk store and warm the cache with the same content
      # so the map has a baseline to render on mount.
      Propagation.replace_scores(
        [%{lat: 33.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000, score: 40, factors: nil}],
        valid_time
      )

      Propagation.warm_cache_and_broadcast(10_000, valid_time)
      ScoreCache.sync()

      {:ok, lv, _html} = live(conn, ~p"/map")

      # Simulate a new forecast-hour compute: rewrite the .ntms file on
      # disk to the updated score, but do *not* touch the cache. This
      # reproduces the race where the cache_refresh message has not yet
      # been applied by the time MapLive handles propagation_updated.
      Propagation.replace_scores(
        [%{lat: 33.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000, score: 90, factors: nil}],
        valid_time
      )

      # Deliver the propagation_updated PubSub message directly.
      send(lv.pid, {:propagation_updated, [valid_time]})

      # The map must emit the new score (90), not the stale cached one (40).
      assert_push_event(lv, "update_scores", %{scores: scores})
      assert Enum.any?(scores, fn s -> s.score == 90 end)
      refute Enum.any?(scores, fn s -> s.score == 40 end)
    end
  end

  describe "pipeline status chip" do
    test "renders the aggregated pipeline status chip on mount", %{conn: conn} do
      {:ok, _lv, html} = live(conn, ~p"/map")
      # Both the mobile and desktop sidebars carry their own chip.
      assert html =~ ~s(id="pipeline-status-mobile")
      assert html =~ ~s(id="pipeline-status-desktop")
      # With a clean test DB no pipeline workers have ever run.
      assert html =~ ~s(data-pipeline-state="unknown")
    end

    test "shows the current forecast hour while PropagationGridWorker runs", %{conn: conn} do
      # Put a grid-worker row in executing state so PipelineStatus.current/0
      # reports :running. Bypassing Oban's normal insert keeps the SQL
      # sandbox from running the worker inline.
      now = DateTime.utc_now()

      Microwaveprop.Repo.insert!(
        struct!(Oban.Job, %{
          state: "executing",
          queue: "propagation",
          worker: "Microwaveprop.Workers.PropagationGridWorker",
          args: %{},
          attempt: 1,
          max_attempts: 3,
          inserted_at: now,
          scheduled_at: now,
          attempted_at: now
        })
      )

      {:ok, lv, html} = live(conn, ~p"/map")
      # Before any progress message arrives the chip uses the base label.
      assert html =~ "Updating propagation"
      assert html =~ ~s(data-pipeline-state="running")

      future = DateTime.add(DateTime.utc_now(), 3 * 3600, :second)

      Phoenix.PubSub.broadcast(
        Microwaveprop.PubSub,
        "propagation:pipeline",
        {:propagation_pipeline_progress, %{forecast_hour: 5, valid_time: future}}
      )

      html = render(lv)
      assert html =~ "through +3h"

      Phoenix.PubSub.broadcast(
        Microwaveprop.PubSub,
        "propagation:pipeline",
        {:propagation_pipeline_progress, %{forecast_hour: 2, valid_time: DateTime.utc_now()}}
      )

      html = render(lv)
      assert html =~ "through now"
    end
  end
end
