"""End-to-end tests for KindredSolver using internal API.

These tests exercise the solver bridge without requiring kcsolve pybind11
bindings (which need a FreeCAD build). Instead we test the internal
pipeline directly.
"""

import math

import pytest
from kindred_solver.constraints import (
    CoincidentConstraint,
    DistancePointPointConstraint,
    FixedConstraint,
)
from kindred_solver.dof import count_dof
from kindred_solver.entities import RigidBody
from kindred_solver.newton import newton_solve
from kindred_solver.params import ParamTable
from kindred_solver.prepass import single_equation_pass, substitution_pass


def _solve_system(bodies, constraint_objs):
    """Helper: run the full solve pipeline on a list of bodies + constraints."""
    params = bodies[0].tx  # hack to get the shared ParamTable
    # Actually, we need the param table. Let's require it as arg.
    raise NotImplementedError


class TestCoincidentSolve:
    def test_two_bodies_coincident(self):
        """Ground body A at origin, body B at (10,0,0).
        Coincident constraint → B moves to origin."""
        pt = ParamTable()
        body_a = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True)
        body_b = RigidBody("b", pt, (10, 0, 0), (1, 0, 0, 0))

        c = CoincidentConstraint(body_a, (0, 0, 0), body_b, (0, 0, 0))

        residuals = list(c.residuals())
        residuals.append(body_b.quat_norm_residual())
        quat_groups = [body_b.quat_param_names()]

        residuals = substitution_pass(residuals, pt)
        residuals = single_equation_pass(residuals, pt)

        converged = newton_solve(residuals, pt, quat_groups=quat_groups)
        assert converged

        env = pt.get_env()
        pos = body_b.extract_position(env)
        assert abs(pos[0]) < 1e-8
        assert abs(pos[1]) < 1e-8
        assert abs(pos[2]) < 1e-8

    def test_coincident_with_markers(self):
        """Body A at origin, body B at (10,0,0).
        Marker on A at local (5,0,0), marker on B at local (-5,0,0).
        After solve, B should be at (10,0,0) since 5+0 == 10-5."""
        pt = ParamTable()
        body_a = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True)
        body_b = RigidBody("b", pt, (20, 0, 0), (1, 0, 0, 0))

        c = CoincidentConstraint(body_a, (5, 0, 0), body_b, (-5, 0, 0))

        residuals = list(c.residuals())
        residuals.append(body_b.quat_norm_residual())
        quat_groups = [body_b.quat_param_names()]

        residuals = substitution_pass(residuals, pt)
        converged = newton_solve(residuals, pt, quat_groups=quat_groups)
        assert converged

        env = pt.get_env()
        pos = body_b.extract_position(env)
        assert abs(pos[0] - 10.0) < 1e-8


class TestDistanceSolve:
    def test_distance_from_origin(self):
        """Ground A at origin, B starts at (1,0,0).
        Distance constraint d=5 → B moves to (5,0,0)."""
        pt = ParamTable()
        body_a = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True)
        body_b = RigidBody("b", pt, (1, 0, 0), (1, 0, 0, 0))

        c = DistancePointPointConstraint(
            body_a,
            (0, 0, 0),
            body_b,
            (0, 0, 0),
            5.0,
        )

        residuals = list(c.residuals())
        residuals.append(body_b.quat_norm_residual())
        quat_groups = [body_b.quat_param_names()]

        residuals = substitution_pass(residuals, pt)
        converged = newton_solve(residuals, pt, quat_groups=quat_groups)
        assert converged

        env = pt.get_env()
        pos = body_b.extract_position(env)
        dist = math.sqrt(pos[0] ** 2 + pos[1] ** 2 + pos[2] ** 2)
        assert abs(dist - 5.0) < 1e-8


class TestFixedSolve:
    def test_fixed_weld(self):
        """Ground A at origin, B at (5,0,0) with identity rotation.
        Fixed constraint with identity markers → B stays at same pose as A."""
        pt = ParamTable()
        body_a = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True)
        body_b = RigidBody("b", pt, (5, 0, 0), (1, 0, 0, 0))

        c = FixedConstraint(
            body_a,
            (0, 0, 0),
            (1, 0, 0, 0),
            body_b,
            (0, 0, 0),
            (1, 0, 0, 0),
        )

        residuals = list(c.residuals())
        residuals.append(body_b.quat_norm_residual())
        quat_groups = [body_b.quat_param_names()]

        residuals = substitution_pass(residuals, pt)
        converged = newton_solve(residuals, pt, quat_groups=quat_groups)
        assert converged

        env = pt.get_env()
        pos = body_b.extract_position(env)
        assert abs(pos[0]) < 1e-8
        assert abs(pos[1]) < 1e-8
        assert abs(pos[2]) < 1e-8

        quat = body_b.extract_quaternion(env)
        assert abs(quat[0] - 1.0) < 1e-8  # w
        assert abs(quat[1]) < 1e-8  # x
        assert abs(quat[2]) < 1e-8  # y
        assert abs(quat[3]) < 1e-8  # z


class TestDOFCounting:
    def test_single_body_unconstrained(self):
        """One non-grounded body: 7 params - 1 quat norm = 6 DOF."""
        pt = ParamTable()
        body = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0))
        residuals = [body.quat_norm_residual()]
        dof = count_dof(residuals, pt)
        assert dof == 6

    def test_two_bodies_coincident(self):
        """Grounded A + free B with coincident: 7 - (3 coincident + 1 quat) = 3 DOF."""
        pt = ParamTable()
        body_a = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True)
        body_b = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0))
        c = CoincidentConstraint(body_a, (0, 0, 0), body_b, (0, 0, 0))
        residuals = list(c.residuals()) + [body_b.quat_norm_residual()]
        residuals = substitution_pass(residuals, pt)
        dof = count_dof(residuals, pt)
        assert dof == 3  # 3 rotation DOF remain

    def test_two_bodies_fixed(self):
        """Grounded A + free B with fixed: 7 - (6 fixed + 1 quat) = 0 DOF."""
        pt = ParamTable()
        body_a = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True)
        body_b = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0))
        c = FixedConstraint(
            body_a,
            (0, 0, 0),
            (1, 0, 0, 0),
            body_b,
            (0, 0, 0),
            (1, 0, 0, 0),
        )
        residuals = list(c.residuals()) + [body_b.quat_norm_residual()]
        residuals = substitution_pass(residuals, pt)
        dof = count_dof(residuals, pt)
        assert dof == 0
