"""Adapter: wrap a Gym-style env so it implements DiscoRL's Environment API.

This is a minimal adapter intended for evaluation / inference (batching=1
or small batches). It converts Gym observations/rewards/dones into the
`types.EnvironmentTimestep` structure expected by DiscoRL and keeps a
Python-side list of env instances for the batch.

Notes:
- This adapter does not attempt to JIT or vectorize with JAX. It simply
  converts numpy -> jax arrays before returning timesteps so the DiscoRL
  agent (Haiku/JAX) can consume them.
- For training at scale you can rework this into a true batched env that
  runs multiple envs in parallel / in subprocesses.
"""

from typing import Any, Tuple

import numpy as np
import jax
import jax.numpy as jnp

from disco_rl.environments import base
from disco_rl import types
try:
  from dm_env import StepType
except ImportError:
  # Fallback if dm_env not available
  class StepType:
    MID = 0
    LAST = 1


class GymToDiscoEnv(base.Environment):
  """Wrap a Gym-compatible environment class.

  The wrapped `gym_env_cls` must follow the Gym API (reset() -> obs, info,
  step(action) -> obs, reward, terminated, truncated, info).
  
  Args:
    gym_env_cls: factory/class that creates Gym environment instances.
    batch_size: number of parallel env instances to manage.
    env_settings: dict of kwargs to pass to gym_env_cls.
    discrete_actions: optional array of shape (num_actions, action_dim) mapping
      discrete action indices to continuous action vectors. If provided, the
      action_spec becomes discrete (int32 scalar indices) and step() will
      map indices to continuous actions before sending to underlying env.
  """

  def __init__(
      self,
      gym_env_cls: Any,
      batch_size: int = 1,
      env_settings=None,
      discrete_actions: np.ndarray | None = None,
  ):
    self.batch_size = batch_size
    env_settings = {} if env_settings is None else env_settings
    # Create multiple env instances for simple batching.
    self._envs = [gym_env_cls(**env_settings) for _ in range(batch_size)]
    self._discrete_actions = (
        np.asarray(discrete_actions) if discrete_actions is not None else None
    )

    # Build single action/observation specs in the simple form expected by
    # DiscoRL (a mapping with key 'observation'). We keep dtype/shape simple.
    obs_space = self._envs[0].observation_space
    act_space = self._envs[0].action_space

    # Use dm_env-like BoundedArray for actions if available, else a simple
    # placeholder (the agent only queries shape/dtype in most places).
    try:
      from dm_env import specs as dm_specs

      # If discrete_actions is provided, create a discrete action spec;
      # otherwise use the original continuous spec.
      if self._discrete_actions is not None:
        num_actions = len(self._discrete_actions)
        self._single_action_spec = dm_specs.BoundedArray(
            shape=(), dtype=np.int32, minimum=0, maximum=num_actions - 1
        )
      else:
        self._single_action_spec = dm_specs.BoundedArray(
            act_space.shape, act_space.dtype, act_space.low, act_space.high
        )
      self._single_observation_spec = {
          'observation': dm_specs.Array(shape=obs_space.shape, dtype=obs_space.dtype)
      }
    except Exception:
      # Fallback to simple numpy-shape descriptors.
      if self._discrete_actions is not None:
        num_actions = len(self._discrete_actions)
        self._single_action_spec = type('ActionSpec', (), {
            'shape': (),
            'dtype': np.int32,
            'low': 0,
            'high': num_actions - 1,
        })
      else:
        self._single_action_spec = act_space
      self._single_observation_spec = {'observation': obs_space}

    # Keep last observations / states for each env
    self._last_obs = [None] * batch_size
    self._dones = [True] * batch_size

  def single_action_spec(self):
    return self._single_action_spec

  def single_observation_spec(self):
    return self._single_observation_spec

  def _obs_to_timestep(self, obs, reward, done):
    # Convert a single env's raw outputs into types.EnvironmentTimestep
    # DiscoRL expects a mapping for observation (e.g. {'observation': ...}).
    obs_map = {'observation': jnp.asarray(obs, dtype=jnp.float32)}
    step_type = jnp.array(StepType.LAST if done else StepType.MID, dtype=jnp.int32)
    return types.EnvironmentTimestep(observation=obs_map, step_type=step_type, reward=jnp.array(float(reward), dtype=jnp.float32))

  def step(self, state_unused, actions) -> Tuple[Any, types.EnvironmentTimestep]:
    # actions expected to be a batched array with shape (batch_size, ...)
    # For simplicity we iterate over envs sequentially.
    # Support actions provided as numpy/jax arrays.
    actions = [np.array(a) for a in list(actions)] if hasattr(actions, '__iter__') else [np.array(actions)]

    obs_batch = []
    reward_batch = []
    done_batch = []

    for i, env in enumerate(self._envs):
      act = actions[i] if i < len(actions) else actions[0]
      # If discrete_actions is provided, map the action index to continuous action.
      if self._discrete_actions is not None:
        act = self._discrete_actions[int(act)]
      # Convert to python scalar if necessary
      obs, reward, terminated, truncated, info = env.step(act)
      done = bool(terminated or truncated)
      self._last_obs[i] = obs
      self._dones[i] = done
      obs_batch.append(jnp.asarray(obs, dtype=jnp.float32))
      reward_batch.append(float(reward))
      done_batch.append(done)

    # Stack to produce batched structures. DiscoRL typically expects
    # observations to be a mapping of arrays with leading batch dimension.
    obs_map = {'observation': jnp.stack(obs_batch)}
    rewards = jnp.asarray(reward_batch, dtype=jnp.float32)
    is_terminal = jnp.asarray(done_batch)
    # Use jnp.where so scalar StepType values broadcast to the array shape.
    step_type = jnp.where(is_terminal, StepType.LAST, StepType.MID)

    timestep = types.EnvironmentTimestep(observation=obs_map, step_type=step_type, reward=rewards)
    return None, timestep

  def reset(self, rng_key=None) -> Tuple[Any, types.EnvironmentTimestep]:
    obs_batch = []
    reward_batch = []
    done_batch = []
    for i, env in enumerate(self._envs):
      # Gym reset returns (obs, info) in Gymnasium; support both
      out = env.reset()
      if isinstance(out, tuple) and len(out) >= 1:
        obs = out[0]
      else:
        obs = out
      self._last_obs[i] = obs
      self._dones[i] = False
      obs_batch.append(jnp.asarray(obs, dtype=jnp.float32))
      reward_batch.append(0.0)
      done_batch.append(False)

    obs_map = {'observation': jnp.stack(obs_batch)}
    rewards = jnp.asarray(reward_batch, dtype=jnp.float32)
    is_terminal = jnp.asarray(done_batch)
    # Use jnp.where so scalar StepType values broadcast to the array shape.
    step_type = jnp.where(is_terminal, StepType.LAST, StepType.MID)
    timestep = types.EnvironmentTimestep(observation=obs_map, step_type=step_type, reward=rewards)
    return None, timestep
