defmodule Microwaveprop.Weather.NarrClient do
  @moduledoc """
  Client for NCEP North American Regional Reanalysis (NARR) data served
  anonymously from NCEI at `https://www.ncei.noaa.gov/data/north-american-regional-reanalysis/`.

  Replaces the broken ERA5/CDS historical backfill with anonymous NCEI
  fetches for pre-HRRR (1979-01-01 → 2014-10-02) contact enrichment. See
  `docs/plans/2026-04-15-merra2-historical-backfill.md` for the full
  architecture — the filename is a historical artifact from an earlier
  MERRA-2 plan.

  Provides URL/time helpers, the inventory parser, the inventory HTTP
  fetch, the cdo-based composite-file profile extractor, and the
  end-to-end byte-range `fetch_profile_at/2` pipeline.
  """

  alias Microwaveprop.Weather.SoundingParams
  alias Microwaveprop.Weather.ThetaE

  @base_url "https://www.ncei.noaa.gov/data/north-american-regional-reanalysis/access/3-hourly"
  @valid_hours [0, 3, 6, 9, 12, 15, 18, 21]

  # NCEI's NARR archive publishes 1979-01-01 through 2014-10-01 21 UTC
  # (the last 3-hourly analysis). Dispatching fetches outside that window
  # just 404s, so callers filter on `in_coverage?/1` first.
  @coverage_start ~U[1979-01-01 00:00:00Z]
  @coverage_end ~U[2014-10-02 00:00:00Z]

  # Surface records we need from the inventory. Tuples of {var, level} keyed
  # exactly as `parse_inventory/2` returns them. Pressure-level records are
  # added dynamically below from whatever (TMP|HGT|SPFH):"NN mb" tuples the
  # parsed inventory contains.
  @surface_records [
    {"HPBL", "sfc"},
    {"PRES", "sfc"},
    {"TMP", "2 m above gnd"},
    {"DPT", "2 m above gnd"},
    {"PWAT", "atmos col"}
  ]

  # Map NCEP GRIB1 parameter codes to our semantic short names.
  # Using `-outputtab,code,...` (instead of `name`) makes cdo emit the raw
  # numeric param code, so the same parser works across cdo versions that
  # would otherwise produce either `varNN` (Mac / cdo 2.6) or `codeNN` +
  # named shortnames like `tpag10` / `2tag2` / `saip` (Debian / cdo 2.5.1).
  @cdo_code_to_name %{
    1 => "PRES",
    7 => "HGT",
    11 => "TMP",
    17 => "DPT",
    33 => "UGRD",
    34 => "VGRD",
    51 => "SPFH",
    54 => "PWAT",
    221 => "HPBL"
  }

  # cdo 2.5.1 on Debian ignores `-outputtab,code` for records whose GRIB1
  # parameter table entry has a shortname, emitting the shortname string
  # instead of the numeric code. Mapping verified against prod discard
  # output: 2tag2=PRES (985 mb surface pressure), saip=DPT (292 K 2-m
  # dewpoint), tpag10=HGT (983 m at 900 hPa geopotential height). UGRD
  # and VGRD aren't yet observed in that output — add them when they
  # surface in a real discard.
  @cdo_shortname_to_code %{
    "2tag2" => 1,
    "saip" => 17,
    "tpag10" => 7
  }

  @doc """
  Builds the NCEI HTTPS URL for the NARR analysis GRIB1 file at `valid_time`.

  Raises `ArgumentError` if `valid_time` is not on a 3-hourly analysis
  boundary — NARR analyses exist only at 00, 03, 06, 09, 12, 15, 18, and
  21 UTC.
  """
  @spec url_for(DateTime.t()) :: String.t()
  def url_for(valid_time) do
    validate_analysis_time!(valid_time)

    yyyymm = Calendar.strftime(valid_time, "%Y%m")
    yyyymmdd = Calendar.strftime(valid_time, "%Y%m%d")
    hhmm = Calendar.strftime(valid_time, "%H%M")

    "#{@base_url}/#{yyyymm}/#{yyyymmdd}/narr-a_221_#{yyyymmdd}_#{hhmm}_000.grb"
  end

  @doc """
  Builds the NCEI HTTPS URL for the NARR analysis inventory (`.inv`) file
  at `valid_time`. Same validation rules as `url_for/1`.
  """
  @spec url_for_inventory(DateTime.t()) :: String.t()
  def url_for_inventory(valid_time) do
    valid_time
    |> url_for()
    |> String.replace_suffix(".grb", ".inv")
  end

  @doc """
  Rounds a `DateTime` *down* to the nearest 3-hourly NARR analysis slot.

  NARR analyses are produced at 00, 03, 06, 09, 12, 15, 18, and 21 UTC.
  Minutes, seconds, and microseconds are zeroed out; the hour becomes
  `(hour div 3) * 3`.
  """
  @spec snap_to_analysis_hour(DateTime.t()) :: DateTime.t()
  def snap_to_analysis_hour(%DateTime{} = datetime) do
    snapped_hour = div(datetime.hour, 3) * 3
    %{datetime | hour: snapped_hour, minute: 0, second: 0, microsecond: {0, 0}}
  end

  @doc """
  True when `datetime` falls inside NCEI's NARR 3-hourly archive window
  (1979-01-01 through 2014-10-01 21 UTC). Callers use this to avoid
  dispatching fetches that would just 404.
  """
  @spec in_coverage?(DateTime.t()) :: boolean()
  def in_coverage?(%DateTime{} = datetime) do
    DateTime.compare(datetime, @coverage_start) != :lt and
      DateTime.before?(datetime, @coverage_end)
  end

  @doc "Exclusive end of the NARR coverage window (first timestamp NOT covered)."
  @spec coverage_end() :: DateTime.t()
  def coverage_end, do: @coverage_end

  @doc """
  Parses the raw text of a NARR `.inv` file into a map keyed by
  `{var, level}` with values of `{byte_offset, length}`.

  Each record's length is computed as `next_offset - this_offset`. The
  final record's length uses `file_size` as its "next offset".

  Blank lines and trailing whitespace are ignored. `var` and `level` are
  returned as-is (no trimming) so levels like `"2 m above gnd"` round-trip
  unchanged.
  """
  @spec parse_inventory(String.t(), non_neg_integer()) ::
          {:ok, %{{String.t(), String.t()} => {non_neg_integer(), non_neg_integer()}}}
  def parse_inventory(inv_text, file_size) when is_binary(inv_text) and is_integer(file_size) do
    records =
      inv_text
      |> String.split("\n", trim: true)
      |> Enum.map(&parse_inventory_line/1)
      |> Enum.reject(&is_nil/1)

    index =
      records
      |> Enum.zip(Enum.drop(records, 1) ++ [{nil, file_size, nil, nil}])
      |> Map.new(fn {{_num, offset, var, level}, {_next_num, next_offset, _next_var, _next_level}} ->
        {{var, level}, {offset, next_offset - offset}}
      end)

    {:ok, index}
  end

  @doc """
  Fetches the `.inv` text and the `.grb` `Content-Length` for `valid_time`,
  then delegates to `parse_inventory/2`.

  Does one `GET` against the inventory URL and one `HEAD` against the GRIB
  URL. Returns `{:ok, index}` on success; `{:error, reason}` if either HTTP
  call is non-200, transport-errored, or missing a `content-length` header.
  """
  @spec fetch_inventory(DateTime.t()) ::
          {:ok, %{{String.t(), String.t()} => {non_neg_integer(), non_neg_integer()}}}
          | {:error, term()}
  def fetch_inventory(%DateTime{} = valid_time) do
    inv_url = url_for_inventory(valid_time)
    grb_url = url_for(valid_time)

    with {:ok, inv_body} <- do_get_inventory(inv_url),
         {:ok, file_size} <- do_head_grib_size(grb_url) do
      parse_inventory(inv_body, file_size)
    end
  end

  @doc """
  Extracts a NarrProfile-shaped attribute map from a composite NARR
  GRIB1 file at the given lat/lon point.

  Pure-ish: shells out to `cdo -outputtab,name,lev,value -remapnn,lon=…_lat=…`
  on the local file. No network.

  Returns `{:ok, attrs}` — a map with the NarrProfile fields
  (`:surface_temp_c`, `:surface_dewpoint_c`, `:surface_pressure_mb`,
  `:hpbl_m`, `:pwat_mm`, `:profile` list plus the derived fields from
  `SoundingParams.derive/1`) — or `{:error, reason}` on missing file,
  cdo failure, or empty parser output.
  """
  @spec extract_profile_from_file(Path.t(), float(), float()) ::
          {:ok, map()} | {:error, term()}
  def extract_profile_from_file(grb_path, lat, lon) when is_binary(grb_path) and is_number(lat) and is_number(lon) do
    if File.exists?(grb_path) do
      run_cdo_outputtab(grb_path, lat, lon)
    else
      {:error, "NARR cdo extract: file not found: #{grb_path}"}
    end
  end

  @doc """
  Fetches all the NARR records we need for `valid_time` via byte-range
  `Range:` GETs against the NCEI grib file, merges them into a composite
  GRIB1 with `cdo -merge`, then extracts the profile at `{lat, lon}`
  using `extract_profile_from_file/3`.

  Returns `{:ok, profile_attrs}` shaped for direct `Repo.insert_all/3`
  against `NarrProfile`, or `{:error, reason}`. Temp files are always
  cleaned up via `try/after`.
  """
  @spec fetch_profile_at(DateTime.t(), {float(), float()}) ::
          {:ok, map()} | {:error, term()}
  def fetch_profile_at(%DateTime{} = valid_time, {lat, lon}) when is_number(lat) and is_number(lon) do
    Microwaveprop.Instrument.span([:narr, :fetch_profile], %{lat: lat, lon: lon}, fn ->
      grb_url = url_for(valid_time)

      with {:ok, index} <- fetch_inventory(valid_time),
           {:ok, record_keys} <- pick_records(index) do
        do_fetch_profile(grb_url, index, record_keys, lat, lon)
      end
    end)
  end

  defp do_fetch_profile(grb_url, index, record_keys, lat, lon) do
    tag = "narr_#{System.unique_integer([:positive])}"
    tmp_dir = System.tmp_dir!()
    merged_path = Path.join(tmp_dir, "#{tag}_merged.grb")

    record_paths =
      Enum.with_index(record_keys, fn key, i ->
        {key, Path.join(tmp_dir, "#{tag}_#{i}.grb")}
      end)

    try do
      with :ok <- fetch_records(grb_url, index, record_paths),
           :ok <- merge_records(record_paths, merged_path) do
        extract_profile_from_file(merged_path, lat, lon)
      end
    after
      Enum.each(record_paths, fn {_key, path} -> File.rm(path) end)
      File.rm(merged_path)
    end
  end

  # Builds the list of {var, level} keys we want to pull from the inventory:
  # the fixed surface set plus every (TMP|HGT|SPFH) at "NN mb" present in
  # the index. Returns `{:error, _}` if any of the required surface records
  # are missing from the inventory.
  defp pick_records(index) do
    missing_surface = Enum.reject(@surface_records, &Map.has_key?(index, &1))

    if missing_surface == [] do
      pressure_keys =
        index
        |> Map.keys()
        |> Enum.filter(fn
          {var, level} when var in ["TMP", "HGT", "SPFH"] ->
            String.ends_with?(level, " mb")

          _ ->
            false
        end)

      {:ok, @surface_records ++ pressure_keys}
    else
      {:error, "NARR inventory missing required surface records: #{inspect(missing_surface)}"}
    end
  end

  defp fetch_records(grb_url, index, record_paths) do
    Enum.reduce_while(record_paths, :ok, fn {key, path}, _acc ->
      {offset, length} = Map.fetch!(index, key)

      case fetch_byte_range(grb_url, offset, length, path) do
        :ok -> {:cont, :ok}
        {:error, reason} -> {:halt, {:error, reason}}
      end
    end)
  end

  defp fetch_byte_range(url, offset, length, dest_path) do
    range_header = "bytes=#{offset}-#{offset + length - 1}"

    case Req.get(url, [headers: [{"range", range_header}], receive_timeout: 60_000] ++ req_options()) do
      {:ok, %{status: status, body: body}} when status in [200, 206] and is_binary(body) ->
        File.write(dest_path, body)

      {:ok, %{status: status}} ->
        {:error, "NARR byte-range HTTP #{status} for #{range_header}"}

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

  defp merge_records(record_paths, merged_path) do
    inputs = Enum.map(record_paths, fn {_key, path} -> path end)
    args = ["-merge"] ++ inputs ++ [merged_path]

    case System.cmd("cdo", args, stderr_to_stdout: true) do
      {_output, 0} -> :ok
      {output, code} -> {:error, "cdo -merge exited #{code}: #{output}"}
    end
  end

  defp run_cdo_outputtab(grb_path, lat, lon) do
    args = [
      "-outputtab,code,lev,value",
      "-remapnn,lon=#{lon}_lat=#{lat}",
      grb_path
    ]

    case System.cmd("cdo", args, stderr_to_stdout: true) do
      {stdout, 0} ->
        parse_cdo_outputtab(stdout)

      {output, code} ->
        {:error, "cdo extract exited #{code}: #{output}"}
    end
  end

  @doc false
  def parse_cdo_outputtab(stdout) do
    raw =
      stdout
      |> String.split("\n", trim: true)
      |> Enum.reduce(%{}, &accumulate_cdo_row/2)

    if raw == %{} do
      # Include the first 500 chars of cdo output so the error surfaces
      # what cdo actually printed — crucial for diagnosing year-specific
      # parameter-table quirks in NARR GRIB1.
      snippet = stdout |> String.slice(0, 500) |> String.replace("\n", " | ")
      {:error, "NARR cdo extract: no parseable values in cdo output (got: #{snippet})"}
    else
      {:ok, build_profile_attrs(raw)}
    end
  end

  defp accumulate_cdo_row(line, acc) do
    case parse_cdo_row(line) do
      {key, value} -> Map.put(acc, key, value)
      :error -> acc
    end
  end

  # Rows are three whitespace-separated columns: <code> <lev> <value>. The
  # <code> token is one of:
  #   * a bare integer (cdo 2.6 on Mac / dev)
  #   * `codeNN` with "code" prefix (cdo 2.5.1 on Debian / prod)
  #   * a GRIB1 shortname like `2tag2` / `saip` / `tpag10` (cdo 2.5.1 again,
  #     for params whose GRIB1 parameter-table entry has a shortname)
  # Header, cdi warnings, and cdo progress lines lack the three-column shape
  # and fall through to :error.
  defp parse_cdo_row(line) do
    with [code_str, lev_str, value_str] <- String.split(line, ~r/\s+/, trim: true),
         {:ok, code_int} <- normalize_cdo_code_token(code_str),
         {:ok, name} <- Map.fetch(@cdo_code_to_name, code_int),
         {lev_int, ""} <- Integer.parse(lev_str),
         {value_float, ""} <- Float.parse(value_str) do
      {{name, lev_int}, value_float}
    else
      _ -> :error
    end
  end

  # Resolves the first column of a cdo `-outputtab,code,...` row to its
  # numeric GRIB1 parameter code. Handles all three cdo output dialects
  # we've seen in the wild (see `parse_cdo_row/1` for context).
  defp normalize_cdo_code_token("code" <> rest) do
    case Integer.parse(rest) do
      {code_int, ""} -> {:ok, code_int}
      _ -> :error
    end
  end

  defp normalize_cdo_code_token(token) do
    case Integer.parse(token) do
      {code_int, ""} ->
        {:ok, code_int}

      _ ->
        case Map.fetch(@cdo_shortname_to_code, token) do
          {:ok, code_int} -> {:ok, code_int}
          :error -> :error
        end
    end
  end

  defp build_profile_attrs(raw) do
    profile = build_pressure_profile(raw)

    base = %{
      profile: profile,
      surface_temp_c: kelvin_to_celsius(raw[{"TMP", 2}]),
      surface_dewpoint_c: kelvin_to_celsius(raw[{"DPT", 2}]),
      surface_pressure_mb: pa_to_mb(raw[{"PRES", 0}]),
      hpbl_m: raw[{"HPBL", 0}],
      pwat_mm: raw[{"PWAT", 0}]
    }

    params = SoundingParams.derive(profile)

    if params do
      Map.merge(base, %{
        surface_refractivity: params.surface_refractivity,
        min_refractivity_gradient: params.min_refractivity_gradient,
        ducting_detected: params.ducting_detected || false,
        duct_characteristics: params.duct_characteristics
      })
    else
      Map.merge(base, %{
        surface_refractivity: nil,
        min_refractivity_gradient: nil,
        ducting_detected: false,
        duct_characteristics: nil
      })
    end
  end

  defp build_pressure_profile(raw) do
    raw
    |> Map.keys()
    |> Enum.filter(fn
      {"TMP", lev} when lev > 100 -> true
      _ -> false
    end)
    |> Enum.map(fn {"TMP", lev} -> lev end)
    |> Enum.sort(:desc)
    |> Enum.map(&pressure_level_entry(&1, raw))
    |> Enum.reject(&is_nil/1)
  end

  defp pressure_level_entry(lev_pa, raw) do
    with t_k when is_float(t_k) <- raw[{"TMP", lev_pa}],
         h_m when is_float(h_m) <- raw[{"HGT", lev_pa}],
         spfh when is_float(spfh) <- raw[{"SPFH", lev_pa}] do
      %{
        "pres" => lev_pa / 100.0,
        "tmpc" => kelvin_to_celsius(t_k),
        "hght" => h_m,
        "dwpc" => dewpoint_from_spfh(spfh, lev_pa)
      }
    else
      _ -> nil
    end
  end

  # ThetaE.dewpoint_from_spfh/2 returns Kelvin (the function adds 273.15
  # at the end of the Magnus-Tetens inversion). Profile entries store
  # `dwpc` in *Celsius* — the `c` is load-bearing — and downstream
  # SoundingParams.compute_refractivity_profile passes p["dwpc"] straight
  # to the Buck saturation-vapor-pressure function which expects °C. So
  # convert here.
  defp dewpoint_from_spfh(spfh, _lev_pa) when not is_number(spfh) or spfh <= 0, do: nil
  defp dewpoint_from_spfh(spfh, lev_pa), do: ThetaE.dewpoint_from_spfh(spfh, lev_pa * 1.0) - 273.15

  defp kelvin_to_celsius(nil), do: nil
  defp kelvin_to_celsius(k) when is_number(k), do: k - 273.15

  defp pa_to_mb(nil), do: nil
  defp pa_to_mb(pa) when is_number(pa), do: pa / 100.0

  defp do_get_inventory(url) do
    case Req.get(url, [receive_timeout: 30_000] ++ req_options()) do
      {:ok, %{status: 200, body: body}} when is_binary(body) ->
        {:ok, body}

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

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

  defp do_head_grib_size(url) do
    case Req.head(url, [receive_timeout: 30_000] ++ req_options()) do
      {:ok, %{status: 200} = response} ->
        case content_length(response) do
          {:ok, size} -> {:ok, size}
          :error -> {:error, "NARR grib HEAD missing content-length"}
        end

      {:ok, %{status: status}} ->
        {:error, "NARR grib HEAD HTTP #{status}"}

      {:error, reason} ->
        {:error, "NARR grib HEAD failed: #{inspect(reason)}"}
    end
  end

  defp content_length(%Req.Response{} = response) do
    case response |> Req.Response.get_header("content-length") |> List.first() do
      nil ->
        :error

      value when is_binary(value) ->
        case Integer.parse(value) do
          {size, ""} when size >= 0 -> {:ok, size}
          _ -> :error
        end
    end
  end

  defp parse_inventory_line(line) do
    case String.split(line, ":") do
      [num, offset, _date, var, level | _rest] ->
        with {num_int, ""} <- Integer.parse(num),
             {offset_int, ""} <- Integer.parse(offset) do
          {num_int, offset_int, var, level}
        else
          _ -> nil
        end

      _ ->
        nil
    end
  end

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

  defp validate_analysis_time!(%DateTime{hour: hour, minute: 0, second: 0} = _vt) when hour in @valid_hours, do: :ok

  defp validate_analysis_time!(vt) do
    raise ArgumentError,
          "NARR analyses are only produced at 00/03/06/09/12/15/18/21 UTC on the hour, " <>
            "got #{inspect(vt)}"
  end
end
