"""Source-stamped bilateral-feedback contracts used by formal experiments.

The current simulator historically delayed a residual and a wrench in separate
queues.  That is sufficient for a visual demonstration but cannot guarantee a
matched H3 comparison.  This module makes the comparison unit explicit: one
return packet carries the reconstructed wrench and ``J_s.T @ wrench`` together,
and both mapping conditions consume that immutable packet.

All spatial vectors use the repository convention ``[linear; angular]`` for
twists and ``[force; moment]`` for wrenches.
"""

from __future__ import annotations

from collections import OrderedDict
from dataclasses import dataclass
from enum import Enum, IntEnum
import hashlib
from typing import Iterable

import numpy as np


def _readonly_vector(value, size: int, name: str) -> np.ndarray:
    array = np.asarray(value, dtype=float).reshape(-1).copy()
    if array.shape != (size,):
        raise ValueError(f"{name} must have shape ({size},), got {array.shape}")
    if not np.all(np.isfinite(array)):
        raise ValueError(f"{name} must contain only finite values")
    array.setflags(write=False)
    return array


def _readonly_matrix(value, shape: tuple[int, int], name: str) -> np.ndarray:
    array = np.asarray(value, dtype=float).copy()
    if array.shape != shape:
        raise ValueError(f"{name} must have shape {shape}, got {array.shape}")
    if not np.all(np.isfinite(array)):
        raise ValueError(f"{name} must contain only finite values")
    array.setflags(write=False)
    return array


def _payload_hash(parts: Iterable[np.ndarray | int | float]) -> str:
    digest = hashlib.sha256()
    for part in parts:
        if isinstance(part, np.ndarray):
            contiguous = np.ascontiguousarray(part, dtype=np.float64)
            digest.update(str(contiguous.shape).encode("ascii"))
            digest.update(contiguous.tobytes())
        elif isinstance(part, (int, np.integer)):
            digest.update(f"i:{int(part)}".encode("ascii"))
        else:
            digest.update(f"f:{float(part):.17g}".encode("ascii"))
    return digest.hexdigest()


class MappingKind(str, Enum):
    """Feedback mappings required by the H3/H4 comparison."""

    DIFFERENTIAL_RESIDUAL = "differential_residual"
    MATCHED_DIFFERENTIAL_WRENCH = "matched_differential_wrench"
    DIRECT_MASTER_JACOBIAN = "direct_master_jacobian"


class MapPolicy(str, Enum):
    """Which differential is used for a delayed return packet."""

    CURRENT = "current"
    SOURCE_STAMPED = "source_stamped"


class PacketState(IntEnum):
    EMPTY = 0
    ACTIVE = 1
    HELD = 2
    TIMED_OUT = 3
    RECOVERING = 4


class PacketRejectReason(IntEnum):
    ACCEPTED = 0
    INVALID = 1
    CORRUPT = 2
    DUPLICATE_OR_STALE = 3


@dataclass(frozen=True)
class MapSnapshot:
    """One accepted 50-Hz retargeting differential."""

    map_id: int
    source_index: int
    source_time: float
    differential: np.ndarray
    valid: bool = True
    reason_code: int = 0

    def __post_init__(self) -> None:
        if self.map_id < 0 or self.source_index < 0:
            raise ValueError("map_id and source_index must be non-negative")
        if not np.isfinite(self.source_time):
            raise ValueError("source_time must be finite")
        matrix = np.asarray(self.differential, dtype=float)
        if matrix.ndim != 2 or matrix.shape[0] == 0 or matrix.shape[1] == 0:
            raise ValueError("differential must be a non-empty matrix")
        object.__setattr__(
            self,
            "differential",
            _readonly_matrix(matrix, matrix.shape, "differential"),
        )

    @property
    def digest(self) -> str:
        return _payload_hash(
            (
                self.map_id,
                self.source_index,
                self.source_time,
                self.differential,
            )
        )


class MapRegistry:
    """Bounded map-id registry for source-stamped delayed feedback."""

    def __init__(self, capacity: int = 512):
        if capacity <= 0:
            raise ValueError("capacity must be positive")
        self.capacity = int(capacity)
        self._maps: OrderedDict[int, MapSnapshot] = OrderedDict()

    def add(self, snapshot: MapSnapshot) -> None:
        existing = self._maps.get(snapshot.map_id)
        if existing is not None and existing.digest != snapshot.digest:
            raise ValueError(
                f"map_id {snapshot.map_id} cannot be reused for different data"
            )
        self._maps[snapshot.map_id] = snapshot
        self._maps.move_to_end(snapshot.map_id)
        while len(self._maps) > self.capacity:
            self._maps.popitem(last=False)

    def get(self, map_id: int) -> MapSnapshot:
        try:
            return self._maps[int(map_id)]
        except KeyError as exc:
            raise KeyError(f"map_id {map_id} is not available") from exc

    @property
    def latest(self) -> MapSnapshot:
        if not self._maps:
            raise KeyError("map registry is empty")
        return next(reversed(self._maps.values()))

    def __len__(self) -> int:
        return len(self._maps)


@dataclass(frozen=True)
class ForwardPacket:
    """Master-to-slave held reference packet."""

    seq: int
    source_index: int
    source_time: float
    map_id: int
    q_slave_ref: np.ndarray
    qd_slave_ctrl: np.ndarray
    valid: bool = True

    def __post_init__(self) -> None:
        if self.seq < 0 or self.source_index < 0 or self.map_id < 0:
            raise ValueError("packet identifiers must be non-negative")
        if not np.isfinite(self.source_time):
            raise ValueError("source_time must be finite")
        q = np.asarray(self.q_slave_ref, dtype=float).reshape(-1)
        qd = np.asarray(self.qd_slave_ctrl, dtype=float).reshape(-1)
        if q.size == 0 or q.shape != qd.shape:
            raise ValueError("q_slave_ref and qd_slave_ctrl must have equal shapes")
        object.__setattr__(
            self, "q_slave_ref", _readonly_vector(q, q.size, "q_slave_ref")
        )
        object.__setattr__(
            self, "qd_slave_ctrl", _readonly_vector(qd, qd.size, "qd_slave_ctrl")
        )


@dataclass(frozen=True)
class ReturnPacket:
    """Slave-to-master packet supporting every declared feedback condition."""

    seq: int
    source_index: int
    source_time: float
    echoed_map_id: int
    residual: np.ndarray
    wrench: np.ndarray
    js_t_wrench: np.ndarray
    qd_slave_actual: np.ndarray
    valid: bool = True

    def __post_init__(self) -> None:
        if self.seq < 0 or self.source_index < 0 or self.echoed_map_id < 0:
            raise ValueError("packet identifiers must be non-negative")
        if not np.isfinite(self.source_time):
            raise ValueError("source_time must be finite")
        residual = np.asarray(self.residual, dtype=float).reshape(-1)
        js_t = np.asarray(self.js_t_wrench, dtype=float).reshape(-1)
        qd = np.asarray(self.qd_slave_actual, dtype=float).reshape(-1)
        if residual.size == 0 or residual.shape != js_t.shape or residual.shape != qd.shape:
            raise ValueError(
                "residual, js_t_wrench and qd_slave_actual must have equal shapes"
            )
        object.__setattr__(
            self, "residual", _readonly_vector(residual, residual.size, "residual")
        )
        object.__setattr__(
            self, "wrench", _readonly_vector(self.wrench, 6, "wrench")
        )
        object.__setattr__(
            self,
            "js_t_wrench",
            _readonly_vector(js_t, js_t.size, "js_t_wrench"),
        )
        object.__setattr__(
            self,
            "qd_slave_actual",
            _readonly_vector(qd, qd.size, "qd_slave_actual"),
        )

    @property
    def matched_input_hash(self) -> str:
        """Hash proving that H3 matched conditions consumed one packet."""
        return _payload_hash(
            (
                self.seq,
                self.source_index,
                self.source_time,
                self.echoed_map_id,
                self.wrench,
                self.js_t_wrench,
            )
        )


@dataclass(frozen=True)
class FeedbackResult:
    tau_master_raw: np.ndarray
    tau_slave_source: np.ndarray
    selected_map_id: int | None
    matched_input_hash: str
    valid: bool
    reason: str


def map_return_feedback(
    *,
    kind: MappingKind,
    packet: ReturnPacket,
    master_jacobian: np.ndarray,
    maps: MapRegistry,
    map_policy: MapPolicy = MapPolicy.CURRENT,
) -> FeedbackResult:
    """Map one immutable return packet to a raw master generalized torque.

    ``MATCHED_DIFFERENTIAL_WRENCH`` and ``DIRECT_MASTER_JACOBIAN`` both use the
    packet's one reconstructed wrench.  The former consumes its stored
    ``J_s.T @ wrench`` value; the latter consumes ``wrench`` directly.
    """
    Jm = np.asarray(master_jacobian, dtype=float)
    if Jm.ndim != 2 or Jm.shape[0] != 6:
        raise ValueError("master_jacobian must have shape (6, nv_master)")
    if not np.all(np.isfinite(Jm)):
        raise ValueError("master_jacobian must contain only finite values")

    matched_hash = packet.matched_input_hash
    if not packet.valid:
        return FeedbackResult(
            tau_master_raw=np.zeros(Jm.shape[1], dtype=float),
            tau_slave_source=np.zeros_like(packet.residual),
            selected_map_id=None,
            matched_input_hash=matched_hash,
            valid=False,
            reason="invalid_return_packet",
        )

    if kind is MappingKind.DIRECT_MASTER_JACOBIAN:
        return FeedbackResult(
            tau_master_raw=np.asarray(Jm.T @ packet.wrench, dtype=float),
            tau_slave_source=packet.js_t_wrench.copy(),
            selected_map_id=None,
            matched_input_hash=matched_hash,
            valid=True,
            reason="ok",
        )

    if map_policy is MapPolicy.SOURCE_STAMPED:
        selected = maps.get(packet.echoed_map_id)
    else:
        selected = maps.latest
    A = selected.differential
    if A.shape[0] != packet.residual.size or A.shape[1] != Jm.shape[1]:
        raise ValueError(
            "differential shape is incompatible with slave/master dimensions"
        )
    if not selected.valid:
        return FeedbackResult(
            tau_master_raw=np.zeros(Jm.shape[1], dtype=float),
            tau_slave_source=np.zeros(A.shape[0], dtype=float),
            selected_map_id=selected.map_id,
            matched_input_hash=matched_hash,
            valid=False,
            reason="invalid_differential",
        )

    source = (
        packet.residual
        if kind is MappingKind.DIFFERENTIAL_RESIDUAL
        else packet.js_t_wrench
    )
    return FeedbackResult(
        tau_master_raw=np.asarray(A.T @ source, dtype=float),
        tau_slave_source=source.copy(),
        selected_map_id=selected.map_id,
        matched_input_hash=matched_hash,
        valid=True,
        reason="ok",
    )


def normalized_actual_power_mismatch(
    tau_master_raw: np.ndarray,
    qd_master: np.ndarray,
    tau_slave_source: np.ndarray,
    qd_slave_actual: np.ndarray,
    dt: float | np.ndarray,
    *,
    sigma_feedback: float = 1.0,
    epsilon_energy: float = 1e-12,
) -> float:
    """Compute the trajectory-level H3 endpoint from source-aligned samples."""
    tm = np.asarray(tau_master_raw, dtype=float)
    qm = np.asarray(qd_master, dtype=float)
    ts = np.asarray(tau_slave_source, dtype=float)
    qs = np.asarray(qd_slave_actual, dtype=float)
    if tm.ndim != 2 or tm.shape != qm.shape or ts.ndim != 2 or ts.shape != qs.shape:
        raise ValueError("torque and velocity arrays must be paired 2-D arrays")
    if tm.shape[0] != ts.shape[0]:
        raise ValueError("master and slave arrays must have the same sample count")
    delta = np.broadcast_to(np.asarray(dt, dtype=float), (tm.shape[0],))
    if np.any(delta <= 0.0) or not np.all(np.isfinite(delta)):
        raise ValueError("dt must contain finite positive values")
    if epsilon_energy <= 0.0:
        raise ValueError("epsilon_energy must be positive")

    pm = np.einsum("ij,ij->i", tm, qm)
    ps = float(sigma_feedback) * np.einsum("ij,ij->i", ts, qs)
    numerator = float(np.sum(np.abs(pm - ps) * delta))
    denominator = float(
        0.5 * np.sum((np.abs(pm) + np.abs(ps)) * delta) + epsilon_energy
    )
    return numerator / denominator
