"""Constraint classes that produce residual Expr lists.

Each constraint takes two RigidBody entities and marker transforms,
then generates residual expressions that equal zero when satisfied.

Phase 1 constraints: Coincident, DistancePointPoint, Fixed
Phase 2 constraints: all remaining BaseJointKind types from Types.h
"""

from __future__ import annotations

import math
from typing import List

from .entities import RigidBody
from .expr import Const, Expr
from .geometry import (
    cross3,
    dot3,
    marker_x_axis,
    marker_y_axis,
    marker_z_axis,
    point_line_perp_components,
    point_plane_distance,
    sub3,
)


class ConstraintBase:
    """Abstract constraint producing residual expressions."""

    def residuals(self) -> List[Expr]:
        raise NotImplementedError


class CoincidentConstraint(ConstraintBase):
    """Point-on-point: marker origin on body_i == marker origin on body_j.

    3 residuals: dx, dy, dz.
    """

    def __init__(
        self,
        body_i: RigidBody,
        marker_i_pos: tuple[float, float, float],
        body_j: RigidBody,
        marker_j_pos: tuple[float, float, float],
    ):
        self.body_i = body_i
        self.body_j = body_j
        self.marker_i_pos = marker_i_pos
        self.marker_j_pos = marker_j_pos

    def residuals(self) -> List[Expr]:
        wx_i, wy_i, wz_i = self.body_i.world_point(*self.marker_i_pos)
        wx_j, wy_j, wz_j = self.body_j.world_point(*self.marker_j_pos)
        return [wx_i - wx_j, wy_i - wy_j, wz_i - wz_j]


class DistancePointPointConstraint(ConstraintBase):
    """Point-to-point distance: ||p_i - p_j||^2 = d^2.

    1 residual (squared form avoids sqrt in Jacobian).
    """

    def __init__(
        self,
        body_i: RigidBody,
        marker_i_pos: tuple[float, float, float],
        body_j: RigidBody,
        marker_j_pos: tuple[float, float, float],
        distance: float,
    ):
        self.body_i = body_i
        self.body_j = body_j
        self.marker_i_pos = marker_i_pos
        self.marker_j_pos = marker_j_pos
        self.distance = distance

    def world_points(self) -> tuple[tuple[Expr, Expr, Expr], tuple[Expr, Expr, Expr]]:
        """Return (world_point_i, world_point_j) expression tuples."""
        return (
            self.body_i.world_point(*self.marker_i_pos),
            self.body_j.world_point(*self.marker_j_pos),
        )

    def residuals(self) -> List[Expr]:
        (wx_i, wy_i, wz_i), (wx_j, wy_j, wz_j) = self.world_points()
        dx = wx_i - wx_j
        dy = wy_i - wy_j
        dz = wz_i - wz_j
        d2 = dx * dx + dy * dy + dz * dz
        return [d2 - Const(self.distance * self.distance)]


class FixedConstraint(ConstraintBase):
    """Lock all 6 DOF of body_j relative to body_i.

    Position: 3 residuals (marker origins coincide).
    Orientation: 3 residuals from relative quaternion (imaginary part == 0
    when orientations match).  The quaternion normalization constraint
    handles the 4th equation.

    The reference relative quaternion q_ref = conj(q_i_marker) * q_j_marker
    is computed from the marker quaternions at constraint creation time.
    At solve time:  q_rel = conj(q_i_world * q_i_marker) * (q_j_world * q_j_marker)
    Residual = imaginary part of (conj(q_ref) * q_rel).
    """

    def __init__(
        self,
        body_i: RigidBody,
        marker_i_pos: tuple[float, float, float],
        marker_i_quat: tuple[float, float, float, float],
        body_j: RigidBody,
        marker_j_pos: tuple[float, float, float],
        marker_j_quat: tuple[float, float, float, float],
    ):
        self.body_i = body_i
        self.body_j = body_j
        self.marker_i_pos = marker_i_pos
        self.marker_i_quat = marker_i_quat
        self.marker_j_pos = marker_j_pos
        self.marker_j_quat = marker_j_quat

    def residuals(self) -> List[Expr]:
        # Position residuals — same as Coincident
        wx_i, wy_i, wz_i = self.body_i.world_point(*self.marker_i_pos)
        wx_j, wy_j, wz_j = self.body_j.world_point(*self.marker_j_pos)
        pos_res = [wx_i - wx_j, wy_i - wy_j, wz_i - wz_j]

        # Orientation residuals via quaternion error
        # q_i_total = q_body_i * q_marker_i  (as Expr)
        # q_j_total = q_body_j * q_marker_j
        # q_error = conj(q_i_total) * q_j_total
        # We want q_error ≈ identity → imaginary parts ≈ 0
        qi = _quat_mul_const(
            self.body_i.qw,
            self.body_i.qx,
            self.body_i.qy,
            self.body_i.qz,
            *self.marker_i_quat,
        )
        qj = _quat_mul_const(
            self.body_j.qw,
            self.body_j.qx,
            self.body_j.qy,
            self.body_j.qz,
            *self.marker_j_quat,
        )
        # conj(qi) * qj
        err = _quat_mul_expr(
            qi[0],
            -qi[1],
            -qi[2],
            -qi[3],  # conjugate
            qj[0],
            qj[1],
            qj[2],
            qj[3],
        )
        # err should be (1, 0, 0, 0) — residuals are the imaginary parts
        ori_res = [err[1], err[2], err[3]]

        return pos_res + ori_res


# ============================================================================
# Phase 2: Point constraints
# ============================================================================


class PointOnLineConstraint(ConstraintBase):
    """Point constrained to a line — 2 DOF removed.

    marker_i origin lies on the line through marker_j origin along
    marker_j Z-axis.  2 residuals: perpendicular distance components.
    """

    def __init__(
        self,
        body_i: RigidBody,
        marker_i_pos: tuple[float, float, float],
        marker_i_quat: tuple[float, float, float, float],
        body_j: RigidBody,
        marker_j_pos: tuple[float, float, float],
        marker_j_quat: tuple[float, float, float, float],
    ):
        self.body_i = body_i
        self.body_j = body_j
        self.marker_i_pos = marker_i_pos
        self.marker_j_pos = marker_j_pos
        self.marker_j_quat = marker_j_quat

    def residuals(self) -> List[Expr]:
        p_i = self.body_i.world_point(*self.marker_i_pos)
        p_j = self.body_j.world_point(*self.marker_j_pos)
        z_j = marker_z_axis(self.body_j, self.marker_j_quat)
        cx, cy = point_line_perp_components(p_i, p_j, z_j)
        return [cx, cy]


class PointInPlaneConstraint(ConstraintBase):
    """Point constrained to a plane — 1 DOF removed.

    marker_i origin lies in the plane through marker_j origin with
    normal = marker_j Z-axis.  Optional offset via params[0].
    1 residual: signed distance to plane.
    """

    def __init__(
        self,
        body_i: RigidBody,
        marker_i_pos: tuple[float, float, float],
        marker_i_quat: tuple[float, float, float, float],
        body_j: RigidBody,
        marker_j_pos: tuple[float, float, float],
        marker_j_quat: tuple[float, float, float, float],
        offset: float = 0.0,
    ):
        self.body_i = body_i
        self.body_j = body_j
        self.marker_i_pos = marker_i_pos
        self.marker_j_pos = marker_j_pos
        self.marker_j_quat = marker_j_quat
        self.offset = offset

    def residuals(self) -> List[Expr]:
        p_i = self.body_i.world_point(*self.marker_i_pos)
        p_j = self.body_j.world_point(*self.marker_j_pos)
        n_j = marker_z_axis(self.body_j, self.marker_j_quat)
        d = point_plane_distance(p_i, p_j, n_j)
        if self.offset != 0.0:
            d = d - Const(self.offset)
        return [d]


# ============================================================================
# Phase 2: Axis orientation constraints
# ============================================================================


class ParallelConstraint(ConstraintBase):
    """Parallel axes — 2 DOF removed.

    marker Z-axes are parallel: z_i x z_j = 0.
    2 residuals from the cross product (only 2 of 3 components are
    independent for unit vectors).
    """

    def __init__(
        self,
        body_i: RigidBody,
        marker_i_quat: tuple[float, float, float, float],
        body_j: RigidBody,
        marker_j_quat: tuple[float, float, float, float],
    ):
        self.body_i = body_i
        self.body_j = body_j
        self.marker_i_quat = marker_i_quat
        self.marker_j_quat = marker_j_quat

    def residuals(self) -> List[Expr]:
        z_i = marker_z_axis(self.body_i, self.marker_i_quat)
        z_j = marker_z_axis(self.body_j, self.marker_j_quat)
        cx, cy, cz = cross3(z_i, z_j)
        return [cx, cy]


class PerpendicularConstraint(ConstraintBase):
    """Perpendicular axes — 1 DOF removed.

    marker Z-axes are perpendicular: z_i . z_j = 0.
    1 residual.
    """

    def __init__(
        self,
        body_i: RigidBody,
        marker_i_quat: tuple[float, float, float, float],
        body_j: RigidBody,
        marker_j_quat: tuple[float, float, float, float],
    ):
        self.body_i = body_i
        self.body_j = body_j
        self.marker_i_quat = marker_i_quat
        self.marker_j_quat = marker_j_quat

    def residuals(self) -> List[Expr]:
        z_i = marker_z_axis(self.body_i, self.marker_i_quat)
        z_j = marker_z_axis(self.body_j, self.marker_j_quat)
        return [dot3(z_i, z_j)]


class AngleConstraint(ConstraintBase):
    """Angle between axes — 1 DOF removed.

    z_i . z_j = cos(angle).
    1 residual.
    """

    def __init__(
        self,
        body_i: RigidBody,
        marker_i_quat: tuple[float, float, float, float],
        body_j: RigidBody,
        marker_j_quat: tuple[float, float, float, float],
        angle: float,
    ):
        self.body_i = body_i
        self.body_j = body_j
        self.marker_i_quat = marker_i_quat
        self.marker_j_quat = marker_j_quat
        self.angle = angle

    def residuals(self) -> List[Expr]:
        z_i = marker_z_axis(self.body_i, self.marker_i_quat)
        z_j = marker_z_axis(self.body_j, self.marker_j_quat)
        return [dot3(z_i, z_j) - Const(math.cos(self.angle))]


# ============================================================================
# Phase 2: Axis/surface constraints
# ============================================================================


class ConcentricConstraint(ConstraintBase):
    """Coaxial / concentric — 4 DOF removed.

    Axes are collinear: parallel Z-axes (2) + point-on-line (2).
    Optional distance offset along axis via params.
    """

    def __init__(
        self,
        body_i: RigidBody,
        marker_i_pos: tuple[float, float, float],
        marker_i_quat: tuple[float, float, float, float],
        body_j: RigidBody,
        marker_j_pos: tuple[float, float, float],
        marker_j_quat: tuple[float, float, float, float],
        distance: float = 0.0,
    ):
        self.body_i = body_i
        self.body_j = body_j
        self.marker_i_pos = marker_i_pos
        self.marker_i_quat = marker_i_quat
        self.marker_j_pos = marker_j_pos
        self.marker_j_quat = marker_j_quat
        self.distance = distance

    def residuals(self) -> List[Expr]:
        # Parallel axes (2 residuals)
        z_i = marker_z_axis(self.body_i, self.marker_i_quat)
        z_j = marker_z_axis(self.body_j, self.marker_j_quat)
        cx, cy, _cz = cross3(z_i, z_j)

        # Point-on-line: marker_i origin on line through marker_j along z_j
        p_i = self.body_i.world_point(*self.marker_i_pos)
        p_j = self.body_j.world_point(*self.marker_j_pos)
        lx, ly = point_line_perp_components(p_i, p_j, z_j)

        return [cx, cy, lx, ly]


class TangentConstraint(ConstraintBase):
    """Face-on-face tangency — 1 DOF removed.

    Signed distance between marker origins along marker_j normal = 0.
    1 residual: (p_i - p_j) . z_j
    """

    def __init__(
        self,
        body_i: RigidBody,
        marker_i_pos: tuple[float, float, float],
        marker_i_quat: tuple[float, float, float, float],
        body_j: RigidBody,
        marker_j_pos: tuple[float, float, float],
        marker_j_quat: tuple[float, float, float, float],
    ):
        self.body_i = body_i
        self.body_j = body_j
        self.marker_i_pos = marker_i_pos
        self.marker_j_pos = marker_j_pos
        self.marker_j_quat = marker_j_quat

    def residuals(self) -> List[Expr]:
        p_i = self.body_i.world_point(*self.marker_i_pos)
        p_j = self.body_j.world_point(*self.marker_j_pos)
        n_j = marker_z_axis(self.body_j, self.marker_j_quat)
        return [point_plane_distance(p_i, p_j, n_j)]


class PlanarConstraint(ConstraintBase):
    """Coplanar faces — 3 DOF removed.

    Parallel normals (2) + point-in-plane (1).  Optional offset.
    """

    def __init__(
        self,
        body_i: RigidBody,
        marker_i_pos: tuple[float, float, float],
        marker_i_quat: tuple[float, float, float, float],
        body_j: RigidBody,
        marker_j_pos: tuple[float, float, float],
        marker_j_quat: tuple[float, float, float, float],
        offset: float = 0.0,
    ):
        self.body_i = body_i
        self.body_j = body_j
        self.marker_i_pos = marker_i_pos
        self.marker_i_quat = marker_i_quat
        self.marker_j_pos = marker_j_pos
        self.marker_j_quat = marker_j_quat
        self.offset = offset

    def residuals(self) -> List[Expr]:
        # Parallel normals
        z_i = marker_z_axis(self.body_i, self.marker_i_quat)
        z_j = marker_z_axis(self.body_j, self.marker_j_quat)
        cx, cy, _cz = cross3(z_i, z_j)

        # Point-in-plane
        p_i = self.body_i.world_point(*self.marker_i_pos)
        p_j = self.body_j.world_point(*self.marker_j_pos)
        d = point_plane_distance(p_i, p_j, z_j)
        if self.offset != 0.0:
            d = d - Const(self.offset)

        return [cx, cy, d]


class LineInPlaneConstraint(ConstraintBase):
    """Line constrained to a plane — 2 DOF removed.

    Line defined by marker_i Z-axis lies in plane defined by marker_j normal.
    2 residuals: point-in-plane (1) + line direction perpendicular to normal (1).
    """

    def __init__(
        self,
        body_i: RigidBody,
        marker_i_pos: tuple[float, float, float],
        marker_i_quat: tuple[float, float, float, float],
        body_j: RigidBody,
        marker_j_pos: tuple[float, float, float],
        marker_j_quat: tuple[float, float, float, float],
        offset: float = 0.0,
    ):
        self.body_i = body_i
        self.body_j = body_j
        self.marker_i_pos = marker_i_pos
        self.marker_i_quat = marker_i_quat
        self.marker_j_pos = marker_j_pos
        self.marker_j_quat = marker_j_quat
        self.offset = offset

    def residuals(self) -> List[Expr]:
        p_i = self.body_i.world_point(*self.marker_i_pos)
        p_j = self.body_j.world_point(*self.marker_j_pos)
        n_j = marker_z_axis(self.body_j, self.marker_j_quat)
        z_i = marker_z_axis(self.body_i, self.marker_i_quat)

        # Point in plane
        d = point_plane_distance(p_i, p_j, n_j)
        if self.offset != 0.0:
            d = d - Const(self.offset)

        # Line direction perpendicular to plane normal
        dir_dot = dot3(z_i, n_j)

        return [d, dir_dot]


# ============================================================================
# Phase 2: Kinematic joints
# ============================================================================


class BallConstraint(ConstraintBase):
    """Spherical joint — 3 DOF removed.

    Coincident marker origins.  Same as CoincidentConstraint.
    """

    def __init__(
        self,
        body_i: RigidBody,
        marker_i_pos: tuple[float, float, float],
        body_j: RigidBody,
        marker_j_pos: tuple[float, float, float],
    ):
        self._inner = CoincidentConstraint(body_i, marker_i_pos, body_j, marker_j_pos)

    def residuals(self) -> List[Expr]:
        return self._inner.residuals()


class RevoluteConstraint(ConstraintBase):
    """Hinge joint — 5 DOF removed.

    Coincident origins (3) + parallel Z-axes (2).
    1 rotational DOF remains (about the common Z-axis).
    """

    def __init__(
        self,
        body_i: RigidBody,
        marker_i_pos: tuple[float, float, float],
        marker_i_quat: tuple[float, float, float, float],
        body_j: RigidBody,
        marker_j_pos: tuple[float, float, float],
        marker_j_quat: tuple[float, float, float, float],
    ):
        self.body_i = body_i
        self.body_j = body_j
        self.marker_i_pos = marker_i_pos
        self.marker_i_quat = marker_i_quat
        self.marker_j_pos = marker_j_pos
        self.marker_j_quat = marker_j_quat

    def residuals(self) -> List[Expr]:
        # Coincident origins
        p_i = self.body_i.world_point(*self.marker_i_pos)
        p_j = self.body_j.world_point(*self.marker_j_pos)
        pos = [p_i[0] - p_j[0], p_i[1] - p_j[1], p_i[2] - p_j[2]]

        # Parallel Z-axes
        z_i = marker_z_axis(self.body_i, self.marker_i_quat)
        z_j = marker_z_axis(self.body_j, self.marker_j_quat)
        cx, cy, _cz = cross3(z_i, z_j)

        return pos + [cx, cy]


class CylindricalConstraint(ConstraintBase):
    """Cylindrical joint — 4 DOF removed.

    Parallel Z-axes (2) + point-on-line (2).
    2 DOF remain: rotation about and translation along the common axis.
    """

    def __init__(
        self,
        body_i: RigidBody,
        marker_i_pos: tuple[float, float, float],
        marker_i_quat: tuple[float, float, float, float],
        body_j: RigidBody,
        marker_j_pos: tuple[float, float, float],
        marker_j_quat: tuple[float, float, float, float],
    ):
        self.body_i = body_i
        self.body_j = body_j
        self.marker_i_pos = marker_i_pos
        self.marker_i_quat = marker_i_quat
        self.marker_j_pos = marker_j_pos
        self.marker_j_quat = marker_j_quat

    def residuals(self) -> List[Expr]:
        # Parallel Z-axes
        z_i = marker_z_axis(self.body_i, self.marker_i_quat)
        z_j = marker_z_axis(self.body_j, self.marker_j_quat)
        cx, cy, _cz = cross3(z_i, z_j)

        # Point-on-line
        p_i = self.body_i.world_point(*self.marker_i_pos)
        p_j = self.body_j.world_point(*self.marker_j_pos)
        lx, ly = point_line_perp_components(p_i, p_j, z_j)

        return [cx, cy, lx, ly]


class SliderConstraint(ConstraintBase):
    """Prismatic / slider joint — 5 DOF removed.

    Parallel Z-axes (2) + point-on-line (2) + rotation lock (1).
    1 DOF remains: translation along the common Z-axis.

    Rotation lock: x_i . y_j = 0  (prevents twist about Z).
    """

    def __init__(
        self,
        body_i: RigidBody,
        marker_i_pos: tuple[float, float, float],
        marker_i_quat: tuple[float, float, float, float],
        body_j: RigidBody,
        marker_j_pos: tuple[float, float, float],
        marker_j_quat: tuple[float, float, float, float],
    ):
        self.body_i = body_i
        self.body_j = body_j
        self.marker_i_pos = marker_i_pos
        self.marker_i_quat = marker_i_quat
        self.marker_j_pos = marker_j_pos
        self.marker_j_quat = marker_j_quat

    def residuals(self) -> List[Expr]:
        # Parallel Z-axes
        z_i = marker_z_axis(self.body_i, self.marker_i_quat)
        z_j = marker_z_axis(self.body_j, self.marker_j_quat)
        cx, cy, _cz = cross3(z_i, z_j)

        # Point-on-line
        p_i = self.body_i.world_point(*self.marker_i_pos)
        p_j = self.body_j.world_point(*self.marker_j_pos)
        lx, ly = point_line_perp_components(p_i, p_j, z_j)

        # Rotation lock: x_i . y_j = 0
        x_i = marker_x_axis(self.body_i, self.marker_i_quat)
        y_j = marker_y_axis(self.body_j, self.marker_j_quat)
        twist = dot3(x_i, y_j)

        return [cx, cy, lx, ly, twist]


class ScrewConstraint(ConstraintBase):
    """Helical / screw joint — 5 DOF removed.

    Cylindrical (4) + coupled rotation-translation via pitch (1).
    1 DOF remains: screw motion (rotation + proportional translation).

    The coupling residual uses the relative quaternion's Z-component
    (proportional to the rotation angle for small angles) and the axial
    displacement: axial_disp - pitch * (2 * qz_rel / qw_rel) / (2*pi) = 0.
    For the Newton solver operating near the solution, the linear
    approximation angle ≈ 2 * qz_rel is adequate.
    """

    def __init__(
        self,
        body_i: RigidBody,
        marker_i_pos: tuple[float, float, float],
        marker_i_quat: tuple[float, float, float, float],
        body_j: RigidBody,
        marker_j_pos: tuple[float, float, float],
        marker_j_quat: tuple[float, float, float, float],
        pitch: float = 1.0,
    ):
        self.body_i = body_i
        self.body_j = body_j
        self.marker_i_pos = marker_i_pos
        self.marker_i_quat = marker_i_quat
        self.marker_j_pos = marker_j_pos
        self.marker_j_quat = marker_j_quat
        self.pitch = pitch

    def residuals(self) -> List[Expr]:
        # Cylindrical residuals (4)
        z_i = marker_z_axis(self.body_i, self.marker_i_quat)
        z_j = marker_z_axis(self.body_j, self.marker_j_quat)
        cx, cy, _cz = cross3(z_i, z_j)

        p_i = self.body_i.world_point(*self.marker_i_pos)
        p_j = self.body_j.world_point(*self.marker_j_pos)
        lx, ly = point_line_perp_components(p_i, p_j, z_j)

        # Pitch coupling: axial_disp = pitch * angle / (2*pi)
        # Axial displacement
        d = sub3(p_i, p_j)
        axial = dot3(d, z_j)

        # Relative rotation about Z via quaternion
        # q_rel = conj(q_i_total) * q_j_total
        qi = _quat_mul_const(
            self.body_i.qw,
            self.body_i.qx,
            self.body_i.qy,
            self.body_i.qz,
            *self.marker_i_quat,
        )
        qj = _quat_mul_const(
            self.body_j.qw,
            self.body_j.qx,
            self.body_j.qy,
            self.body_j.qz,
            *self.marker_j_quat,
        )
        rel = _quat_mul_expr(qi[0], -qi[1], -qi[2], -qi[3], qj[0], qj[1], qj[2], qj[3])
        # For small angles: angle ≈ 2 * qz_rel, but qw_rel ≈ 1
        # Use sin(angle/2) form: residual = axial - pitch * 2*qz / (2*pi)
        # = axial - pitch * qz / pi
        coupling = axial - Const(self.pitch / math.pi) * rel[3]

        return [cx, cy, lx, ly, coupling]


class UniversalConstraint(ConstraintBase):
    """Universal / Cardan joint — 4 DOF removed.

    Coincident origins (3) + perpendicular Z-axes (1).
    2 DOF remain: rotation about each body's Z-axis.
    """

    def __init__(
        self,
        body_i: RigidBody,
        marker_i_pos: tuple[float, float, float],
        marker_i_quat: tuple[float, float, float, float],
        body_j: RigidBody,
        marker_j_pos: tuple[float, float, float],
        marker_j_quat: tuple[float, float, float, float],
    ):
        self.body_i = body_i
        self.body_j = body_j
        self.marker_i_pos = marker_i_pos
        self.marker_i_quat = marker_i_quat
        self.marker_j_pos = marker_j_pos
        self.marker_j_quat = marker_j_quat

    def residuals(self) -> List[Expr]:
        # Coincident origins
        p_i = self.body_i.world_point(*self.marker_i_pos)
        p_j = self.body_j.world_point(*self.marker_j_pos)
        pos = [p_i[0] - p_j[0], p_i[1] - p_j[1], p_i[2] - p_j[2]]

        # Perpendicular Z-axes
        z_i = marker_z_axis(self.body_i, self.marker_i_quat)
        z_j = marker_z_axis(self.body_j, self.marker_j_quat)

        return pos + [dot3(z_i, z_j)]


# ============================================================================
# Phase 2: Mechanical element constraints
# ============================================================================


class GearConstraint(ConstraintBase):
    """Gear pair or belt — 1 DOF removed.

    Couples rotation angles: r_i * theta_i + r_j * theta_j = 0.
    For belts (same-direction rotation), r_j is passed as negative.

    Uses the Z-component of the relative quaternion as a proxy for
    rotation angle (linear for small angles, which is the regime
    where Newton operates).
    """

    def __init__(
        self,
        body_i: RigidBody,
        marker_i_quat: tuple[float, float, float, float],
        body_j: RigidBody,
        marker_j_quat: tuple[float, float, float, float],
        radius_i: float,
        radius_j: float,
    ):
        self.body_i = body_i
        self.body_j = body_j
        self.marker_i_quat = marker_i_quat
        self.marker_j_quat = marker_j_quat
        self.radius_i = radius_i
        self.radius_j = radius_j

    def residuals(self) -> List[Expr]:
        # Rotation angle proxy via relative quaternion Z-component
        # For body_i: q_rel_i = conj(q_marker_i) * q_body_i * q_marker_i
        # Simplified: use 2*qz of (conj(marker) * body * marker) as angle proxy
        qz_i = _rotation_z_component(self.body_i, self.marker_i_quat)
        qz_j = _rotation_z_component(self.body_j, self.marker_j_quat)

        # r_i * theta_i + r_j * theta_j = 0
        # Using qz as proportional to theta/2:
        # r_i * qz_i + r_j * qz_j = 0
        return [Const(self.radius_i) * qz_i + Const(self.radius_j) * qz_j]


class RackPinionConstraint(ConstraintBase):
    """Rack-and-pinion — 1 DOF removed.

    Couples rotation of body_i to translation of body_j along marker_j Z-axis.
    translation = pitch_radius * theta
    """

    def __init__(
        self,
        body_i: RigidBody,
        marker_i_pos: tuple[float, float, float],
        marker_i_quat: tuple[float, float, float, float],
        body_j: RigidBody,
        marker_j_pos: tuple[float, float, float],
        marker_j_quat: tuple[float, float, float, float],
        pitch_radius: float,
    ):
        self.body_i = body_i
        self.body_j = body_j
        self.marker_i_pos = marker_i_pos
        self.marker_i_quat = marker_i_quat
        self.marker_j_pos = marker_j_pos
        self.marker_j_quat = marker_j_quat
        self.pitch_radius = pitch_radius

    def residuals(self) -> List[Expr]:
        # Translation of j along its Z-axis
        p_i = self.body_i.world_point(*self.marker_i_pos)
        p_j = self.body_j.world_point(*self.marker_j_pos)
        z_j = marker_z_axis(self.body_j, self.marker_j_quat)
        d = sub3(p_j, p_i)
        translation = dot3(d, z_j)

        # Rotation angle of i about its Z-axis
        qz_i = _rotation_z_component(self.body_i, self.marker_i_quat)

        # translation - pitch_radius * angle = 0
        # angle ≈ 2 * qz, so: translation - pitch_radius * 2 * qz = 0
        return [translation - Const(2.0 * self.pitch_radius) * qz_i]


class CamConstraint(ConstraintBase):
    """Cam-follower constraint — future, stub."""

    def residuals(self) -> List[Expr]:
        return []


class SlotConstraint(ConstraintBase):
    """Slot constraint — future, stub."""

    def residuals(self) -> List[Expr]:
        return []


class DistanceCylSphConstraint(ConstraintBase):
    """Cylinder-sphere distance — stub.

    Semantics depend on geometry classification; placeholder for now.
    """

    def residuals(self) -> List[Expr]:
        return []


# -- rotation helpers for mechanical constraints ------------------------------


def _rotation_z_component(
    body: RigidBody,
    marker_quat: tuple[float, float, float, float],
) -> Expr:
    """Extract the Z-component of the relative quaternion about a marker axis.

    Returns the qz component of conj(q_marker) * q_body * q_marker,
    which is proportional to sin(theta/2) where theta is the rotation
    angle about the marker Z-axis.
    """
    mw, mx, my, mz = marker_quat
    # q_local = conj(marker) * q_body * marker
    # Step 1: temp = conj(marker) * q_body
    cmw, cmx, cmy, cmz = Const(mw), Const(-mx), Const(-my), Const(-mz)
    # temp = conj(marker) * q_body
    tw = cmw * body.qw - cmx * body.qx - cmy * body.qy - cmz * body.qz
    tx = cmw * body.qx + cmx * body.qw + cmy * body.qz - cmz * body.qy
    ty = cmw * body.qy - cmx * body.qz + cmy * body.qw + cmz * body.qx
    tz = cmw * body.qz + cmx * body.qy - cmy * body.qx + cmz * body.qw
    # q_local = temp * marker
    mmw, mmx, mmy, mmz = Const(mw), Const(mx), Const(my), Const(mz)
    # rz = tw * mmz + tx * mmy - ty * mmx + tz * mmw
    rz = tw * mmz + tx * mmy - ty * mmx + tz * mmw
    return rz


# -- quaternion multiplication helpers ----------------------------------------


def _quat_mul_const(
    aw: Expr,
    ax: Expr,
    ay: Expr,
    az: Expr,
    bw: float,
    bx: float,
    by: float,
    bz: float,
) -> tuple[Expr, Expr, Expr, Expr]:
    """Multiply Expr quaternion (a) by constant quaternion (b)."""
    cbw, cbx, cby, cbz = Const(bw), Const(bx), Const(by), Const(bz)
    return _quat_mul_expr(aw, ax, ay, az, cbw, cbx, cby, cbz)


def _quat_mul_expr(
    aw: Expr,
    ax: Expr,
    ay: Expr,
    az: Expr,
    bw: Expr,
    bx: Expr,
    by: Expr,
    bz: Expr,
) -> tuple[Expr, Expr, Expr, Expr]:
    """Hamilton product of two Expr quaternions."""
    rw = aw * bw - ax * bx - ay * by - az * bz
    rx = aw * bx + ax * bw + ay * bz - az * by
    ry = aw * by - ax * bz + ay * bw + az * bx
    rz = aw * bz + ax * by - ay * bx + az * bw
    return rw, rx, ry, rz
