defmodule Microwaveprop.Weather.UwyoSoundingClient do
  @moduledoc """
  Client for University of Wyoming atmospheric sounding archive
  (`weather.uwyo.edu`), which serves near-real-time radiosonde data for
  the global WMO network — including Canadian stations that the IEM RAOB
  endpoint no longer keeps current.

  Produces the same shape as `Microwaveprop.Weather.IemClient.parse_raob_json/1`:
  `{:ok, [%{observed_at: DateTime.t(), profile: [map()]}]}`, where each
  profile level is `%{"pres", "hght", "tmpc", "dwpc", "drct", "sknt"}` with
  string keys so `Microwaveprop.Weather.SoundingParams.derive/1` can consume
  it directly.

  Station codes are UWYO's 3-letter form — for Canadian ICAO codes (CWSE,
  CYYR, ...) the caller must strip the leading `C`.
  """

  @base "https://weather.uwyo.edu/cgi-bin/sounding"

  @type profile_level :: %{String.t() => float() | nil}
  @type sounding :: %{observed_at: DateTime.t(), profile: [profile_level()]}

  @spec sounding_url(String.t(), DateTime.t()) :: String.t()
  def sounding_url(station_code, %DateTime{} = dt) do
    month = dt.month |> Integer.to_string() |> String.pad_leading(2, "0")
    day = dt.day |> Integer.to_string() |> String.pad_leading(2, "0")
    hour = dt.hour |> Integer.to_string() |> String.pad_leading(2, "0")
    dayhour = day <> hour

    "#{@base}?region=naconf&TYPE=TEXT%3ALIST" <>
      "&YEAR=#{dt.year}&MONTH=#{month}&FROM=#{dayhour}&TO=#{dayhour}&STNM=#{station_code}"
  end

  @spec fetch_sounding(String.t(), DateTime.t()) :: {:ok, [sounding()]} | {:error, term()}
  def fetch_sounding(station_code, %DateTime{} = dt) do
    Microwaveprop.Instrument.span([:uwyo, :fetch_sounding], %{station: station_code}, fn ->
      url = sounding_url(station_code, dt)

      case Req.get(url, req_options()) do
        {:ok, %{status: 200, body: body}} -> {:ok, parse_sounding_html(body)}
        {:ok, %{status: status}} -> {:error, "UWYO sounding HTTP #{status}"}
        {:error, reason} -> {:error, reason}
      end
    end)
  end

  @spec parse_sounding_html(String.t()) :: [sounding()]
  def parse_sounding_html(html) when is_binary(html) do
    with {:ok, observed_at} <- parse_h2_header(html),
         {:ok, pre_text} <- extract_pre_block(html) do
      profile =
        pre_text
        |> String.split("\n")
        |> Enum.flat_map(&parse_profile_row/1)

      [%{observed_at: observed_at, profile: profile}]
    else
      _ -> []
    end
  end

  # ---------- Private ----------

  defp req_options do
    defaults = [retry: &retry?/2, max_retries: 3, retry_delay: &retry_delay/1]
    overrides = Application.get_env(:microwaveprop, :uwyo_req_options, [])
    Keyword.merge(defaults, overrides)
  end

  defp retry?(_request, response) do
    case response do
      %Req.Response{status: status} when status in [429, 500, 502, 503, 504] -> true
      %{__exception__: true} -> true
      _ -> false
    end
  end

  defp retry_delay(n) do
    base = Integer.pow(2, n) * 1_000
    jitter = :rand.uniform(1_000)
    base + jitter
  end

  # Header line example:
  #   <H2>71816 YYR Goose Bay Observations at 00Z 13 Apr 2026</H2>
  defp parse_h2_header(html) do
    case Regex.run(~r/<H2>\s*\d+\s+\w+.*?Observations at (\d{2})Z (\d{1,2}) (\w+) (\d{4})/i, html) do
      [_, hh, dd, mon, yyyy] ->
        with {:ok, month} <- month_num(mon),
             {day, ""} <- Integer.parse(dd),
             {hour, ""} <- Integer.parse(hh),
             {year, ""} <- Integer.parse(yyyy),
             {:ok, dt} <- DateTime.new(Date.new!(year, month, day), Time.new!(hour, 0, 0)) do
          {:ok, dt}
        else
          _ -> :error
        end

      _ ->
        :error
    end
  end

  defp extract_pre_block(html) do
    case Regex.run(~r/<PRE>(.*?)<\/PRE>/s, html) do
      [_, body] -> {:ok, body}
      _ -> :error
    end
  end

  # UWYO rows are fixed-width, 7 chars per column. We care about the first 8
  # columns: PRES, HGHT, TEMP, DWPT, RELH, MIXR, DRCT, SKNT. Any line without
  # both a numeric pressure AND a numeric temperature is a header/separator
  # line or an incomplete observation and is skipped.
  defp parse_profile_row(line) when is_binary(line) do
    if String.length(line) < 28 do
      []
    else
      pres = parse_column(line, 0)
      hght = parse_column(line, 7)
      tmpc = parse_column(line, 14)
      dwpc = parse_column(line, 21)
      drct = parse_column(line, 42)
      sknt = parse_column(line, 49)

      if is_number(pres) and is_number(tmpc) do
        [%{"pres" => pres, "hght" => hght, "tmpc" => tmpc, "dwpc" => dwpc, "drct" => drct, "sknt" => sknt}]
      else
        []
      end
    end
  end

  defp parse_column(line, offset) do
    field = line |> String.slice(offset, 7) |> String.trim()

    if field == "" do
      nil
    else
      case Float.parse(field) do
        {val, ""} -> val
        _ -> nil
      end
    end
  end

  defp month_num("Jan"), do: {:ok, 1}
  defp month_num("Feb"), do: {:ok, 2}
  defp month_num("Mar"), do: {:ok, 3}
  defp month_num("Apr"), do: {:ok, 4}
  defp month_num("May"), do: {:ok, 5}
  defp month_num("Jun"), do: {:ok, 6}
  defp month_num("Jul"), do: {:ok, 7}
  defp month_num("Aug"), do: {:ok, 8}
  defp month_num("Sep"), do: {:ok, 9}
  defp month_num("Oct"), do: {:ok, 10}
  defp month_num("Nov"), do: {:ok, 11}
  defp month_num("Dec"), do: {:ok, 12}
  defp month_num(_), do: :error
end
