import numpy as np
import torch
import torch.nn.functional as F
from torch_geometric.data import Batch
from torch_scatter import scatter_add


class RolloutBuffer:

    def __init__(self, buffer_size: int,
        gae_lambda: float,
        discount_factor: float,
        device=None,
    ):
        self.buffer_size = buffer_size
        self.gae_lambda = gae_lambda
        self.discount_factor = discount_factor
        self.device = device
        self.reset()

    def reset(self):
        self.observations = []
        self.actions = []
        self.log_probs = []
        self.rewards = []  # per-agent rewards (list of tensors, varying shapes)
        self.values = []  # per-agent values (list of tensors, varying shapes)
        self.dones = []
        self.agent_mappings = []  # mapping from new → old agent indices per step
        self.pos = 0

    def add(
        self, observation, actions, reward, done, value, log_probs,
        agent_mapping=None,
    ):
        dev = self.device
        self.observations.append(observation.to(dev))
        self.actions.append(actions.to(dev))
        self.log_probs.append(log_probs.to(dev))
        self.rewards.append(torch.as_tensor(reward, dtype=torch.float32, device=dev).flatten())
        self.values.append(value.flatten().to(dev))
        self.dones.append(float(done))
        self.agent_mappings.append(
            torch.as_tensor(agent_mapping, dtype=torch.long, device=dev).flatten()
        )
        self.pos += 1

    def compute_returns_and_advantage(self, last_value):
        """Single-path GAE: potential-shaped per-agent reward with scatter_add for mesh refinement."""
        last_value = last_value.to(self.device).flatten()
        n = self.buffer_size

        dones = torch.as_tensor(self.dones, device=self.device)

        # ---- 0. Normalize rewards to unit scale ----
        all_rews = torch.cat([r.flatten() for r in self.rewards])
        rew_mean = all_rews.mean()
        rew_std = all_rews.std()
        if rew_std > 1e-8:
            self.rewards = [(r - rew_mean) / rew_std for r in self.rewards]

        # ---- 1. Per-agent GAE (scatter_add for mesh refinement) ----
        advantages = [None] * n
        deltas = []
        next_values = self.values[1:] + [last_value]

        for step in range(n):
            if dones[step]:
                next_val = self.values[step]
            else:
                next_val = scatter_add(next_values[step], self.agent_mappings[step], dim=0)
            delta = self.rewards[step] + (0 if dones[step] else self.discount_factor * next_val) - self.values[step]
            deltas.append(delta)

        last_gae = torch.zeros_like(self.agent_mappings[-1], dtype=torch.float32, device=self.device)
        for step in reversed(range(n)):
            if dones[step]:
                last_gae = deltas[step]
            else:
                last_gae = deltas[step] + self.discount_factor * self.gae_lambda * scatter_add(last_gae, self.agent_mappings[step], dim=0)
            advantages[step] = last_gae

        self.returns = [adv + val for adv, val in zip(advantages, self.values)]

        # ---- 2. Normalize advantages (per-batch, zero-mean unit-std) ----
        all_advs = torch.cat([a.flatten() for a in advantages])
        adv_mean = all_advs.mean()
        adv_std = all_advs.std()
        if adv_std > 1e-8:
            advantages = [(a - adv_mean) / adv_std for a in advantages]
        # NOTE: returns and values keep their original scale — no unit-scale normalization,
        # so the value network sees a stable regression target across iterations.

        self.advantages = [ret - val for ret, val in zip(self.returns, self.values)]

    def get(self, batch_size: int):
        """Yield random minibatches from the buffer."""
        indices = np.random.permutation(self.buffer_size)
        start = 0
        while start < self.buffer_size:
            batch_idx = indices[start : start + batch_size]
            start += batch_size

            obs_batch = Batch.from_data_list([self.observations[i] for i in batch_idx])
            acts = torch.cat([self.actions[i] for i in batch_idx], dim=0)
            lps = torch.cat([self.log_probs[i].flatten() for i in batch_idx], dim=0)
            vals = torch.cat([self.values[i].flatten() for i in batch_idx], dim=0)
            advs = torch.cat([self.advantages[i].flatten() for i in batch_idx], dim=0)
            rets = torch.cat([self.returns[i].flatten() for i in batch_idx], dim=0)

            obs_batch, acts, lps, vals, advs, rets = (
                x.to(self.device) for x in (obs_batch, acts, lps, vals, advs, rets)
            )
            yield obs_batch, acts, lps, vals, advs, rets

    @property
    def full(self):
        return self.pos >= self.buffer_size

    @property
    def explained_variance(self):
        all_vals = torch.cat([v.flatten() for v in self.values])
        all_rets = torch.cat([r.flatten() for r in self.returns])
        var_ret = torch.var(all_rets)
        if var_ret < 1e-12:
            return 0.0
        return float(1.0 - torch.var(all_rets - all_vals) / var_ret)


# ── PPO losses ────────────────────────────────────────────
def policy_loss(advantages: torch.Tensor, ratio: torch.Tensor, clip_range: float) -> torch.Tensor:
    """Clipped PPO policy loss."""
    advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)
    loss1 = advantages * ratio
    loss2 = advantages * torch.clamp(ratio, 1.0 - clip_range, 1.0 + clip_range)
    return -torch.min(loss1, loss2).mean()


def value_loss(
    returns: torch.Tensor, values: torch.Tensor,
    old_values: torch.Tensor, clip_range: float,
) -> torch.Tensor:
    """Clipped value function loss."""
    vf_loss = F.mse_loss(returns, values)
    if clip_range > 0:
        v_clipped = old_values + (values - old_values).clamp(-clip_range, clip_range)
        vf_loss = torch.max(vf_loss, F.mse_loss(returns, v_clipped))
    return vf_loss


def entropy_loss(entropy) -> torch.Tensor:
    """Entropy bonus for exploration."""
    return -torch.mean(entropy)


class PPOTrainer:

    def __init__(self, actor_critic, environment, config: dict, device=None):
        self.policy = actor_critic
        self.env = environment
        self.device = device

        ppo_cfg = config.get("ppo", {})
        self.num_rollout_steps = ppo_cfg.get("num_rollout_steps", 256)
        self.epochs_per_iteration = ppo_cfg.get("epochs_per_iteration", 5)
        self.batch_size = config.get("batch_size", 32)
        self.clip_range = ppo_cfg.get("clip_range", 0.2)
        self.max_grad_norm = ppo_cfg.get("max_grad_norm", 0.5)
        self.entropy_coef = ppo_cfg.get("entropy_coefficient", 0.0)
        self.vf_coef = ppo_cfg.get("value_function_coefficient", 0.5)
        self.vf_clip_range = ppo_cfg.get("value_function_clip_range", 0.2)
        self.gae_lambda = ppo_cfg.get("gae_lambda", 0.95)
        self.discount_factor = config.get("discount_factor", 1.0)

        self.buffer = RolloutBuffer(
            buffer_size=self.num_rollout_steps,
            gae_lambda=self.gae_lambda,
            discount_factor=self.discount_factor,
            device=device,
        )

    def collect_rollouts(self):
        self.policy.eval()
        self.buffer.reset()
        obs = self.env.reset()
        step_rewards, step_num_agents = [], []
        _rho_keys = ("rho_int_mean", "rho_jump_mean", "rho_sbc_mean",
                      "w_rho_int", "w_rho_jump", "w_rho_sbc")
        rho_accum = {k: 0.0 for k in _rho_keys}
        diag_keys = ("neg_action_ratio", "eligible_ratio", "selected_count")
        diag_accum = {k: 0.0 for k in diag_keys}
        diag_steps = 0

        for _ in range(self.num_rollout_steps):
            with torch.no_grad():
                actions, values, log_probs = self.policy(
                    Batch.from_data_list([obs]), deterministic=False
                )
            values = values.flatten()
            next_obs, reward, done, info = self.env.step(actions.cpu().numpy())
            step_rewards.append(float(np.sum(reward)))
            step_num_agents.append(int(len(reward)))
            for k in _rho_keys:
                if k in info:
                    rho_accum[k] += float(info[k])
            for k in diag_keys:
                if k in info:
                    diag_accum[k] += float(info[k])
            diag_steps += 1

            self.buffer.add(
                observation=obs, actions=actions, reward=reward,
                done=float(done), value=values, log_probs=log_probs,
                agent_mapping=self.env.agent_mapping,
            )
            obs = self.env.reset() if done else next_obs

        with torch.no_grad():
            _, last_value, _ = self.policy(Batch.from_data_list([obs]), deterministic=True)
        last_value = last_value.squeeze(-1).flatten()
        self.buffer.compute_returns_and_advantage(last_value)

        n = max(1, self.num_rollout_steps)
        metrics = {
            "num_agents": step_num_agents[-1], "reward": step_rewards[-1],
            "avg_agents": np.mean(step_num_agents),
            "avg_reward": np.mean(step_rewards),
            "min_reward": np.min(step_rewards),
            "max_reward": np.max(step_rewards),
            "sum_reward": np.sum(step_rewards),
        }
        # rho diagnostics for weight calibration (averaged over rollout)
        for k in _rho_keys:
            metrics[k] = rho_accum[k] / n
        # score-based refinement diagnostics
        n_diag = max(1, diag_steps)
        for k in diag_keys:
            metrics[k] = diag_accum[k] / n_diag
        return metrics

    def train_step(self):
        self.policy.train()
        total_losses = []
        for _ in range(self.epochs_per_iteration):
            for obs_batch, acts, old_lp, old_vals, advs, rets in self.buffer.get(self.batch_size):
                values, log_probs, entropy = self.policy.evaluate_actions(obs_batch, acts)
                values = values.squeeze(-1)

                ratio = torch.exp(log_probs - old_lp)

                pl = policy_loss(advs, ratio, self.clip_range)
                vl = self.vf_coef * value_loss(rets, values, old_vals, self.vf_clip_range)
                el = self.entropy_coef * entropy_loss(entropy)
                loss = pl + vl + el

                self.policy.optimizer.zero_grad()
                loss.backward()
                torch.nn.utils.clip_grad_norm_(self.policy.parameters(), self.max_grad_norm)
                self.policy.optimizer.step()
                if self.policy.log_std is not None:
                    self.policy.log_std.data.clamp_(-4.0, -1.0)
                total_losses.append(loss.item())

        if self.policy.lr_scheduler is not None:
            self.policy.lr_scheduler.step()

        return {
            "loss": np.mean(total_losses) if total_losses else 0.0,
            "explained_variance": self.buffer.explained_variance,
        }

    def fit_iteration(self):
        metrics = self.collect_rollouts()
        metrics.update(self.train_step())
        return metrics
