from typing import List, Tuple, Optional
import numpy as np
from PIL import Image, ImageDraw

from pyWebLayout.core.base import Renderable, Queriable
from pyWebLayout.core.query import QueryResult, SelectionRange
from pyWebLayout.core.callback_registry import CallbackRegistry
from pyWebLayout.style.page_style import PageStyle


class Page(Renderable, Queriable):
    """
    A page represents a canvas that can hold and render child renderable objects.
    It handles layout, rendering, and provides query capabilities to find which child
    contains a given point.
    """

    def __init__(self, size: Tuple[int, int], style: Optional[PageStyle] = None,
                 origin: Tuple[int, int] = (0, 0)):
        """
        Initialize a new page.

        Args:
            size: The total size of the page (width, height) including borders
            style: The PageStyle defining borders, spacing, and appearance
            origin: Absolute position of the page's top-left corner. Non-zero for
                a page nested inside another surface, such as a table cell.
        """
        self._size = size
        self._origin = origin
        self._style = style if style is not None else PageStyle()
        self._children: List[Renderable] = []
        self._canvas: Optional[Image.Image] = None
        self._draw: Optional[ImageDraw.Draw] = None
        # Initialize y_offset to start of content area
        # Position the first line so its baseline is close to the top boundary
        # For subsequent lines, baseline-to-baseline spacing is used
        self._current_y_offset = (self._origin[1] + self._style.border_width
                                  + self._style.padding_top)
        self._is_first_line = True  # Track if we're placing the first line
        # Callback registry for managing interactable elements
        self._callbacks = CallbackRegistry()
        # Dirty flag to track if page needs re-rendering due to state changes
        self._dirty = True

    def free_space(self) -> Tuple[int, int]:
        """
        Get the remaining space in the content area.

        Deprecated: use content_rect and remaining_height, which this delegates to.
        """
        return (self.content_rect[2], self.remaining_height)

    def can_fit_line(
            self,
            baseline_spacing: int,
            ascent: int = 0,
            descent: int = 0) -> bool:
        """
        Check if a line with the given metrics can fit on the page.

        Args:
            baseline_spacing: Distance from current position to next baseline
            ascent: Font ascent (height above baseline), defaults to 0 for backward compat
            descent: Font descent (height below baseline), defaults to 0 for backward compat

        Returns:
            True if the line fits within page boundaries
        """
        # Calculate the maximum Y position allowed (bottom boundary)
        content_y, content_h = self.content_rect[1], self.content_rect[3]
        max_y = content_y + content_h

        # If ascent/descent not provided, use simple check (backward compatibility)
        if ascent == 0 and descent == 0:
            return (self._current_y_offset + baseline_spacing) <= max_y

        # Calculate where the bottom of the text would be
        # Text bottom = current_y_offset + ascent + descent
        text_bottom = self._current_y_offset + ascent + descent

        # Check if text bottom would exceed the boundary
        return text_bottom <= max_y

    @property
    def size(self) -> Tuple[int, int]:
        """Get the total page size including borders"""
        return self._size

    @property
    def origin(self) -> Tuple[int, int]:
        """Absolute position of the page's top-left corner"""
        return self._origin

    @property
    def content_origin(self) -> Tuple[int, int]:
        """
        Absolute top-left of the content box: the page origin plus its border and
        top/left padding. Layout starts here.
        """
        return (
            self._origin[0] + self._style.border_width + self._style.padding_left,
            self._origin[1] + self._style.border_width + self._style.padding_top,
        )

    @property
    def content_rect(self) -> Tuple[int, int, int, int]:
        """(x, y, width, height) of the content box, in absolute coordinates"""
        x, y = self.content_origin
        return (x, y, self.content_size[0], self.content_size[1])

    @property
    def remaining_height(self) -> int:
        """Content-box height still available below the current layout cursor"""
        _, y, _, h = self.content_rect
        return max(0, y + h - self._current_y_offset)

    @property
    def canvas_size(self) -> Tuple[int, int]:
        """Get the canvas size (page size minus borders)"""
        border_reduction = self._style.total_border_width
        return (
            self._size[0] - border_reduction,
            self._size[1] - border_reduction
        )

    @property
    def content_size(self) -> Tuple[int, int]:
        """Get the content area size (canvas minus padding)"""
        canvas_w, canvas_h = self.canvas_size
        return (
            canvas_w - self._style.total_horizontal_padding,
            canvas_h - self._style.total_vertical_padding
        )

    @property
    def border_size(self) -> int:
        """Get the border width"""
        return self._style.border_width

    @property
    def available_width(self) -> int:
        """Get the available width for content (content area width)"""
        return self.content_size[0]

    @property
    def style(self) -> PageStyle:
        """Get the page style"""
        return self._style

    @property
    def callbacks(self) -> CallbackRegistry:
        """Get the callback registry for managing interactable elements"""
        return self._callbacks

    @property
    def is_dirty(self) -> bool:
        """Check if the page needs re-rendering due to state changes"""
        return self._dirty

    def mark_dirty(self):
        """Mark the page as needing re-rendering"""
        self._dirty = True

    def mark_clean(self):
        """Mark the page as clean (up-to-date render)"""
        self._dirty = False

    @property
    def draw(self) -> Optional[ImageDraw.Draw]:
        """Get the ImageDraw object for drawing on this page's canvas"""
        if self._draw is None:
            # Initialize canvas and draw context if not already done
            self._canvas = self._create_canvas()
            self._draw = ImageDraw.Draw(self._canvas)
        return self._draw

    def add_child(self, child: Renderable) -> 'Page':
        """
        Add a child renderable object to this page.

        Args:
            child: The renderable object to add

        Returns:
            Self for method chaining
        """
        self._children.append(child)
        self._current_y_offset = child.origin[1] + child.size[1]
        # Invalidate the canvas when children change
        self._canvas = None
        return self

    def remove_child(self, child: Renderable) -> bool:
        """
        Remove a child from the page.

        Args:
            child: The child to remove

        Returns:
            True if the child was found and removed, False otherwise
        """
        try:
            self._children.remove(child)
            self._canvas = None
            return True
        except ValueError:
            return False

    def clear_children(self) -> 'Page':
        """
        Remove all children from the page.

        Returns:
            Self for method chaining
        """
        self._children.clear()
        self._canvas = None
        # Clear callback registry when clearing children
        self._callbacks.clear()
        # Reset y_offset to start of content area (after border and padding)
        self._current_y_offset = self.content_origin[1]
        return self

    @property
    def children(self) -> List[Renderable]:
        """Get a copy of the children list"""
        return self._children.copy()

    def _get_child_property(self, child: Renderable, private_attr: str,
                            public_attr: str, index: Optional[int] = None,
                            default: Optional[int] = None) -> Optional[int]:
        """
        Generic helper to extract properties from child objects with multiple fallback strategies.

        Args:
            child: The child object
            private_attr: Name of the private attribute (e.g., '_size')
            public_attr: Name of the public property (e.g., 'size')
            index: Optional index for array-like properties (0 for width, 1 for height)
            default: Default value if property cannot be determined

        Returns:
            Property value or default
        """
        # Try private attribute first
        if hasattr(child, private_attr):
            value = getattr(child, private_attr)
            if value is not None:
                if isinstance(value, (list, tuple, np.ndarray)):
                    if index is not None and len(value) > index:
                        return int(value[index])
                    elif index is None:
                        return value

        # Try public property
        if hasattr(child, public_attr):
            value = getattr(child, public_attr)
            if value is not None:
                if isinstance(value, (list, tuple, np.ndarray)):
                    if index is not None and len(value) > index:
                        return int(value[index])
                    elif index is None:
                        return value
                else:
                    return int(value)

        return default

    def _get_child_height(self, child: Renderable) -> int:
        """
        Get the height of a child object.

        Args:
            child: The child to measure

        Returns:
            Height in pixels
        """
        # Try to get height from size property (index 1)
        height = self._get_child_property(child, '_size', 'size', index=1)
        if height is not None:
            return height

        # Try direct height attribute
        height = self._get_child_property(child, '_height', 'height')
        if height is not None:
            return height

        # Default fallback height
        return 20

    def render_children(self):
        """
        Call render on all children in the list.
        Children draw directly onto the page's canvas via the shared ImageDraw object.
        """
        for child in self._children:
            # Synchronize draw context for Line objects before rendering
            if hasattr(child, '_draw'):
                child._draw = self._draw
            # Synchronize canvas for Image objects before rendering
            if hasattr(child, '_canvas'):
                child._canvas = self._canvas
            if hasattr(child, 'render'):
                child.render()

    def render(self) -> Image.Image:
        """
        Render the page with all its children.

        Returns:
            PIL Image containing the rendered page
        """
        # Create the base canvas and draw object
        self._canvas = self._create_canvas()
        self._draw = ImageDraw.Draw(self._canvas)

        # Render all children - they draw directly onto the canvas
        self.render_children()

        # Mark as clean after rendering
        self._dirty = False

        return self._canvas

    def _create_canvas(self) -> Image.Image:
        """
        Create the base canvas with background and borders.

        Returns:
            PIL Image with background and borders applied
        """
        # Create base image
        canvas = Image.new('RGBA', self._size, (*self._style.background_color, 255))

        # Draw borders if needed
        if self._style.border_width > 0:
            draw = ImageDraw.Draw(canvas)
            border_color = (*self._style.border_color, 255)

            # Draw border rectangle inside the content area
            border_offset = self._style.border_width
            draw.rectangle([
                (border_offset, border_offset),
                (self._size[0] - border_offset - 1, self._size[1] - border_offset - 1)
            ], outline=border_color)

        return canvas

    def _get_child_position(self, child: Renderable) -> Tuple[int, int]:
        """
        Get the position where a child should be rendered.

        Args:
            child: The child object

        Returns:
            Tuple of (x, y) coordinates
        """
        # Try to get x coordinate
        x = self._get_child_property(child, '_origin', 'position', index=0, default=0)
        # Try to get y coordinate
        y = self._get_child_property(child, '_origin', 'position', index=1, default=0)

        return (x, y)

    def query_point(self, point: Tuple[int, int]) -> Optional[QueryResult]:
        """
        Query a point to find the deepest object at that location.
        Traverses children and uses Queriable.in_object() for hit-testing.

        Args:
            point: The (x, y) coordinates to query

        Returns:
            QueryResult with metadata about what was found, or None if nothing hit
        """
        point_array = np.array(point)

        # Check each child (in reverse order so topmost child is found first)
        for child in reversed(self._children):
            # Use Queriable mixin's in_object() for hit-testing
            if isinstance(child, Queriable) and child.in_object(point_array):
                # If child can also query (has children of its own), recurse
                if hasattr(child, 'query_point'):
                    result = child.query_point(point)
                    if result:
                        result.parent_page = self
                        return result
                    # If child's query returned None, continue to next child
                    continue

                # Otherwise, package this child as the result
                return self._make_query_result(child, point)

        # Nothing hit - return empty result
        return QueryResult(
            object=self,
            object_type="empty",
            bounds=(int(point[0]), int(point[1]), 0, 0)
        )

    def _point_in_child(self, point: np.ndarray, child: Renderable) -> bool:
        """
        Check if a point is within a child's bounds.

        Args:
            point: The point to check
            child: The child to check against

        Returns:
            True if the point is within the child's bounds
        """
        # If child implements Queriable interface, use it
        if isinstance(child, Queriable) and hasattr(child, 'in_object'):
            try:
                return child.in_object(point)
            except BaseException:
                pass  # Fall back to bounds checking

        # Get child position and size for bounds checking
        child_pos = self._get_child_position(child)
        child_size = self._get_child_size(child)

        if child_size is None:
            return False

        # Check if point is within child bounds
        return (
            child_pos[0] <= point[0] < child_pos[0] + child_size[0] and
            child_pos[1] <= point[1] < child_pos[1] + child_size[1]
        )

    def _get_child_size(self, child: Renderable) -> Optional[Tuple[int, int]]:
        """
        Get the size of a child object.

        Args:
            child: The child to measure

        Returns:
            Tuple of (width, height) or None if size cannot be determined
        """
        # Try to get width and height from size property
        width = self._get_child_property(child, '_size', 'size', index=0)
        height = self._get_child_property(child, '_size', 'size', index=1)

        # If size property worked, return it
        if width is not None and height is not None:
            return (width, height)

        # Try direct width/height attributes
        width = self._get_child_property(child, '_width', 'width')
        height = self._get_child_property(child, '_height', 'height')

        if width is not None and height is not None:
            return (width, height)

        return None

    def _make_query_result(self, obj, point: Tuple[int, int]) -> QueryResult:
        """
        Package an object into a QueryResult with metadata.

        Args:
            obj: The object to package
            point: The query point

        Returns:
            QueryResult with extracted metadata
        """
        from .text import Text
        from .functional import LinkText, ButtonText

        # Extract bounds
        origin = getattr(obj, '_origin', np.array([0, 0]))
        size = getattr(obj, 'size', np.array([0, 0]))
        bounds = (
            int(origin[0]),
            int(origin[1]),
            int(size[0]) if hasattr(size, '__getitem__') else 0,
            int(size[1]) if hasattr(size, '__getitem__') else 0
        )

        # Determine type and extract metadata
        if isinstance(obj, LinkText):
            return QueryResult(
                object=obj,
                object_type="link",
                bounds=bounds,
                text=obj._text,
                is_interactive=True,
                link_target=obj._link.location if hasattr(obj, '_link') else None
            )
        elif isinstance(obj, ButtonText):
            return QueryResult(
                object=obj,
                object_type="button",
                bounds=bounds,
                text=obj._text,
                is_interactive=True,
                callback=obj._callback if hasattr(obj, '_callback') else None
            )
        elif isinstance(obj, Text):
            return QueryResult(
                object=obj,
                object_type="text",
                bounds=bounds,
                text=obj._text if hasattr(obj, '_text') else None
            )
        else:
            return QueryResult(
                object=obj,
                object_type="unknown",
                bounds=bounds
            )

    def query_range(self, start: Tuple[int, int],
                    end: Tuple[int, int]) -> SelectionRange:
        """
        Query all text objects between two points (for text selection).
        Uses Queriable.in_object() to determine which objects are in range.

        Args:
            start: Starting (x, y) point
            end: Ending (x, y) point

        Returns:
            SelectionRange with all text objects between the points
        """
        results = []
        in_selection = False

        start_result = self.query_point(start)
        end_result = self.query_point(end)

        if not start_result or not end_result:
            return SelectionRange(start, end, [])

        # Walk through all children (Lines) and their text objects
        from .text import Line, Text

        for child in self._children:
            if isinstance(child, Line) and hasattr(child, '_text_objects'):
                for text_obj in child._text_objects:
                    # Check if this text is the start or is between start and end
                    if text_obj == start_result.object:
                        in_selection = True

                    if in_selection and isinstance(text_obj, Text):
                        result = self._make_query_result(text_obj, start)
                        results.append(result)

                    if text_obj == end_result.object:
                        in_selection = False
                        break

        return SelectionRange(start, end, results)

    def in_object(self, point: Tuple[int, int]) -> bool:
        """
        Check if a point is within this page's bounds.

        Args:
            point: The (x, y) coordinates to check

        Returns:
            True if the point is within the page bounds
        """
        return (
            self._origin[0] <= point[0] < self._origin[0] + self._size[0] and
            self._origin[1] <= point[1] < self._origin[1] + self._size[1]
        )
