defmodule Microwaveprop.Weather.HrrrNativeProfile do
  @moduledoc """
  A single HRRR profile extracted on the native hybrid-sigma vertical
  coordinate (50 levels), as opposed to `HrrrProfile` which is
  interpolated to a coarse set of pressure levels.

  This schema is intentionally additive: it does not replace
  `HrrrProfile`. The legacy scorer keeps running against
  `hrrr_profiles` until Phase 9 swaps it out.

  Columns marked "derived (Phase 2/4)" are written by the
  boundary-layer turbulence and duct-geometry phases — they stay
  nullable so the ingestion worker can populate just the raw profile
  columns and a later `mix hrrr_native_derive` task fills the rest in.
  """
  use Ecto.Schema

  import Ecto.Changeset

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id

  schema "hrrr_native_profiles" do
    field :valid_time, :utc_datetime
    field :run_time, :utc_datetime
    field :lat, :float
    field :lon, :float

    # Raw native-level columns, level 1 (surface) first, strictly
    # monotone in height.
    field :level_count, :integer
    field :heights_m, {:array, :float}
    field :temp_k, {:array, :float}
    field :spfh, {:array, :float}
    field :pressure_pa, {:array, :float}
    field :u_wind_ms, {:array, :float}
    field :v_wind_ms, {:array, :float}
    field :tke_m2s2, {:array, :float}

    # Cached surface scalars — convenient for backtests that only need
    # the lowest level without unpacking the whole profile.
    field :surface_temp_k, :float
    field :surface_spfh, :float
    field :surface_pressure_pa, :float

    # Derived (Phase 2/4).
    field :inversion_top_m, :float
    field :bulk_richardson, :float
    field :theta_e_jump_k, :float
    field :shear_at_top_ms, :float
    field :ducts, {:array, :map}
    field :best_duct_band_ghz, :float

    timestamps(type: :utc_datetime)
  end

  @type t :: %__MODULE__{}

  @required ~w(valid_time lat lon)a
  @optional ~w(
    run_time level_count heights_m temp_k spfh pressure_pa
    u_wind_ms v_wind_ms tke_m2s2
    surface_temp_k surface_spfh surface_pressure_pa
    inversion_top_m bulk_richardson theta_e_jump_k shear_at_top_ms
    ducts best_duct_band_ghz
  )a

  @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
  def changeset(profile, attrs) do
    profile
    |> cast(attrs, @required ++ @optional)
    |> validate_required(@required)
    |> validate_array_lengths()
    |> unique_constraint([:lat, :lon, :valid_time])
  end

  @doc """
  Convert a native profile's parallel arrays into the list-of-maps
  shape the skew-T renderer expects (`%{"pres", "tmpc", "dwpc", "hght"}`).
  Dewpoint is derived from specific humidity, pressure, and temperature
  via the Magnus inverse so the chart gets a full-atmosphere trace even
  though the native file only carries SPFH. The result is sorted
  surface-first (descending pressure).
  """
  @spec to_skew_t_profile(t()) :: [map()]
  def to_skew_t_profile(%__MODULE__{} = profile) do
    heights = profile.heights_m || []
    temps = profile.temp_k || []
    spfhs = profile.spfh || []
    pressures = profile.pressure_pa || []

    [heights, temps, spfhs, pressures]
    |> Enum.zip()
    |> Enum.flat_map(&level_to_skew_t/1)
    |> Enum.sort_by(& &1["pres"], :desc)
  end

  defp level_to_skew_t({h, t_k, q, p_pa}) when is_number(t_k) and is_number(p_pa) do
    p_hpa = p_pa / 100.0

    [
      %{
        "pres" => p_hpa,
        "tmpc" => t_k - 273.15,
        "dwpc" => dewpoint_c(q, p_pa),
        "hght" => h
      }
    ]
  end

  defp level_to_skew_t(_), do: []

  defp dewpoint_c(q, p_pa) when is_number(q) and q > 0 and is_number(p_pa) do
    e_hpa = q * p_pa / (0.622 + 0.378 * q) / 100.0
    ln = :math.log(e_hpa / 6.112)
    243.5 * ln / (17.67 - ln)
  end

  defp dewpoint_c(_, _), do: nil

  # All level arrays must have the same length, matching level_count.
  defp validate_array_lengths(changeset) do
    level_count = get_field(changeset, :level_count)

    arrays =
      [:heights_m, :temp_k, :spfh, :pressure_pa, :u_wind_ms, :v_wind_ms, :tke_m2s2]
      |> Enum.map(&{&1, get_field(changeset, &1)})
      |> Enum.reject(fn {_k, v} -> is_nil(v) end)

    expected = level_count || arrays |> Enum.map(fn {_, v} -> length(v) end) |> Enum.max(fn -> 0 end)

    Enum.reduce(arrays, changeset, fn {key, values}, acc ->
      if length(values) == expected do
        acc
      else
        add_error(acc, key, "length #{length(values)} does not match expected #{expected}")
      end
    end)
  end
end
