"""Dimensionally scaled residual-to-wrench solvers for H2.

Twists and Jacobians use ``[linear; angular]`` order and wrenches use
``[force; moment]`` order.  For characteristic length ``ell`` this module
implements the manuscript convention

``S = diag(1/ell I3, I3)``, ``J_tilde = S J`` and
``F_tilde = S**(-T) F``.

Consequently ``J_tilde.T @ F_tilde == J.T @ F`` and every element of
``F_tilde`` has torque units.  Both the damped and undamped methods use the
same scaling and frozen relative rank tolerance so their H2 comparison is
well-defined.
"""

from __future__ import annotations

from dataclasses import dataclass
from enum import Enum
from typing import Protocol, runtime_checkable

import numpy as np


def _vector(value, size: int, name: str) -> np.ndarray:
    array = np.asarray(value, dtype=float).reshape(-1)
    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")
    return array


def _jacobian(value) -> np.ndarray:
    array = np.asarray(value, dtype=float)
    if array.ndim != 2 or array.shape[0] != 6 or array.shape[1] == 0:
        raise ValueError(
            f"jacobian must have shape (6, n_joints), got {array.shape}"
        )
    if not np.all(np.isfinite(array)):
        raise ValueError("jacobian must contain only finite values")
    return array


def _readonly(value: np.ndarray) -> np.ndarray:
    result = np.asarray(value, dtype=float).copy()
    result.setflags(write=False)
    return result


class WrenchSolveStatus(str, Enum):
    """Numerical rank status after the frozen scaled-Jacobian test."""

    FULL_RANK = "full_rank"
    RANK_DEFICIENT = "rank_deficient"


@dataclass(frozen=True)
class WrenchSolveResult:
    """Solver result and all quantities needed for H2 stratification."""

    method: str
    wrench: np.ndarray
    scaled_wrench: np.ndarray
    reconstructed_residual: np.ndarray
    residual_error_norm: float
    singular_values: np.ndarray
    rank: int
    rank_threshold: float
    condition_number: float
    status: WrenchSolveStatus
    characteristic_length_m: float
    damping: float

    def __post_init__(self) -> None:
        wrench = _vector(self.wrench, 6, "wrench")
        scaled_wrench = _vector(self.scaled_wrench, 6, "scaled_wrench")
        reconstructed = np.asarray(
            self.reconstructed_residual, dtype=float
        ).reshape(-1)
        singular_values = np.asarray(self.singular_values, dtype=float).reshape(-1)
        if reconstructed.size == 0 or not np.all(np.isfinite(reconstructed)):
            raise ValueError("reconstructed_residual must be a finite vector")
        if singular_values.size == 0 or not np.all(np.isfinite(singular_values)):
            raise ValueError("singular_values must be a non-empty finite vector")
        if self.rank < 0 or self.rank > 6:
            raise ValueError("rank must lie in [0, 6]")
        finite_nonnegative = (
            self.residual_error_norm,
            self.rank_threshold,
            self.characteristic_length_m,
            self.damping,
        )
        if any(not np.isfinite(value) or value < 0.0 for value in finite_nonnegative):
            raise ValueError(
                "solver scalar diagnostics must be finite and non-negative"
            )
        if not (
            np.isfinite(self.condition_number)
            or np.isinf(self.condition_number)
        ):
            raise ValueError("condition_number must be finite or infinity")
        object.__setattr__(self, "wrench", _readonly(wrench))
        object.__setattr__(self, "scaled_wrench", _readonly(scaled_wrench))
        object.__setattr__(
            self, "reconstructed_residual", _readonly(reconstructed)
        )
        object.__setattr__(self, "singular_values", _readonly(singular_values))

    @property
    def force(self) -> np.ndarray:
        return self.wrench[:3].copy()

    @property
    def moment(self) -> np.ndarray:
        return self.wrench[3:].copy()


@runtime_checkable
class WrenchSolver(Protocol):
    """Common H2 solver interface."""

    name: str
    characteristic_length_m: float
    relative_rank_tolerance: float

    def solve(
        self, jacobian: np.ndarray, joint_residual: np.ndarray
    ) -> WrenchSolveResult:
        ...


class _ScaledSolverBase:
    def __init__(
        self,
        characteristic_length_m: float,
        *,
        relative_rank_tolerance: float = 1e-9,
    ) -> None:
        length = float(characteristic_length_m)
        tolerance = float(relative_rank_tolerance)
        if not np.isfinite(length) or length <= 0.0:
            raise ValueError(
                "characteristic_length_m must be finite and positive"
            )
        if not np.isfinite(tolerance) or tolerance <= 0.0 or tolerance >= 1.0:
            raise ValueError(
                "relative_rank_tolerance must be finite and lie in (0, 1)"
            )
        self.characteristic_length_m = length
        self.relative_rank_tolerance = tolerance
        self._scaling = np.diag(
            np.array([1.0 / length] * 3 + [1.0] * 3, dtype=float)
        )

    def scaled_jacobian(self, jacobian: np.ndarray) -> np.ndarray:
        """Return ``S_ell @ J`` using the frozen characteristic length."""
        return self._scaling @ _jacobian(jacobian)

    def _decompose(
        self, jacobian: np.ndarray, joint_residual: np.ndarray
    ) -> tuple[
        np.ndarray,
        np.ndarray,
        np.ndarray,
        np.ndarray,
        np.ndarray,
        float,
        int,
    ]:
        jacobian = _jacobian(jacobian)
        residual = _vector(
            joint_residual, jacobian.shape[1], "joint_residual"
        )
        scaled_jacobian = self._scaling @ jacobian
        u, singular_values, vt = np.linalg.svd(
            scaled_jacobian, full_matrices=False
        )
        largest = float(singular_values[0]) if singular_values.size else 0.0
        threshold = self.relative_rank_tolerance * largest
        rank = int(np.count_nonzero(singular_values > threshold))
        return (
            jacobian,
            residual,
            u,
            singular_values,
            vt,
            threshold,
            rank,
        )

    def _result(
        self,
        *,
        jacobian: np.ndarray,
        residual: np.ndarray,
        scaled_wrench: np.ndarray,
        singular_values: np.ndarray,
        threshold: float,
        rank: int,
        damping: float,
    ) -> WrenchSolveResult:
        wrench = self._scaling.T @ scaled_wrench
        reconstructed = jacobian.T @ wrench
        smallest = float(singular_values[-1])
        condition = (
            float(singular_values[0] / smallest)
            if rank == 6 and smallest > 0.0
            else float("inf")
        )
        return WrenchSolveResult(
            method=self.name,
            wrench=wrench,
            scaled_wrench=scaled_wrench,
            reconstructed_residual=reconstructed,
            residual_error_norm=float(np.linalg.norm(reconstructed - residual)),
            singular_values=singular_values,
            rank=rank,
            rank_threshold=threshold,
            condition_number=condition,
            status=(
                WrenchSolveStatus.FULL_RANK
                if rank == 6
                else WrenchSolveStatus.RANK_DEFICIENT
            ),
            characteristic_length_m=self.characteristic_length_m,
            damping=damping,
        )


class ScaledDLSSolver(_ScaledSolverBase):
    """Dimensionally scaled damped least-squares wrench reconstruction."""

    name = "scaled_dls"

    def __init__(
        self,
        characteristic_length_m: float,
        damping: float,
        *,
        relative_rank_tolerance: float = 1e-9,
    ) -> None:
        super().__init__(
            characteristic_length_m,
            relative_rank_tolerance=relative_rank_tolerance,
        )
        damping_value = float(damping)
        if not np.isfinite(damping_value) or damping_value <= 0.0:
            raise ValueError("damping must be finite and positive")
        self.damping = damping_value

    def solve(
        self, jacobian: np.ndarray, joint_residual: np.ndarray
    ) -> WrenchSolveResult:
        (
            jacobian,
            residual,
            u,
            singular_values,
            vt,
            threshold,
            rank,
        ) = self._decompose(jacobian, joint_residual)
        gains = singular_values / (singular_values**2 + self.damping**2)
        scaled_wrench = u @ (gains * (vt @ residual))
        return self._result(
            jacobian=jacobian,
            residual=residual,
            scaled_wrench=scaled_wrench,
            singular_values=singular_values,
            threshold=threshold,
            rank=rank,
            damping=self.damping,
        )


class UndampedSVDSolver(_ScaledSolverBase):
    """Frozen-tolerance, dimensionally scaled undamped pseudoinverse baseline."""

    name = "undamped_svd"

    def solve(
        self, jacobian: np.ndarray, joint_residual: np.ndarray
    ) -> WrenchSolveResult:
        (
            jacobian,
            residual,
            u,
            singular_values,
            vt,
            threshold,
            rank,
        ) = self._decompose(jacobian, joint_residual)
        inverse = np.zeros_like(singular_values)
        retained = singular_values > threshold
        inverse[retained] = 1.0 / singular_values[retained]
        scaled_wrench = u @ (inverse * (vt @ residual))
        return self._result(
            jacobian=jacobian,
            residual=residual,
            scaled_wrench=scaled_wrench,
            singular_values=singular_values,
            threshold=threshold,
            rank=rank,
            damping=0.0,
        )
