"""Discrete time-domain passivity observer/controller at the master port."""

from __future__ import annotations

from dataclasses import dataclass

import numpy as np


@dataclass(frozen=True)
class POPCDiagnostics:
    tau_candidate: np.ndarray
    tau_applied: np.ndarray
    observer_before: float
    observer_preclip: float
    observer_after: float
    candidate_power: float
    applied_power: float
    damping_gain: float
    intervention_active: bool
    fail_safe_active: bool


class TimeDomainPOPC:
    """Causal PO/PC using dissipative velocity feedback.

    Positive ``tau @ qd`` is energy delivered by the device.  When the
    candidate would exhaust the observer balance, the controller injects
    ``-beta * qd``.  This is intentionally a separate baseline from the radial
    tank projection implemented in :mod:`core.haptic_render`.
    """

    def __init__(
        self,
        *,
        initial_energy: float = 0.0,
        minimum_energy: float = 0.0,
        maximum_energy: float = np.inf,
        velocity_epsilon: float = 1e-12,
    ):
        if not np.isfinite(initial_energy) or not np.isfinite(minimum_energy):
            raise ValueError("initial and minimum energy must be finite")
        if maximum_energy <= minimum_energy:
            raise ValueError("maximum_energy must exceed minimum_energy")
        if not minimum_energy <= initial_energy <= maximum_energy:
            raise ValueError("initial_energy must lie within observer bounds")
        if velocity_epsilon <= 0.0:
            raise ValueError("velocity_epsilon must be positive")
        self.minimum_energy = float(minimum_energy)
        self.maximum_energy = float(maximum_energy)
        self.velocity_epsilon = float(velocity_epsilon)
        self.energy = float(initial_energy)
        self.last_diagnostics: POPCDiagnostics | None = None

    def reset(self, energy: float | None = None) -> None:
        target = self.energy if energy is None else float(energy)
        if not self.minimum_energy <= target <= self.maximum_energy:
            raise ValueError("reset energy lies outside observer bounds")
        self.energy = target
        self.last_diagnostics = None

    def apply(
        self,
        tau_candidate: np.ndarray,
        qd_master: np.ndarray,
        dt: float,
    ) -> tuple[np.ndarray, POPCDiagnostics]:
        candidate = np.asarray(tau_candidate, dtype=float).reshape(-1)
        velocity = np.asarray(qd_master, dtype=float).reshape(-1)
        if candidate.shape != velocity.shape or candidate.size == 0:
            raise ValueError("candidate and velocity must have equal non-empty shapes")
        before = self.energy
        if (
            not np.isfinite(dt)
            or dt <= 0.0
            or not np.all(np.isfinite(candidate))
            or not np.all(np.isfinite(velocity))
        ):
            applied = np.zeros_like(candidate)
            diagnostics = POPCDiagnostics(
                tau_candidate=candidate.copy(),
                tau_applied=applied,
                observer_before=before,
                observer_preclip=before,
                observer_after=before,
                candidate_power=0.0,
                applied_power=0.0,
                damping_gain=0.0,
                intervention_active=True,
                fail_safe_active=True,
            )
            self.last_diagnostics = diagnostics
            return applied, diagnostics

        candidate_power = float(candidate @ velocity)
        available = max(0.0, before - self.minimum_energy)
        allowed_power = available / dt
        damping_gain = 0.0
        applied = candidate.copy()
        velocity_norm_sq = float(velocity @ velocity)
        if candidate_power > allowed_power:
            if velocity_norm_sq <= self.velocity_epsilon:
                # With an almost-zero velocity, nonzero power is numerical
                # contamination; zero output is the conservative response.
                applied.fill(0.0)
            else:
                damping_gain = (
                    candidate_power - allowed_power
                ) / velocity_norm_sq
                applied = candidate - damping_gain * velocity

        applied_power = float(applied @ velocity)
        preclip = before - applied_power * dt
        self.energy = float(
            np.clip(preclip, self.minimum_energy, self.maximum_energy)
        )
        diagnostics = POPCDiagnostics(
            tau_candidate=candidate.copy(),
            tau_applied=applied.copy(),
            observer_before=before,
            observer_preclip=preclip,
            observer_after=self.energy,
            candidate_power=candidate_power,
            applied_power=applied_power,
            damping_gain=damping_gain,
            intervention_active=bool(
                np.any(np.abs(applied - candidate) > 1e-12)
            ),
            fail_safe_active=False,
        )
        self.last_diagnostics = diagnostics
        return applied, diagnostics
