"""Tests for per-entity DOF diagnostics and overconstrained detection."""

import math

import numpy as np
import pytest
from kindred_solver.constraints import (
    CoincidentConstraint,
    CylindricalConstraint,
    DistancePointPointConstraint,
    FixedConstraint,
    ParallelConstraint,
    RevoluteConstraint,
)
from kindred_solver.diagnostics import (
    ConstraintDiag,
    EntityDOF,
    find_overconstrained,
    per_entity_dof,
)
from kindred_solver.entities import RigidBody
from kindred_solver.params import ParamTable


def _make_two_bodies(
    params,
    pos_a=(0, 0, 0),
    pos_b=(5, 0, 0),
    quat_a=(1, 0, 0, 0),
    quat_b=(1, 0, 0, 0),
    ground_a=True,
    ground_b=False,
):
    body_a = RigidBody(
        "a", params, position=pos_a, quaternion=quat_a, grounded=ground_a
    )
    body_b = RigidBody(
        "b", params, position=pos_b, quaternion=quat_b, grounded=ground_b
    )
    return body_a, body_b


def _build_residuals_and_ranges(constraint_objs, bodies, params):
    """Build residuals list, quat norms, and residual_ranges."""
    all_residuals = []
    residual_ranges = []
    row = 0
    for i, obj in enumerate(constraint_objs):
        r = obj.residuals()
        n = len(r)
        residual_ranges.append((row, row + n, i))
        all_residuals.extend(r)
        row += n

    for body in bodies.values():
        if not body.grounded:
            all_residuals.append(body.quat_norm_residual())

    return all_residuals, residual_ranges


# ============================================================================
# Per-entity DOF tests
# ============================================================================


class TestPerEntityDOF:
    """Per-entity DOF computation."""

    def test_unconstrained_body_6dof(self):
        """Unconstrained non-grounded body has 6 DOF."""
        params = ParamTable()
        body = RigidBody(
            "b", params, position=(0, 0, 0), quaternion=(1, 0, 0, 0), grounded=False
        )
        bodies = {"b": body}

        # Only quat norm constraint
        residuals = [body.quat_norm_residual()]

        result = per_entity_dof(residuals, params, bodies)
        assert len(result) == 1
        assert result[0].entity_id == "b"
        assert result[0].remaining_dof == 6
        assert len(result[0].free_motions) == 6

    def test_fixed_body_0dof(self):
        """Body welded to ground has 0 DOF."""
        params = ParamTable()
        body_a, body_b = _make_two_bodies(params)
        bodies = {"a": body_a, "b": body_b}

        c = FixedConstraint(
            body_a,
            (0, 0, 0),
            (1, 0, 0, 0),
            body_b,
            (0, 0, 0),
            (1, 0, 0, 0),
        )
        residuals, _ = _build_residuals_and_ranges([c], bodies, params)

        result = per_entity_dof(residuals, params, bodies)
        # Only non-grounded body (b) reported
        assert len(result) == 1
        assert result[0].entity_id == "b"
        assert result[0].remaining_dof == 0
        assert len(result[0].free_motions) == 0

    def test_revolute_1dof(self):
        """Revolute joint leaves 1 DOF (rotation about Z)."""
        params = ParamTable()
        body_a, body_b = _make_two_bodies(params, pos_b=(0, 0, 0))
        bodies = {"a": body_a, "b": body_b}

        c = RevoluteConstraint(
            body_a,
            (0, 0, 0),
            (1, 0, 0, 0),
            body_b,
            (0, 0, 0),
            (1, 0, 0, 0),
        )
        residuals, _ = _build_residuals_and_ranges([c], bodies, params)

        result = per_entity_dof(residuals, params, bodies)
        assert len(result) == 1
        assert result[0].remaining_dof == 1
        # Should have one free motion that mentions rotation
        assert len(result[0].free_motions) == 1
        assert "rotation" in result[0].free_motions[0].lower()

    def test_cylindrical_2dof(self):
        """Cylindrical joint leaves 2 DOF (rotation about Z + translation along Z)."""
        params = ParamTable()
        body_a, body_b = _make_two_bodies(params, pos_b=(0, 0, 0))
        bodies = {"a": body_a, "b": body_b}

        c = CylindricalConstraint(
            body_a,
            (0, 0, 0),
            (1, 0, 0, 0),
            body_b,
            (0, 0, 0),
            (1, 0, 0, 0),
        )
        residuals, _ = _build_residuals_and_ranges([c], bodies, params)

        result = per_entity_dof(residuals, params, bodies)
        assert len(result) == 1
        assert result[0].remaining_dof == 2
        assert len(result[0].free_motions) == 2

    def test_coincident_3dof(self):
        """Coincident (ball) joint leaves 3 DOF (3 rotations)."""
        params = ParamTable()
        body_a, body_b = _make_two_bodies(params, pos_b=(0, 0, 0))
        bodies = {"a": body_a, "b": body_b}

        c = CoincidentConstraint(body_a, (0, 0, 0), body_b, (0, 0, 0))
        residuals, _ = _build_residuals_and_ranges([c], bodies, params)

        result = per_entity_dof(residuals, params, bodies)
        assert len(result) == 1
        assert result[0].remaining_dof == 3
        # All 3 should be rotations
        for motion in result[0].free_motions:
            assert "rotation" in motion.lower()

    def test_no_constraints_6dof(self):
        """No residuals at all gives 6 DOF."""
        params = ParamTable()
        body = RigidBody(
            "b", params, position=(0, 0, 0), quaternion=(1, 0, 0, 0), grounded=False
        )
        bodies = {"b": body}

        result = per_entity_dof([], params, bodies)
        assert len(result) == 1
        assert result[0].remaining_dof == 6

    def test_grounded_body_excluded(self):
        """Grounded bodies are not reported."""
        params = ParamTable()
        body_a, body_b = _make_two_bodies(params)
        bodies = {"a": body_a, "b": body_b}

        residuals = [body_b.quat_norm_residual()]
        result = per_entity_dof(residuals, params, bodies)

        entity_ids = [r.entity_id for r in result]
        assert "a" not in entity_ids  # grounded
        assert "b" in entity_ids

    def test_multiple_bodies(self):
        """Two free bodies: each gets its own DOF report."""
        params = ParamTable()
        body_g = RigidBody(
            "g", params, position=(0, 0, 0), quaternion=(1, 0, 0, 0), grounded=True
        )
        body_b = RigidBody(
            "b", params, position=(5, 0, 0), quaternion=(1, 0, 0, 0), grounded=False
        )
        body_c = RigidBody(
            "c", params, position=(10, 0, 0), quaternion=(1, 0, 0, 0), grounded=False
        )
        bodies = {"g": body_g, "b": body_b, "c": body_c}

        # Fix b to ground, leave c unconstrained
        c_fix = FixedConstraint(
            body_g,
            (0, 0, 0),
            (1, 0, 0, 0),
            body_b,
            (0, 0, 0),
            (1, 0, 0, 0),
        )
        residuals, _ = _build_residuals_and_ranges([c_fix], bodies, params)

        result = per_entity_dof(residuals, params, bodies)
        result_map = {r.entity_id: r for r in result}

        assert result_map["b"].remaining_dof == 0
        assert result_map["c"].remaining_dof == 6


# ============================================================================
# Overconstrained detection tests
# ============================================================================


class TestFindOverconstrained:
    """Redundant and conflicting constraint detection."""

    def test_well_constrained_no_diagnostics(self):
        """Well-constrained system produces no diagnostics."""
        params = ParamTable()
        body_a, body_b = _make_two_bodies(params, pos_b=(0, 0, 0))
        bodies = {"a": body_a, "b": body_b}

        c = FixedConstraint(
            body_a,
            (0, 0, 0),
            (1, 0, 0, 0),
            body_b,
            (0, 0, 0),
            (1, 0, 0, 0),
        )
        residuals, ranges = _build_residuals_and_ranges([c], bodies, params)

        diags = find_overconstrained(residuals, params, ranges)
        assert len(diags) == 0

    def test_duplicate_coincident_redundant(self):
        """Duplicate coincident constraint is flagged as redundant."""
        params = ParamTable()
        body_a, body_b = _make_two_bodies(params, pos_b=(0, 0, 0))
        bodies = {"a": body_a, "b": body_b}

        c1 = CoincidentConstraint(body_a, (0, 0, 0), body_b, (0, 0, 0))
        c2 = CoincidentConstraint(body_a, (0, 0, 0), body_b, (0, 0, 0))
        residuals, ranges = _build_residuals_and_ranges([c1, c2], bodies, params)

        diags = find_overconstrained(residuals, params, ranges)
        assert len(diags) > 0
        # At least one should be redundant
        kinds = {d.kind for d in diags}
        assert "redundant" in kinds

    def test_conflicting_distance(self):
        """Distance constraint that can't be satisfied is flagged as conflicting."""
        params = ParamTable()
        body_a, body_b = _make_two_bodies(params, pos_b=(0, 0, 0))
        bodies = {"a": body_a, "b": body_b}

        # Coincident forces distance=0, but distance constraint says 50
        c1 = CoincidentConstraint(body_a, (0, 0, 0), body_b, (0, 0, 0))
        c2 = DistancePointPointConstraint(
            body_a,
            (0, 0, 0),
            body_b,
            (0, 0, 0),
            distance=50.0,
        )
        residuals, ranges = _build_residuals_and_ranges([c1, c2], bodies, params)

        diags = find_overconstrained(residuals, params, ranges)
        assert len(diags) > 0
        kinds = {d.kind for d in diags}
        assert "conflicting" in kinds

    def test_empty_system_no_diagnostics(self):
        """Empty system has no diagnostics."""
        params = ParamTable()
        diags = find_overconstrained([], params, [])
        assert len(diags) == 0
