# Copyright 2024 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Defines `diagnostic` modules that compute diagnostic predictions."""

from collections import abc
from typing import Any, Callable, Optional, Protocol

from dinosaur import coordinate_systems
from dinosaur import scales
from dinosaur import sigma_coordinates
from dinosaur import typing

import gin
import haiku as hk
import jax
import jax.numpy as jnp
import numpy as np


TransformModule = typing.TransformModule

PRECIPITATION = 'precipitation'
EVAPORATION = 'evaporation'


class DiagnosticFn(Protocol):
  """Implements initialization and computation of model diagnostic fields."""

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: dict[str, Any],
  ):
    del coords, dt, physics_specs, aux_features

  def __call__(
      self,
      model_state: typing.ModelState,
      physics_tendencies: typing.Pytree,
      forcing: typing.Forcing | None = None,
  ) -> dict[str, jax.Array]:
    """Computes diagnostic field from `model_state` and `physics_tendencies`."""
    ...


DiagnosticModule = Callable[..., DiagnosticFn]


@gin.register
class NoDiagnostics:
  """Diagnostic module that computes no diagnostics."""

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: dict[str, Any],
  ):
    del coords, dt, physics_specs, aux_features

  def __call__(
      self,
      model_state: typing.ModelState,
      physics_tendencies: typing.Pytree,
      forcing: typing.Forcing | None = None,
  ) -> dict[str, jax.Array]:
    return {}


@gin.register
class CombinedDiagnostics:
  """Computes a combination of multiple diagnostics."""

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: dict[str, Any],
      diagnostic_modules: abc.Sequence[DiagnosticModule] = gin.REQUIRED,
  ):
    self.diagnostic_fns = [
        module(coords, dt, physics_specs, aux_features)
        for module in diagnostic_modules
    ]

  def __call__(
      self,
      model_state: typing.ModelState,
      physics_tendencies: typing.Pytree,
      forcing: typing.Forcing | None = None,
  ) -> dict[str, jax.Array]:
    diagnostics = {}
    for fn in self.diagnostic_fns:
      new_diagnostics = fn(model_state, physics_tendencies, forcing)
      if any(k in diagnostics for k in new_diagnostics):
        raise ValueError(
            f'{new_diagnostics.keys()} overlaps with {diagnostics.keys()}'
        )
      diagnostics.update(new_diagnostics)
    return diagnostics


@gin.register
class PrecipitationMinusEvaporationDiagnostics:
  """Computes `P-E` by integrating physics_tendencies.

  Depending on the `method` computes either precipitation minus evaporation
  rate, which in ERA5 has units `kg m**-2 s**-1` or time-accumulated value
  in `kg m**-2` if `method == cumulative`.
  """

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: dict[str, Any],
      moisture_species: tuple[str, ...] = (
          'specific_humidity',
          'specific_cloud_ice_water_content',
          'specific_cloud_liquid_water_content',
      ),
      method: str = 'rate',
  ):
    del aux_features
    self.coords = coords
    self.dt = dt
    self.physics_specs = physics_specs
    self.moisture_species = moisture_species
    self.method = method
    self.to_nodal_fn = coords.horizontal.to_nodal

  def _compute_evaporation_minus_precipitation(
      self, model_state: typing.ModelState, physics_tendencies: typing.Pytree
  ) -> typing.Array:
    """Computes evaporation minus precipitation."""
    lsp = model_state.state.log_surface_pressure
    p_surface = jnp.squeeze(jnp.exp(self.to_nodal_fn(lsp)), axis=0)
    moisture_tendencies = [
        v
        for tracer, v in physics_tendencies.tracers.items()
        if tracer in self.moisture_species
    ]
    moisture_tendencies = sum(self.to_nodal_fn(moisture_tendencies))
    scale = p_surface / self.physics_specs.g
    e_minus_p = scale * sigma_coordinates.sigma_integral(
        moisture_tendencies, self.coords.vertical, keepdims=False
    )
    return e_minus_p

  def __call__(
      self,
      model_state: typing.ModelState,
      physics_tendencies: typing.Pytree,
      forcing: typing.Forcing | None = None,
  ) -> typing.Pytree:
    """Computes precipitation minus evaporation."""
    del forcing  # unused
    e_minus_p = self._compute_evaporation_minus_precipitation(
        model_state, physics_tendencies
    )
    if self.method == 'rate':
      return {'P_minus_E_rate': -e_minus_p}
    elif self.method == 'cumulative':
      # TODO(dkochkov) Address possible precision loss due to small deltas.
      surface_nodal_shape = self.coords.horizontal.nodal_shape
      previous = model_state.diagnostics.get(
          'P_minus_E_cumulative',
          jnp.zeros(surface_nodal_shape))
      return {'P_minus_E_cumulative': previous - (e_minus_p * self.dt)}
    else:
      raise ValueError(f'Unknown {self.method=}, must be `rate`/`cumulative`')


@gin.register
class PrecipitableWaterDiagnostics:
  """Computes cumulative preciptable water in the state."""

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: dict[str, Any],
      moisture_species: tuple[str, ...] = (
          'specific_humidity',
          'specific_cloud_ice_water_content',
          'specific_cloud_liquid_water_content',
      ),
  ):
    del dt, aux_features
    self.coords = coords
    self.physics_specs = physics_specs
    self.moisture_species = moisture_species
    self.to_nodal_fn = coords.horizontal.to_nodal

  def __call__(
      self,
      model_state: typing.ModelState,
      physics_tendencies: typing.Pytree,
      forcing: typing.Forcing | None = None,
  ) -> typing.Pytree:
    """Computes preciptable water."""
    del physics_tendencies, forcing  # unused
    lsp = model_state.state.log_surface_pressure
    p_surface = jnp.squeeze(jnp.exp(self.to_nodal_fn(lsp)), axis=0)
    moisture_tracers = [
        v
        for tracer, v in model_state.tracers.items()
        if tracer in self.moisture_species
    ]
    moisture = sum(self.to_nodal_fn(moisture_tracers))
    water_density = self.physics_specs.nondimensionalize(scales.WATER_DENSITY)
    scale = p_surface / (self.physics_specs.g * water_density)
    water = scale * sigma_coordinates.sigma_integral(
        moisture, self.coords.vertical, keepdims=False
    )
    return {'precipitable_water': water}


@gin.register
class NodalModelDiagnosticsDecoder:
  """Diagnostics decoder that returns elements from model_state.diagnostics."""

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: dict[str, Any],
  ):
    del dt, aux_features
    self.coords = coords
    self.physics_specs = physics_specs

  def __call__(
      self,
      model_state: typing.ModelState,
      physics_tendencies: typing.Pytree,
      forcing: typing.Forcing | None = None,
  ) -> typing.Pytree:
    """Computes precipitation minus evaporation."""
    del physics_tendencies, forcing  # unused.
    nodal_diagnostics = coordinate_systems.maybe_to_nodal(
        model_state.diagnostics, self.coords
    )
    return nodal_diagnostics


# TODO(janniyuval) add a decoder that can add some Gaussian noise to evap/precip
@gin.register
class PrecipitationDiagnosticsConstrained(
    hk.Module, PrecipitationMinusEvaporationDiagnostics
):
  """Predict evaporation and computes cumulative precipitation.

  Calculation is based on calculating `P-E` by integrating physics_tendencies.
  Depending on the `method` computes either precipitation
  rate, (which in ERA5 has units `kg m**-2 s**-1`) or time-accumulated value
  in `Length` units (GPCP uses mm/day) if `method == cumulative`.
  Evaporation has the units of `kg m**-2 s**-1` in ERA5.
  """

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: dict[str, Any],
      embedding_module: typing.EmbeddingModule,
      moisture_species: tuple[str, ...] = (
          'specific_humidity',
          'specific_cloud_ice_water_content',
          'specific_cloud_liquid_water_content',
      ),
      is_precipitation: bool = True,
      method_precipitation: str = 'cumulative',
      method_evaporation: str = 'rate',
      name: Optional[str] = None,
      field_name: str = 'total_precipitation',
  ):
    # del aux_features
    super().__init__(name=name)
    self.coords = coords
    self.dt = dt
    self.physics_specs = physics_specs
    self.moisture_species = moisture_species
    self.method_precipitation = method_precipitation
    self.method_evaporation = method_evaporation
    self.to_nodal_fn = coords.horizontal.to_nodal
    self.is_precipitation = is_precipitation
    if self.is_precipitation:
      predicted_name = PRECIPITATION
      diagnosed_name = EVAPORATION
    else:
      predicted_name = EVAPORATION
      diagnosed_name = PRECIPITATION

    self.predicted_name = predicted_name
    self.diagnosed_name = diagnosed_name

    output_shapes = {
        f'{predicted_name}': np.asarray(coords.surface_nodal_shape)
    }

    self.embedding_fn = embedding_module(
        coords, dt, physics_specs, aux_features, output_shapes=output_shapes
    )
    self.water_density = self.physics_specs.nondimensionalize(
        scales.WATER_DENSITY
    )
    self.field_name = field_name

  def __call__(
      self,
      model_state: typing.ModelState,
      physics_tendencies: typing.Pytree,
      forcing: typing.Forcing | None = None,
  ) -> typing.Pytree:
    """Computes precipitation minus evaporation."""
    e_minus_p = self._compute_evaporation_minus_precipitation(
        model_state, physics_tendencies
    )
    water_budget = self.embedding_fn(
        model_state.state,
        model_state.memory,
        model_state.diagnostics,
        model_state.randomness,
        forcing,
    )
    water_budget[self.diagnosed_name] = (
        -e_minus_p - water_budget[self.predicted_name]
    )

    # Note: In ERA5 mean_evaporation_rate (kg m**-2 s**-1)
    # is negative for evaporation.
    # In GPCP precipitation is positive (mm/day).
    # Here e_minus_p is positive for evaporation.
    output_dict = {}
    surface_nodal_shape = self.coords.horizontal.nodal_shape
    if self.method_precipitation == 'rate':  # units: length/time
      output_dict[PRECIPITATION + '_rate'] = (
          water_budget[PRECIPITATION]
      ) / self.water_density
    elif self.method_precipitation == 'cumulative':  # units: length
      previous = model_state.diagnostics.get(
          self.field_name, jnp.zeros(surface_nodal_shape)
      )
      # TODO(janniyuval) remove precipitation_cumulative_mean once no models
      # use it.
      assert self.field_name in [
          'total_precipitation',
          'precipitation_cumulative_mean',
      ], self.field_name
      output_dict[self.field_name] = previous + (
          (water_budget[PRECIPITATION] / self.water_density) * self.dt
      )
    else:
      raise ValueError(
          f'Precipitation method is {self.method_precipitation=}, but it must'
          ' be `rate`/`cumulative`'
      )
    if self.method_evaporation == 'rate':  # units: mass length**-2 time**-1
      output_dict[EVAPORATION] = water_budget[EVAPORATION]
    elif self.method_evaporation == 'cumulative':  # units: length
      previous_evap = model_state.diagnostics.get(
          EVAPORATION + '_cumulative', jnp.zeros(surface_nodal_shape)
      )
      output_dict[EVAPORATION + '_cumulative'] = (
          previous_evap
          + (water_budget[EVAPORATION] / self.water_density) * self.dt
      )
    else:
      raise ValueError(
          f'Evaporation method is {self.method_evaporation=},  but it must be'
          ' `rate`/`cumulative`'
      )
    return output_dict


@gin.register
class SurfacePressureDiagnostics:
  """Getting the surface pressure of the state."""

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: dict[str, Any],
  ):
    del dt, aux_features, physics_specs
    self.to_nodal_fn = coords.horizontal.to_nodal

  def __call__(
      self,
      model_state: typing.ModelState,
      physics_tendencies: typing.Pytree,
      forcing: typing.Forcing | None = None,
  ) -> typing.Pytree:
    """Computes surface pressure."""
    del physics_tendencies, forcing  # unused
    lsp = model_state.state.log_surface_pressure
    surface_pressure = jnp.squeeze(jnp.exp(self.to_nodal_fn(lsp)), axis=0)
    return {'surface_pressure': surface_pressure}
