defmodule MicrowavepropWeb.PathLiveTest do
  use MicrowavepropWeb.ConnCase, async: false
  use ExUnitProperties

  import Phoenix.LiveViewTest

  alias Microwaveprop.Ionosphere
  alias Microwaveprop.Propagation
  alias Microwaveprop.Propagation.BandConfig
  alias Microwaveprop.Propagation.ScoreCache
  alias Microwaveprop.Terrain.ElevationClient

  setup do
    ScoreCache.clear()

    # Flat-terrain stub so compute_path can build a result without hitting
    # the real elevation 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)

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

  describe "GET /path (no params)" do
    test "renders the empty form with default band + height values", %{conn: conn} do
      {:ok, _lv, html} = live(conn, ~p"/path")

      assert html =~ "Path Calculator"
      assert html =~ ~s(name="source")
      assert html =~ ~s(name="destination")
      # Default band = 10000 MHz → the "10 GHz" option is selected.
      assert html =~ ~s(value="10000" selected)
    end

    test "renders the supplied form values from URL params", %{conn: conn} do
      {:ok, _lv, html} =
        live(conn, ~p"/path?source=EM13&destination=EM12&band=1296&src_height_ft=45&tx_power_dbm=50")

      # Form echoes what we passed in.
      assert html =~ ~s(value="EM13")
      assert html =~ ~s(value="EM12")
      assert html =~ ~s(value="1296" selected)
      assert html =~ ~s(value="45")
      assert html =~ ~s(value="50")
    end
  end

  describe "handle_event calculate + update_form" do
    test "calculate submits the form and patches the URL with the fields", %{conn: conn} do
      {:ok, lv, _html} = live(conn, ~p"/path")

      render_submit(
        form(lv, "form",
          source: "EM13",
          destination: "EM12",
          band: "1296"
        )
      )

      # handle_event("calculate", ...) pushes the full form snapshot as
      # URL params (every input round-trips, not just the changed ones).
      assert_patch(lv)
      html = render(lv)
      assert html =~ ~s(value="EM13")
      assert html =~ ~s(value="EM12")
      assert html =~ ~s(value="1296" selected)
    end

    test "update_form event keeps a form change locally without navigating", %{conn: conn} do
      {:ok, lv, _html} = live(conn, ~p"/path?source=EM13&destination=EM12")

      html =
        render_change(
          form(lv, "form",
            source: "EM13",
            destination: "EM00",
            band: "10000"
          )
        )

      # New destination reflected in the rendered form (bound assign),
      # but we don't assert on URL because update_form doesn't patch.
      assert html =~ ~s(value="EM00")
    end

    test "gps_location event fills source with the received lat/lon", %{conn: conn} do
      {:ok, lv, _html} = live(conn, ~p"/path")

      # Simulate the JS hook pushing a fix back after request_gps.
      render_hook(lv, "gps_location", %{"lat" => 32.9, "lon" => -97.0})

      html = render(lv)
      assert html =~ "32.9"
      assert html =~ "-97.0"
    end

    test "?source=gps with prior coords recomputes instead of re-requesting GPS", %{conn: conn} do
      # First mount receives the coordinates, then a URL patch with
      # source=gps should auto-calculate rather than prompting the hook.
      {:ok, lv, _html} = live(conn, ~p"/path?source=gps&destination=EM12&band=1296")

      # Initial render sends request_gps.
      assert_push_event(lv, "request_gps", %{})

      # Push a GPS fix into the LV.
      render_hook(lv, "gps_location", %{"lat" => 33.0, "lon" => -97.0})

      # URL patch keeps source=gps.
      assert render(lv) =~ "33.0"
    end
  end

  describe "update_form event" do
    test "rebinds form assigns from arbitrary params and fills defaults for missing keys", %{conn: conn} do
      {:ok, lv, _html} = live(conn, ~p"/path")

      html = render_hook(lv, "update_form", %{"source" => "EM13ng"})
      assert html =~ ~s(value="EM13ng")
      # Unspecified fields fall back to their defaults.
      assert html =~ ~s(value="10000" selected)
      assert html =~ ~s(value="30")
    end

    test "accepts the full form payload with every height/gain field", %{conn: conn} do
      {:ok, lv, _html} = live(conn, ~p"/path")

      html =
        render_hook(lv, "update_form", %{
          "source" => "32.5,-97.0",
          "destination" => "33.5,-97.0",
          "band" => "432",
          "src_height_ft" => "50",
          "dst_height_ft" => "60",
          "tx_power_dbm" => "40",
          "src_gain_dbi" => "20",
          "dst_gain_dbi" => "25"
        })

      assert html =~ ~s(value="32.5,-97.0")
      assert html =~ ~s(value="432" selected)
      assert html =~ ~s(value="50")
      assert html =~ ~s(value="60")
      assert html =~ ~s(value="40")
      assert html =~ ~s(value="20")
      assert html =~ ~s(value="25")
    end
  end

  describe "propagation_updated handler" do
    test "re-reads and re-renders the forecast when new scores arrive", %{conn: conn} do
      # Path midpoint is (33.0, -97.0) — the point point_forecast queries.
      {mid_lat, mid_lon} = {33.0, -97.0}

      # Seed three hourly valid_times with a LOW score of 30.
      base =
        DateTime.utc_now()
        |> DateTime.truncate(:second)
        |> Map.put(:minute, 0)
        |> Map.put(:second, 0)

      times = for h <- 0..2, do: DateTime.add(base, h * 3600, :second)

      Enum.each(times, fn t ->
        Propagation.replace_scores(
          [%{lat: mid_lat, lon: mid_lon, valid_time: t, band_mhz: 10_000, score: 30, factors: nil}],
          t
        )
      end)

      {:ok, lv, _html} =
        live(conn, ~p"/path?source=32.5,-97.0&destination=33.5,-97.0&band=10000")

      initial = render(lv)
      assert initial =~ "Propagation Forecast"
      assert initial =~ ~r/Best:.*?<span[^>]*>\s*30\s*<\/span>/s

      # Publish a new forecast: same times, new score of 80.
      Enum.each(times, fn t ->
        Propagation.replace_scores(
          [%{lat: mid_lat, lon: mid_lon, valid_time: t, band_mhz: 10_000, score: 80, factors: nil}],
          t
        )
      end)

      send(lv.pid, {:propagation_updated, times})

      refreshed = render(lv)
      assert refreshed =~ ~r/Best:.*?<span[^>]*>\s*80\s*<\/span>/s
      refute refreshed =~ ~r/Best:.*?<span[^>]*>\s*30\s*<\/span>/s
    end
  end

  describe "ionosphere panel" do
    setup do
      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

    test "renders Es panel with live foEs and score when nearest station has fresh data", %{conn: conn} do
      # Seed Millstone Hill with an extreme-Es observation (foEs = 18 MHz).
      recent = DateTime.truncate(DateTime.utc_now(), :second)

      {:ok, _} =
        Ionosphere.upsert_observations("MHJ45", [
          %{valid_time: recent, fo_es_mhz: 18.0, fo_f2_mhz: 9.0, mufd_mhz: 28.0}
        ])

      # 52.6N, -71.5W to 32.6N, -71.5W → 2220 km pure N-S across
      # Millstone Hill's latitude (midpoint 42.6N).
      {:ok, lv, _} = live(conn, ~p"/path?source=52.6,-71.5&destination=32.6,-71.5&band=144")

      html = render(lv)
      assert html =~ "Ionosphere"
      assert html =~ "MHJ45"
      assert html =~ "foEs"
      assert html =~ ~r/18\.\d/
    end

    test "renders 'tropo only' notice when path is outside the single-hop Es window", %{conn: conn} do
      recent = DateTime.truncate(DateTime.utc_now(), :second)

      {:ok, _} =
        Ionosphere.upsert_observations("MHJ45", [
          %{valid_time: recent, fo_es_mhz: 18.0, fo_f2_mhz: 9.0, mufd_mhz: 28.0}
        ])

      # ~100 km path — way under the 500 km Es minimum.
      {:ok, lv, _} = live(conn, ~p"/path?source=42.6,-71.5&destination=43.5,-71.5&band=144")

      html = render(lv)
      assert html =~ "Ionosphere"
      assert html =~ ~r/out of range|not applicable|tropo only/i
    end

    test "omits the Es panel entirely when nearest ionosonde has no recent data", %{conn: conn} do
      {:ok, lv, _} = live(conn, ~p"/path?source=42.6,-71.5&destination=32.6,-71.5&band=144")

      html = render(lv)
      refute html =~ "Ionosphere"
    end
  end

  describe "input parsing + rendering edge cases" do
    test "renders with coordinate-pair source + destination (no geocoding)", %{conn: conn} do
      {:ok, lv, _html} =
        live(conn, ~p"/path?source=33.0,-97.0&destination=33.5,-97.5&band=10000")

      # A second render after the initial mount drains the self-sent
      # {:compute_path, ...} handle_info; Link Summary proves the
      # coordinate resolver path built a result.
      html = render(lv)
      assert html =~ "Link Summary"
      assert html =~ "Distance"
    end

    test "invalid grid square in destination surfaces an error", %{conn: conn} do
      # "EM12ZZ" passes the LV's lenient `maidenhead?` prefix check but
      # fails Maidenhead.to_latlon because subsquare chars must be A-X.
      {:ok, lv, _html} =
        live(conn, ~p"/path?source=33.0,-97.0&destination=EM12ZZ&band=10000")

      html = render(lv)
      assert html =~ "Invalid grid square"
    end

    test "missing destination keeps the form visible with no result panel", %{conn: conn} do
      {:ok, _lv, html} = live(conn, ~p"/path?source=33.0,-97.0&band=10000")

      assert html =~ "Path Calculator"
      refute html =~ "Link Summary"
    end

    test "missing keys on update_form fall back to defaults (nil-safe assignment)", %{conn: conn} do
      {:ok, lv, _html} = live(conn, ~p"/path?source=EM13&destination=EM12&band=1296")

      # update_form with ONLY source — missing band/heights/etc. default
      # because `params["band"] || "10000"` hits the nil branch.
      html = render_hook(lv, "update_form", %{"source" => "EM99"})

      assert html =~ ~s(value="EM99")
      assert html =~ ~s(value="10000" selected)
      assert html =~ ~s(value="30")
      assert html =~ ~s(value="20")
    end
  end

  describe "propagation_updated with no result" do
    test "is a no-op when @result is nil (no Form submitted yet)", %{conn: conn} do
      {:ok, lv, _html} = live(conn, ~p"/path")
      send(lv.pid, {:propagation_updated, []})
      # The process stays alive, the empty form still renders.
      assert render(lv) =~ "Path Calculator"
    end
  end

  describe "compute_path edge cases" do
    test "identical source and destination renders with Distance 0 km", %{conn: conn} do
      {:ok, lv, _html} =
        live(conn, ~p"/path?source=33.0,-97.0&destination=33.0,-97.0&band=10000")

      html = render(lv)
      assert html =~ "Link Summary"
      assert html =~ "Distance"
      # Haversine of identical points is exactly 0 — distance_km formats
      # small distances with one decimal place ("0.0 km").
      assert html =~ ~r/0\.0 km/
    end

    test "long path > 3000 km still renders a result without crashing", %{conn: conn} do
      # New York City → Los Angeles (~3940 km). The path calculator
      # doesn't filter by distance the way the scoring set does, so the
      # result panel should build even for coast-to-coast ranges.
      {:ok, lv, _html} =
        live(conn, ~p"/path?source=40.7,-74.0&destination=34.0,-118.0&band=10000")

      html = render(lv)
      assert html =~ "Link Summary"
      assert html =~ "Distance"
      # Somewhere in the 3000s or 4000s — distance_km renders >10mi
      # as an integer "NNNN km" rather than decimal.
      assert html =~ ~r/\d{4} km/
    end

    test "blank source with a filled destination keeps the empty form (auto_calculate skipped)",
         %{conn: conn} do
      {:ok, _lv, html} = live(conn, ~p"/path?destination=EM12&band=10000")

      assert html =~ "Path Calculator"
      refute html =~ "Link Summary"
    end

    test "My Location GPS fix is echoed back into the source input", %{conn: conn} do
      # The hook normally emits gps_location after the user clicks the
      # button. The handler should echo the rounded coords into the
      # source input AND leave source_is_gps true for the URL.
      {:ok, lv, _html} = live(conn, ~p"/path?source=gps&destination=EM12&band=10000")

      assert_push_event(lv, "request_gps", %{})

      render_hook(lv, "gps_location", %{"lat" => 32.897, "lon" => -97.038})
      html = render(lv)

      # The input shows the rounded coordinates, not the literal "gps".
      assert html =~ ~s(value="32.897)
      assert html =~ "-97.038"
    end
  end

  describe "handle_event calculate with gps source" do
    test "calculate from an already-fixed GPS source keeps source=gps in the URL", %{conn: conn} do
      {:ok, lv, _html} = live(conn, ~p"/path?source=gps&destination=EM12&band=10000")
      assert_push_event(lv, "request_gps", %{})

      # Simulate the JS hook pushing a fix.
      render_hook(lv, "gps_location", %{"lat" => 32.9, "lon" => -97.0})

      # Now submit the form — source_is_gps should stay true so url_params
      # retains `"source" => "gps"` instead of the literal coords.
      render_submit(
        form(lv, "form",
          source: "32.9,-97.0",
          destination: "EM12",
          band: "10000"
        )
      )

      assert_patch(lv)
      html = render(lv)
      # The input still shows the coords we filled in for the user.
      assert html =~ ~s(value="32.9,-97.0")
    end
  end

  describe "invalid source input surfacing" do
    test "unknown callsign destination surfaces a 'Could not find' error", %{conn: conn} do
      # Stub the QRZ lookup so the fallback branch returns a deterministic
      # error instead of raising about a missing Req.Test stub.
      Req.Test.stub(Microwaveprop.Qrz.Client, fn conn ->
        Plug.Conn.send_resp(conn, 404, "not found")
      end)

      # "NOSUCHCALL" isn't a coord pair and isn't a 4+ char maidenhead
      # prefix, so resolve_location falls to CallsignClient.locate.
      {:ok, lv, _html} = live(conn, ~p"/path?source=33.0,-97.0&destination=NOSUCHCALL&band=10000")

      html = render(lv)
      # Match only the prefix — the reason-text portion varies with how
      # CallsignClient errors (network stub missing vs explicit not-found).
      assert html =~ "Could not find"
    end
  end

  describe "propagation_updated recompute when destination is not a result" do
    test "no-op when the result is set but source and destination collapse to same point", %{conn: conn} do
      # If a compute produces a result at identical src/dst, the
      # propagation_updated handler still runs point_forecast at that
      # midpoint. Verify the branch doesn't crash.
      {:ok, lv, _html} =
        live(conn, ~p"/path?source=33.0,-97.0&destination=33.0,-97.0&band=10000")

      # A result was built (Link Summary shows), so the propagation_updated
      # message should take the {valid_time, source, destination} path.
      assert render(lv) =~ "Link Summary"

      send(lv.pid, {:propagation_updated, [DateTime.utc_now()]})
      # Still alive, still rendering.
      assert render(lv) =~ "Path Calculator"
    end
  end

  describe "property: band round-trip in form" do
    property "for every configured band, submitting the form re-renders with that band selected" do
      # Re-stub the elevation client for each property iteration —
      # Req.Test.stub is scoped to the async: false test pid.
      check all(
              band <- StreamData.member_of(Enum.map(BandConfig.band_options(), fn {_l, v} -> v end)),
              max_runs: 10
            ) do
        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)

        conn = Phoenix.ConnTest.build_conn()
        {:ok, lv, _html} = live(conn, ~p"/path")

        html =
          render_change(
            form(lv, "form",
              source: "EM13",
              destination: "EM12",
              band: band
            )
          )

        assert html =~ ~s(value="#{band}" selected)
      end
    end
  end

  describe "property: height/power field round-trip through update_form" do
    property "update_form preserves arbitrary numeric heights and TX power through the render" do
      check all(
              src_height <- StreamData.integer(1..300),
              dst_height <- StreamData.integer(1..300),
              tx_power <- StreamData.integer(-10..60),
              max_runs: 10
            ) do
        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)

        conn = Phoenix.ConnTest.build_conn()
        {:ok, lv, _html} = live(conn, ~p"/path")

        html =
          render_hook(lv, "update_form", %{
            "source" => "EM13",
            "destination" => "EM12",
            "band" => "10000",
            "src_height_ft" => to_string(src_height),
            "dst_height_ft" => to_string(dst_height),
            "tx_power_dbm" => to_string(tx_power)
          })

        assert html =~ ~s(value="#{src_height}")
        assert html =~ ~s(value="#{dst_height}")
        assert html =~ ~s(value="#{tx_power}")
      end
    end
  end
end
