# drl_pinball/cfd/pinball_env.py
"""
PinballEnv — wraps CelerisLab.Simulation for DRL inference.

This class provides the same telemetry interface as LegacyCelerisLab.FlowField.run(),
but using the new Simulation API. The key difference is that the new API returns
N-step cumulative values, while the old API returned per-step averages.

Usage::

    from pinball_env import PinballEnv

    env = PinballEnv(lbm_config, body_config, device_id=0)
    env.set_cylinders({front_id: 0.0, bottom_id: -0.04, top_id: 0.04})
    result = env.run_and_read(800)
    # result['forces'][body_id] = [fx_per_step, fy_per_step]
    # result['sensors'][body_id] = [ux_per_step, uy_per_step]
    
    env.snapshot()
    env.restore()
    
    env.save_field_tecplot("output.dat")
    env.export_vorticity_png("vorticity.png")
"""

from __future__ import annotations

import json
import os
import sys
import tempfile
from typing import Dict, List, Optional, Tuple, Any

import numpy as np
import pycuda.driver as cuda

_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
_SRC = os.path.join(_REPO, "src")
if _SRC not in sys.path:
    sys.path.insert(0, _SRC)
_DEFAULT_LBM = os.path.join(_REPO, "configs", "config_lbm_pinball.json")
_DEFAULT_BODY = os.path.join(_REPO, "configs", "config_body.json")

# LBM constants
_CS2 = 1.0 / 3.0  # lattice speed of sound squared


class PinballEnv:
    """High-level wrapper around CelerisLab.Simulation for pinball DRL tasks.
    
    Responsibilities:
    - Create and manage a Simulation instance
    - Provide run_and_read() that matches old API semantics (per-step averages)
    - Manage body ids for sensors and cylinders
    - Support snapshot/restore for checkpointing
    - Export macroscopic fields
    
    Body ID convention (all envs follow this order):
        sensors[0], sensors[1], sensors[2], [disturbance_cylinder], 
        front_cylinder, bottom_cylinder, top_cylinder
    
    Some scenes (illusion, vortex) omit the disturbance cylinder.
    """
    
    def __init__(
        self,
        lbm_config_path: Optional[str] = None,
        body_config_path: Optional[str] = None,
        device_id: int = 0,
        *,
        viscosity: Optional[float] = None,
        velocity: Optional[float] = None,
    ):
        # Build config with optional physics override
        if lbm_config_path is None:
            lbm_config_path = _DEFAULT_LBM
        if body_config_path is None:
            body_config_path = _DEFAULT_BODY
        
        if viscosity is not None or velocity is not None:
            # Create a temp config with overridden physics
            with open(lbm_config_path) as f:
                cfg = json.load(f)
            if viscosity is not None:
                cfg["physics"]["viscosity"] = float(viscosity)
            if velocity is not None:
                cfg["physics"]["velocity"] = float(velocity)
            tmpd = tempfile.mkdtemp(prefix="pinball_env_cfg_")
            tmp_cfg_path = os.path.join(tmpd, "config_lbm.json")
            with open(tmp_cfg_path, "w") as f:
                json.dump(cfg, f, indent=2)
            lbm_config_path = tmp_cfg_path
            
        from CelerisLab import Simulation
        
        self.sim = Simulation(
            lbm_config_path=lbm_config_path,
            body_config_path=body_config_path,
            device_id=device_id,
        )
        
        self._velocity = float(velocity) if velocity is not None else 0.01
        self._device_id = device_id
        self._stream = cuda.Stream()
        
        # Body tracking
        self._body_ids: Dict[str, List[int]] = {
            "sensors": [],
            "cylinders": [],
            "disturbance": [],
        }
        self._body_id_to_name: Dict[int, str] = {}
        
    # -------------------------------------------------------------------
    # Geometry construction
    # -------------------------------------------------------------------
    
    def add_cylinder(self, center: Tuple[float, float], radius: float) -> int:
        """Add a cylinder body. Returns body_id."""
        from CelerisLab import Simulation
        body_id = self.sim.add_body("circle", center=center, radius=radius)
        self._body_ids["cylinders"].append(body_id)
        self._body_id_to_name[body_id] = f"cylinder_{len(self._body_ids['cylinders'])}"
        return body_id
    
    def add_sensor(self, center: Tuple[float, float], radius: float) -> int:
        """Add a sensor body. Returns body_id."""
        body_id = self.sim.add_body("sensor", center=center, radius=radius)
        self._body_ids["sensors"].append(body_id)
        self._body_id_to_name[body_id] = f"sensor_{len(self._body_ids['sensors'])}"
        return body_id
    
    def add_disturbance_cylinder(self, center: Tuple[float, float], radius: float) -> int:
        """Add a disturbance cylinder (upstream). Returns body_id."""
        body_id = self.sim.add_body("circle", center=center, radius=radius)
        self._body_ids["disturbance"].append(body_id)
        self._body_id_to_name[body_id] = "disturbance"
        return body_id
    
    def reinitialize(self):
        """Recompile and reinitialize after adding bodies."""
        self.sim.initialize()
    
    # -------------------------------------------------------------------
    # Runtime control
    # -------------------------------------------------------------------
    
    def set_cylinder_omega(self, body_id: int, omega: float):
        """Set cylinder rotation speed in lattice units."""
        self.sim.set_body(body_id, omega=float(omega))
    
    def set_cylinders(self, omegas: Dict[int, float]):
        """Set multiple cylinder omegas at once. {body_id: omega}."""
        for bid, omega in omegas.items():
            self.sim.set_body(bid, omega=float(omega))
    
    def run_and_read(
        self,
        steps: int,
        omegas: Optional[Dict[int, float]] = None,
        *,
        read_fields: bool = False,
    ) -> Dict[str, Any]:
        """Run N LBM steps and read telemetry.
        
        Parameters
        ----------
        steps : int
            Number of LBM steps to run.
        omegas : dict, optional
            Cylinder omegas to set before running. {body_id: omega}
        read_fields : bool
            If True, also return macroscopic field.
        
        Returns
        -------
        dict with:
            forces : dict {body_id: [fx, fy]} per-step average
            sensors : dict {body_id: [ux, uy]} per-step average
            fields : dict (only if read_fields=True)
        """
        # Set omegas if provided
        if omegas is not None:
            self.set_cylinders(omegas)
        
        # Zero GPU telemetry
        self.sim.bodies.zero_force_segment_async(self._stream)
        if self.sim.field.n_sensor > 0:
            self.sim.bodies.zero_sensor_segment_async(self._stream)
        
        # Run steps
        self.sim.stepper.step(
            int(steps),
            action_gpu=self.sim.bodies.action_gpu,
            obs_gpu=self.sim.bodies.obs_gpu,
            stream=self._stream,
        )
        
        # Download telemetry
        self.sim.bodies.download_obs_full_async(self._stream)
        self._stream.synchronize()
        
        # Read forces (per-step average)
        forces = {}
        for bid in self._body_ids["cylinders"]:
            f = self.sim.read_force(bid)
            forces[bid] = (np.array(f, dtype=np.float32) / float(steps)).tolist()
        
        for bid in self._body_ids["disturbance"]:
            f = self.sim.read_force(bid)
            forces[bid] = (np.array(f, dtype=np.float32) / float(steps)).tolist()
        
        # Read sensors (per-step average, raw sum / steps, NO cell count division)
        sensors = {}
        for bid in self._body_ids["sensors"]:
            s = self.sim.read_sensor(bid, normalize=False)
            sensors[bid] = (np.array(s, dtype=np.float32) / float(steps)).tolist()
        
        result = {
            "forces": forces,
            "sensors": sensors,
            "n_steps": int(steps),
        }
        
        if read_fields:
            macro = self.sim.get_macroscopic()
            result["fields"] = {
                "ux": np.asarray(macro["ux"], dtype=np.float32),
                "uy": np.asarray(macro["uy"], dtype=np.float32),
                "rho": np.asarray(macro["rho"], dtype=np.float32),
            }
        
        return result
    
    def get_sensor_array(self, sensors: Dict[int, list]) -> np.ndarray:
        """Convert sensor dict to flat array in body_id order: [s0_ux, s0_uy, s1_ux, ...]."""
        arr = []
        for bid in sorted(sensors.keys()):
            arr.extend(sensors[bid])
        return np.array(arr, dtype=np.float32)
    
    def get_force_array(self, forces: Dict[int, list]) -> np.ndarray:
        """Convert force dict to flat array in cylinder body_id order."""
        arr = []
        for bid in self._body_ids["cylinders"]:
            if bid in forces:
                arr.extend(forces[bid])
        for bid in self._body_ids["disturbance"]:
            if bid in forces:
                arr.extend(forces[bid])
        return np.array(arr, dtype=np.float32)
    
    def get_force_array_legacy_order(self, forces: Dict[int, list]) -> np.ndarray:
        """Return forces in legacy obs order: dist_cyl first, then front, bottom, top.
        
        This matches the old API's flat obs array layout:
        [dist_fx, dist_fy, front_fx, front_fy, bottom_fx, bottom_fy, top_fx, top_fy]
        """
        arr = []
        # Disturbance cylinders first
        for bid in self._body_ids["disturbance"]:
            if bid in forces:
                arr.extend(forces[bid])
        # Then regular cylinders
        for bid in self._body_ids["cylinders"]:
            if bid in forces:
                arr.extend(forces[bid])
        return np.array(arr, dtype=np.float32)
    
    # -------------------------------------------------------------------
    # Checkpoint / Snapshot
    # -------------------------------------------------------------------
    
    def snapshot(self):
        """Save in-memory snapshot of current DDF state."""
        self.sim.snapshot()
    
    def restore(self):
        """Restore from in-memory snapshot."""
        self.sim.restore()
    
    def save_checkpoint(self, path: str):
        """Save HDF5 checkpoint to disk."""
        self.sim.save_checkpoint(path)
    
    def load_checkpoint(self, path: str):
        """Load HDF5 checkpoint from disk."""
        self.sim.load_checkpoint(path)
    
    # -------------------------------------------------------------------
    # Field export
    # -------------------------------------------------------------------
    
    def get_macroscopic(self) -> Dict[str, np.ndarray]:
        """Download macroscopic field. Returns {rho, ux, uy}."""
        return self.sim.get_macroscopic()
    
    def save_field_tecplot(self, filename: str):
        """Save current flow field in Tecplot format.
        
        Matches the format of old save_field() in legacy envs.
        """
        macro = self.get_macroscopic()
        ux = np.asarray(macro["ux"], dtype=np.float32)
        uy = np.asarray(macro["uy"], dtype=np.float32)
        
        nx, ny = ux.shape
        u0 = self._velocity
        
        with open(filename, "w") as f:
            f.write('Title= "LBM 2D"\r\n')
            f.write('VARIABLES= "X","Y","flag","U","V",\r\n')
            f.write(f"ZONE T= \"BOX\",I= {nx},J= {ny},F=POINT\r\n")
            for j in range(ny):
                for i in range(nx):
                    u_val = ux[i, j] / u0
                    v_val = uy[i, j] / u0
                    f.write(f"{i},{j},0,{u_val},{v_val}\r\n")
    
    def export_vorticity_png(self, path: str, title: str = ""):
        """Export vorticity field as PNG."""
        try:
            import matplotlib
            matplotlib.use("Agg")
            import matplotlib.pyplot as plt
        except ImportError:
            return
        
        macro = self.get_macroscopic()
        ux = np.asarray(macro["ux"], dtype=np.float64)
        uy = np.asarray(macro["uy"], dtype=np.float64)
        
        omega = np.gradient(uy, axis=1) - np.gradient(ux, axis=0)
        
        abs_o = np.abs(omega[np.isfinite(omega)])
        vmax = float(np.percentile(abs_o, 99.5)) if abs_o.size > 0 else 1.0
        if vmax <= 0:
            vmax = 1.0
        
        ny, nx = omega.shape
        fig, ax = plt.subplots(figsize=(min(18, max(8, nx / 60)), min(10, max(3, ny / 40))))
        im = ax.imshow(omega, origin="lower", aspect="equal", cmap="RdBu_r",
                       vmin=-vmax, vmax=vmax, extent=(0, nx - 1, 0, ny - 1))
        ax.set_xlabel("x (lattice)")
        ax.set_ylabel("y (lattice)")
        if title:
            ax.set_title(title)
        fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04, label=r"$\omega_z$")
        fig.tight_layout()
        fig.savefig(path, dpi=150, bbox_inches="tight")
        plt.close(fig)
    
    # -------------------------------------------------------------------
    # Cleanup
    # -------------------------------------------------------------------
    
    def close(self):
        """Release GPU resources."""
        self.sim.close()
    
    def __del__(self):
        try:
            self.close()
        except Exception:
            pass


def _omega_from_nu(nu: float) -> float:
    """Convert kinematic viscosity to relaxation parameter omega."""
    cs2 = 1.0 / 3.0
    return 1.0 / (3.0 * nu / 1.0 + 0.5)
