"""
ContentNode class with C bindings to Go implementation.
"""

import json
import weakref
from typing import Optional, Any, Dict, List
from .._native import lib, ffi
from ..errors import check_error
from .content_feature import ContentFeature
from .tag import Tag


class ContentNode:
    """
    ContentNode represents a node in the document tree.
    
    This class provides a Python interface to the Go ContentNode implementation,
    using C bindings for communication.
    """
    
    def __init__(self, handle=None, document=None, node_type: Optional[str] = None, 
                 content: Optional[str] = None, index: Optional[int] = None):
        """
        Initialize a ContentNode.
        
        Args:
            handle: Internal handle for existing nodes (do not use directly)
            document: Parent document for creating new nodes
            node_type: Type of node (e.g., 'paragraph', 'line', 'word')
            content: Text content of the node
            index: Optional index for ordering
        """
        self._handle = handle
        self._document = document
        self._closed = False
        
        # If no handle provided but we have document and node_type, create new node
        if handle is None and document is not None and node_type is not None:
            # Create through document
            content_bytes = (content or "").encode('utf-8') if content is not None else b""
            node_type_bytes = node_type.encode('utf-8')
            
            # Use CreateContentNode with index parameter
            index_val = index if index is not None else -1
            handle = lib.CreateContentNode(
                document._handle,
                node_type_bytes,
                content_bytes,
                index_val
            )
            check_error()
            
            if handle == 0:
                raise RuntimeError("Failed to create content node")
            
            self._handle = handle
            
        # Set up finalizer for cleanup
        if self._handle:
            self._finalizer = weakref.finalize(self, self._cleanup_handle, self._handle)
    
    @staticmethod
    def _cleanup_handle(handle):
        """Clean up a handle when object is garbage collected."""
        if handle and handle != 0:
            try:
                lib.FreeHandle(handle)
            except:
                pass  # Ignore errors during cleanup
    
    @classmethod
    def _from_handle(cls, handle):
        """Create a ContentNode instance from an existing handle."""
        if handle == 0:
            return None
        
        # Create wrapper instance without going through __init__
        node = cls.__new__(cls)
        node._handle = handle
        node._document = None  # Will be set by caller if needed
        node._closed = False
        node._finalizer = weakref.finalize(node, cls._cleanup_handle, handle)
        return node
    
    def _check_not_closed(self):
        """Check that the node hasn't been closed."""
        if self._closed:
            raise ValueError("ContentNode has been closed")
        if not self._handle:
            raise ValueError("ContentNode has no handle")

    @property
    def document(self):
        """
        Get the document this node belongs to (legacy_python parity).

        Returns:
            The Document instance, or None if not set.
        """
        return self._document

    @property
    def node_type(self) -> str:
        """Get the node type."""
        self._check_not_closed()
        result = lib.GetNodeType(self._handle)
        check_error()
        if result == ffi.NULL:
            return ""
        return ffi.string(result).decode('utf-8')
    
    @node_type.setter
    def node_type(self, value: str):
        """Set the node type (legacy_python compatibility)."""
        self.set_node_type(value)
    
    
    @property
    def content(self) -> str:
        """Get the node content."""
        self._check_not_closed()
        result = lib.GetNodeContent(self._handle)
        check_error()
        if result == ffi.NULL:
            return ""
        return ffi.string(result).decode('utf-8')
    
    @content.setter
    def content(self, value: str):
        """Set the node content."""
        self._check_not_closed()
        content_bytes = value.encode('utf-8') if value else b""
        success = lib.SetNodeContent(self._handle, content_bytes)
        check_error()
        if not success:
            raise RuntimeError("Failed to set node content")
    
    @property
    def index(self) -> Optional[int]:
        """Get the node index."""
        self._check_not_closed()
        idx = lib.GetNodeIndex(self._handle)
        check_error()
        return idx if idx >= 0 else None
    
    @index.setter
    def index(self, value: Optional[int]):
        """Set the node index."""
        self._check_not_closed()
        idx_val = value if value is not None else -1
        success = lib.SetNodeIndex(self._handle, idx_val)
        check_error()
        if not success:
            raise RuntimeError("Failed to set node index")
    
    @property
    def id(self) -> int:
        """Get the node database ID."""
        self._check_not_closed()
        node_id = lib.GetNodeID(self._handle)
        check_error()
        return node_id
    
    @property
    def uuid(self) -> str:
        """Get the node UUID."""
        self._check_not_closed()
        result = lib.GetNodeUUID(self._handle)
        check_error()
        if result == ffi.NULL:
            return ""
        return ffi.string(result).decode('utf-8')
    
    @property
    def virtual(self) -> bool:
        """Check if the node is virtual."""
        self._check_not_closed()
        is_virtual = lib.IsNodeVirtual(self._handle)
        check_error()
        return bool(is_virtual)
    
    @virtual.setter
    def virtual(self, value: bool):
        """Set the virtual flag (legacy_python compatibility)."""
        self.set_virtual(value)
    
    
    # Tree Navigation Methods
    
    def get_parent(self):
        """Get the parent node."""
        self._check_not_closed()
        parent_handle = lib.GetNodeParent(self._handle)
        check_error()
        
        if parent_handle == 0:
            return None
        
        return ContentNode(handle=parent_handle, document=self._document)
    
    def get_children(self):
        """Get all child nodes."""
        self._check_not_closed()
        count_ptr = ffi.new("int*")
        handles_ptr = lib.GetNodeChildren(self._handle, count_ptr)
        check_error()
        
        count = count_ptr[0]
        if count == 0 or handles_ptr == ffi.NULL:
            return []
        
        try:
            # Convert C array to Python list
            children = []
            handles = ffi.cast("unsigned long long*", handles_ptr)
            for i in range(count):
                child_handle = handles[i]
                children.append(ContentNode(handle=child_handle, document=self._document))
            return children
        finally:
            # Free the allocated array
            lib.FreeHandleArray(handles_ptr)
    
    def add_child(self, child, index=None):
        """
        Add a child node.
        
        Args:
            child: ContentNode to add as child
            index: Optional position to insert at
        """
        self._check_not_closed()
        if not hasattr(child, '_handle'):
            raise ValueError("Invalid ContentNode object")
        
        index_val = index if index is not None else -1
        success = lib.AddNodeChild(self._handle, child._handle, index_val)
        check_error()
        if not success:
            raise RuntimeError("Failed to add child node")
    
    def remove_child(self, child):
        """
        Remove a child node.
        
        Args:
            child: ContentNode to remove
        """
        self._check_not_closed()
        if not hasattr(child, '_handle'):
            raise ValueError("Invalid ContentNode object")
        
        success = lib.RemoveNodeChild(self._handle, child._handle)
        check_error()
        if not success:
            raise RuntimeError("Failed to remove child node")
    
    def get_child(self, index):
        """
        Get child at specific index.
        
        Args:
            index: Index of child to get
            
        Returns:
            ContentNode or None if index out of bounds
        """
        self._check_not_closed()
        child_handle = lib.GetNodeChild(self._handle, index)
        # Don't check error - out of bounds returns 0 without error
        
        if child_handle == 0:
            return None
        
        return ContentNode(handle=child_handle, document=self._document)
    
    @property
    def child_count(self):
        """Get the number of child nodes."""
        self._check_not_closed()
        count = lib.GetNodeChildCount(self._handle)
        check_error()
        return count if count >= 0 else 0
    
    # Sibling Navigation Methods
    
    def next_node(self):
        """Get the next sibling node."""
        self._check_not_closed()
        next_handle = lib.GetNextNode(self._handle)
        check_error()
        
        if next_handle == 0:
            return None
        
        return ContentNode(handle=next_handle, document=self._document)
    
    def previous_node(self):
        """Get the previous sibling node."""
        self._check_not_closed()
        prev_handle = lib.GetPreviousNode(self._handle)
        check_error()
        
        if prev_handle == 0:
            return None
        
        return ContentNode(handle=prev_handle, document=self._document)
    
    def get_siblings(self):
        """Get all sibling nodes (excludes this node)."""
        self._check_not_closed()
        count_ptr = ffi.new("int*")
        handles_ptr = lib.GetNodeSiblings(self._handle, count_ptr)
        check_error()
        
        count = count_ptr[0]
        if count == 0 or handles_ptr == ffi.NULL:
            return []
        
        try:
            # Convert C array to Python list
            siblings = []
            handles = ffi.cast("unsigned long long*", handles_ptr)
            for i in range(count):
                sibling_handle = handles[i]
                siblings.append(ContentNode(handle=sibling_handle, document=self._document))
            return siblings
        finally:
            # Free the allocated array
            if handles_ptr != ffi.NULL:
                lib.FreeHandleArray(handles_ptr)
    
    def get_path(self):
        """Get the path from root to this node as a list of nodes."""
        self._check_not_closed()
        count_ptr = ffi.new("int*")
        handles_ptr = lib.GetNodePath(self._handle, count_ptr)
        check_error()
        
        count = count_ptr[0]
        if count == 0 or handles_ptr == ffi.NULL:
            return []
        
        try:
            # Convert C array to Python list
            path = []
            handles = ffi.cast("unsigned long long*", handles_ptr)
            for i in range(count):
                node_handle = handles[i]
                path.append(ContentNode(handle=node_handle, document=self._document))
            return path
        finally:
            # Free the allocated array
            if handles_ptr != ffi.NULL:
                lib.FreeHandleArray(handles_ptr)
    
    def get_all_content(self, separator: str = ' ', strip: bool = False):
        """
        Get all text content from this node and its descendants.
        
        Args:
            separator: String to use to join content (default: ' ')
            strip: Whether to strip whitespace from each piece of content
            
        Returns:
            Combined text content
        """
        self._check_not_closed()
        
        # Convert separator to bytes
        separator_bytes = separator.encode('utf-8') if separator else None
        
        # Call C function
        content_ptr = lib.GetNodeAllContent(
            self._handle,
            separator_bytes if separator_bytes else ffi.NULL,
            1 if strip else 0
        )
        check_error()
        
        if content_ptr == ffi.NULL:
            return ""
        
        try:
            return ffi.string(content_ptr).decode('utf-8')
        finally:
            lib.FreeString(content_ptr)
    
    # Feature Management Methods
    
    def add_feature(self, feature_type: str, name: str, value: Any, single: bool = True, serialized: bool = False) -> Optional[ContentFeature]:
        """
        Add a feature to the node. If a feature with same type:name exists, appends the value.
        
        Args:
            feature_type: Type of feature
            name: Name of the feature
            value: Value of the feature (will be JSON serialized unless serialized=True)
            single: If True and serialized=False, wraps scalar values in an array
            serialized: If True, stores value exactly as provided without wrapping
            
        Returns:
            ContentFeature: The feature that was added to this ContentNode
        """
        self._check_not_closed()
        
        feature_type_bytes = feature_type.encode('utf-8')
        name_bytes = name.encode('utf-8')
        value_json = json.dumps(value).encode('utf-8')
        
        result = lib.AddNodeFeature(
            self._handle,
            feature_type_bytes,
            name_bytes,
            value_json,
            1 if single else 0,
            1 if serialized else 0
        )
        check_error()
        
        if result == ffi.NULL:
            raise RuntimeError(f"Failed to add feature {feature_type}:{name}")
        
        # Parse the returned JSON to create ContentFeature
        json_str = ffi.string(result).decode('utf-8')
        lib.FreeString(result)  # Free the C string
        
        # Parse the JSON and create ContentFeature instance
        feature_data = json.loads(json_str)
        created_feature = ContentFeature.from_dict(feature_data)
        return created_feature
    
    def set_feature(self, feature_type: str, name: str, value: Any):
        """
        Set a feature on the node, replacing any existing feature with same type:name.
        This is equivalent to remove_feature then add_feature.
        
        Args:
            feature_type: Type of feature
            name: Name of the feature
            value: Value of the feature (will be JSON serialized)
        """
        self._check_not_closed()
        
        feature_type_bytes = feature_type.encode('utf-8')
        name_bytes = name.encode('utf-8')
        value_json = json.dumps(value).encode('utf-8')
        
        success = lib.SetNodeFeature(
            self._handle,
            feature_type_bytes,
            name_bytes,
            value_json
        )
        check_error()
        
        if not success:
            raise RuntimeError(f"Failed to set feature {feature_type}:{name}")
    
    def get_feature(self, feature_type: str, name: str) -> Optional[ContentFeature]:
        """
        Get a specific feature by type and name.
        
        Args:
            feature_type: Type of feature
            name: Name of the feature
            
        Returns:
            ContentFeature object or None if not found
        """
        self._check_not_closed()
        
        feature_type_bytes = feature_type.encode('utf-8')
        name_bytes = name.encode('utf-8')
        
        result = lib.GetNodeFeature(self._handle, feature_type_bytes, name_bytes)
        check_error()
        
        if result == ffi.NULL:
            return None
        
        json_str = ffi.string(result).decode('utf-8')
        lib.FreeString(result)
        
        if not json_str or json_str == "null":
            return None
        
        # Parse the JSON and create ContentFeature instance
        feature_data = json.loads(json_str)
        return ContentFeature.from_dict(feature_data)
    
    def get_features(self) -> List[ContentFeature]:
        """
        Get all features on this node.
        
        Returns:
            List of ContentFeature objects on this node
        """
        self._check_not_closed()
        
        result = lib.GetAllNodeFeatures(self._handle)
        check_error()
        
        if result == ffi.NULL:
            return []
        
        json_str = ffi.string(result).decode('utf-8')
        lib.FreeString(result)
        
        if not json_str or json_str == "{}" or json_str == "[]":
            return []
        
        # Parse JSON response from Go - could be dict or list format
        try:
            data = json.loads(json_str)
            features = []
            
            if isinstance(data, dict):
                # If grouped by type, flatten to list of features
                for feature_type, type_features in data.items():
                    if isinstance(type_features, list):
                        for feature_dict in type_features:
                            features.append(ContentFeature.from_dict(feature_dict))
                    elif isinstance(type_features, dict):
                        features.append(ContentFeature.from_dict(type_features))
            elif isinstance(data, list):
                # If already a list of features
                for feature_dict in data:
                    features.append(ContentFeature.from_dict(feature_dict))
            
            return features
        except (json.JSONDecodeError, AttributeError):
            return []
    
    def get_features_of_type(self, feature_type: str) -> List[ContentFeature]:
        """
        Get all features of a specific type.
        
        Args:
            feature_type: Type of features to get
            
        Returns:
            List of ContentFeature objects of the specified type
        """
        self._check_not_closed()
        
        feature_type_bytes = feature_type.encode('utf-8')
        
        result = lib.GetNodeFeaturesOfType(self._handle, feature_type_bytes)
        check_error()
        
        if result == ffi.NULL:
            return []
        
        json_str = ffi.string(result).decode('utf-8')
        lib.FreeString(result)
        
        if not json_str or json_str == "[]":
            return []
        
        # Parse JSON and convert to ContentFeature instances
        try:
            feature_dicts = json.loads(json_str)
            if isinstance(feature_dicts, list):
                result_features = []
                for feature_dict in feature_dicts:
                    try:
                        cf = ContentFeature.from_dict(feature_dict)
                        result_features.append(cf)
                    except Exception:
                        pass  # Skip features that can't be parsed
                return result_features
            elif isinstance(feature_dicts, dict):
                # Single feature returned as dict instead of list
                return [ContentFeature.from_dict(feature_dicts)]
            else:
                return []
        except (json.JSONDecodeError, AttributeError):
            return []
    
    def remove_feature(self, feature_type: str, name: str):
        """
        Remove a feature from the node.
        
        Args:
            feature_type: Type of feature to remove
            name: Name of the feature to remove
        """
        self._check_not_closed()
        
        feature_type_bytes = feature_type.encode('utf-8')
        name_bytes = name.encode('utf-8')
        
        success = lib.RemoveNodeFeature(self._handle, feature_type_bytes, name_bytes)
        check_error()
        
        if not success:
            raise RuntimeError(f"Failed to remove feature {feature_type}:{name}")
    
    def get_feature_value(self, feature_type: str, name: str) -> Any:
        """
        Get the value of a specific feature, unwrapping single values.
        This matches legacy_python's get_feature_value behavior.
        
        Args:
            feature_type: Type of feature
            name: Name of the feature
            
        Returns:
            Feature value (unwrapped if single) or None if not found
        """
        self._check_not_closed()
        
        feature_type_bytes = feature_type.encode('utf-8')
        name_bytes = name.encode('utf-8')
        
        result = lib.GetNodeFeatureValue(self._handle, feature_type_bytes, name_bytes)
        check_error()
        
        if result == ffi.NULL:
            return None
        
        json_str = ffi.string(result).decode('utf-8')
        lib.FreeString(result)
        
        if not json_str or json_str == "null":
            return None
        
        return json.loads(json_str)
    
    def has_feature(self, feature_type: str, name: str) -> bool:
        """
        Check if the node has a specific feature.
        
        Args:
            feature_type: Type of feature
            name: Name of the feature
            
        Returns:
            True if the feature exists, False otherwise
        """
        self._check_not_closed()
        
        feature_type_bytes = feature_type.encode('utf-8')
        name_bytes = name.encode('utf-8')
        
        has_feat = lib.HasNodeFeature(self._handle, feature_type_bytes, name_bytes)
        check_error()
        
        return bool(has_feat)
    
    # Tagging System Methods
    
    def tag(self, tag_name: str, **options):
        """
        Tag this node with the specified tag name and options.
        
        Args:
            tag_name: Name/path of the tag (e.g., 'important', 'section/header')
            **options: Tag options including:
                - content_re: Regular expression for content matching
                - tag_uuid: UUID for the tag
                - confidence: Confidence score (0.0-1.0)
                - value: Value associated with the tag
                - fixed_position: Whether position is fixed
                - group_uuid: UUID for grouping tags
        """
        self._check_not_closed()
        
        tag_name_bytes = tag_name.encode('utf-8')
        
        # Convert options to JSON
        if options:
            options_json = json.dumps(options).encode('utf-8')
        else:
            options_json = ffi.NULL
        
        success = lib.TagNode(self._handle, tag_name_bytes, options_json)
        check_error()
        
        if not success:
            raise RuntimeError(f"Failed to tag node with '{tag_name}'")
    
    def get_tags(self) -> List[Tag]:
        """
        Get all tags on this node as Tag objects.
        
        Returns:
            List of Tag objects (matches legacy_python behavior)
        """
        self._check_not_closed()
        
        result = lib.GetNodeTags(self._handle)
        check_error()
        
        if result == ffi.NULL:
            return []
        
        json_str = ffi.string(result).decode('utf-8')
        lib.FreeString(result)
        
        if not json_str or json_str == "[]":
            return []
        
        # Parse the tag data and convert to Tag objects
        try:
            tag_dicts = json.loads(json_str)
            if isinstance(tag_dicts, list):
                return [Tag.from_go_dict(tag_dict) for tag_dict in tag_dicts]
            else:
                return []
        except (json.JSONDecodeError, AttributeError):
            return []
    
    def get_tag_names(self) -> List[str]:
        """
        Get all tag names on this node as strings.
        
        Returns:
            List of tag names (for backward compatibility)
        """
        tags = self.get_tags()
        return [getattr(tag, 'name', '') for tag in tags if hasattr(tag, 'name')]
    
    def get_tag_features(self) -> List[ContentFeature]:
        """
        Get all tag features on this node as ContentFeature objects.
        
        Returns:
            List of ContentFeature objects where feature_type="tag" (matches legacy_python behavior)
        """
        return self.get_features_of_type("tag")
    
    def get_tag(self, tag_name: str) -> Optional[Tag]:
        """
        Get a specific tag by name.
        
        Args:
            tag_name: Name of the tag to get
            
        Returns:
            Tag object or None if not found
        """
        self._check_not_closed()
        
        tag_name_bytes = tag_name.encode('utf-8')
        
        result = lib.GetNodeTag(self._handle, tag_name_bytes)
        check_error()
        
        if result == ffi.NULL:
            return None
        
        json_str = ffi.string(result).decode('utf-8')
        lib.FreeString(result)
        
        if not json_str or json_str == "{}":
            return None
        
        # Parse JSON and convert to Tag object
        try:
            tag_dict = json.loads(json_str)
            return Tag.from_go_dict(tag_dict)
        except (json.JSONDecodeError, AttributeError):
            return None
    
    def remove_tag(self, tag_name: str):
        """
        Remove a tag from this node.
        
        Args:
            tag_name: Name of the tag to remove
        """
        self._check_not_closed()
        
        tag_name_bytes = tag_name.encode('utf-8')
        
        success = lib.RemoveNodeTag(self._handle, tag_name_bytes)
        check_error()
        
        if not success:
            raise RuntimeError(f"Failed to remove tag '{tag_name}'")
    
    def has_tag(self, tag_name: str) -> bool:
        """
        Check if this node has a specific tag.
        
        Args:
            tag_name: Name of the tag to check
            
        Returns:
            True if the node has the tag, False otherwise
        """
        self._check_not_closed()
        
        tag_name_bytes = tag_name.encode('utf-8')
        
        has_tag = lib.HasNodeTag(self._handle, tag_name_bytes)
        check_error()
        
        return bool(has_tag)
    
    def has_tags(self) -> bool:
        """
        Check if this node has any tags.
        
        Returns:
            True if the node has at least one tag, False otherwise
        """
        return len(self.get_tags()) > 0
    
    def add_tag(self, tag: Dict[str, Any]):
        """
        Add a tag object to the node.
        
        Args:
            tag: Dictionary containing tag data with 'name' (required) and optional
                 'value', 'confidence', 'group_uuid' fields
        
        Example:
            node.add_tag({
                'name': 'important',
                'value': 'high_priority',
                'confidence': 0.95,
                'group_uuid': 'some-uuid'
            })
        """
        self._check_not_closed()
        
        # Validate required field
        if not isinstance(tag, dict) or 'name' not in tag:
            raise ValueError("Tag must be a dictionary with required 'name' field")
        
        # Convert to JSON
        tag_json = json.dumps(tag).encode('utf-8')
        
        # Call C function
        result = lib.AddTag(self._handle, tag_json)
        check_error()
        
        if result != 1:
            raise RuntimeError("Failed to add tag")
    
    def delete(self):
        """
        Delete this node from its parent.
        
        Note: This follows the legacy_python pattern where a node is removed
        from its parent via parent.remove_child(node).
        """
        self._check_not_closed()
        
        # Call C function which handles the parent.remove_child logic
        result = lib.DeleteContentNode(self._handle)
        check_error()
        
        if result != 1:
            raise RuntimeError("Failed to delete content node")
        
        # Mark as closed since it's deleted
        self._closed = True
        if hasattr(self, '_finalizer'):
            self._finalizer.detach()
    
    
    
    # Note: node_type and virtual properties already exist in this class
    # We just need to add setters to make them compatible with legacy_python style
    
    def select(self, selector: str, variables: Optional[Dict[str, Any]] = None):
        """
        Execute selector query on this node and return matching nodes.
        
        Args:
            selector: XPath-like selector string
            variables: Optional variables for parameterized queries
            
        Returns:
            List of ContentNode objects matching the selector
        """
        self._check_not_closed()
        
        # Convert parameters to C types
        selector_bytes = selector.encode('utf-8')
        variables_json = ffi.NULL
        if variables:
            variables_json = json.dumps(variables).encode('utf-8')
        
        # Call C function
        count_ptr = ffi.new("int*")
        handles_ptr = lib.SelectNodes(self._handle, selector_bytes, variables_json, count_ptr)
        check_error()
        
        if handles_ptr == ffi.NULL or count_ptr[0] == 0:
            return []
        
        # Convert handles to ContentNode objects
        nodes = []
        for i in range(count_ptr[0]):
            handle = handles_ptr[i]
            if handle != 0:
                node = ContentNode._from_handle(handle)
                node._document = self._document  # Preserve document reference
                nodes.append(node)

        # Free the handle array
        lib.FreeHandleArray(handles_ptr)

        return nodes

    def select_first(self, selector: str, variables: Optional[Dict[str, Any]] = None):
        """
        Execute selector query on this node and return the first matching node.
        
        Args:
            selector: XPath-like selector string
            variables: Optional variables for parameterized queries
            
        Returns:
            First ContentNode matching the selector, or None if no match
        """
        self._check_not_closed()
        
        # Convert parameters to C types
        selector_bytes = selector.encode('utf-8')
        variables_json = ffi.NULL
        if variables:
            variables_json = json.dumps(variables).encode('utf-8')
        
        # Call C function
        handle = lib.SelectSingleNode(self._handle, selector_bytes, variables_json)
        check_error()

        if handle == 0:
            return None

        # Convert handle to ContentNode object
        node = ContentNode._from_handle(handle)
        node._document = self._document  # Preserve document reference
        return node

    def set_node_type(self, node_type: str):
        """
        Set the type of this node.
        
        Args:
            node_type: New type for the node (e.g., 'paragraph', 'line', 'word')
        """
        self._check_not_closed()
        
        # Call C function
        node_type_bytes = node_type.encode('utf-8')
        result = lib.SetNodeType(self._handle, node_type_bytes)
        check_error()
        
        if result != 1:
            raise RuntimeError("Failed to set node type")
    
    def set_virtual(self, virtual: bool):
        """
        Set the virtual flag of this node.
        
        Args:
            virtual: True to mark node as virtual, False otherwise
        """
        self._check_not_closed()
        
        # Call C function
        virtual_int = 1 if virtual else 0
        result = lib.SetNodeVirtual(self._handle, virtual_int)
        check_error()
        
        if result != 1:
            raise RuntimeError("Failed to set node virtual flag")
    
    def close(self):
        """Close the node and free its resources."""
        if not self._closed and self._handle:
            lib.FreeHandle(self._handle)
            self._handle = None
            self._closed = True
            if hasattr(self, '_finalizer'):
                self._finalizer.detach()
    
    def __enter__(self):
        """Context manager entry."""
        return self
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        """Context manager exit."""
        self.close()
    
    def __repr__(self):
        """String representation."""
        if self._closed:
            return "ContentNode(closed)"
        
        try:
            content_preview = self.content[:50] + "..." if len(self.content) > 50 else self.content
            return f"ContentNode(type='{self.node_type}', content='{content_preview}', id={self.id})"
        except:
            return f"ContentNode(handle={self._handle})"
    
    def __str__(self):
        """String conversion."""
        if self._closed:
            return "[Closed ContentNode]"
        
        try:
            return f"{self.node_type}: {self.content}"
        except:
            return "[ContentNode]"

    def __eq__(self, other):
        """Check equality by UUID."""
        if not isinstance(other, ContentNode):
            return False
        if self._closed or other._closed:
            return False
        try:
            return self.uuid == other.uuid
        except:
            return False

    def __hash__(self):
        """Hash by UUID for use in sets and dicts."""
        if self._closed:
            return hash(None)
        try:
            return hash(self.uuid)
        except:
            return hash(self._handle)

    def matches(self, pattern: str) -> bool:
        """
        Check if this node matches a selector pattern.
        
        This is equivalent to checking if select_first(pattern) returns this node,
        but may be more efficient for simple pattern matching.
        
        Args:
            pattern: Selector pattern to match against
            
        Returns:
            True if the node matches the pattern, False otherwise
            
        Raises:
            DocumentError: If the pattern is invalid or node is closed
        """
        if self._closed:
            raise DocumentError("Cannot check pattern on closed node")
        
        if not pattern:
            return False
            
        try:
            # Use the parent's select to see if this node is found
            # This is a simple implementation - could be optimized in the future
            if hasattr(self, '_document') and self._document:
                # Try to find this node using the pattern from the document root
                result = self._document.select_first(pattern)
                return result is not None and result.id == self.id
            else:
                # If no document reference, try pattern matching from this node's parent
                parent = self.get_parent()
                if parent:
                    result = parent.select_first(pattern)
                    return result is not None and result.id == self.id
                else:
                    # No parent, try from this node itself
                    result = self.select_first(pattern)
                    return result is not None and result.id == self.id
                    
        except Exception as e:
            from ..errors import DocumentError
            raise DocumentError(f"Pattern matching failed: {e}")
    
    def get_bbox(self) -> Optional[List[float]]:
        """
        Get the bounding box for the node.

        Returns:
            The bounding box array [x1, y1, x2, y2] if it exists, None otherwise
        """
        bbox = self.get_feature_value("spatial", "bbox")
        if not bbox or not isinstance(bbox, (list, tuple)):
            return None

        # Feature values are stored as arrays - unwrap if needed
        # Format is [[x1, y1, x2, y2]] so we need to get the inner array
        if len(bbox) == 1 and isinstance(bbox[0], (list, tuple)):
            bbox = bbox[0]

        if len(bbox) >= 4:
            # Ensure all values are floats
            return [float(bbox[0]), float(bbox[1]), float(bbox[2]), float(bbox[3])]
        return None
    
    def set_bbox(self, bbox: List[float]):
        """
        Set the bounding box for the node.
        
        Args:
            bbox: The bounding box array [x1, y1, x2, y2]
        """
        if not isinstance(bbox, (list, tuple)) or len(bbox) != 4:
            raise ValueError("Bounding box must be a list/tuple of 4 values [x1, y1, x2, y2]")
        
        # Convert to floats to ensure consistent storage
        bbox_floats = [float(bbox[0]), float(bbox[1]), float(bbox[2]), float(bbox[3])]
        
        # Use set_feature which calls the C binding to save to Go
        self.set_feature("spatial", "bbox", bbox_floats)
    
    def get_x(self) -> Optional[float]:
        """
        Get the X position (left edge) of the node's bounding box.

        Returns:
            The X coordinate (x1) if bbox exists, None otherwise
        """
        bbox = self.get_bbox()
        if bbox:
            return bbox[0]
        return None

    def get_y(self) -> Optional[float]:
        """
        Get the Y position (top edge) of the node's bounding box.

        Returns:
            The Y coordinate (y1) if bbox exists, None otherwise
        """
        bbox = self.get_bbox()
        if bbox:
            return bbox[1]
        return None

    def get_width(self) -> Optional[float]:
        """
        Get the width of the node's bounding box.

        Returns:
            The width (x2 - x1) if bbox exists, None otherwise
        """
        bbox = self.get_bbox()
        if bbox:
            return bbox[2] - bbox[0]
        return None

    def get_height(self) -> Optional[float]:
        """
        Get the height of the node's bounding box.

        Returns:
            The height (y2 - y1) if bbox exists, None otherwise
        """
        bbox = self.get_bbox()
        if bbox:
            return bbox[3] - bbox[1]
        return None
    
    def get_rotate(self) -> Optional[float]:
        """
        Get the rotation of the node.

        Returns:
            The rotation value if it exists, None otherwise
        """
        rotate = self.get_feature_value("spatial", "rotate")
        if rotate is None:
            return None

        # Feature values are stored as arrays - unwrap if needed
        if isinstance(rotate, (list, tuple)) and len(rotate) == 1:
            rotate = rotate[0]

        if rotate is not None:
            return float(rotate)
        return None
    
    def set_rotate(self, rotate: float):
        """
        Set the rotation of the node.
        
        Args:
            rotate: The rotation value
        """
        # Use set_feature for cleaner behavior - ensures single value
        self.set_feature("spatial", "rotate", float(rotate))
    
    def get_content_parts(self) -> List[Any]:
        """
        Get the content parts for this node.

        Content parts can be either strings or integers. Integer values represent
        child node indices (for rollup operations where parent content references
        child nodes).

        Returns:
            List of content parts (strings and/or integers)
        """
        self._check_not_closed()

        result = lib.GetNodeContentParts(self._handle)
        check_error()

        if result == ffi.NULL:
            return []

        json_str = ffi.string(result).decode('utf-8')
        lib.FreeString(result)

        if not json_str:
            return []

        try:
            parts = json.loads(json_str)
            return parts if isinstance(parts, list) else []
        except (json.JSONDecodeError, AttributeError):
            return []

    def set_content_parts(self, content_parts: List[Any]):
        """
        Set the content parts for this node.

        Content parts can be either strings or integers. Integer values represent
        child node indices (for rollup operations where parent content references
        child nodes).

        Args:
            content_parts: List of content parts (strings and/or integers)
        """
        self._check_not_closed()

        if not isinstance(content_parts, list):
            raise ValueError("Content parts must be a list")

        # Validate each part is either string or integer
        for i, part in enumerate(content_parts):
            if not isinstance(part, (str, int)):
                raise ValueError(f"Content part at index {i} must be a string or integer, got {type(part).__name__}")

        # Convert to JSON
        json_str = json.dumps(content_parts)
        json_bytes = json_str.encode('utf-8')

        success = lib.SetNodeContentParts(self._handle, json_bytes)
        check_error()

        if not success:
            raise RuntimeError("Failed to set content parts")

    # Compatibility Methods (for migration from legacy kodexa SDK)

    def adopt_children(self, nodes_to_adopt: List['ContentNode'], replace: bool = False):
        """
        Adopt children from another node or list, preserving their order.

        This will take a list of content nodes and adopt them under this node,
        ensuring they are re-parented. The order of nodes in nodes_to_adopt
        determines their final index order.

        Args:
            nodes_to_adopt: List of ContentNodes to adopt
            replace: If True, remove children not in nodes_to_adopt

        Example:
            # select all nodes of type 'line', then the root node 'adopts' them
            document.content_node.adopt_children(document.select('//line'), replace=True)
        """
        self._check_not_closed()

        child_idx_base = 0

        # Copy to avoid mutation during iteration
        children = list(nodes_to_adopt)
        existing_children = self.get_children()

        for existing_child in existing_children:
            if existing_child not in children:
                existing_child.index = child_idx_base
            else:
                existing_child.index = children.index(existing_child)
            child_idx_base += 1

        # Add new children that aren't already children of this node
        # Note: Don't call remove_child() first - Go's AddChild handles re-parenting
        # via Update() when the child already has an ID. Calling remove_child() would
        # DELETE the node from the database entirely.
        current_children = self.get_children()
        for new_child in children:
            if new_child not in current_children:
                # Just call add_child - Go backend will update parent_id
                self.add_child(new_child, children.index(new_child))
                child_idx_base += 1

        if replace:
            # Remove existing children not in the adopt list
            for child in self.get_children():
                if child not in children:
                    self.remove_child(child)

    def set_bbox_from_children(self):
        """
        Calculate and set bounding box from child nodes.

        Computes the union of all child bounding boxes and sets it on this node.
        """
        self._check_not_closed()

        children = self.get_children()
        if not children:
            return

        bboxes = [c.get_bbox() for c in children if c.get_bbox()]
        if not bboxes:
            return

        x1 = min(b[0] for b in bboxes)
        y1 = min(b[1] for b in bboxes)
        x2 = max(b[2] for b in bboxes)
        y2 = max(b[3] for b in bboxes)
        self.set_bbox([x1, y1, x2, y2])

    def set_statistics(self, statistics: Dict[str, Any]):
        """
        Set spatial statistics for this node.

        Statistics are stored as a feature under spatial:statistics,
        matching the legacy kodexa SDK behavior.

        Args:
            statistics: Dictionary of statistics to store
        """
        self._check_not_closed()

        self.add_feature("spatial", "statistics", statistics)

    def to_dict(self) -> Dict[str, Any]:
        """
        Convert this node to a dictionary representation.

        This includes core fields (type, content), optional fields (index, virtual,
        confidence, uuid), features (including bbox), and children (recursive).

        The format matches the legacy kodexa SDK's to_dict() method.

        Returns:
            Dictionary representation of this node and its subtree
        """
        self._check_not_closed()

        result = lib.NodeToDict(self._handle)
        check_error()

        if result == ffi.NULL:
            return {}

        json_str = ffi.string(result).decode('utf-8')
        lib.FreeString(result)

        if not json_str or json_str == "{}":
            return {}

        return json.loads(json_str)

