import math
from typing import Optional, Tuple, Callable, Dict, Any, List
    
from panda3d.core import (
    NodePath,
    Vec3,
    Point2,
    Vec4,
    Quat,
    CardMaker,
    GeomNode,
    GeomVertexFormat,
    GeomVertexData,
    GeomVertexWriter,
    GeomTriangles,
    Geom,
    CollisionNode,
    CollisionRay,
    CollisionHandlerQueue,
    CollisionTraverser,
    CollisionTube,
    CollisionSphere,
    CollisionPolygon,
    CollisionEntry,
    Point3,
    Lens,
    PerspectiveLens
)
from direct.showbase.DirectObject import DirectObject
from direct.task import Task
from panda3d.core import BitMask32,LPoint2f
from direct.showbase.ShowBase import ShowBase

from .events import GizmoEvent


class MoveGizmo(DirectObject):
    """
    A Unity-like Move Gizmo for Panda3D.
    Features:
    - 3 Axes (X=Red, Y=Green, Z=Blue) with Cylinder + Cone.
    - Renders on top of other geometry.
    - Handles mouse interaction for dragging.
    """

    def __init__(
        self,
        world:ShowBase,
        camera_np: NodePath = None,
        on_action_committed: Optional[Callable[[Dict[str, Any]], None]] = None,
        event_hooks: Optional[Dict[str, List[Callable[[Dict[str, Any]], None]]]] = None
    ):
        super().__init__()
        self.world = world
        self.is_local = True
        self.debug = False
        self._debug_drag_counter = 0
        self._picker_added = False
        self.is_hovering = False
        # Optional callback to report completed move actions to a higher-level
        # manager (e.g. TransformGizmo) so it can build a global undo stack.
        self.on_action_committed = on_action_committed
        # Event hooks: drag_start / drag_move / drag_end
        self._event_hooks = self._normalize_event_hooks(event_hooks)
        # If camera is not provided, try to find it in the world or base
        self.camera = camera_np if camera_np else getattr(world, 'cam', None)
        if not self.camera and hasattr(world, 'base'):
            self.camera = world.base.cam
        if not self.camera:
            self._log("No camera found")
        else:
            self._log(f"Camera found: {self.camera}")

        self.root = NodePath("MoveGizmo")
        self.color_higher = 0.05
        self.color_normal = 0.01

        self.target_node: Optional[NodePath] = None
        self.attached = False

        # 控制柄外观设置
        self.arrow_cylider_length = 0.45  # 圆柱长度
        self.arrow_cylider_radius = 0.005  # 圆柱粗细
        # 碰撞体半径，可独立于可视半径调整
        self.arrow_cylider_col_radius = max(0.02, self.arrow_cylider_radius * 5.0)
        self.arrow_cone_height = 0.1  # 圆锥高度
        self.arrow_cone_radius = 0.03  # 圆锥半径
        self.arrow_transparency: float = 0.8  # 箭头透明度

        self.panel_transparency: float = 0.65
        self.plane_size: float = 0.1
        self.plane_offset: float = self.plane_size * 0.5 + 0.01

        # Collision setup for picking
        self.picker_ray = CollisionRay()
        self.picker_node = CollisionNode('gizmo_picker_ray')
        self.picker_node.addSolid(self.picker_ray)
        self.picker_node.setFromCollideMask(BitMask32.bit(20))  # Specific mask for gizmo
        self.picker_node.setIntoCollideMask(BitMask32.allOff())
        self.picker_np = NodePath(self.picker_node)

        self.c_trav = CollisionTraverser()
        self.c_queue = CollisionHandlerQueue()

        # Dragging state
        self.dragging: bool = False
        self.drag_axis: int = None  # 0=X, 1=Y, 2=Z
        self.start_mouse: Point2 = None  # NDC Point2
        self.start_node_pos = None
        # param value of closest point on axis at drag start
        self._start_param_on_ray: float = 0.0
        # sign so that dragging along axis arrow matches mouse direction
        self._axis_param_sign: float = 1.0
        self.last_mouse_pos: Point3 = None
        self.drag_axis_dir: Vec3 = Vec3(0, 0, 0)   # world-space axis dir
        self.drag_axis_screen: Point2 = Point2(1, 0)  # axis direction in screen space
        self.drag_base_dist: float = 1.0            # camera distance at drag start

        # Visuals
        self.axes: list[NodePath] = []
        self.planes: list[NodePath] = []  # plane handles: 0=XY,1=YZ,2=ZX
        self.axes_base_colors: list[Tuple[NodePath,Vec4]] = []
        self.planes_base_colors: list[Tuple[NodePath,Vec4]] = []
        # Plane drag state
        self.drag_plane_id: Optional[int] = None    # 0=XY,1=YZ,2=ZX
        self.drag_plane_normal: Vec3 = Vec3(0, 0, 0)
        self.drag_plane_origin: Point3 = Point3(0, 0, 0)  # point on plane at drag start
        # target_pos - hit_point at drag start
        self._plane_drag_offset: Vec3 = Vec3(0, 0, 0)

        # --- Undo stack for move operations ---------------------------------
        # Each entry: (target_node, old_pos(world), new_pos(world))
        self._move_undo_stack: list[Tuple[NodePath, Vec3, Vec3]] = []
        # Minimal distance to consider as a real move (avoid float noise)
        self._undo_pos_epsilon: float = 1e-4

        self._build_gizmo()

        # 初始挂在 world.render，后续通过更新同步到目标
        self.root.reparentTo(self.world.render)
        self.root.setBin('fixed',40)
        self.root.setDepthTest(False)
        self.root.setDepthWrite(False)
        self.root.setLightOff()

        self.root.hide()

        # Update gizmo scale every frame so that it
        # stays roughly the same size on screen
        # taskMgr.add(self._update_task, "MoveGizmoUpdateTask")

    def _log(self, msg: str):
        if self.debug:
            print(f"[MoveGizmo] {msg}")

    def _normalize_event_hooks(self, hooks):
        base = {name: [] for name in GizmoEvent.ALL}
        if not hooks:
            return base
        for name in list(base.keys()):
            cbs = hooks.get(name)
            if cbs:
                base[name] = list(cbs)
        return base

    def _emit_event(self, name: str, payload: Dict[str, Any]):
        handlers = self._event_hooks.get(name, [])
        for cb in handlers:
            try:
                cb(payload)
            except Exception as exc:
                self._log(f"event hook '{name}' error: {exc}")

    def _build_gizmo(self):
        """Builds the 3 axes geometry and plane handles."""
        # Axis params

        # X Axis (Red)
        self.axes.append(self._create_axis(Vec3(1, 0, 0), Vec4(
            1, 0, 0, 1), self.arrow_cylider_length, self.arrow_cylider_radius, self.arrow_cylider_col_radius, self.arrow_cone_height, self.arrow_cone_radius, 0))
        # Y Axis (Green)
        self.axes.append(self._create_axis(Vec3(0, 1, 0), Vec4(
            0, 1, 0, 1), self.arrow_cylider_length, self.arrow_cylider_radius, self.arrow_cylider_col_radius, self.arrow_cone_height, self.arrow_cone_radius, 1))
        # Z Axis (Blue)
        self.axes.append(self._create_axis(Vec3(0, 0, 1), Vec4(
            0, 0, 1, 1), self.arrow_cylider_length, self.arrow_cylider_radius, self.arrow_cylider_col_radius, self.arrow_cone_height, self.arrow_cone_radius, 2))

        # Plane handles: small squares between axes, slightly offset from origin.
        # 0 = XY, 1 = YZ, 2 = ZX
        half = self.plane_size * 0.5
        inner = self.plane_offset + half
        # XY plane：贴近 +X/+Y 象限
        self.planes.append(
            self._create_plane_handle(
                name="plane_xy",
                plane_id=0,
                axis_a=0,
                axis_b=1,
                color=Vec4(1, 1, 0, self.panel_transparency),  # 黄色
                size=self.plane_size,
                # 以平面中心为轴心，让靠近原点的角距离原点 plane_offset
                offset_vec=Vec3(inner, inner, 0.0),
                hpr=Vec3(0, -90, 0),
            )
        )
        # YZ plane (normal +X)
        self.planes.append(
            self._create_plane_handle(
                name="plane_yz",
                plane_id=1,
                axis_a=1,
                axis_b=2,
                color=Vec4(0, 1, 1, self.panel_transparency),  # 青色
                size=self.plane_size,
                offset_vec=Vec3(0.0, inner, inner),
                hpr=Vec3(90, 0, 0),  # rotate XY card to YZ
            )
        )
        # ZX plane (normal +Y)
        self.planes.append(
            self._create_plane_handle(
                name="plane_zx",
                plane_id=2,
                axis_a=2,
                axis_b=0,
                color=Vec4(0.5, 0, 0, self.panel_transparency),  # 深红
                size=self.plane_size,
                offset_vec=Vec3(inner, 0.0, inner),
                # Rotate XY card so it lies in the ZX plane (normal ±Y)
                hpr=Vec3(0, 0, 0),
            )
        )

    def _create_axis(self, direction: Vec3, color: Vec4, length, radius_visual, radius_collision, cone_height, cone_radius, axis_id):
        """Creates a single axis arrow."""
        axis_root:NodePath = self.root.attachNewNode(f"axis_{axis_id}")

        # Align axis (default geometry faces +Z) to the requested direction.
        align_quat = self._quat_from_z(direction)
        axis_root.setQuat(align_quat)
        self._log(
            f"axis {axis_id} dir={direction} quat={align_quat} "
            f"world_dir={axis_root.getQuat(self.world.render).xform(Vec3(0, 0, 1))}"
        )

        # Visual Geometry
        geom = self._create_arrow_geom(length, radius_visual, cone_height, cone_radius, color)
        geom_np: NodePath = axis_root.attachNewNode(geom)
        geom_np.setTransparency(True)
        geom_np.setAlphaScale(self.arrow_transparency)
        self._log(f"created geom_np -> {geom_np}")

        # Collision Geometry (Tube)
        # Tube is defined by point A and B.
        shaft_len = max(length - cone_height, 0.0)
        shaft_radius = max(1e-6, radius_collision)
        c_node = CollisionNode(f"axis_col_{axis_id}")
        # Shaft collider
        if shaft_len > 0.0:
            c_node.addSolid(CollisionTube(
                Point3(0, 0, 0), 
                Point3(0, 0, shaft_len), 
                shaft_radius))
        # Cone collider: a slightly wider tube plus a small sphere at the tip
        if cone_height > 0.0:
            cone_start = Point3(0, 0, shaft_len)
            cone_tip = Point3(0, 0, length)
            cone_radius_pad = max(cone_radius, shaft_radius)
            c_node.addSolid(CollisionTube(cone_start, cone_tip, cone_radius_pad))
            c_node.addSolid(CollisionSphere(cone_tip, cone_radius_pad))
        c_node.setIntoCollideMask(BitMask32.bit(20))
        c_node.setFromCollideMask(BitMask32.allOff())
        c_node.setTag('gizmo_axis', str(axis_id))

        col_np = axis_root.attachNewNode(c_node)
        # col_np.show()  # Debug collision
        self._log(f"created col_np -> {col_np}")

        return axis_root

    def _create_plane_handle(
        self,
        name: str,
        plane_id: int,
        axis_a: int,
        axis_b: int,
        color: Vec4,
        size: float,
        offset_vec: Vec3,
        hpr: Vec3 = Vec3(0, 0, 0),
    ) -> NodePath:
        """
        Create a small square plane handle between two axes.
        plane_id: 0=XY, 1=YZ, 2=ZX
        axis_a / axis_b: not currently used for math, kept for clarity.
        """
        cm = CardMaker(name)
        # Square centered at origin in local X/Z (pivot at center)
        half = size * 0.5
        cm.setFrame(-half, half, -half, half)
        card_np: NodePath = self.root.attachNewNode(cm.generate())
        card_np.setPos(offset_vec)
        card_np.setHpr(hpr)
        card_np.setColor(color)
        card_np.setTransparency(True)
        # Double-sided rendering so the plane looks like Unity handles
        card_np.setTwoSided(True)

        # Collision polygon matching the card in local space
        # Define square in the same local coordinates as the CardMaker frame.
        p0 = Point3(-half, 0.0, -half)
        p1 = Point3(half, 0.0, -half)
        p2 = Point3(half, 0.0, half)
        p3 = Point3(-half, 0.0, half)
        # Create two polygons with opposite winding so the plane is hit
        # from both sides by the picking ray.
        poly_front = CollisionPolygon(p0, p1, p2, p3)
        poly_back = CollisionPolygon(p3, p2, p1, p0)

        c_node = CollisionNode(f"plane_col_{plane_id}")
        c_node.addSolid(poly_front)
        c_node.addSolid(poly_back)
        c_node.setIntoCollideMask(BitMask32.bit(20))
        c_node.setFromCollideMask(BitMask32.allOff())
        c_node.setTag("gizmo_plane", str(plane_id))

        col_np = card_np.attachNewNode(c_node)
        # col_np.show()
        self._log(f"created plane {name} col_np -> {col_np}")

        return card_np

    def _create_arrow_geom(self, length, radius, cone_height, cone_radius, color):
        """Creates the mesh for cylinder + cone."""
        # Using simple line or loading model is easier, but let's try procedural for "no external assets" requirement
        # Actually, Panda has `loader.loadModel("models/misc/cylinder")` but let's make a simple one or use LineSegs for shaft?
        # User requested "Cylinder + Cone".

        # Let's use a helper function to generate a cylinder and cone using GeomVertexWriter
        # For simplicity in this script, I will use a very thin box or simple geometry.
        # Or better, load a basic shape if available, but procedural is safer to avoid missing assets.

        # Simplified: Use Line for shaft (thick) and Triangle fan for cone?
        # No, user wants "Cylinder".

        # Let's use CardMaker for a cross-section look or just build a simple mesh.
        # To save code space, I'll implement a basic procedural cylinder/cone generator.

        vdata = GeomVertexData(
            'name', GeomVertexFormat.getV3c4(), Geom.UHStatic)
        vertex = GeomVertexWriter(vdata, 'vertex')
        vcolor = GeomVertexWriter(vdata, 'color')

        # Shaft (Cylinder) - Simplified as a prism with 8 sides
        segments = 12
        shaft_len = length - cone_height

        def add_circle_verts(z, r):
            start_idx = vertex.getWriteRow()
            for i in range(segments):
                angle = 2 * math.pi * i / segments
                x = r * math.cos(angle)
                y = r * math.sin(angle)
                vertex.addData3(x, y, z)
                vcolor.addData4(color)
            return start_idx

        # Bottom circle
        base_idx = add_circle_verts(0, radius)
        # Top of shaft
        top_shaft_idx = add_circle_verts(shaft_len, radius)

        prim = GeomTriangles(Geom.UHStatic)
        for i in range(segments):
            next_i = (i + 1) % segments
            # Side quads (2 tris)
            prim.addVertices(base_idx + i, base_idx +
                             next_i, top_shaft_idx + next_i)
            prim.addVertices(base_idx + i, top_shaft_idx +
                             next_i, top_shaft_idx + i)

        # Cone base
        cone_base_idx = add_circle_verts(shaft_len, cone_radius)
        # Cone tip
        vertex.addData3(0, 0, length)
        vcolor.addData4(color)
        tip_idx = vertex.getWriteRow() - 1

        # Cone sides
        for i in range(segments):
            next_i = (i + 1) % segments
            prim.addVertices(cone_base_idx + i,
                             cone_base_idx + next_i, tip_idx)
            # Cone bottom cap (optional, usually hidden by shaft)
            prim.addVertices(cone_base_idx + next_i,
                             cone_base_idx + i, top_shaft_idx + i)  # Stitching?

        geom = Geom(vdata)
        geom.addPrimitive(prim)
        node = GeomNode('axis_geom')
        node.addGeom(geom)
        return node

    def _quat_from_z(self, direction: Vec3) -> Quat:
        """Return a quaternion rotating +Z to the given (normalized) direction."""
        dir_norm = Vec3(direction)
        if dir_norm.length_squared() == 0:
            return Quat.identQuat()
        dir_norm.normalize()
        z_axis = Vec3(0, 0, 1)
        dot = z_axis.dot(dir_norm)
        if abs(dot - 1.0) < 1e-6:
            return Quat.identQuat()  # Already aligned
        if abs(dot + 1.0) < 1e-6:
            # Opposite; rotate 180° around any axis perpendicular to Z
            ortho = Vec3(1, 0, 0) if abs(z_axis.dot(
                Vec3(1, 0, 0))) < 0.9 else Vec3(0, 1, 0)
            axis = z_axis.cross(ortho)
            axis.normalize()
            q = Quat()
            q.setFromAxisAngle(180.0, axis)
            return q
        axis = z_axis.cross(dir_norm)
        axis_len = axis.length()
        if axis_len == 0:
            return Quat.identQuat()
        axis.normalize()
        angle_rad = math.atan2(axis_len, dot)
        q = Quat()
        q.setFromAxisAngle(math.degrees(angle_rad), axis)
        return q

    def attach(self, node_path: NodePath):
        """Attach gizmo to a node."""
        if not node_path:
            return
        self.target_node = node_path
        self.attached = True
        self._update_root_transform()
        self.root.show()
        self._log(
            f"attached to {node_path.getName()} world_pos={self.root.getPos(self.world.render)} "
            f"target_hpr={node_path.getHpr(self.world.render)}"
        )
        self._register_events()
        self._reset_planels()

    def detach(self):
        """Detach gizmo."""
        self.target_node = None
        self.root.hide()
        self.attached = False
        self.drag_plane_id = None
        self.is_hovering = False
        self._log("detached")
        self._ignore_events()

    # --- Undo support -------------------------------------------------
    def _record_move_action(self, node: NodePath, old_pos: Vec3, new_pos: Vec3):
        """
        Push a move operation to the undo stack if the position changed
        by more than a small epsilon.
        """
        if not node or node.isEmpty():
            return
        if not old_pos or new_pos is None:
            return
        delta = new_pos - old_pos
        if delta.length() < self._undo_pos_epsilon:
            # Ignore tiny moves caused by float noise
            return
        # Store copies so later modifications don't affect history
        old_copy = Vec3(old_pos)
        new_copy = Vec3(new_pos)
        self._move_undo_stack.append((node, old_copy, new_copy))
        self._log(
            f"record move action: node={node.getName()} "
            f"old_pos={old_copy} new_pos={new_copy} stack_size={len(self._move_undo_stack)}"
        )
        # Report to external listener (e.g. TransformGizmo) to build a
        # unified, cross-gizmo undo history.
        if self.on_action_committed is not None:
            try:
                self.on_action_committed(
                    {
                        "kind": "move",
                        "node": node,
                        "old_pos": Vec3(old_copy),
                        "new_pos": Vec3(new_copy),
                    }
                )
            except Exception as exc:
                # Avoid breaking interaction if user callback fails.
                self._log(f"on_action_committed(move) error: {exc}")

    def undo_last_move(self):
        """
        Undo the last committed move.
        Default hotkey (from Qt -> Panda3D translation): Ctrl+Z => 'control-z'.
        """
        if not self._move_undo_stack:
            self._log("undo_last_move: stack empty")
            return
        node, old_pos, new_pos = self._move_undo_stack.pop()
        if node is None or node.isEmpty():
            self._log("undo_last_move: target node invalid")
            return
        node.setPos(self.world.render, old_pos)
        self._log(
            f"undo_last_move: node={node.getName()} "
            f"old_pos={old_pos} new_pos={new_pos} remaining={len(self._move_undo_stack)}"
        )

    def _register_events(self):
        self.accept("mouse1", self._on_mouse_down)
        self.accept("mouse1-up", self._on_mouse_up)
        self.accept("mouse2-up", self._on_mouse_up)
        self.accept("mouse3-up", self._on_mouse_up)
        self.accept("mouse-move", self._on_mouse_move)
        self._log("event listeners registered (mouse)")

    def _ignore_events(self):
        self.ignore("mouse1")
        self.ignore("mouse1-up")
        self.ignore("mouse2-up")
        self.ignore("mouse3-up")
        self.ignore("mouse-move")
        self._log("event listeners removed")

    # --- Mouse helpers -------------------------------------------------
    def _get_normalized_mouse(self, extra) -> Optional[Point3]:
        """
        Convert Qt pixel coordinates (from QPanda3DWidget) to Panda's
        normalized device coordinates (-1..1), or fall back to
        mouseWatcherNode when available.
        """
        if self.world.mouseWatcherNode.hasMouse():
            mouse = self.world.mouseWatcherNode.getMouse()
            return Point3(mouse.x, mouse.y, 0)
        
        # 1) Extra payload from QPanda3DWidget (pixels)
        if isinstance(extra, dict) and "x" in extra and "y" in extra:
            parent = getattr(self.world, "parent", None)
            if parent is not None:
                w = max(parent.width(), 1)
                h = max(parent.height(), 1)
                nx = (extra["x"] / w) * 2.0 - 1.0
                # Qt origin is top‑left, Panda origin is center with +Y up
                ny = 1.0 - (extra["y"] / h) * 2.0
                return Point3(nx, ny, 0)

        return None

    def _on_mouse_down(self, extra=None):
        if not self.attached or not self.camera:
            return

        # Check for picking
        mpos = self._get_normalized_mouse(extra)

        if mpos is None:
            self._log("mouse_down ignored: no mouse pos")
            return

        # Setup ray
        self.picker_np.reparentTo(self.camera)
        if not self._picker_added:
            self.c_trav.addCollider(self.picker_np, self.c_queue)
            self._picker_added = True
        self.picker_ray.setFromLens(self.camera.node(), mpos.x, mpos.y)

        self.c_queue.clearEntries()
        self.c_trav.traverse(self.root)

        self._log(
            f"mouse_down mpos={mpos} cam_pos={self.camera.getPos(self.world.render)} "
            f"cam_hpr={self.camera.getHpr(self.world.render)} entries={self.c_queue.getNumEntries()}"
        )

        num_entries = self.c_queue.getNumEntries()
        if num_entries > 0:
            self.c_queue.sortEntries()

            # Unity 风格：优先选择平面，其次才是轴。
            plane_entry: Optional[CollisionEntry] = None
            axis_entry: Optional[CollisionEntry] = None
            for i in range(num_entries):
                e: CollisionEntry = self.c_queue.getEntry(i)
                if not plane_entry and e.getIntoNode().getTag("gizmo_plane"):
                    plane_entry = e
                if not axis_entry and e.getIntoNode().getTag("gizmo_axis"):
                    axis_entry = e
                if plane_entry and axis_entry:
                    break

            # Prefer plane hit when available
            if plane_entry is not None:
                entry = plane_entry
                plane_tag: str = entry.getIntoNode().getTag("gizmo_plane")
                # --- Begin plane dragging ---
                # --- Begin plane dragging ---
                self.dragging = True
                self.drag_plane_id = int(plane_tag)  # 0=XY,1=YZ,2=ZX
                # print(f"[Gizmo] drag_plane_id={self.drag_plane_id}")
                self.drag_axis = None

                cam_pos = self.camera.getPos(self.world.render)
                node_pos_w = self.target_node.getPos(self.world.render)
                lens: PerspectiveLens = self.camera.node().getLens()

                # Determine plane normal in world space from the actual plane node,
                # so that dragging follows the visual orientation even when the
                # target node is rotated (local mode).
                plane_np = self.planes[self.drag_plane_id]
                plane_normal: Vec3 = Quat(plane_np.getQuat(self.world.render)).xform(
                    Vec3(0, 1, 0)
                )
                plane_normal.normalize()
                self.drag_plane_normal = plane_normal
                self.drag_plane_origin = node_pos_w
                self.start_node_pos = node_pos_w
                self.start_mouse = Point2(mpos.x, mpos.y)

                # Build initial mouse ray and compute intersection with plane
                p_from = Point3()
                p_to = Point3()
                hit_point = None
                if lens.extrude(Point2(mpos.x, mpos.y), p_from, p_to):
                    ray_origin = self.world.render.getRelativePoint(
                        self.camera, p_from
                    )
                    ray_to = self.world.render.getRelativePoint(
                        self.camera, p_to
                    )
                    ray_dir = ray_to - ray_origin
                    if ray_dir.length_squared() != 0:
                        ray_dir.normalize()
                        denom = ray_dir.dot(plane_normal)
                        if abs(denom) > 1e-6:
                            t = (self.drag_plane_origin -
                                 ray_origin).dot(plane_normal) / denom
                            hit_point = ray_origin + ray_dir * t

                if hit_point is None:
                    # Fallback: use current node position as hit point
                    hit_point = node_pos_w

                self._plane_drag_offset = node_pos_w - hit_point
                self._debug_drag_counter = 0
                self._highlight_plane(self.drag_plane_id)
                self._log(
                    f"pick plane={self.drag_plane_id} target={self.target_node.getName()} "
                    f"plane_origin={self.drag_plane_origin} plane_normal={self.drag_plane_normal} "
                    f"hit_point={hit_point}"
                )
                self._emit_event(
                    GizmoEvent.DRAG_START,
                    {
                        "gizmo": "move",
                        "target": self.target_node,
                        "axis": None,
                        "plane": self.drag_plane_id,
                        "mouse": Point2(mpos.x, mpos.y),
                        "start_pos": self.start_node_pos,
                    },
                )

            elif axis_entry is not None:
                entry = axis_entry
                axis_tag: str = entry.getIntoNode().getTag("gizmo_axis")
                # --- Begin axis dragging (existing behavior) ---
                self.dragging = True
                self.drag_axis = int(axis_tag)
                self.drag_plane_id = None

                # --- Freeze axis direction & screen-space mapping at drag start ---
                axis_index = int(axis_tag)
                axis_np: NodePath = self.axes[axis_index]
                axis_dir: Vec3 = axis_np.getQuat(
                    self.world.render
                ).xform(Vec3(0, 0, 1))
                if axis_dir.length_squared() == 0:
                    axis_dir = Vec3(0, 0, 1)
                axis_dir.normalize()
                cam_pos = self.camera.getPos(self.world.render)
                node_pos_w = self.target_node.getPos(self.world.render)
                view_dir = node_pos_w - cam_pos
                if view_dir.length_squared() != 0:
                    view_dir.normalize()

                self.drag_axis_dir = axis_dir

                lens: PerspectiveLens = self.camera.node().getLens()
                self.drag_base_dist = (cam_pos - node_pos_w).length()
                sample_len = max(self.drag_base_dist * 0.25, 0.25)

                p0_cam = self.camera.getRelativePoint(
                    self.world.render, node_pos_w
                )
                p1_cam = self.camera.getRelativePoint(
                    self.world.render, node_pos_w + axis_dir * sample_len
                )
                p0_2d = Point2()
                p1_2d = Point2()
                if lens.project(p0_cam, p0_2d) and lens.project(p1_cam, p1_2d):
                    axis_screen: Point2 = p1_2d - p0_2d
                    if axis_screen.length_squared() > 0:
                        axis_screen.normalize()
                    else:
                        axis_screen = Point2(1, 0)
                else:
                    axis_screen = Point2(1, 0)
                self.drag_axis_screen = axis_screen

                self.drag_base_dist = max(self.drag_base_dist, 0.5)
                self.start_mouse = Point2(mpos.x, mpos.y)
                self.start_node_pos = node_pos_w
                self.last_mouse_pos = Point3(mpos.x, mpos.y, 0)

                p_from = Point3()
                p_to = Point3()
                if lens.extrude(Point2(mpos.x, mpos.y), p_from, p_to):
                    ray_origin: Point3 = self.world.render.getRelativePoint(
                        self.camera, p_from
                    )
                    ray_to: Point3 = self.world.render.getRelativePoint(
                        self.camera, p_to
                    )
                    ray_dir: Vec3 = ray_to - ray_origin
                    if ray_dir.length_squared() != 0:
                        ray_dir.normalize()
                        axis_origin = self.start_node_pos
                        a = self.drag_axis_dir
                        d = ray_dir
                        w0 = ray_origin - axis_origin
                        a_dot_a = a.dot(a)
                        d_dot_d = d.dot(d)
                        a_dot_d = a.dot(d)
                        a_dot_w0 = a.dot(w0)
                        d_dot_w0 = d.dot(w0)
                        denom = a_dot_a * d_dot_d - a_dot_d * a_dot_d
                        if abs(denom) >= 1e-6:
                            s0 = (a_dot_d * d_dot_w0 -
                                  d_dot_d * a_dot_w0) / denom
                            self._start_param_on_ray = s0
                        else:
                            self._start_param_on_ray = 0.0
                    else:
                        self._start_param_on_ray = 0.0
                else:
                    self._start_param_on_ray = 0.0

                self._debug_drag_counter = 0
                self._highlight_axis(self.drag_axis)
                self._log(
                    f"pick axis={self.drag_axis} target={self.target_node.getName()} "
                    f"start_pos={self.start_node_pos} world_root={self.root.getPos(self.world.render)} "
                    f"axis_dir={axis_dir} axis_screen={self.drag_axis_screen}"
                )
                self._emit_event(
                    GizmoEvent.DRAG_START,
                    {
                        "gizmo": "move",
                        "target": self.target_node,
                        "axis": self.drag_axis,
                        "plane": None,
                        "mouse": Point2(mpos.x, mpos.y),
                        "start_pos": self.start_node_pos,
                    },
                )
            else:
                self._log(
                    "pick hit but no gizmo_axis or gizmo_plane tag on node")
        else:
            self._log("mouse_down: no collision entries on gizmo")

    def _on_mouse_up(self, extra=None):
        if self.dragging and self.target_node is not None:
            # Commit a move action to the undo stack if position changed
            try:
                final_pos = self.target_node.getPos(self.world.render)
            except Exception:
                final_pos = None
            old_pos = self.start_node_pos
            if old_pos is not None and final_pos is not None:
                self._record_move_action(self.target_node, old_pos, final_pos)

            axis_id = self.drag_axis
            plane_id = self.drag_plane_id
            self._emit_event(
                GizmoEvent.DRAG_END,
                {
                    "gizmo": "move",
                    "target": self.target_node,
                    "axis": axis_id,
                    "plane": plane_id,
                    "final_pos": final_pos,
                    "start_pos": old_pos,
                },
            )
            self.dragging = False
            self.drag_axis = None
            self.drag_plane_id = None
            if not self.is_local:
                self.root.setHpr(self.world.render, 0, 0, 0)
            self._reset_highlights()
            self._log("mouse_up -> stop dragging")

        self._reset_planels()

    def _reset_planels(self):
        if not self.camera:
            return

        # 摄像机在 gizmo 局部坐标系中的位置（会跟随 root 旋转）
        local_cam_pos: Vec3 = self.camera.getPos(self.root)

        # 三个轴向的符号：摄像机在 gizmo 局部坐标系里的哪一侧
        sx = 1.0 if local_cam_pos.x >= 0.0 else -1.0
        sy = 1.0 if local_cam_pos.y >= 0.0 else -1.0
        sz = 1.0 if local_cam_pos.z >= 0.0 else -1.0

        o = self.plane_offset

        # XY 平面：在 X/Y 方向选离摄像机最近的那一角，Z 固定 0
        self.planes[0].setPos(sx * o, sy * o, 0.0)

        # YZ 平面：在 Y/Z 方向选离摄像机最近的那一角，X 固定 0
        self.planes[1].setPos(0.0, sy * o, sz * o)

        # ZX 平面：在 Z/X 方向选离摄像机最近的那一角，Y 固定 0
        self.planes[2].setPos(sx * o, 0.0, sz * o)

    def _on_mouse_move(self, extra=None):
        if not self.attached or not self.camera:
            return

        mpos = self._get_normalized_mouse(extra)
        if mpos is None:
            if self.dragging:
                self._log("mouse_move ignored: no mouse pos while dragging")
            return

        # Hover highlight when not dragging.
        if not self.dragging:
            self._update_hover_highlight(mpos)
            return

        if not self.target_node:
            return

        # Build world-space mouse ray from current mouse position
        lens:Lens = self.camera.node().getLens()
        p_from = Point3()
        p_to = Point3()
        if not lens.extrude(Point2(mpos.x, mpos.y), p_from, p_to):
            self._log("mouse_move: lens.extrude failed")
            return

        ray_origin = self.world.render.getRelativePoint(self.camera, p_from)
        ray_to = self.world.render.getRelativePoint(self.camera, p_to)
        ray_dir = ray_to - ray_origin
        if ray_dir.length_squared() == 0:
            return
        ray_dir.normalize()

        # Plane dragging: move freely within the plane defined at drag start.
        if self.drag_plane_id is not None:
            n = self.drag_plane_normal
            if n.length_squared() == 0:
                return
            denom = ray_dir.dot(n)
            if abs(denom) < 1e-6:
                # Ray nearly parallel to plane; skip this move to avoid jumps.
                return
            t = (self.drag_plane_origin - ray_origin).dot(n) / denom
            hit_point = ray_origin + ray_dir * t
            new_pos = hit_point + self._plane_drag_offset
            self.target_node.setPos(self.world.render, new_pos)

            if self._debug_drag_counter % 4 == 0:
                self._log(
                    f"drag plane={self.drag_plane_id} mpos={mpos} "
                    f"ray_origin={ray_origin} ray_dir={ray_dir} "
                    f"plane_origin={self.drag_plane_origin} plane_normal={n} "
                    f"hit_point={hit_point} new_pos={new_pos}"
                )
            self._debug_drag_counter += 1
            self._emit_event(
                GizmoEvent.DRAG_MOVE,
                {
                    "gizmo": "move",
                    "target": self.target_node,
                    "axis": None,
                    "plane": self.drag_plane_id,
                    "mouse": Point2(mpos.x, mpos.y),
                    "new_pos": new_pos,
                },
            )
            return

        # --- 3D-based drag along axis: use closest point between mouse ray and axis line ---
        axis_dir = self.drag_axis_dir
        if axis_dir.length_squared() == 0:
            return

        axis_origin = self.start_node_pos
        a = axis_dir
        d = ray_dir
        w0 = ray_origin - axis_origin

        a_dot_a = a.dot(a)
        d_dot_d = d.dot(d)
        a_dot_d = a.dot(d)
        a_dot_w0 = a.dot(w0)
        d_dot_w0 = d.dot(w0)

        denom = a_dot_a * d_dot_d - a_dot_d * a_dot_d
        if abs(denom) < 1e-6:
            mouse_ndc = Point2(mpos.x, mpos.y)
            delta = mouse_ndc - self.start_mouse
            s_scalar = delta.dot(self.drag_axis_screen)
            move_amount = s_scalar * self.drag_base_dist * 0.5
            new_pos = axis_origin + a * move_amount
            self._log(
                f"drag fallback (parallel): s={s_scalar:.4f} move={move_amount:.4f}"
            )
        else:
            s = (a_dot_d * d_dot_w0 - d_dot_d * a_dot_w0) / denom
            delta_s = self._start_param_on_ray - s
            new_pos = axis_origin + a * delta_s

        self.target_node.setPos(self.world.render, new_pos)

        if self._debug_drag_counter % 4 == 0:
            self._log(
                f"drag axis={self.drag_axis} mpos={mpos} "
                f"ray_origin={ray_origin} ray_dir={ray_dir} "
                f"axis_origin={axis_origin} axis_dir={axis_dir} new_pos={new_pos}"
            )
        self._debug_drag_counter += 1
        self._emit_event(
            GizmoEvent.DRAG_MOVE,
            {
                "gizmo": "move",
                "target": self.target_node,
                "axis": self.drag_axis,
                "plane": None,
                "mouse": Point2(mpos.x, mpos.y),
                "new_pos": new_pos,
            },
        )

    def _update_hover_highlight(self, mpos: Point3):
        """Highlight the handle under cursor during hover (no drag)."""
        self.picker_np.reparentTo(self.camera)
        if not self._picker_added:
            self.c_trav.addCollider(self.picker_np, self.c_queue)
            self._picker_added = True
        self.picker_ray.setFromLens(self.camera.node(), mpos.x, mpos.y)

        self.c_queue.clearEntries()
        self.c_trav.traverse(self.root)

        num_entries = self.c_queue.getNumEntries()
        if num_entries <= 0:
            self._reset_highlights()
            self.is_hovering = False
            return

        self.c_queue.sortEntries()

        plane_entry: Optional[CollisionEntry] = None
        axis_entry: Optional[CollisionEntry] = None
        for i in range(num_entries):
            e = self.c_queue.getEntry(i)
            if plane_entry is None and e.getIntoNode().getTag("gizmo_plane"):
                plane_entry = e
            if axis_entry is None and e.getIntoNode().getTag("gizmo_axis"):
                axis_entry = e
            if plane_entry and axis_entry:
                break

        if plane_entry is not None:
            plane_tag = plane_entry.getIntoNode().getTag("gizmo_plane")
            self._highlight_plane(int(plane_tag))
            self.is_hovering = True
        elif axis_entry is not None:
            axis_tag = axis_entry.getIntoNode().getTag("gizmo_axis")
            self._highlight_axis(int(axis_tag))
            self.is_hovering = True
        else:
            self._reset_highlights()
            self.is_hovering = False

    def _highlight_axis(self, axis_id):
        """高亮被选中的轴，其他轴和平面变暗。"""
        for i, ax in enumerate(self.axes):
            if i == axis_id:
                ax.setColor(1, 1, 0, 1)
                ax.clearColorScale()
            else:
                ax.clearColor()
                ax.setAlphaScale(0.3)
        # 所有平面也变暗
        for plane in self.planes:
            plane.setAlphaScale(0.3)

    def _reset_highlights(self):
        """重置所有高亮效果。"""
        for ax in self.axes:
            ax.clearColor()
            ax.clearColorScale()
            ax.setAlphaScale(self.arrow_transparency)
        for plane in self.planes:
            plane.clearColorScale()
            plane.setAlphaScale(self.panel_transparency)

    def _highlight_plane(self, plane_id: int):
        """高亮被选中的平面，其他平面和轴变暗。"""
        for i, plane in enumerate(self.planes):
            if i == plane_id:
                plane.setColorScale(1.6, 1.6, 1.6, 2.0)
            else:
                plane.setAlphaScale(0.3)
        # 所有轴也变暗
        for ax in self.axes:
            ax.clearColor()
            ax.setAlphaScale(0.3)

    def update(self):
        """Update gizmo scale based on camera distance."""
        if self.world.mouseWatcherNode.has_mouse():
            current_mouse = LPoint2f(self.world.mouseWatcherNode.get_mouse())
            if current_mouse != self.last_mouse_pos:
                self._on_mouse_move()
                self.last_mouse_pos = current_mouse
        if self.attached and self.camera:
            self._update_root_transform()
            dist = (self.camera.getPos(self.world.render) -
                    self.root.getPos(self.world.render)).length()
            scale = dist * 0.15  # Constant screen size factor
            self.root.setScale(self.world.render, scale)

    def _update_task(self, task: Task):
        """Panda3D task that keeps the gizmo updated every frame."""
        self.update()
        return Task.cont

    # ------------------------------------------------------------------ #
    # Internal transform sync
    # ------------------------------------------------------------------ #
    def _update_root_transform(self):
        """Keep gizmo root aligned to target without inheriting its render state."""
        if not self.attached or not self.target_node:
            return
        render = self.world.render
        tgt = self.target_node
        self.root.setPos(render, tgt.getPos(render))
        if self.is_local:
            self.root.setQuat(render, tgt.getQuat(render))
        else:
            self.root.setQuat(render, Quat.identQuat())
