# 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.
"""Modules that parameterize composed time-steppers."""

import abc
import functools
from typing import Any, Callable, Optional, Sequence
from dinosaur import coordinate_systems
from dinosaur import primitive_equations
from dinosaur import time_integration
from dinosaur import typing
import gin
import haiku as hk
from neuralgcm import diagnostics
from neuralgcm import integrators
from neuralgcm import perturbations
from neuralgcm import stochastic

DiagnosticModule = diagnostics.DiagnosticModule
Forcing = typing.Forcing
Pytree = typing.Pytree
ModelState = typing.ModelState
EquationModule = Callable[..., time_integration.ImplicitExplicitODE]
CorrectorModule = typing.CorrectorModule
PerturbationModule = perturbations.PerturbationModule
RandomnessModule = stochastic.RandomnessModule
PyTreeStepFilterModule = typing.PyTreeStepFilterModule
TimeIntegrator = integrators.TimeIntegrator
TransformModule = typing.TransformModule


class BaseStep(abc.ABC):
  """Base class for Step modules."""

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: typing.AuxFeatures,
      diagnostics_module: DiagnosticModule = diagnostics.NoDiagnostics,
      randomness_module: RandomnessModule = stochastic.NoRandomField,
  ):
    self.diagnostics_fn = diagnostics_module(
        coords, dt, physics_specs, aux_features)
    self.randomness_fn = randomness_module(
        coords, dt, physics_specs, aux_features)

  @abc.abstractmethod
  def __call__(
      self,
      state: ModelState,
      forcing: typing.Forcing,
  ) -> ModelState:
    """Computes the state of the system evolved in time by `self.dt`."""

  def finalize_state(
      self,
      x: ModelState,
      forcing: typing.Forcing,
  ) -> ModelState:
    """Finalizes initialization of a model state `x`, encoded from data.

    This method ensures that state has all of the `ModelState` fields
    initialized in a way compatible with this step function. This includes
    populating initial `diagnostics`, `memory` and `randomness` fields.

    Args:
      x: Initial values for the model state typically provided by the encoder.
        forcing: Data covariates from the same time slice as `x`.

    Returns:
      Initialized model state.
    """
    x.randomness = self.randomness_fn.unconditional_sample(
        hk.maybe_next_rng_key()
    )
    x.diagnostics = self.diagnostics_fn(
        x, physics_tendencies=None, forcing=forcing)
    return x


@gin.register
class EquationStep(BaseStep, hk.Module):
  """Step module that advances the state by integrating an equation in time."""

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: typing.AuxFeatures,
      equation_module: EquationModule,
      time_integrator: TimeIntegrator = integrators.imex_rk_sil3,
      filter_modules: Sequence[PyTreeStepFilterModule] = tuple(),
      checkpoint_explicit_terms: bool = True,
      name: Optional[str] = None,
  ):
    hk.Module.__init__(self, name=name)
    BaseStep.__init__(self, coords, dt, physics_specs, aux_features)
    equation = equation_module(coords, dt, physics_specs, aux_features)
    if checkpoint_explicit_terms:
      equation = time_integration.ImplicitExplicitODE.from_functions(
          hk.remat(equation.explicit_terms),
          equation.implicit_terms,
          equation.implicit_inverse)
    step_fn = time_integrator(equation, dt)
    filter_fns = [
        module(coords, dt, physics_specs, aux_features)
        for module in filter_modules]
    self.dt = dt
    self.step_fn = time_integration.step_with_filters(step_fn, filter_fns)

  def __call__(
      self,
      x: ModelState,
      forcing: Optional[typing.Forcing] = None,
  ) -> ModelState:
    """Computes the state of the system evolved in time by `dt`."""
    del forcing
    next_state = time_integration.maybe_fix_sim_time_roundoff(
        self.step_fn(x.state), self.dt)
    return ModelState(next_state)


@gin.register
class RepeatedStep(BaseStep, hk.Module):
  """Step module that consists of repeated substeps of the same form."""

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: typing.AuxFeatures,
      inner_step_module: typing.StepModule,
      num_inner_steps: int = 1,
      name: Optional[str] = None,
  ):
    hk.Module.__init__(self, name=name)
    BaseStep.__init__(self, coords, dt, physics_specs, aux_features)
    inner_dt = dt / num_inner_steps
    self.step_fn = inner_step_module(
        coords, inner_dt, physics_specs, aux_features)
    self.num_inner_steps = num_inner_steps

  def __call__(
      self,
      state: ModelState,
      forcing: typing.Forcing,
  ) -> ModelState:
    """Computes the state of the system evolved in time by `dt`."""
    step_fn = functools.partial(self.step_fn, forcing=forcing)
    step_fn = time_integration.repeated(step_fn, self.num_inner_steps, hk.scan)
    return step_fn(state)


@gin.register
class CustomCoordsStep(BaseStep, hk.Module):
  """Step module that uses gin-configured coordinates instead of coords.

  This class currently supports model states in spectral representation. It
  could be easily extended to nodal-state models by converting to modal space
  prior to spectral interpolation and back after the timestep if performed.
  """

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: typing.AuxFeatures,
      step_module: typing.StepModule,
      custom_coords: coordinate_systems.CoordinateSystem = gin.REQUIRED,
      name: Optional[str] = None,
  ):
    hk.Module.__init__(self, name=name)
    BaseStep.__init__(self, coords, dt, physics_specs, aux_features)
    self.step_fn = step_module(
        custom_coords, dt, physics_specs, aux_features)
    self.to_custom_coords_fn = coordinate_systems.get_spectral_interpolate_fn(
        coords, custom_coords)
    self.from_custom_coords_fn = coordinate_systems.get_spectral_interpolate_fn(
        custom_coords, coords)

  def __call__(
      self,
      x: typing.PyTreeState,
      forcing: typing.Forcing,
  ) -> typing.PyTreeState:
    del forcing  # currently not supported.
    x = self.to_custom_coords_fn(x)
    custom_out = self.step_fn(x, None)
    return self.from_custom_coords_fn(custom_out)


@gin.register
class StochasticPhysicsParameterizationStep(BaseStep, hk.Module):
  """Step module that uses stochastic physics tendencies with dycore."""

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: typing.AuxFeatures,
      corrector_module: CorrectorModule,
      physics_parameterization_module: typing.ParameterizationModule,
      num_substeps: int = 1,
      diagnostics_module: DiagnosticModule = diagnostics.NoDiagnostics,
      randomness_module: RandomnessModule = stochastic.ZerosRandomField,
      perturbation_module: PerturbationModule = perturbations.NoPerturbation,
      checkpoint_substep: bool = False,
      name: Optional[str] = None,
  ):
    hk.Module.__init__(self, name=name)
    BaseStep.__init__(
        self, coords, dt, physics_specs, aux_features,
        diagnostics_module=diagnostics_module,
        randomness_module=randomness_module)
    inner_dt = dt / num_substeps
    self.num_substeps = num_substeps
    self.corrector_fn = corrector_module(
        coords, inner_dt, physics_specs, aux_features)
    self.physics_parameterization_fn = physics_parameterization_module(
        coords, inner_dt, physics_specs, aux_features)
    self.perturbation_fn = perturbation_module(
        coords, inner_dt, physics_specs, aux_features)
    self.checkpoint_substep = checkpoint_substep
    self.coords = coords

  def finalize_state(
      self,
      x: ModelState,
      forcing: typing.Forcing,
  ) -> ModelState:
    """Finalizes initialization of a model state `x`, encoded from data.

    This method ensures that state has all of the `ModelState` fields
    initialized in a way compatible with this step function. This includes
    populating initial `diagnostics`, `memory` and `randomness` fields.

    This is called by StochasticModularStepModel.encode, after encoding the data

    Args:
      x: Initial values for the model state typically provided by the encoder.
      forcing: Data covariates from the same time slice as `x`.

    Returns:
      Initialized model state.
    """
    # TODO(dkochkov) Consider adding an option of not overriding randomness.
    x.randomness = self.randomness_fn.unconditional_sample(
        hk.maybe_next_rng_key()
    )
    pp_tendency = self.physics_parameterization_fn(
        x.state, x.memory, x.diagnostics, x.randomness.nodal_value, forcing
    )
    x.diagnostics = self.diagnostics_fn(x, pp_tendency, forcing)
    return x

  def __call__(
      self,
      state: ModelState,
      forcing: typing.Forcing,
  ) -> ModelState:
    """Computes the state of the system evolved in time by `dt`."""

    def step_fn(x):
      x = self.coords.with_dycore_sharding(x)
      # TODO(dkochkov) Consider passing `x` to physics_parameterization.
      pp_tendency = self.physics_parameterization_fn(
          x.state, x.memory, x.diagnostics, x.randomness.nodal_value, forcing
      )

      pp_tendency = self.perturbation_fn(
          pp_tendency,
          state=x.state,
          randomness=x.randomness.nodal_value,
      )

      next_state = self.corrector_fn(x.state, pp_tendency, forcing)
      # TODO(dkochkov) update stochastic modules to take optional state.
      next_randomness = self.randomness_fn.advance(x.randomness)
      next_memory = x.state if x.memory is not None else None
      next_diagnostics = self.diagnostics_fn(x, pp_tendency, forcing)
      x_next = ModelState(
          state=next_state, memory=next_memory, diagnostics=next_diagnostics,
          randomness=next_randomness)
      x_next = self.coords.with_dycore_sharding(x_next)
      return x_next

    if self.checkpoint_substep:
      step_fn = hk.remat(step_fn)
    step_fn = time_integration.repeated(step_fn, self.num_substeps, hk.scan)
    return step_fn(state)


# TODO(dkochkov) Move vertical advection step to transforms.py.


@gin.register
class SemiLagrangianVerticalAdvectionStep(hk.Module):
  """Step module that applies vertical advection for the primitive equations."""

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: typing.AuxFeatures,
      name: Optional[str] = None,
  ):
    super().__init__(name=name)
    self.coords = coords
    self.dt = dt

  def __call__(self, state):
    return primitive_equations.semi_lagrangian_vertical_advection_step(
        state, self.coords, self.dt
    )
