defmodule MicrowavepropWeb.SkewtLive do
  @moduledoc """
  `/skewt` interactive Skew-T-Log-P diagram.

  The user types an address, Maidenhead grid square, or callsign into
  the search bar; the page resolves it to lat/lon, snaps to the HRRR
  grid, and renders the latest analysis profile as a Skew-T plus the
  derived stability / refractivity stats. A time selector switches
  between the analysis hour and each available forecast hour out to
  HRRR's f18 horizon.
  """
  use MicrowavepropWeb, :live_view

  alias Microwaveprop.Propagation.ProfilesFile
  alias Microwaveprop.Weather.HrrrClient
  alias Microwaveprop.Weather.HrrrProfileLookup
  alias Microwaveprop.Weather.SkewtParams
  alias Microwaveprop.Weather.SoundingParams
  alias MicrowavepropWeb.SkewtLocationResolver
  alias MicrowavepropWeb.SkewtSvg

  @impl true
  def mount(_params, _session, socket) do
    {:ok,
     assign(socket,
       page_title: "Skew-T",
       query: "",
       location: nil,
       error: nil,
       valid_times: [],
       selected_valid_time: nil,
       profile: nil,
       derived: nil,
       svg: nil,
       loading?: false
     )}
  end

  @impl true
  def handle_params(params, _uri, socket) do
    query = String.trim(params["q"] || "")

    {:noreply, refresh(socket, query)}
  end

  @impl true
  def handle_event("search", %{"q" => q}, socket) do
    {:noreply, push_patch(socket, to: ~p"/skewt?#{[q: q]}")}
  end

  # Time selection stays in-process — no URL round-trip — so refreshing
  # or sharing a `/skewt?q=...` link always lands on the most recent
  # available analysis hour rather than re-rendering whatever forecast
  # hour happened to be selected when the URL was copied.
  def handle_event("select_time", %{"valid_time" => iso}, socket) do
    case parse_time(iso) do
      nil ->
        {:noreply, socket}

      %DateTime{} = chosen ->
        %{location: location} = socket.assigns

        if location do
          {:noreply,
           socket
           |> assign(selected_valid_time: chosen, loading?: true, error: nil)
           |> start_async(:load_skewt_time, fn -> load_for_time(chosen, location) end)}
        else
          {:noreply, socket}
        end
    end
  end

  @impl true
  def handle_async(:load_skewt, {:ok, {:ok, result}}, socket) do
    {:noreply,
     assign(socket,
       location: result.location,
       error: nil,
       valid_times: result.valid_times,
       selected_valid_time: result.selected_valid_time,
       profile: result.profile,
       derived: result.derived,
       svg: result.svg,
       loading?: false
     )}
  end

  def handle_async(:load_skewt, {:ok, {:error, reason}}, socket) do
    {:noreply,
     assign(socket,
       location: nil,
       error: reason,
       valid_times: [],
       selected_valid_time: nil,
       profile: nil,
       derived: nil,
       svg: nil,
       loading?: false
     )}
  end

  def handle_async(:load_skewt, {:exit, reason}, socket) do
    require Logger

    Logger.warning("SkewtLive async exit: #{inspect(reason)}")

    {:noreply,
     assign(socket,
       error: "Sorry, something went wrong loading this profile. Try again.",
       loading?: false
     )}
  end

  def handle_async(:load_skewt_time, {:ok, {profile, derived, svg}}, socket) do
    {:noreply,
     assign(socket,
       profile: profile,
       derived: derived,
       svg: svg,
       loading?: false
     )}
  end

  def handle_async(:load_skewt_time, {:exit, reason}, socket) do
    require Logger

    Logger.warning("SkewtLive time-select async exit: #{inspect(reason)}")

    {:noreply, assign(socket, loading?: false)}
  end

  # ── Refresh logic ──────────────────────────────────────────────────

  # The page chrome (header + search bar) renders synchronously on
  # mount; everything else (geocoding, HRRR file/DB read, sounding
  # parameter derivation, SVG render) lands via `:load_skewt` async.
  # Repeated calls under the same key cancel any in-flight task, so
  # rapid URL patches don't race.

  defp refresh(socket, "") do
    assign(socket,
      query: "",
      location: nil,
      error: nil,
      valid_times: [],
      selected_valid_time: nil,
      profile: nil,
      derived: nil,
      svg: nil,
      loading?: false
    )
  end

  defp refresh(socket, query) do
    socket
    |> assign(query: query, error: nil, loading?: true)
    |> start_async(:load_skewt, fn -> resolve_and_load(query) end)
  end

  defp resolve_and_load(query) do
    case SkewtLocationResolver.resolve(query) do
      {:ok, %{lat: lat, lon: lon} = location} ->
        valid_times = available_valid_times(lat, lon)
        chosen = default_valid_time(valid_times)

        {profile, derived, svg} =
          case chosen do
            nil -> {nil, nil, nil}
            time -> load_profile(time, lat, lon)
          end

        {:ok,
         %{
           location: location,
           valid_times: valid_times,
           selected_valid_time: chosen,
           profile: profile,
           derived: derived,
           svg: svg
         }}

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp load_for_time(%DateTime{} = valid_time, %{lat: lat, lon: lon}) do
    load_profile(valid_time, lat, lon)
  end

  defp load_profile(valid_time, lat, lon) do
    cached = ProfilesFile.read_point(valid_time, lat, lon) || HrrrProfileLookup.read_point_near(valid_time, lat, lon)
    rich = fetch_rich_cell(valid_time, lat, lon, cached)

    case rich || cached do
      nil ->
        {nil, nil, nil}

      cell ->
        profile = extract_profile(cell)
        build_profile_assigns(profile)
    end
  end

  defp build_profile_assigns([]), do: {nil, nil, nil}

  defp build_profile_assigns(profile) do
    sounding = SoundingParams.derive(profile)
    spc = SkewtParams.derive(profile)
    # Merge so the LiveView template only has to read one map.
    # SoundingParams keys (atoms) and SkewtParams keys (atoms)
    # are disjoint by construction.
    derived = Map.merge(sounding || %{}, spc)

    svg =
      SkewtSvg.render(profile,
        parcel_trace: spc[:parcel_trace] || [],
        critical_levels: critical_levels(spc, profile)
      )

    {profile, derived, svg}
  end

  # The grid-cached profile only contains 13 pressure levels (1000→700 mb)
  # because the hourly chain trims its fetch to keep memory bounded. For
  # the skew-T page we want the full 25-level set (1000→100 mb), so issue
  # a per-point byte-range fetch directly against the same HRRR cycle the
  # cached row came from. On any failure we silently fall back to the
  # cached truncated profile — better to render *something* than to fail.
  defp fetch_rich_cell(valid_time, lat, lon, cached) do
    {run_time, forecast_hour} = run_time_and_fh(cached, valid_time)

    case HrrrClient.fetch_profile(lat, lon, run_time, forecast_hour: forecast_hour) do
      {:ok, profile_map} -> Map.put(profile_map, :valid_time, valid_time)
      {:error, _} -> nil
    end
  rescue
    _ -> nil
  end

  defp run_time_and_fh(%{run_time: %DateTime{} = run_time}, %DateTime{} = valid_time) do
    fh = max(0, div(DateTime.diff(valid_time, run_time, :second), 3600))
    {run_time, min(fh, 18)}
  end

  # Cached cells don't currently carry `run_time` (neither the on-disk
  # ProfilesFile rows nor `HrrrProfileLookup`), so we have to guess the
  # cycle. For valid_times in the past, treat them as analysis hours
  # (cycle == valid_time, fh = 0). For future valid_times, walk back to
  # the most recent cycle that's likely published (~2 h ago) and pick
  # the forecast hour that lands on the requested valid_time.
  defp run_time_and_fh(_cached, %DateTime{} = valid_time) do
    now = DateTime.utc_now()

    if DateTime.after?(valid_time, now) do
      cycle =
        now
        |> DateTime.add(-2 * 3600, :second)
        |> HrrrClient.nearest_hrrr_hour()

      fh = max(0, div(DateTime.diff(valid_time, cycle, :second), 3600))
      {cycle, min(fh, 18)}
    else
      {valid_time, 0}
    end
  end

  defp extract_profile(cell) do
    cell[:profile] || cell["profile"] || []
  end

  # Right-edge critical-level markers, mirroring SPC's annotation rail.
  # Pressure → label is enough; SkewtSvg uses pressure_y/1 to position.
  # 0 °C and wet-bulb-zero come back from SkewtParams as heights, so
  # we walk the profile to recover the corresponding pressure.
  defp critical_levels(spc, profile) do
    Enum.flat_map(
      [
        {"LCL", spc[:lcl_pressure_mb]},
        {"LFC", spc[:lfc_pressure_mb]},
        {"EL", spc[:el_pressure_mb]},
        {"0 °C", pressure_at_height(profile, spc[:freezing_level_m])},
        {"WBZ", pressure_at_height(profile, spc[:wbz_m])}
      ],
      fn
        {label, p} when is_number(p) -> [%{label: label, pressure_mb: p}]
        _ -> []
      end
    )
  end

  defp pressure_at_height(_profile, nil), do: nil

  defp pressure_at_height(profile, target_m) when is_number(target_m) do
    profile
    |> Enum.map(&extract_pres_hght/1)
    |> Enum.filter(&(is_number(&1.pres) and is_number(&1.hght)))
    |> Enum.sort_by(& &1.pres, :desc)
    |> Enum.chunk_every(2, 1, :discard)
    |> Enum.find_value(&interpolate_pressure_at_height(&1, target_m))
  end

  defp extract_pres_hght(lvl) do
    %{
      pres: lvl[:pres] || lvl["pres"] || lvl[:pres_mb] || lvl["pres_mb"],
      hght: lvl[:hght] || lvl["hght"] || lvl[:hght_m] || lvl["hght_m"]
    }
  end

  defp interpolate_pressure_at_height([a, b], target_m) do
    cond do
      a.hght == b.hght -> nil
      (a.hght - target_m) * (b.hght - target_m) > 0 -> nil
      true -> a.pres + (target_m - a.hght) / (b.hght - a.hght) * (b.pres - a.pres)
    end
  end

  defp available_valid_times(lat, lon) do
    # The chain produces analysis + 18 forecast hours every wall-clock
    # hour. By the time the next chain finishes the previous chain's
    # analysis is up to 70 min old, and during a missed cycle it can be
    # ~2 h old. A 1-hour past cutoff would leave the page empty while
    # the chain catches up — keep 3 h so a stale analysis still shows.
    now = DateTime.utc_now()
    past_cutoff = DateTime.add(now, -3 * 3600, :second)
    future_cutoff = DateTime.add(now, 18 * 3600, :second)

    on_disk =
      Enum.filter(ProfilesFile.list_valid_times(), fn t ->
        DateTime.compare(t, past_cutoff) != :lt and DateTime.compare(t, future_cutoff) != :gt
      end)

    case on_disk do
      [] ->
        # On-disk store is empty for this window — common in dev (no NFS
        # mount, no local Rust pipeline) and during missed-cycle windows
        # in prod. Fall back to whatever HRRR profiles the DB has near
        # the location, regardless of how old. Better to render historic
        # data than a "no profiles stored" stub.
        HrrrProfileLookup.list_valid_times_near(lat, lon)

      times ->
        Enum.sort(times, DateTime)
    end
  end

  defp default_valid_time([]), do: nil

  defp default_valid_time(times) do
    # The most recent valid_time at or before "now". If everything is
    # in the future (cold start), fall back to the earliest available.
    now = DateTime.utc_now()

    times
    |> Enum.filter(fn t -> DateTime.compare(t, now) != :gt end)
    |> case do
      [] -> List.first(times)
      past -> Enum.max(past, DateTime)
    end
  end

  defp parse_time(nil), do: nil
  defp parse_time(""), do: nil

  defp parse_time(iso) when is_binary(iso) do
    case DateTime.from_iso8601(iso) do
      {:ok, dt, _} -> DateTime.truncate(dt, :second)
      _ -> nil
    end
  end

  # ── Render ─────────────────────────────────────────────────────────

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-6xl">
      <.header>
        Skew-T-Log-P
        <:subtitle>
          Vertical atmospheric profile from the latest HRRR analysis (or any
          available forecast hour) for an address, grid square, or callsign.
        </:subtitle>
      </.header>

      <form phx-submit="search" class="mt-4 flex gap-2">
        <input
          type="text"
          name="q"
          value={@query}
          placeholder="EM12kp, W5ISP, or '123 Main St, Plano TX'"
          class="input input-bordered flex-1"
          autocomplete="off"
          autofocus
        />
        <button type="submit" class="btn btn-primary">Plot</button>
      </form>

      <%= if @error do %>
        <div role="alert" class="alert alert-error mt-4">
          <p>{@error}</p>
        </div>
      <% end %>

      <%= if @loading? do %>
        <div class="mt-4 flex items-center gap-3 text-sm opacity-80">
          <span class="loading loading-spinner loading-sm"></span>
          <span>Resolving location and loading HRRR profile…</span>
        </div>
      <% end %>

      <%= if @location do %>
        <div class="mt-4 flex flex-wrap items-baseline gap-4 text-sm">
          <span class="font-semibold">{@location.label}</span>
          <span class="opacity-70">
            {fmt_coord(@location.lat)}, {fmt_coord(@location.lon)}
          </span>
        </div>
      <% end %>

      <%= if @valid_times != [] do %>
        <div class="mt-4 flex flex-wrap gap-2">
          <%= for t <- @valid_times do %>
            <button
              type="button"
              phx-click="select_time"
              phx-value-valid_time={DateTime.to_iso8601(t)}
              class={[
                "btn btn-xs",
                if(@selected_valid_time && DateTime.compare(t, @selected_valid_time) == :eq,
                  do: "btn-primary",
                  else: "btn-outline"
                )
              ]}
            >
              {time_button_label(t, @valid_times)}
            </button>
          <% end %>
        </div>
      <% end %>

      <%= if @svg do %>
        <div class="mt-6 grid grid-cols-1 lg:grid-cols-3 gap-6">
          <div
            id="skewt-container"
            phx-hook="Skewt"
            phx-update="ignore"
            data-profile={Jason.encode!(@profile)}
            data-geometry={Jason.encode!(SkewtSvg.geometry())}
            class="lg:col-span-2 bg-base-100 rounded-box p-3 border border-base-300"
          >
            {Phoenix.HTML.raw(@svg)}
          </div>
          <div class="bg-base-200 rounded-box p-4 text-sm space-y-4">
            <h3 class="font-semibold text-base">Sounding parameters</h3>
            <%= if @derived do %>
              <%= for {section_title, rows} <- derived_sections(@derived) do %>
                <div class="space-y-1">
                  <h4 class="font-semibold text-xs uppercase tracking-wide opacity-70">
                    {section_title}
                  </h4>
                  <dl class="grid grid-cols-2 gap-x-3 gap-y-1">
                    <%= for {label, value} <- rows do %>
                      <dt class="opacity-70">{label}</dt>
                      <dd class="font-mono text-right">{value}</dd>
                    <% end %>
                  </dl>
                </div>
              <% end %>

              <%= if @derived[:ducting_detected] && @derived[:ducts] != [] do %>
                <div class="pt-2 border-t border-base-300 space-y-1">
                  <h4 class="font-semibold text-xs uppercase tracking-wide opacity-70">
                    Ducts
                  </h4>
                  <%= for duct <- @derived.ducts do %>
                    <div class="font-mono text-xs">
                      base {round(duct["base"])} m → top {round(duct["top"])} m, ΔM {duct["strength"]}
                    </div>
                  <% end %>
                </div>
              <% end %>

              <p class="pt-2 border-t border-base-300 text-xs opacity-60 leading-relaxed">
                Wind-derived indices (SRH, BWD, Bunkers motion, SCP/STP/SHIP)
                aren't shown — HRRR persists wind only at 10 m AGL, so per-
                level shear can't be computed from this data source.
              </p>
            <% end %>
          </div>
        </div>
      <% else %>
        <%= if @location && @selected_valid_time do %>
          <div role="alert" class="alert alert-warning mt-6">
            <p>
              No HRRR profile is stored for this point at {fmt_time(@selected_valid_time)}.
              Profiles are written by the hourly chain and only kept for the active
              forecast window.
            </p>
          </div>
        <% else %>
          <%= if @location && @valid_times == [] do %>
            <div role="alert" class="alert alert-info mt-6">
              <p>
                No HRRR profiles are currently stored. Wait for the next hourly
                chain to publish, or check the propagation pipeline status.
              </p>
            </div>
          <% end %>
        <% end %>
      <% end %>
    </Layouts.app>
    """
  end

  defp fmt_coord(c) when is_number(c), do: :erlang.float_to_binary(c * 1.0, decimals: 3)
  defp fmt_coord(_), do: "—"

  defp fmt_time(%DateTime{} = t) do
    Calendar.strftime(t, "%Y-%m-%d %H:%MZ")
  end

  defp time_button_label(%DateTime{} = t, all_times) do
    base = Enum.min(all_times, DateTime)
    delta_h = div(DateTime.diff(t, base, :second), 3600)
    IO.iodata_to_binary("f#{:io_lib.format("~2..0B", [delta_h])} · #{Calendar.strftime(t, "%H:%MZ")}")
  end

  # Grouped derived-parameter sections rendered on the right side of
  # the page. Mirrors the SPC viewer's "parcel / level / lapse rate /
  # moisture / refractivity" layout so users moving between the two
  # tools don't have to context-switch.
  defp derived_sections(d) do
    [
      {"Surface",
       [
         {"T", fmt_temp(d[:surface_temp_c])},
         {"Td", fmt_temp(d[:surface_dewpoint_c])},
         {"P", fmt_num(d[:surface_pressure_mb], "mb")}
       ]},
      {"Convective parcel",
       [
         {"SBCAPE", fmt_num(d[:sbcape], "J/kg")},
         {"SBCIN", fmt_num(d[:sbcin], "J/kg")},
         {"3 km CAPE", fmt_num(d[:cape_3km], "J/kg")},
         {"Lifted index", fmt_num(d[:lifted_index], "")},
         {"DCAPE", fmt_num(d[:dcape], "J/kg")}
       ]},
      {"Levels",
       [
         {"LCL", fmt_pressure_height(d[:lcl_pressure_mb], d[:lcl_height_m])},
         {"LFC", fmt_pressure_height(d[:lfc_pressure_mb], d[:lfc_height_m])},
         {"EL", fmt_pressure_height(d[:el_pressure_mb], d[:el_height_m])},
         {"0 °C", fmt_height(d[:freezing_level_m])},
         {"Wet-bulb 0", fmt_height(d[:wbz_m])}
       ]},
      {"Lapse rates",
       [
         {"Sfc–3 km", fmt_num(d[:lapse_rate_sfc_3km_c_per_km], "°C/km")},
         {"700–500 mb", fmt_num(d[:lapse_rate_700_500_c_per_km], "°C/km")},
         {"850–500 mb", fmt_num(d[:lapse_rate_850_500_c_per_km], "°C/km")}
       ]},
      {"Moisture / stability",
       [
         {"PWAT", fmt_num(d[:precipitable_water_mm], "mm")},
         {"BL depth", fmt_num(d[:boundary_layer_depth_m], "m")},
         {"K-index", fmt_num(d[:k_index], "")}
       ]},
      {"Refractivity",
       [
         {"Surface N", fmt_num(d[:surface_refractivity], "")},
         {"Min dN/dh", fmt_num(d[:min_refractivity_gradient], "/km")},
         {"Ducting", if(d[:ducting_detected], do: "yes", else: "no")}
       ]}
    ]
  end

  defp fmt_temp(nil), do: "—"
  defp fmt_temp(v) when is_number(v), do: "#{Float.round(v * 1.0, 1)} °C"

  defp fmt_num(nil, _suffix), do: "—"
  defp fmt_num(v, ""), do: format_value(v)
  defp fmt_num(v, suffix), do: "#{format_value(v)} #{suffix}"

  defp fmt_height(nil), do: "—"

  defp fmt_height(v) when is_number(v) do
    "#{round(v)} m / #{round(v * 3.28084)} ft"
  end

  defp fmt_pressure_height(nil, _), do: "—"
  defp fmt_pressure_height(p, nil), do: "#{round(p)} mb"

  defp fmt_pressure_height(p, h) when is_number(p) and is_number(h) do
    "#{round(p)} mb · #{round(h)} m"
  end

  defp format_value(v) when is_float(v), do: v |> Float.round(1) |> :erlang.float_to_binary(decimals: 1)
  defp format_value(v) when is_integer(v), do: Integer.to_string(v)
  defp format_value(v), do: to_string(v)
end
