# Copyright 2021 DeepMind Technologies Limited. All Rights Reserved.
#
# 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
#
#     http://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.
# ==============================================================================
"""decoders utilities."""

import functools
from typing import Dict, Optional

import chex
from clrs._src import probing
from clrs._src import specs
import haiku as hk
import jax
import jax.numpy as jnp

_Array = chex.Array
_DataPoint = probing.DataPoint
_Location = specs.Location
_Spec = specs.Spec
_Stage = specs.Stage
_Type = specs.Type


def log_sinkhorn(x: _Array, steps: int, temperature: float, zero_diagonal: bool,
                 noise_rng_key: Optional[_Array]) -> _Array:
  """Sinkhorn operator in log space, to postprocess permutation pointer logits.

  Args:
    x: input of shape [..., n, n], a batch of square matrices.
    steps: number of iterations.
    temperature: temperature parameter (as temperature approaches zero, the
      output approaches a permutation matrix).
    zero_diagonal: whether to force the diagonal logits towards -inf.
    noise_rng_key: key to add Gumbel noise.

  Returns:
    Elementwise logarithm of a doubly-stochastic matrix (a matrix with
    non-negative elements whose rows and columns sum to 1).
  """
  assert x.ndim >= 2
  assert x.shape[-1] == x.shape[-2]
  if noise_rng_key is not None:
    # Add standard Gumbel noise (see https://arxiv.org/abs/1802.08665)
    noise = -jnp.log(-jnp.log(jax.random.uniform(noise_rng_key,
                                                 x.shape) + 1e-12) + 1e-12)
    x = x + noise
  x /= temperature
  if zero_diagonal:
    x = x - 1e6 * jnp.eye(x.shape[-1])
  for _ in range(steps):
    x = jax.nn.log_softmax(x, axis=-1)
    x = jax.nn.log_softmax(x, axis=-2)
  return x


def construct_decoders(loc: str, t: str, hidden_dim: int, nb_dims: int,
                       name: str):
  """Constructs decoders."""
  linear = functools.partial(hk.Linear, name=f"{name}_dec_linear")
  if loc == _Location.NODE:
    # Node decoders.
    if t in [_Type.SCALAR, _Type.MASK, _Type.MASK_ONE]:
      decoders = (linear(1),)
    elif t == _Type.CATEGORICAL:
      decoders = (linear(nb_dims),)
    elif t in [_Type.POINTER, _Type.PERMUTATION_POINTER]:
      decoders = (linear(hidden_dim), linear(hidden_dim), linear(hidden_dim),
                  linear(1))
    else:
      raise ValueError(f"Invalid Type {t}")

  elif loc == _Location.EDGE:
    # Edge decoders.
    if t in [_Type.SCALAR, _Type.MASK, _Type.MASK_ONE]:
      decoders = (linear(1), linear(1), linear(1))
    elif t == _Type.CATEGORICAL:
      decoders = (linear(nb_dims), linear(nb_dims), linear(nb_dims))
    elif t == _Type.POINTER:
      decoders = (linear(hidden_dim), linear(hidden_dim),
                  linear(hidden_dim), linear(hidden_dim), linear(1))
    else:
      raise ValueError(f"Invalid Type {t}")

  elif loc == _Location.GRAPH:
    # Graph decoders.
    if t in [_Type.SCALAR, _Type.MASK, _Type.MASK_ONE]:
      decoders = (linear(1), linear(1))
    elif t == _Type.CATEGORICAL:
      decoders = (linear(nb_dims), linear(nb_dims))
    elif t == _Type.POINTER:
      decoders = (linear(1), linear(1),
                  linear(1))
    else:
      raise ValueError(f"Invalid Type {t}")

  else:
    raise ValueError(f"Invalid Location {loc}")

  return decoders


def construct_diff_decoders(name: str):
  """Constructs diff decoders."""
  linear = functools.partial(hk.Linear, name=f"{name}_diffdec_linear")
  decoders = {}
  decoders[_Location.NODE] = linear(1)
  decoders[_Location.EDGE] = (linear(1), linear(1), linear(1))
  decoders[_Location.GRAPH] = (linear(1), linear(1))

  return decoders


def postprocess(spec: _Spec, preds: Dict[str, _Array],
                sinkhorn_temperature: float,
                sinkhorn_steps: int,
                hard: bool) -> Dict[str, _DataPoint]:
  """Postprocesses decoder output.

  This is done on outputs in order to score performance, and on hints in
  order to score them but also in order to feed them back to the model.
  At scoring time, the postprocessing mode is "hard", logits will be
  arg-maxed and masks will be thresholded. However, for the case of the hints
  that are fed back in the model, the postprocessing can be hard or soft,
  depending on whether we want to let gradients flow through them or not.

  Args:
    spec: The spec of the algorithm whose outputs/hints we are postprocessing.
    preds: Output and/or hint predictions, as produced by decoders.
    sinkhorn_temperature: Parameter for the sinkhorn operator on permutation
      pointers.
    sinkhorn_steps: Parameter for the sinkhorn operator on permutation
      pointers.
    hard: whether to do hard postprocessing, which involves argmax for
      MASK_ONE, CATEGORICAL and POINTERS, thresholding for MASK, and stop
      gradient through for SCALAR. If False, soft postprocessing will be used,
      with softmax, sigmoid and gradients allowed.
  Returns:
    The postprocessed `preds`. In "soft" post-processing, POINTER types will
    change to SOFT_POINTER, so encoders know they do not need to be
    pre-processed before feeding them back in.
  """
  result = {}
  for name in preds.keys():
    _, loc, t = spec[name]
    new_t = t
    data = preds[name]
    if t == _Type.SCALAR:
      if hard:
        data = jax.lax.stop_gradient(data)
    elif t == _Type.MASK:
      if hard:
        data = (data > 0.0) * 1.0
      else:
        data = jax.nn.sigmoid(data)
    elif t in [_Type.MASK_ONE, _Type.CATEGORICAL]:
      cat_size = data.shape[-1]
      if hard:
        best = jnp.argmax(data, -1)
        data = hk.one_hot(best, cat_size)
      else:
        data = jax.nn.softmax(data, axis=-1)
    elif t == _Type.POINTER:
      if hard:
        data = jnp.argmax(data, -1).astype(float)
      else:
        data = jax.nn.softmax(data, -1)
        new_t = _Type.SOFT_POINTER
    elif t == _Type.PERMUTATION_POINTER:
      # Convert the matrix of logits to a doubly stochastic matrix.
      data = log_sinkhorn(
          x=data,
          steps=sinkhorn_steps,
          temperature=sinkhorn_temperature,
          zero_diagonal=True,
          noise_rng_key=None)
      data = jnp.exp(data)
      if hard:
        data = jax.nn.one_hot(jnp.argmax(data, axis=-1), data.shape[-1])
    else:
      raise ValueError("Invalid type")
    result[name] = probing.DataPoint(
        name=name, location=loc, type_=new_t, data=data)

  return result


def decode_fts(
    decoders,
    spec: _Spec,
    h_t: _Array,
    adj_mat: _Array,
    edge_fts: _Array,
    graph_fts: _Array,
    inf_bias: bool,
    inf_bias_edge: bool,
    repred: bool,
):
  """Decodes node, edge and graph features."""
  output_preds = {}
  hint_preds = {}

  for name in decoders:
    decoder = decoders[name]
    stage, loc, t = spec[name]

    if loc == _Location.NODE:
      preds = _decode_node_fts(decoder, t, h_t, edge_fts, adj_mat,
                               inf_bias, repred)
    elif loc == _Location.EDGE:
      preds = _decode_edge_fts(decoder, t, h_t, edge_fts, adj_mat,
                               inf_bias_edge)
    elif loc == _Location.GRAPH:
      preds = _decode_graph_fts(decoder, t, h_t, graph_fts)
    else:
      raise ValueError("Invalid output type")

    if stage == _Stage.OUTPUT:
      output_preds[name] = preds
    elif stage == _Stage.HINT:
      hint_preds[name] = preds
    else:
      raise ValueError(f"Found unexpected decoder {name}")

  return hint_preds, output_preds


def _decode_node_fts(decoders, t: str, h_t: _Array, edge_fts: _Array,
                     adj_mat: _Array, inf_bias: bool, repred: bool) -> _Array:
  """Decodes node features."""

  if t in [_Type.SCALAR, _Type.MASK, _Type.MASK_ONE]:
    preds = jnp.squeeze(decoders[0](h_t), -1)
  elif t == _Type.CATEGORICAL:
    preds = decoders[0](h_t)
  elif t in [_Type.POINTER, _Type.PERMUTATION_POINTER]:
    p_1 = decoders[0](h_t)
    p_2 = decoders[1](h_t)
    p_3 = decoders[2](edge_fts)

    p_e = jnp.expand_dims(p_2, -2) + p_3
    p_m = jnp.maximum(jnp.expand_dims(p_1, -2),
                      jnp.transpose(p_e, (0, 2, 1, 3)))

    preds = jnp.squeeze(decoders[3](p_m), -1)

    if inf_bias:
      per_batch_min = jnp.min(preds, axis=range(1, preds.ndim), keepdims=True)
      preds = jnp.where(adj_mat > 0.5,
                        preds,
                        jnp.minimum(-1.0, per_batch_min - 1.0))
    if t == _Type.PERMUTATION_POINTER:
      if repred:  # testing or validation, no Gumbel noise
        preds = log_sinkhorn(
            x=preds, steps=10, temperature=0.1,
            zero_diagonal=True, noise_rng_key=None)
      else:  # training, add Gumbel noise
        preds = log_sinkhorn(
            x=preds, steps=10, temperature=0.1,
            zero_diagonal=True, noise_rng_key=hk.next_rng_key())
  else:
    raise ValueError("Invalid output type")

  return preds


def _decode_edge_fts(decoders, t: str, h_t: _Array, edge_fts: _Array,
                     adj_mat: _Array, inf_bias_edge: bool) -> _Array:
  """Decodes edge features."""

  pred_1 = decoders[0](h_t)
  pred_2 = decoders[1](h_t)
  pred_e = decoders[2](edge_fts)
  pred = (jnp.expand_dims(pred_1, -2) + jnp.expand_dims(pred_2, -3) + pred_e)
  if t in [_Type.SCALAR, _Type.MASK, _Type.MASK_ONE]:
    preds = jnp.squeeze(pred, -1)
  elif t == _Type.CATEGORICAL:
    preds = pred
  elif t == _Type.POINTER:
    pred_2 = decoders[3](h_t)

    p_m = jnp.maximum(jnp.expand_dims(pred, -2),
                      jnp.expand_dims(
                          jnp.expand_dims(pred_2, -3), -3))

    preds = jnp.squeeze(decoders[4](p_m), -1)
  else:
    raise ValueError("Invalid output type")
  if inf_bias_edge and t in [_Type.MASK, _Type.MASK_ONE]:
    per_batch_min = jnp.min(preds, axis=range(1, preds.ndim), keepdims=True)
    preds = jnp.where(adj_mat > 0.5,
                      preds,
                      jnp.minimum(-1.0, per_batch_min - 1.0))

  return preds


def _decode_graph_fts(decoders, t: str, h_t: _Array,
                      graph_fts: _Array) -> _Array:
  """Decodes graph features."""

  gr_emb = jnp.max(h_t, axis=-2)
  pred_n = decoders[0](gr_emb)
  pred_g = decoders[1](graph_fts)
  pred = pred_n + pred_g
  if t in [_Type.SCALAR, _Type.MASK, _Type.MASK_ONE]:
    preds = jnp.squeeze(pred, -1)
  elif t == _Type.CATEGORICAL:
    preds = pred
  elif t == _Type.POINTER:
    pred_2 = decoders[2](h_t)
    ptr_p = jnp.expand_dims(pred, 1) + jnp.transpose(pred_2, (0, 2, 1))
    preds = jnp.squeeze(ptr_p, 1)
  else:
    raise ValueError("Invalid output type")

  return preds


def maybe_decode_diffs(
    diff_decoders,
    h_t: _Array,
    edge_fts: _Array,
    graph_fts: _Array,
    decode_diffs: bool,
) -> Optional[Dict[str, _Array]]:
  """Optionally decodes node, edge and graph diffs."""

  if decode_diffs:
    preds = {}
    node = _Location.NODE
    edge = _Location.EDGE
    graph = _Location.GRAPH
    preds[node] = _decode_node_diffs(diff_decoders[node], h_t)
    preds[edge] = _decode_edge_diffs(diff_decoders[edge], h_t, edge_fts)
    preds[graph] = _decode_graph_diffs(diff_decoders[graph], h_t, graph_fts)

  else:
    preds = None

  return preds


def _decode_node_diffs(decoders, h_t: _Array) -> _Array:
  """Decodes node diffs."""
  return jnp.squeeze(decoders(h_t), -1)


def _decode_edge_diffs(decoders, h_t: _Array, edge_fts: _Array) -> _Array:
  """Decodes edge diffs."""

  e_pred_1 = decoders[0](h_t)
  e_pred_2 = decoders[1](h_t)
  e_pred_e = decoders[2](edge_fts)
  preds = jnp.squeeze(
      jnp.expand_dims(e_pred_1, -1) + jnp.expand_dims(e_pred_2, -2) + e_pred_e,
      -1,
  )

  return preds


def _decode_graph_diffs(decoders, h_t: _Array, graph_fts: _Array) -> _Array:
  """Decodes graph diffs."""

  gr_emb = jnp.max(h_t, axis=-2)
  g_pred_n = decoders[0](gr_emb)
  g_pred_g = decoders[1](graph_fts)
  preds = jnp.squeeze(g_pred_n + g_pred_g, -1)

  return preds
