"""
Phase 5 in-client console tests.

Paste into the FreeCAD Python console (or run via: exec(open(...).read())).
Tests the full Assembly -> KindredSolver pipeline without the unittest harness.

Expected output: all lines print PASS. Any FAIL indicates a regression.
"""

import FreeCAD as App
import JointObject
import kcsolve

_pref = App.ParamGet("User parameter:BaseApp/Preferences/Mod/Assembly")
_orig_solver = _pref.GetString("Solver", "")

_results = []


def _report(name, passed, detail=""):
    status = "PASS" if passed else "FAIL"
    msg = f"  [{status}] {name}"
    if detail:
        msg += f" — {detail}"
    print(msg)
    _results.append((name, passed))


def _new_doc(name="Phase5Test"):
    if App.ActiveDocument and App.ActiveDocument.Name == name:
        App.closeDocument(name)
    App.newDocument(name)
    App.setActiveDocument(name)
    return App.ActiveDocument


def _cleanup(doc):
    App.closeDocument(doc.Name)


def _make_assembly(doc):
    asm = doc.addObject("Assembly::AssemblyObject", "Assembly")
    asm.resetSolver()
    jg = asm.newObject("Assembly::JointGroup", "Joints")
    return asm, jg


def _make_box(asm, x=0, y=0, z=0, size=10):
    box = asm.newObject("Part::Box", "Box")
    box.Length = size
    box.Width = size
    box.Height = size
    box.Placement = App.Placement(App.Vector(x, y, z), App.Rotation())
    return box


def _ground(jg, obj):
    gnd = jg.newObject("App::FeaturePython", "GroundedJoint")
    JointObject.GroundedJoint(gnd, obj)
    return gnd


def _make_joint(jg, joint_type, ref1, ref2):
    joint = jg.newObject("App::FeaturePython", "Joint")
    JointObject.Joint(joint, joint_type)
    refs = [[ref1[0], ref1[1]], [ref2[0], ref2[1]]]
    joint.Proxy.setJointConnectors(joint, refs)
    return joint


# ── Test 1: Registry ────────────────────────────────────────────────


def test_solver_registry():
    """Verify kindred solver is registered and available."""
    names = kcsolve.available()
    _report(
        "registry: kindred in available()", "kindred" in names, f"available={names}"
    )

    solver = kcsolve.load("kindred")
    _report("registry: load('kindred') succeeds", solver is not None)
    _report(
        "registry: solver name",
        solver.name() == "Kindred (Newton-Raphson)",
        f"got '{solver.name()}'",
    )

    joints = solver.supported_joints()
    _report(
        "registry: supported_joints non-empty",
        len(joints) > 0,
        f"{len(joints)} joint types",
    )


# ── Test 2: Preference switching ────────────────────────────────────


def test_preference_switching():
    """Verify solver preference controls which backend is used."""
    doc = _new_doc("PrefTest")
    try:
        # Set to kindred
        _pref.SetString("Solver", "kindred")
        asm, jg = _make_assembly(doc)

        box1 = _make_box(asm, 0, 0, 0)
        box2 = _make_box(asm, 50, 0, 0)
        _ground(box1)
        _make_joint(jg, 0, [box1, ["Face6", "Vertex7"]], [box2, ["Face6", "Vertex7"]])

        result = asm.solve()
        _report(
            "pref: kindred solve succeeds", result == 0, f"solve() returned {result}"
        )

        # Switch back to ondsel
        _pref.SetString("Solver", "ondsel")
        asm.resetSolver()
        result2 = asm.solve()
        _report(
            "pref: ondsel solve succeeds after switch",
            result2 == 0,
            f"solve() returned {result2}",
        )
    finally:
        _cleanup(doc)


# ── Test 3: Fixed joint ─────────────────────────────────────────────


def test_fixed_joint():
    """Two boxes + ground + fixed joint -> placements match."""
    _pref.SetString("Solver", "kindred")
    doc = _new_doc("FixedTest")
    try:
        asm, jg = _make_assembly(doc)
        box1 = _make_box(asm, 10, 20, 30)
        box2 = _make_box(asm, 40, 50, 60)
        _ground(box2)
        _make_joint(jg, 0, [box2, ["Face6", "Vertex7"]], [box1, ["Face6", "Vertex7"]])

        same = box1.Placement.isSame(box2.Placement, 1e-6)
        _report(
            "fixed: box1 matches box2 placement",
            same,
            f"box1={box1.Placement.Base}, box2={box2.Placement.Base}",
        )
    finally:
        _cleanup(doc)


# ── Test 4: Revolute joint + DOF ─────────────────────────────────────


def test_revolute_dof():
    """Revolute joint -> solve succeeds, DOF = 1."""
    _pref.SetString("Solver", "kindred")
    doc = _new_doc("RevoluteTest")
    try:
        asm, jg = _make_assembly(doc)
        box1 = _make_box(asm, 0, 0, 0)
        box2 = _make_box(asm, 100, 0, 0)
        _ground(box1)
        _make_joint(jg, 1, [box1, ["Face6", "Vertex7"]], [box2, ["Face6", "Vertex7"]])

        result = asm.solve()
        _report("revolute: solve succeeds", result == 0, f"solve() returned {result}")

        dof = asm.getLastDoF()
        _report("revolute: DOF = 1", dof == 1, f"DOF = {dof}")
    finally:
        _cleanup(doc)


# ── Test 5: No ground ───────────────────────────────────────────────


def test_no_ground():
    """No grounded parts -> returns -6."""
    _pref.SetString("Solver", "kindred")
    doc = _new_doc("NoGroundTest")
    try:
        asm, jg = _make_assembly(doc)
        box1 = _make_box(asm, 0, 0, 0)
        box2 = _make_box(asm, 50, 0, 0)

        joint = jg.newObject("App::FeaturePython", "Joint")
        JointObject.Joint(joint, 0)
        refs = [[box1, ["Face6", "Vertex7"]], [box2, ["Face6", "Vertex7"]]]
        joint.Proxy.setJointConnectors(joint, refs)

        result = asm.solve()
        _report("no-ground: returns -6", result == -6, f"solve() returned {result}")
    finally:
        _cleanup(doc)


# ── Test 6: Solve stability ─────────────────────────────────────────


def test_stability():
    """Solving twice gives identical placements."""
    _pref.SetString("Solver", "kindred")
    doc = _new_doc("StabilityTest")
    try:
        asm, jg = _make_assembly(doc)
        box1 = _make_box(asm, 10, 20, 30)
        box2 = _make_box(asm, 40, 50, 60)
        _ground(box2)
        _make_joint(jg, 0, [box2, ["Face6", "Vertex7"]], [box1, ["Face6", "Vertex7"]])

        asm.solve()
        plc1 = App.Placement(box1.Placement)
        asm.solve()
        plc2 = box1.Placement

        same = plc1.isSame(plc2, 1e-6)
        _report("stability: two solves identical", same)
    finally:
        _cleanup(doc)


# ── Test 7: Standalone solver API ────────────────────────────────────


def test_standalone_api():
    """Use kcsolve types directly without FreeCAD Assembly objects."""
    solver = kcsolve.load("kindred")

    # Two parts: one grounded, one free
    p1 = kcsolve.Part()
    p1.id = "base"
    p1.placement = kcsolve.Transform.identity()
    p1.grounded = True

    p2 = kcsolve.Part()
    p2.id = "arm"
    p2.placement = kcsolve.Transform()
    p2.placement.position = [100.0, 0.0, 0.0]
    p2.placement.quaternion = [1.0, 0.0, 0.0, 0.0]
    p2.grounded = False

    # Fixed joint
    c = kcsolve.Constraint()
    c.id = "fix1"
    c.part_i = "base"
    c.marker_i = kcsolve.Transform.identity()
    c.part_j = "arm"
    c.marker_j = kcsolve.Transform.identity()
    c.type = kcsolve.BaseJointKind.Fixed

    ctx = kcsolve.SolveContext()
    ctx.parts = [p1, p2]
    ctx.constraints = [c]

    result = solver.solve(ctx)
    _report(
        "standalone: solve status",
        result.status == kcsolve.SolveStatus.Success,
        f"status={result.status}",
    )
    _report("standalone: DOF = 0", result.dof == 0, f"dof={result.dof}")

    # Check that arm moved to origin
    for pr in result.placements:
        if pr.id == "arm":
            dist = sum(x**2 for x in pr.placement.position) ** 0.5
            _report("standalone: arm at origin", dist < 1e-6, f"distance={dist:.2e}")
            break


# ── Test 8: Diagnose API ────────────────────────────────────────────


def test_diagnose():
    """Diagnose overconstrained system via standalone API."""
    solver = kcsolve.load("kindred")

    p1 = kcsolve.Part()
    p1.id = "base"
    p1.placement = kcsolve.Transform.identity()
    p1.grounded = True

    p2 = kcsolve.Part()
    p2.id = "arm"
    p2.placement = kcsolve.Transform()
    p2.placement.position = [50.0, 0.0, 0.0]
    p2.placement.quaternion = [1.0, 0.0, 0.0, 0.0]
    p2.grounded = False

    # Two fixed joints = overconstrained
    c1 = kcsolve.Constraint()
    c1.id = "fix1"
    c1.part_i = "base"
    c1.marker_i = kcsolve.Transform.identity()
    c1.part_j = "arm"
    c1.marker_j = kcsolve.Transform.identity()
    c1.type = kcsolve.BaseJointKind.Fixed

    c2 = kcsolve.Constraint()
    c2.id = "fix2"
    c2.part_i = "base"
    c2.marker_i = kcsolve.Transform.identity()
    c2.part_j = "arm"
    c2.marker_j = kcsolve.Transform.identity()
    c2.type = kcsolve.BaseJointKind.Fixed

    ctx = kcsolve.SolveContext()
    ctx.parts = [p1, p2]
    ctx.constraints = [c1, c2]

    diags = solver.diagnose(ctx)
    _report(
        "diagnose: returns diagnostics", len(diags) > 0, f"{len(diags)} diagnostic(s)"
    )
    if diags:
        kinds = [d.kind for d in diags]
        _report(
            "diagnose: found redundant",
            kcsolve.DiagnosticKind.Redundant in kinds,
            f"kinds={[str(k) for k in kinds]}",
        )


# ── Run all ──────────────────────────────────────────────────────────


def run_all():
    print("\n=== Phase 5 Console Tests ===\n")

    test_solver_registry()
    test_preference_switching()
    test_fixed_joint()
    test_revolute_dof()
    test_no_ground()
    test_stability()
    test_standalone_api()
    test_diagnose()

    # Restore original preference
    _pref.SetString("Solver", _orig_solver)

    # Summary
    passed = sum(1 for _, p in _results if p)
    total = len(_results)
    print(f"\n=== {passed}/{total} passed ===\n")
    if passed < total:
        failed = [name for name, p in _results if not p]
        print(f"FAILED: {', '.join(failed)}")


run_all()
