defmodule Microwaveprop.Propagation.Model do
  @moduledoc """
  Neural network model for microwave propagation prediction.

  Uses Axon to define a model that predicts propagation conditions
  (score 0-100 per band) from atmospheric and temporal features.

  ## Features (inputs)

  Atmospheric (from HRRR):
    - surface_temp_c — surface temperature
    - surface_dewpoint_c — surface dewpoint
    - surface_pressure_mb — surface pressure
    - abs_humidity — absolute humidity (g/m³), derived
    - td_depression — temp minus dewpoint (°C)
    - min_refractivity_gradient — minimum dN/dh from profile
    - hpbl_m — planetary boundary layer height
    - pwat_mm — precipitable water

  Temporal:
    - utc_hour — hour of day (0-23), sin/cos encoded
    - month — month of year (1-12), sin/cos encoded

  Band:
    - freq_mhz — operating frequency, log-scaled

  ## Target (output)

  - score — propagation score (0-100), normalized to 0-1 for training

  ## Architecture

  Feed-forward neural network with:
    - Input: 20 features (12 atmospheric + 2 solar + 1 geographic + 4 cyclical temporal + 1 frequency)
    - Hidden: 2 dense layers with ReLU activation
    - Output: 1 sigmoid unit (score 0-1, scaled to 0-100)

  The model is intentionally simple to start. We can add complexity
  (LSTM for temporal sequences, attention, ensemble) once we have
  baseline performance metrics.
  """

  @feature_count 20
  @models_dir Path.join(:code.priv_dir(:microwaveprop), "models")
  @default_path Path.join(@models_dir, "propagation_v1.nx")

  def default_path, do: @default_path

  @doc """
  Builds the Axon model graph. Does not initialize parameters.

  Returns an `%Axon{}` struct ready for `Axon.build/2` or `Axon.Loop.trainer/3`.
  """
  def build do
    "features"
    |> Axon.input(shape: {nil, @feature_count})
    |> Axon.dense(128, activation: :relu, name: "hidden_1")
    |> Axon.dropout(rate: 0.2, name: "dropout_1")
    |> Axon.dense(64, activation: :relu, name: "hidden_2")
    |> Axon.dropout(rate: 0.15, name: "dropout_2")
    |> Axon.dense(32, activation: :relu, name: "hidden_3")
    |> Axon.dropout(rate: 0.1, name: "dropout_3")
    |> Axon.dense(1, activation: :sigmoid, name: "output")
  end

  @doc """
  Initializes random model parameters.

  Returns `%{...}` map of layer name → tensor params.
  """
  def init do
    model = build()
    {init_fn, _predict_fn} = Axon.build(model)
    template = Nx.template({1, @feature_count}, :f32)
    init_fn.(template, Axon.ModelState.empty())
  end

  @doc """
  Saves model parameters to disk. Defaults to `priv/models/propagation_v1.nx`.
  """
  def save(params, path \\ @default_path) do
    File.mkdir_p!(Path.dirname(path))
    binary = Nx.serialize(params)
    File.write!(path, binary)
    :ok
  end

  @doc """
  Loads model parameters from disk. Returns `{:ok, params}` or `:error`.
  """
  def load(path \\ @default_path) do
    case File.read(path) do
      {:ok, binary} -> {:ok, Nx.deserialize(binary)}
      {:error, _} -> :error
    end
  end

  @doc """
  Loads saved parameters if available, otherwise initializes random ones.
  """
  def load_or_init(path \\ @default_path) do
    case load(path) do
      {:ok, params} -> params
      :error -> init()
    end
  end

  @doc """
  Trains the model on the given features and targets.

  Features should be an `{n, #{@feature_count}}` tensor and targets an `{n, 1}` tensor
  with values in [0, 1].

  Returns `{trained_state, %{final_loss: float}}`.

  ## Options

    * `:epochs` - number of training epochs (default: 50)
    * `:batch_size` - batch size (default: 256)
    * `:learning_rate` - Adam learning rate (default: 0.001)
    * `:initial_state` - pre-trained model state to resume from (default: empty)
  """
  def train(features, targets, opts \\ []) do
    epochs = Keyword.get(opts, :epochs, 50)
    batch_size = Keyword.get(opts, :batch_size, 256)
    lr = Keyword.get(opts, :learning_rate, 0.001)
    initial_state = Keyword.get(opts, :initial_state)

    model = build()

    loop = Axon.Loop.trainer(model, :mean_squared_error, Polaris.Optimizers.adam(learning_rate: lr))

    data =
      features
      |> Nx.to_batched(batch_size)
      |> Stream.zip(Nx.to_batched(targets, batch_size))
      |> Stream.map(fn {x, y} -> {%{"features" => x}, y} end)

    init_state = initial_state || Axon.ModelState.empty()

    trained_state =
      Axon.Loop.run(loop, data, init_state,
        epochs: epochs,
        compiler: EXLA
      )

    # Compute final loss on full dataset
    {_init_fn, predict_fn} = Axon.build(model, compiler: EXLA)
    preds = predict_fn.(trained_state, %{"features" => features})
    final_loss = preds |> Nx.subtract(targets) |> Nx.pow(2) |> Nx.mean() |> Nx.to_number()

    {trained_state, %{final_loss: final_loss}}
  end

  @doc """
  Evaluates trained parameters against features and targets.

  Returns `%{rmse: float, r_squared: float}`.
  """
  def evaluate(params, features, targets) do
    model = build()
    {_init_fn, predict_fn} = Axon.build(model, compiler: EXLA)
    preds = predict_fn.(params, %{"features" => features})

    mse = targets |> Nx.subtract(preds) |> Nx.pow(2) |> Nx.mean() |> Nx.to_number()
    rmse = :math.sqrt(mse)

    ss_res = targets |> Nx.subtract(preds) |> Nx.pow(2) |> Nx.sum() |> Nx.to_number()
    mean_y = targets |> Nx.mean() |> Nx.to_number()
    ss_tot = targets |> Nx.subtract(mean_y) |> Nx.pow(2) |> Nx.sum() |> Nx.to_number()
    r_squared = if ss_tot > 0, do: 1.0 - ss_res / ss_tot, else: 0.0

    %{rmse: rmse, r_squared: r_squared}
  end

  @doc """
  Predicts a propagation score (0-100) from raw conditions.

  Takes a compiled predict function, model parameters, and a conditions map.
  Returns an integer score clamped to [0, 100].
  """
  def predict_score(predict_fn, params, conditions) do
    features =
      conditions
      |> encode_features()
      |> Nx.tensor(type: :f32)
      |> Nx.reshape({1, @feature_count})

    params
    |> predict_fn.(%{"features" => features})
    |> Nx.squeeze()
    |> Nx.multiply(100)
    |> Nx.round()
    |> Nx.to_number()
    |> trunc()
    |> max(0)
    |> min(100)
  end

  @doc """
  Predicts scores for multiple condition maps in a single batched forward pass.

  Takes a compiled predict function, model parameters, and a list of conditions maps.
  Returns a list of integer scores (0-100).
  """
  @batch_chunk_size 10_000

  def predict_scores_batch(predict_fn, params, conditions_list) do
    conditions_list
    |> Enum.chunk_every(@batch_chunk_size)
    |> Enum.flat_map(fn chunk ->
      feature_rows = Enum.map(chunk, &encode_features/1)
      batch = Nx.tensor(feature_rows, type: :f32)

      params
      |> predict_fn.(%{"features" => batch})
      |> Nx.multiply(100)
      |> Nx.round()
      |> Nx.squeeze(axes: [1])
      |> Nx.to_flat_list()
      |> Enum.map(fn score -> score |> trunc() |> max(0) |> min(100) end)
    end)
  end

  @doc """
  Compiles the predict function for reuse. Call once at load time.
  """
  def compile_predict do
    model = build()
    {_init_fn, predict_fn} = Axon.build(model, compiler: EXLA)
    predict_fn
  end

  @doc """
  Runs a forward pass with the given parameters and input features.

  `features` should be an `{batch_size, 13}` tensor of float32.
  Returns an `{batch_size, 1}` tensor of scores in [0, 1].
  """
  def predict(params, features) do
    model = build()
    {_init_fn, predict_fn} = Axon.build(model)
    predict_fn.(params, features)
  end

  @doc """
  Returns the ordered list of feature names expected by the model.
  """
  def feature_names do
    [
      :surface_temp_c,
      :surface_dewpoint_c,
      :surface_pressure_mb,
      :abs_humidity,
      :td_depression,
      :min_refractivity_gradient,
      :hpbl_m,
      :pwat_mm,
      :surface_refractivity,
      :latitude,
      :sfi,
      :kp_max,
      :ducting_detected,
      :k_index,
      :lifted_index,
      :solar_hour_sin,
      :solar_hour_cos,
      :month_sin,
      :month_cos,
      :log_freq_mhz
    ]
  end

  @doc """
  Encodes raw condition data into a feature tensor row.

  Takes a map of raw values and returns a flat list of 13 floats
  ready to be stacked into a batch tensor.
  """
  def encode_features(%{} = conditions) do
    temp_c = conditions[:surface_temp_c] || 20.0
    dewpoint_c = conditions[:surface_dewpoint_c] || 10.0
    pressure_mb = conditions[:surface_pressure_mb] || 1013.0
    grad = conditions[:min_refractivity_gradient] || -70.0
    hpbl = conditions[:hpbl_m] || 500.0
    pwat = conditions[:pwat_mm] || 20.0
    refractivity = conditions[:surface_refractivity] || 320.0
    latitude = conditions[:latitude] || 37.0
    sfi = conditions[:sfi] || 120.0
    kp_max = conditions[:kp_max] || 2.0
    ducting = if conditions[:ducting_detected], do: 1.0, else: 0.0
    k_index = conditions[:k_index] || 20.0
    lifted_index = conditions[:lifted_index] || 0.0
    utc_hour = conditions[:utc_hour] || 12
    longitude = conditions[:longitude] || -97.0
    month = conditions[:month] || 6
    freq_mhz = conditions[:freq_mhz] || 10_000

    abs_humidity = abs_humidity(temp_c, dewpoint_c)
    td_depression = temp_c - dewpoint_c

    # Cyclical encoding for time features (local solar time)
    local_hour = :math.fmod(utc_hour + longitude / 15 + 24, 24)
    hour_rad = 2 * :math.pi() * local_hour / 24
    month_rad = 2 * :math.pi() * (month - 1) / 12

    # Normalize all features to ~[0, 1] using physical bounds.
    [
      (temp_c + 40) / 80,
      (dewpoint_c + 40) / 80,
      (pressure_mb - 960) / 80,
      abs_humidity / 25.0,
      td_depression / 40.0,
      (grad + 300) / 300,
      hpbl / 3000.0,
      pwat / 60.0,
      (refractivity - 250) / 120,
      (latitude - 25) / 25,
      (sfi - 60) / 200,
      kp_max / 9.0,
      ducting,
      (k_index + 10) / 50,
      (lifted_index + 10) / 20,
      :math.sin(hour_rad),
      :math.cos(hour_rad),
      :math.sin(month_rad),
      :math.cos(month_rad),
      :math.log(freq_mhz) / 13.0
    ]
  end

  defp abs_humidity(temp_c, dewpoint_c) do
    e_sat = 6.112 * :math.exp(17.67 * dewpoint_c / (dewpoint_c + 243.5))
    217.0 * e_sat / (temp_c + 273.15)
  end
end
