defmodule Microwaveprop.Ionosphere.GiroClient do
  @moduledoc """
  Client for GIRO's `DIDBGetValues` tabular characteristic endpoint.

  GIRO (Global Ionospheric Radio Observatory, operated by LGDC at UMass
  Lowell) publishes real-time ionosonde measurements for stations
  worldwide. We poll the scaled characteristics for a handful of US
  stations every ~10 minutes so the propagation scorer has a live view
  of the ionosphere for 144 MHz sporadic-E and HF MUF predictions.

  The endpoint returns a plain-text tabular response, not JSON:

      # Global Ionospheric Radio Observatory
      # Location: GEO 42.6N 288.5E, URSI-Code MHJ45 MILLSTONE HILL
      # ...header comment block...
      #Time                     CS   foF2 QD  MUFD QD  foEs QD   foE QD   hmF2 QD
      2026-04-15T18:00:00.000Z  80  7.113 // 23.00 //  3.08 //  3.10 //  242.0 //

  Columns:
  - `Time` — ISO-8601 UTC
  - `CS` — autoscaling confidence score (0–100, 999 = manual, -1 = unknown)
  - `foF2`, `foE`, `foEs` — critical frequencies in MHz
  - `hmF2` — F2 peak height in km
  - `MUFD` — maximum usable frequency for the distance D requested

  Each characteristic is followed by a QD (quality descriptor) flag — we
  drop the QD column when indexing. Missing values are encoded as `---`
  with a `__` QD flag and become `nil` in the parsed map.
  """

  require Logger

  @base_url "https://lgdc.uml.edu/common/DIDBGetValues"

  @default_chars ~w(foF2 foE foEs hmF2 MUFD)
  @default_dmuf 3000
  @default_timeout_ms 15_000

  @type observation :: %{
          valid_time: DateTime.t(),
          confidence_score: integer() | nil,
          fo_f2_mhz: float() | nil,
          fo_e_mhz: float() | nil,
          fo_es_mhz: float() | nil,
          hm_f2_km: float() | nil,
          mufd_mhz: float() | nil
        }

  @doc """
  Fetches scaled characteristics for a station between two UTC DateTimes.
  Returns `{:ok, [observation]}` or `{:error, reason}`.
  """
  @spec fetch(String.t(), DateTime.t(), DateTime.t()) ::
          {:ok, [observation()]} | {:error, term()}
  def fetch(ursi_code, %DateTime{} = from_dt, %DateTime{} = to_dt) do
    Microwaveprop.Instrument.span([:giro, :fetch], %{station: ursi_code}, fn ->
      url = build_url(ursi_code, from_dt, to_dt)

      case Req.get(url, [receive_timeout: @default_timeout_ms] ++ req_options()) do
        {:ok, %{status: 200, body: body}} when is_binary(body) ->
          parse_tabular(body)

        {:ok, %{status: status, body: body}} ->
          {:error, "GIRO HTTP #{status}: #{inspect(body)}"}

        {:error, reason} ->
          {:error, "GIRO request failed: #{inspect(reason)}"}
      end
    end)
  end

  @doc """
  Parses a GIRO tabular response body into a list of observation maps.
  """
  @spec parse_tabular(String.t()) :: {:ok, [observation()]} | {:error, atom()}
  def parse_tabular(body) when is_binary(body) do
    lines = String.split(body, ~r/\r?\n/, trim: true)

    case find_header(lines) do
      {:ok, columns} ->
        rows =
          lines
          |> Enum.reject(&header_or_comment?/1)
          |> Enum.flat_map(&parse_data_line(&1, columns))

        {:ok, rows}

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

  defp build_url(ursi_code, from_dt, to_dt) do
    params = [
      {"ursiCode", ursi_code},
      {"charName", Enum.join(@default_chars, ",")},
      {"DMUF", Integer.to_string(@default_dmuf)},
      {"fromDate", format_giro_date(from_dt)},
      {"toDate", format_giro_date(to_dt)}
    ]

    query = Enum.map_join(params, "&", fn {k, v} -> "#{k}=#{URI.encode(v)}" end)
    "#{@base_url}?#{query}"
  end

  # GIRO wants `YYYY.MM.DD+HH:MM:SS` with a literal `+`. Using `/` or
  # url-escaping the space silently returns "no data".
  defp format_giro_date(%DateTime{} = dt) do
    dt
    |> DateTime.truncate(:second)
    |> Calendar.strftime("%Y.%m.%d+%H:%M:%S")
  end

  # Column header line starts with `#Time`. Returns the list of value
  # column names, stripping the `Time`/`CS` prefix columns and the `QD`
  # filler tokens (which are paired with each value).
  defp find_header(lines) do
    case Enum.find(lines, &String.starts_with?(&1, "#Time")) do
      nil ->
        :error

      line ->
        columns =
          line
          # Drop the leading "#"
          |> String.trim_leading("#")
          |> String.split(~r/\s+/, trim: true)
          |> Enum.reject(&(&1 in ["Time", "CS", "QD"]))

        {:ok, columns}
    end
  end

  defp header_or_comment?(line), do: String.starts_with?(line, "#")

  defp parse_data_line(line, columns) do
    tokens = String.split(line, ~r/\s+/, trim: true)
    # Expected: time + CS + 2 tokens per column (value + QD)
    expected = 2 + length(columns) * 2

    if length(tokens) == expected do
      [time_str, cs_str | rest] = tokens

      case DateTime.from_iso8601(time_str) do
        {:ok, valid_time, _} ->
          values = take_values(rest)
          row = columns |> Enum.zip(values) |> Map.new() |> then(&build_observation(valid_time, cs_str, &1))
          [row]

        _ ->
          []
      end
    else
      []
    end
  end

  # Drop the QD flags by keeping every other element.
  defp take_values([]), do: []
  defp take_values([value, _qd | rest]), do: [value | take_values(rest)]
  defp take_values([value]), do: [value]

  defp build_observation(valid_time, cs_str, values_by_col) do
    %{
      valid_time: DateTime.truncate(valid_time, :second),
      confidence_score: parse_int(cs_str),
      fo_f2_mhz: parse_float(values_by_col["foF2"]),
      fo_e_mhz: parse_float(values_by_col["foE"]),
      fo_es_mhz: parse_float(values_by_col["foEs"]),
      hm_f2_km: parse_float(values_by_col["hmF2"]),
      mufd_mhz: parse_float(values_by_col["MUFD"])
    }
  end

  defp parse_int(nil), do: nil

  defp parse_int(s) do
    case Integer.parse(s) do
      {n, ""} -> n
      _ -> nil
    end
  end

  defp parse_float(nil), do: nil
  defp parse_float("---"), do: nil

  defp parse_float(s) do
    case Float.parse(s) do
      {f, ""} -> f
      _ -> nil
    end
  end

  defp req_options do
    Application.get_env(:microwaveprop, :giro_req_options, [])
  end
end
