#!/usr/bin/env python3
"""
robot_fk.py
-----------
Minimal forward kinematics engine for the robot.json format.

Matches the Blender hierarchy used by render_robot.py exactly:
  world_T_link = world_T_parent
                 @ Translate(mountPosition) @ Rotate_xyz(mountRotation)
                 @ Translate(jointOrigin)   @ Rotate_xyz(joint.rotation)
                 @ T_motion

  T_motion =  Rotate(axis, value_deg)   for revolute joints
              Translate(axis * value_mm) for linear joints

Units throughout: millimetres, degrees.

Public API
----------
fk = RobotFK.from_file("robot.json")

transforms = fk.compute({"x": 180, "y": 86, "z": -120,
                          "a": -60, "b": 22, "c": 91, "e": 10})
# → dict  link_name -> 4×4 np.ndarray (world frame, mm)

p_world = fk.marker_world(transforms, "Arm1", [0, -160, 35])
# → np.ndarray shape (3,), in mm

all_m = fk.all_markers_world(transforms)
# → dict  marker_id -> {"world_mm", "link", "local_mm"}

# Cumulative x-offset for a link at all-zero state
# (useful for 4a: x_slider = world_x - local_x - link_x_at_zero)
x0 = fk.link_x_at_zero_state("Arm1")   # → float mm
"""

from __future__ import annotations

import json
import math
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple

import numpy as np

STATE_KEYS = ("x", "y", "z", "a", "b", "c", "e")


# ──────────────────────────────────────────────────────────────
# Low-level matrix helpers
# ──────────────────────────────────────────────────────────────

def _rot_axis_angle(axis: Sequence[float], angle_deg: float) -> np.ndarray:
    """3×3 rotation matrix via Rodrigues (axis need not be normalised)."""
    ax = np.asarray(axis, dtype=float)
    n = float(np.linalg.norm(ax))
    if n < 1e-12:
        return np.eye(3)
    ax = ax / n
    c = math.cos(math.radians(angle_deg))
    s = math.sin(math.radians(angle_deg))
    t = 1.0 - c
    x, y, z = ax
    return np.array([
        [t*x*x + c,   t*x*y - s*z, t*x*z + s*y],
        [t*x*y + s*z, t*y*y + c,   t*y*z - s*x],
        [t*x*z - s*y, t*y*z + s*x, t*z*z + c  ],
    ])


def _rot_xyz_euler(rx: float, ry: float, rz: float) -> np.ndarray:
    """XYZ Euler angles (degrees) → 3×3 — matches Blender XYZ Euler mode."""
    return (_rot_axis_angle([0, 0, 1], rz)
            @ _rot_axis_angle([0, 1, 0], ry)
            @ _rot_axis_angle([1, 0, 0], rx))


def make_T(R: np.ndarray, t: Sequence[float]) -> np.ndarray:
    """4×4 homogeneous transform."""
    T = np.eye(4)
    T[:3, :3] = R
    T[:3, 3] = np.asarray(t, dtype=float)
    return T


def transform_point(T: np.ndarray, p: Sequence[float]) -> np.ndarray:
    """Apply 4×4 transform to a 3-D point."""
    h = np.array([p[0], p[1], p[2], 1.0])
    return (T @ h)[:3]


# ──────────────────────────────────────────────────────────────
# FK engine
# ──────────────────────────────────────────────────────────────

class RobotFK:
    """Forward kinematics for the robot.json format."""

    def __init__(self, robot_data: Dict[str, Any]):
        self.robot = robot_data
        self.links: Dict[str, Any] = robot_data.get("links", {})
        self._topo: List[str] = self._topological_sort()

    # ── construction ─────────────────────────────────────────

    @classmethod
    def from_file(cls, path) -> "RobotFK":
        with open(path, "r", encoding="utf-8") as f:
            return cls(json.load(f))

    def _topological_sort(self) -> List[str]:
        parent_map = {n: d.get("parent") for n, d in self.links.items()}
        visited, order = set(), []

        def visit(name: str) -> None:
            if name in visited:
                return
            visited.add(name)
            p = parent_map.get(name)
            if p and p in self.links:
                visit(p)
            order.append(name)

        for name in self.links:
            visit(name)
        return order

    # ── core computation ──────────────────────────────────────

    def compute(self, joint_values: Dict[str, float]) -> Dict[str, np.ndarray]:
        """
        Compute link world transforms for the given joint state.

        Parameters
        ----------
        joint_values : dict  variable_name -> value
            Linear joints (x, e): mm
            Revolute joints (y, z, a, b, c): degrees

        Returns
        -------
        dict  link_name -> 4×4 np.ndarray (world frame, mm)
        """
        state = {k: 0.0 for k in STATE_KEYS}
        for k, v in joint_values.items():
            if k in state:
                state[k] = float(v)

        transforms: Dict[str, np.ndarray] = {}

        for link_name in self._topo:
            d = self.links[link_name]
            parent = d.get("parent")
            T_parent = transforms.get(parent, np.eye(4)) if parent else np.eye(4)

            # 1 · Mount (static in parent frame)
            mp  = d.get("mountPosition", [0, 0, 0])
            mr  = d.get("mountRotation", [0, 0, 0])
            T_m = make_T(_rot_xyz_euler(*mr), mp)

            # 2 · Joint origin (pivot point in mount frame)
            ji  = d.get("jointToParent", {}) or {}
            jp  = ji.get("origin", [0, 0, 0])
            jr  = ji.get("rotation", [0, 0, 0])
            T_j = make_T(_rot_xyz_euler(*jr), jp)

            # 3 · Motion
            jtype = str(ji.get("type", "fixed")).lower()
            var   = str(ji.get("variable", "")).lower()
            axis  = np.asarray(ji.get("axis", [1, 0, 0]), dtype=float)
            val   = state.get(var, 0.0)

            if jtype == "revolute":
                T_mot = make_T(_rot_axis_angle(axis, val), [0, 0, 0])
            elif jtype == "linear":
                n = float(np.linalg.norm(axis))
                u = axis / n if n > 1e-12 else np.array([1.0, 0, 0])
                T_mot = make_T(np.eye(3), u * val)
            else:
                T_mot = np.eye(4)

            transforms[link_name] = T_parent @ T_m @ T_j @ T_mot

        return transforms

    # ── marker helpers ────────────────────────────────────────

    @staticmethod
    def marker_world(transforms: Dict[str, np.ndarray],
                     link_name: str,
                     local_pos: Sequence[float]) -> np.ndarray:
        """Transform a local marker position → world (mm)."""
        return transform_point(transforms.get(link_name, np.eye(4)), local_pos)

    def all_markers_world(self,
                          transforms: Dict[str, np.ndarray]
                          ) -> Dict[int, Dict[str, Any]]:
        """
        Returns
        -------
        dict  marker_id -> {world_mm, local_mm, link, normal_world}
        """
        result: Dict[int, Dict[str, Any]] = {}
        for lname, ldata in self.links.items():
            T = transforms.get(lname, np.eye(4))
            R = T[:3, :3]
            for m in ldata.get("markers", []):
                mid = int(m.get("id", -1))
                if mid < 0 or "position" not in m:
                    continue
                loc = np.array(m["position"], dtype=float)
                nor = np.array(m.get("normal", [0, 0, 1]), dtype=float)
                result[mid] = {
                    "world_mm":    transform_point(T, loc),
                    "local_mm":    loc,
                    "link":        lname,
                    "normal_world": (R @ nor) / max(np.linalg.norm(R @ nor), 1e-12),
                }
        return result

    # ── x-axis invariant helpers (used by 4a) ────────────────

    def link_x_at_zero_state(self, link_name: str) -> float:
        """
        Return the world x-coordinate of the link-frame origin
        when ALL joint variables are zero.

        For any link reachable via only x-axis rotations (Arm1, Ellbow, Arm2),
        this value is constant regardless of the actual revolute angles.
        Adding the slider value x_mm gives the true link origin x:
            link_origin_world_x = x_mm + link_x_at_zero_state(link_name)

        And for any marker in that link:
            marker_world_x = x_mm + link_x_at_zero_state(link_name) + marker_local_x
        """
        T = self.compute({k: 0.0 for k in STATE_KEYS})
        return float(T[link_name][0, 3])

    def joint_origin_world(self,
                            link_name: str,
                            joint_state: Dict[str, float]) -> np.ndarray:
        """
        World position of the pivot that link_name rotates / slides around.
        """
        d = self.links[link_name]
        parent = d.get("parent")
        T_all = self.compute(joint_state)
        T_parent = T_all.get(parent, np.eye(4)) if parent else np.eye(4)

        mp  = d.get("mountPosition", [0, 0, 0])
        mr  = d.get("mountRotation", [0, 0, 0])
        T_m = make_T(_rot_xyz_euler(*mr), mp)

        ji  = d.get("jointToParent", {}) or {}
        jp  = ji.get("origin", [0, 0, 0])
        jr  = ji.get("rotation", [0, 0, 0])
        T_j = make_T(_rot_xyz_euler(*jr), jp)

        return transform_point(T_parent @ T_m @ T_j, [0, 0, 0])

    def joint_axis_world(self,
                          link_name: str,
                          joint_state: Dict[str, float]) -> np.ndarray:
        """
        Joint axis of link_name expressed in world frame.
        """
        d = self.links[link_name]
        parent = d.get("parent")
        T_all = self.compute(joint_state)
        T_parent = T_all.get(parent, np.eye(4)) if parent else np.eye(4)

        mp  = d.get("mountPosition", [0, 0, 0])
        mr  = d.get("mountRotation", [0, 0, 0])
        T_m = make_T(_rot_xyz_euler(*mr), mp)

        ji  = d.get("jointToParent", {}) or {}
        jp  = ji.get("origin", [0, 0, 0])
        jr  = ji.get("rotation", [0, 0, 0])
        T_j = make_T(_rot_xyz_euler(*jr), jp)

        R_to_pivot = (T_parent @ T_m @ T_j)[:3, :3]
        axis_local = np.asarray(ji.get("axis", [1, 0, 0]), dtype=float)
        world = R_to_pivot @ axis_local
        n = float(np.linalg.norm(world))
        return world / n if n > 1e-12 else world

    # ── utility ───────────────────────────────────────────────

    def chain(self, link_name: str) -> List[str]:
        """Return chain from root to link_name (inclusive)."""
        out, cur = [], link_name
        while cur:
            out.append(cur)
            cur = self.links.get(cur, {}).get("parent")
        return list(reversed(out))

    def board_marker_world_positions(self, length_scale: float = 1.0) -> Dict[int, np.ndarray]:
        """
        Return known world positions for all Board markers (in mm).
        Board is the root, so its marker positions ARE world positions.
        length_scale: 1/1000 if robot.json uses mm.
        """
        board = self.links.get("Board", {})
        result: Dict[int, np.ndarray] = {}
        for m in board.get("markers", []):
            mid = int(m.get("id", -1))
            if mid >= 0 and "position" in m:
                p = np.array(m["position"], dtype=float) * length_scale
                result[mid] = p
        return result
