from __future__ import annotations
from pyWebLayout.core.base import Renderable, Queriable
from pyWebLayout.core.query import QueryResult
from .box import Box
from pyWebLayout.style import Alignment, Font, TextDecoration
from pyWebLayout.abstract import Word
from pyWebLayout.abstract.inline import LinkedWord
from pyWebLayout.abstract.functional import Link
from pyWebLayout.core.cache import UsageCache, SizedUsageCache
from PIL import ImageDraw, ImageFont
from typing import Tuple, List, Optional, Any, Dict
import logging
import math
import numpy as np
from abc import ABC, abstractmethod

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Text rendering caches
#
# A page re-measures and re-rasterises the same words constantly: measured over a
# novel at 1404x1872, a page issues ~2800 width measurements and ~2500 glyph
# rasterisations for fewer than 1000 distinct (font, string) pairs. Caching both
# turns a ~225ms page into a ~30ms page. Both caches are bounded so that a long
# reading session cannot grow without limit on a memory-constrained device.
# ---------------------------------------------------------------------------

# Word widths are small floats; 8192 entries costs well under 1MB and comfortably
# spans the working set of several chapters at a couple of font sizes.
DEFAULT_WIDTH_CACHE_ENTRIES = 8192

# Glyph bitmaps are the expensive ones: ~700 bytes each on average at 1404x1872,
# so an unbounded cache reaches ~12MB after 40 pages. 4MB holds several pages'
# worth of distinct words while leaving headroom on a 512MB Pi Zero 2.
DEFAULT_GLYPH_CACHE_BYTES = 4 * 1024 * 1024

# PIL rasterises text at sub-pixel horizontal offsets, so a cache keyed only on
# (font, string) would quantise every word to a whole pixel. Bucketing the
# sub-pixel phase keeps that error negligible at the cost of more entries. 2 steps
# holds the mean error to ~3.6/255 -- a fifth of one step of a 16-level e-ink
# panel -- while keeping the cache four times smaller than 4 steps would.
DEFAULT_GLYPH_SUBPIXEL_STEPS = 2


def _glyph_entry_bytes(entry: Tuple[Any, Tuple[int, int]]) -> int:
    """Approximate footprint of a cached (mask, offset) pair, in bytes."""
    mask = entry[0]
    try:
        width, height = mask.size
    except (AttributeError, TypeError, ValueError):
        return 0
    return width * height


_width_cache: UsageCache = UsageCache(DEFAULT_WIDTH_CACHE_ENTRIES)
_glyph_cache: SizedUsageCache = SizedUsageCache(DEFAULT_GLYPH_CACHE_BYTES, _glyph_entry_bytes)
_glyph_subpixel_steps: int = DEFAULT_GLYPH_SUBPIXEL_STEPS

# Set to False the first time the fast rasterisation path is found to be
# unavailable (e.g. a PIL build without the private ImageDraw internals it uses),
# after which every Text falls back to ImageDraw.text().
_glyph_fast_path_available: bool = True


def configure_text_caches(width_entries: Optional[int] = None,
                          glyph_bytes: Optional[int] = None,
                          subpixel_steps: Optional[int] = None):
    """
    Tune the text rendering caches.

    Memory-constrained targets should shrink these; a desktop rendering many font
    sizes may benefit from raising them.

    Args:
        width_entries: Maximum cached word-width measurements.
        glyph_bytes: Maximum total size of cached glyph bitmaps, in bytes.
        subpixel_steps: Sub-pixel phase buckets per axis. 1 disables sub-pixel
            positioning entirely (smallest cache, slightly softer text).
    """
    global _glyph_subpixel_steps

    if width_entries is not None:
        _width_cache.resize(width_entries)
    if glyph_bytes is not None:
        _glyph_cache.resize(glyph_bytes)
    if subpixel_steps is not None:
        if subpixel_steps <= 0:
            raise ValueError(f"subpixel_steps must be positive, got {subpixel_steps}")
        if subpixel_steps != _glyph_subpixel_steps:
            # Cached entries embed the phase bucket in their key.
            _glyph_cache.clear()
            _glyph_subpixel_steps = subpixel_steps


def clear_text_caches():
    """Drop all cached widths and glyph bitmaps."""
    _width_cache.clear()
    _glyph_cache.clear()


def text_cache_stats() -> Dict[str, Any]:
    """Occupancy and hit rates for both text caches, for tuning and diagnostics."""
    return {
        'width': _width_cache.stats(),
        'glyph': _glyph_cache.stats(),
        'glyph_subpixel_steps': _glyph_subpixel_steps,
        'glyph_fast_path': _glyph_fast_path_available,
    }


def prewarm_text_caches(entries,
                        draw: Optional[ImageDraw.ImageDraw] = None,
                        budget_bytes: Optional[int] = None,
                        max_words: Optional[int] = None) -> Tuple[int, int]:
    """
    Preload the caches with a document's most frequent words.

    A document states its own access distribution up front: the words it uses most
    are the words every page will draw. Rasterising them once at open time moves
    that work off the page-turn path, and seeding each entry with its document
    frequency puts it in the right place in the eviction order immediately, rather
    than after the cache has learned it.

    This depends on eviction ranking by use count. Under recency eviction the
    preloaded entries would be discarded by the first page of unfamiliar text; under
    usage ranking a word occurring 4000 times outranks anything met while scanning
    and stays resident. Measured over a 50-page trace, preloading cut misses by 27%
    with usage ranking against 12% with recency.

    Args:
        entries: Iterable of ``(font, text, colour, frequency)``, where `font` is a
            PIL font object, `colour` the fill the text will be drawn in, and
            `frequency` the number of times the word occurs in the document.
        draw: An ImageDraw sharing the page's mode, used to resolve ink and font
            mode. A scratch RGBA context is used if omitted.
        budget_bytes: Cap on bytes to preload. Defaults to half the glyph budget so
            that live rendering keeps room to cache what preloading missed.
        max_words: Cap on distinct words to preload, before sub-pixel variants.

    Returns:
        Tuple of (words preloaded, bytes preloaded).
    """
    if not _glyph_fast_path_available:
        return 0, 0

    if draw is None:
        from PIL import Image
        draw = ImageDraw.Draw(Image.new('RGBA', (1, 1)))

    if budget_bytes is None:
        budget_bytes = _glyph_cache.max_bytes // 2
    budget_bytes = min(budget_bytes, _glyph_cache.max_bytes)

    ranked = sorted(entries, key=lambda e: -e[3])
    if max_words is not None:
        ranked = ranked[:max_words]

    steps = _glyph_subpixel_steps
    mode = draw.fontmode
    draw_mode = draw.mode
    ink_cache: Dict[Any, Any] = {}
    words = 0
    used = 0

    for font, text, colour, frequency in ranked:
        if frequency <= 1 or used >= budget_bytes:
            break
        if not isinstance(font, ImageFont.FreeTypeFont):
            continue

        try:
            ink = ink_cache.get(colour)
            if ink is None:
                ink, _ = draw._getink(colour)
                if ink is None:
                    continue
                ink_cache[colour] = ink

            # Measuring is cheap and every layout pass needs it.
            _width_cache.put((font, text, draw_mode),
                             draw.textlength(text, font=font), count=frequency)

            # Words land on arbitrary sub-pixel offsets, so cover every horizontal
            # phase. Baselines are whole pixels, so only phase 0 is needed
            # vertically.
            for x_bucket in range(steps):
                entry = font.getmask2(text, mode, anchor="ls", ink=ink,
                                      start=(x_bucket / steps, 0.0))
                _glyph_cache.put((font, text, mode, ink, x_bucket, 0), entry,
                                 count=frequency)
                used += _glyph_entry_bytes(entry)
            words += 1

        except AttributeError:
            logger.warning("Glyph cache unavailable for this Pillow build; "
                           "skipping prewarm.", exc_info=True)
            return words, used
        except (TypeError, ValueError):
            continue

    logger.debug("Prewarmed %d words (%.2fMB) into the text caches",
                 words, used / 1e6)
    return words, used


class AlignmentHandler(ABC):
    """
    Abstract base class for text alignment handlers.
    Each handler implements a specific alignment strategy.
    """

    @abstractmethod
    def calculate_spacing_and_position(self, text_objects: List['Text'],
                                       available_width: int, min_spacing: int,
                                       max_spacing: int,
                                       natural_spacing: Optional[int] = None
                                       ) -> Tuple[int, int, bool]:
        """
        Calculate the spacing between words and starting position for the line.

        Args:
            text_objects: List of Text objects in the line
            available_width: Total width available for the line
            min_spacing: Minimum spacing between words
            max_spacing: Maximum spacing between words
            natural_spacing: The font's own space width. Ragged alignments use it
                as a constant gap; justification ignores it. Defaults to
                min_spacing when not supplied.

        Returns:
            Tuple of (spacing_between_words, starting_x_position, overflow)
        """


class LeftAlignmentHandler(AlignmentHandler):
    """Handler for left-aligned text."""

    def calculate_spacing_and_position(self,
                                       text_objects: List['Text'],
                                       available_width: int,
                                       min_spacing: int,
                                       max_spacing: int,
                                       natural_spacing: Optional[int] = None
                                       ) -> Tuple[int, int, bool]:
        """
        Calculate spacing and position for left-aligned text objects.

        Left-aligned text uses a constant word space and leaves whatever is left
        over as a ragged right edge. It must not spread the residual space across
        the gaps: that stretches each line by a different amount, which reads as
        badly-set justified text rather than as ragged-right.

        Args:
            text_objects (List[Text]): A list of text objects to be laid out.
            available_width (int): The total width available for layout.
            min_spacing (int): Minimum spacing between text objects.
            max_spacing (int): Maximum spacing between text objects.
            natural_spacing (Optional[int]): The font's own space width.

        Returns:
            Tuple[int, int, bool]: Spacing, start position, and overflow flag.
        """
        # Handle single word case
        if len(text_objects) <= 1:
            return 0, 0, False

        spacing = min_spacing if natural_spacing is None else natural_spacing
        spacing = max(min_spacing, min(max_spacing, int(spacing)))

        text_length = sum([text.width for text in text_objects])
        num_gaps = len(text_objects) - 1

        # The spacing is constant whether or not the content fits: tightening a
        # full line here would make it differ from its neighbours, which is the
        # variation this alignment is supposed to avoid. Report the overflow and
        # let line breaking move the offending word instead.
        overflow = text_length + (spacing * num_gaps) > available_width

        return spacing, 0, overflow


class CenterRightAlignmentHandler(AlignmentHandler):
    """Handler for center and right-aligned text."""

    def __init__(self, alignment: Alignment):
        self._alignment = alignment

    def calculate_spacing_and_position(self, text_objects: List['Text'],
                                       available_width: int, min_spacing: int,
                                       max_spacing: int,
                                       natural_spacing: Optional[int] = None
                                       ) -> Tuple[int, int, bool]:
        """
        Centre/right alignment: constant word space, line shifted as a block.

        Like left alignment, the residual space must not be spread across the
        gaps - it belongs in the margin. The start position is then derived from
        the same spacing that will actually be used, so the line lands where it
        was measured to land.
        """
        word_length = sum([word.width for word in text_objects])

        # Handle single word case
        if len(text_objects) <= 1:
            if self._alignment == Alignment.CENTER:
                start_position = (available_width - word_length) // 2
            else:  # RIGHT
                start_position = available_width - word_length
            return 0, max(0, int(start_position)), False

        spacing = min_spacing if natural_spacing is None else natural_spacing
        spacing = max(min_spacing, min(max_spacing, int(spacing)))

        num_gaps = len(text_objects) - 1
        overflow = word_length + (spacing * num_gaps) > available_width

        content_length = word_length + num_gaps * spacing
        if self._alignment == Alignment.CENTER:
            start_position = (available_width - content_length) // 2
        else:
            start_position = available_width - content_length

        return spacing, max(0, int(start_position)), overflow


class JustifyAlignmentHandler(AlignmentHandler):
    """Handler for justified text with full justification."""

    def __init__(self):
        # Store variable spacing for each gap to distribute remainder pixels
        self._gap_spacings: List[int] = []

    def calculate_spacing_and_position(self, text_objects: List['Text'],
                                       available_width: int, min_spacing: int,
                                       max_spacing: int,
                                       natural_spacing: Optional[int] = None
                                       ) -> Tuple[int, int, bool]:
        """
        Justified alignment distributes space to fill the entire line width.

        natural_spacing is ignored: filling the measure is the whole point.

        For justified text, we ALWAYS try to fill the entire width by distributing
        space between words, regardless of max_spacing constraints. The only limit
        is min_spacing to ensure readability.
        """

        word_length = sum([word.width for word in text_objects])
        residual_space = available_width - word_length
        num_gaps = max(1, len(text_objects) - 1)

        # Check if we have enough space for minimum spacing
        if residual_space // num_gaps < min_spacing:
            # Not enough space - this is overflow
            self._gap_spacings = [min_spacing] * num_gaps
            return min_spacing, 0, True

        # Distribute the residual by cumulative rounding rather than by taking a
        # floor per gap and scattering the remainder. Word widths are fractional,
        # so flooring each gap loses part of a pixel and truncating the remainder
        # loses up to another - the line then stops one or two pixels short of the
        # margin, and by a different amount on each line, which is visible as a
        # ragged right edge on otherwise justified text. Rounding the running
        # total makes the gaps sum to the residual exactly.
        total = int(round(residual_space))
        self._gap_spacings = []
        placed = 0
        for i in range(1, num_gaps + 1):
            cumulative = int(round(total * i / num_gaps))
            self._gap_spacings.append(cumulative - placed)
            placed = cumulative

        return self._gap_spacings[0], 0, False


class Text(Renderable, Queriable):
    """
    Concrete implementation for rendering text.
    This class handles the visual representation of text fragments.
    """

    def __init__(
            self,
            text: str,
            style: Font,
            draw: ImageDraw.Draw,
            source: Optional[Word] = None,
            line: Optional[Line] = None):
        """
        Initialize a Text object.

        Args:
            text: The text content to render
            style: The font style to use for rendering
        """
        super().__init__()
        self._text = text
        self._style = style
        self._line = line
        self._source = source
        self._origin = np.array([0, 0])
        self._draw = draw

        # Calculate dimensions
        self._calculate_dimensions()

    def _calculate_dimensions(self):
        """Calculate the width and height of the text based on the font metrics"""
        # Measuring a word costs a FreeType shaping pass, and the same words recur
        # constantly within a document, so results are cached per (font, string).
        # The draw's image mode is part of the key because PIL derives advance
        # widths differently for bilevel ("1") targets.
        font = self._style.font
        key = (font, self._text, self._draw.mode)

        width = _width_cache.get(key)
        if width is None:
            width = self._draw.textlength(self._text, font=font)
            _width_cache.put(key, width)
        self._width = width

        ascent, descent = font.getmetrics()
        self._ascent = ascent
        self._middle_y = ascent - descent / 2

    @classmethod
    def from_word(cls, word: Word, draw: ImageDraw.Draw):
        return cls(word.text, word.style, draw)

    @property
    def text(self) -> str:
        """Get the text content"""
        return self._text

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

    @property
    def origin(self) -> np.ndarray:
        """Get the origin of the text"""
        return self._origin

    @property
    def line(self) -> Optional[Line]:
        """Get the line containing this text"""
        return self._line

    @line.setter
    def line(self, line):
        """Set the line containing this text"""
        self._line = line

    @property
    def width(self) -> int:
        """Get the width of the text"""
        return self._width

    @property
    def size(self) -> int:
        """Get the width and height of the text"""
        # Return actual rendered height (ascent + descent) not just font_size
        ascent, descent = self._style.font.getmetrics()
        actual_height = ascent + descent
        return np.array((self._width, actual_height))

    def set_origin(self, origin: np.generic):
        """Set the origin (left baseline ("ls")) of this text element"""
        self._origin = origin

    def add_line(self, line):
        """Add this text to a line"""
        self._line = line

    def in_object(self, point: np.generic):
        """
        Check if a point is in the text object.

        Override Queriable.in_object() because Text uses baseline-anchored positioning.
        The origin is at the baseline (anchor="ls"), not the top-left corner.

        Args:
            point: The coordinates to check

        Returns:
            True if the point is within the text bounds
        """
        point_array = np.array(point)

        # Text origin is at baseline, so visual top is origin[1] - ascent
        visual_top = self._origin[1] - self._ascent
        visual_bottom = self._origin[1] + (self.size[1] - self._ascent)

        # Check if point is within bounds
        # X: origin[0] to origin[0] + width
        # Y: visual_top to visual_bottom
        return (self._origin[0] <= point_array[0] < self._origin[0] + self.size[0] and
                visual_top <= point_array[1] < visual_bottom)

    def _apply_decoration(self, next_text: Optional['Text'] = None, spacing: int = 0):
        """
        Apply text decoration (underline or strikethrough).

        Args:
            next_text: The next Text object in the line (if any)
            spacing: The spacing to the next text object
        """
        if self._style.decoration == TextDecoration.UNDERLINE:
            # Draw underline at about 90% of the height
            y_position = self._origin[1] - 0.1 * self._style.font_size
            line_width = max(1, int(self._style.font_size / 15))

            # Determine end x-coordinate
            end_x = self._origin[0] + self._width

            # If next text also has underline decoration, extend to connect them
            if (next_text is not None and
                next_text.style.decoration == TextDecoration.UNDERLINE and
                    next_text.style.colour == self._style.colour):
                # Extend the underline through the spacing to connect with next word
                end_x += spacing

            self._draw.line([(self._origin[0], y_position), (end_x, y_position)],
                            fill=self._style.colour, width=line_width)

        elif self._style.decoration == TextDecoration.STRIKETHROUGH:
            # Draw strikethrough at about 50% of the height
            y_position = self._origin[1] + self._middle_y
            line_width = max(1, int(self._style.font_size / 15))

            # Determine end x-coordinate
            end_x = self._origin[0] + self._width

            # If next text also has strikethrough decoration, extend to connect them
            if (next_text is not None and
                next_text.style.decoration == TextDecoration.STRIKETHROUGH and
                    next_text.style.colour == self._style.colour):
                # Extend the strikethrough through the spacing to connect with next word
                end_x += spacing

            self._draw.line([(self._origin[0], y_position), (end_x, y_position)],
                            fill=self._style.colour, width=line_width)

    def render(self, next_text: Optional['Text'] = None, spacing: int = 0):
        """
        Render the text to an image.

        Args:
            next_text: The next Text object in the line (if any)
            spacing: The spacing to the next text object

        Returns:
            A PIL Image containing the rendered text
        """

        style = self._style

        # Draw the text background if specified
        if style.background and style.background[3] > 0:  # If alpha > 0
            self._draw.rectangle([tuple(self._origin), tuple(self._origin + self.size)],
                                 fill=style.background)

        # Draw the text using baseline as anchor point ("ls" = left-baseline)
        # This ensures the origin represents the baseline, not the top-left
        if not self._render_from_glyph_cache(style):
            self._draw.text(
                (self.origin[0],
                 self._origin[1]),
                self._text,
                font=style.font,
                fill=style.colour,
                anchor="ls")

        # Apply any text decorations with knowledge of next text
        if style.decoration != TextDecoration.NONE:
            self._apply_decoration(next_text, spacing)

    def _render_from_glyph_cache(self, style) -> bool:
        """
        Blit this word from the cached glyph bitmap.

        Rasterising a word is the single most expensive step in drawing a page, and
        the same words recur constantly, so the bitmap PIL would produce is cached
        and blitted directly. This reproduces what ImageDraw.text() does internally
        (getmask2 followed by draw_bitmap) minus the per-call setup.

        Returns:
            True if the word was drawn. False means the caller must fall back to
            ImageDraw.text().
        """
        global _glyph_fast_path_available

        if not _glyph_fast_path_available:
            return False

        draw = self._draw
        font = style.font

        # Bitmap and other non-FreeType fonts do not expose getmask2's anchor and
        # sub-pixel arguments; let PIL handle them.
        if not isinstance(font, ImageFont.FreeTypeFont):
            return False

        try:
            ink, _ = draw._getink(style.colour)
            if ink is None:
                return False

            # floor() rather than modf() so the fraction is always in [0, 1),
            # keeping bucket indices non-negative for negative coordinates.
            x = float(self._origin[0])
            y = float(self._origin[1])
            x_whole = math.floor(x)
            y_whole = math.floor(y)

            steps = _glyph_subpixel_steps
            x_bucket = int((x - x_whole) * steps)
            y_bucket = int((y - y_whole) * steps)

            mode = draw.fontmode
            key = (font, self._text, mode, ink, x_bucket, y_bucket)

            entry = _glyph_cache.get(key)
            if entry is None:
                entry = font.getmask2(
                    self._text, mode, anchor="ls", ink=ink,
                    start=(x_bucket / steps, y_bucket / steps))
                _glyph_cache.put(key, entry)

            mask, offset = entry
            draw.draw.draw_bitmap((x_whole + offset[0], y_whole + offset[1]), mask, ink)
            return True

        except AttributeError:
            # A PIL build without the internals this path relies on. Stop trying.
            logger.warning(
                "Glyph cache unavailable for this Pillow build; falling back to "
                "ImageDraw.text() for all text rendering.", exc_info=True)
            _glyph_fast_path_available = False
            return False
        except (TypeError, ValueError):
            # This particular colour/mode combination is not supported by the fast
            # path (e.g. an ink PIL cannot resolve). Others may still be.
            return False


class Line(Box):
    """
    A line of text consisting of Text objects with consistent spacing.
    Each Text represents a word or word fragment that can be rendered.
    """

    def __init__(self,
                 spacing: Tuple[int,
                                int],
                 origin,
                 size,
                 draw: ImageDraw.Draw,
                 font: Optional[Font] = None,
                 callback=None,
                 sheet=None,
                 mode=None,
                 halign=Alignment.CENTER,
                 valign=Alignment.CENTER,
                 previous=None,
                 min_word_length_for_brute_force: int = 8,
                 min_chars_before_hyphen: int = 2,
                 min_chars_after_hyphen: int = 2):
        """
        Initialize a new line.

        Args:
            spacing: A tuple of (min_spacing, max_spacing) between words
            origin: The top-left position of the line
            size: The width and height of the line
            font: The default font to use for text in this line
            callback: Optional callback function
            sheet: Optional image sheet
            mode: Optional image mode
            halign: Horizontal alignment of text within the line
            valign: Vertical alignment of text within the line
            previous: Reference to the previous line
            min_word_length_for_brute_force: Minimum word length to attempt brute force hyphenation (default: 8)
            min_chars_before_hyphen: Minimum characters before hyphen in any split (default: 2)
            min_chars_after_hyphen: Minimum characters after hyphen in any split (default: 2)
        """
        super().__init__(origin, size, callback, sheet, mode, halign, valign)
        self._text_objects: List['Text'] = []  # Store Text objects directly
        self._spacing = spacing  # (min_spacing, max_spacing)
        self._font = font if font else Font()  # Use default font if none provided
        self._current_width = 0  # Track the current width used
        self._words: List['Word'] = []
        self._previous = previous
        self._next = None
        ascent, descent = self._font.font.getmetrics()
        # Store baseline as offset from line origin (top), not absolute position
        self._baseline = ascent
        self._draw = draw
        self._spacing_render = (spacing[0] + spacing[1]) // 2
        self._position_render = 0

        # The font's own space advance. Ragged alignments use this as their
        # constant word gap rather than stretching to fill the measure.
        try:
            self._natural_spacing = int(round(self._font.font.getlength(" ")))
        except (AttributeError, TypeError, ValueError):
            self._natural_spacing = None

        # Hyphenation configuration parameters
        self._min_word_length_for_brute_force = min_word_length_for_brute_force
        self._min_chars_before_hyphen = min_chars_before_hyphen
        self._min_chars_after_hyphen = min_chars_after_hyphen

        # Create the appropriate alignment handler
        self._alignment_handler = self._create_alignment_handler(halign)

        # Set on the final line of a paragraph. Justification stretches a line to
        # fill the column, which is wrong for the last line - a three-word tail
        # would be spread across the full measure. The last line takes its
        # natural width instead, as in every other typesetting system.
        self._is_paragraph_end = False

    @property
    def is_paragraph_end(self) -> bool:
        """Whether this is the final line of its paragraph"""
        return self._is_paragraph_end

    @is_paragraph_end.setter
    def is_paragraph_end(self, value: bool):
        self._is_paragraph_end = value

    @property
    def render_alignment_handler(self) -> AlignmentHandler:
        """
        The handler used to position text when rendering.

        This differs from the fitting handler only for the last line of a
        justified paragraph, which is rendered flush left.
        """
        if self._is_paragraph_end and isinstance(
                self._alignment_handler, JustifyAlignmentHandler):
            return LeftAlignmentHandler()
        return self._alignment_handler

    def _create_alignment_handler(self, alignment: Alignment) -> AlignmentHandler:
        """
        Create the appropriate alignment handler based on the alignment type.

        Args:
            alignment: The alignment type

        Returns:
            The appropriate alignment handler instance
        """
        if alignment == Alignment.LEFT:
            return LeftAlignmentHandler()
        elif alignment == Alignment.JUSTIFY:
            return JustifyAlignmentHandler()
        else:  # CENTER or RIGHT
            return CenterRightAlignmentHandler(alignment)

    @property
    def text_objects(self) -> List[Text]:
        """Get the list of Text objects in this line"""
        return self._text_objects

    def set_next(self, line: Line):
        """Set the next line in sequence"""
        self._next = line

    def add_word(self,
                 word: 'Word',
                 part: Optional[Text] = None) -> Tuple[bool,
                                                       Optional['Text']]:
        """
        Add a word to this line using intelligent word fitting strategies.

        Args:
            word: The word to add to the line
            part: Optional pretext from a previous hyphenated word

        Returns:
            Tuple of (success, overflow_text):
            - success: True if word/part was added, False if it couldn't fit
            - overflow_text: Remaining text if word was hyphenated, None otherwise
        """
        # First, add any pretext from previous hyphenation
        if part is not None:
            self._text_objects.append(part)
            self._words.append(word)
            part.add_line(self)

        # Try to add the full word - create LinkText for LinkedWord, regular Text
        # otherwise
        if isinstance(word, LinkedWord):
            # Import here to avoid circular dependency
            from .functional import LinkText
            # Create a LinkText which includes the link functionality
            # LinkText constructor needs: (link, text, font, draw, source, line)
            # But LinkedWord itself contains the link properties
            # We'll create a Link object from the LinkedWord properties
            link = Link(
                location=word.location,
                link_type=word.link_type,
                callback=word.link_callback,
                params=word.params,
                title=word.link_title
            )
            text = LinkText(
                link,
                word.text,
                word.style,
                self._draw,
                source=word,
                line=self)
        else:
            text = Text.from_word(word, self._draw)
        self._text_objects.append(text)
        spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
            self._text_objects, self._size[0], self._spacing[0], self._spacing[1],
                self._natural_spacing)

        if not overflow:
            # Word fits! Add it completely
            self._words.append(word)
            word.add_concete(text)
            text.add_line(self)
            self._position_render = position
            self._spacing_render = spacing
            return True, None

        # Word doesn't fit, remove it and try hyphenation
        _ = self._text_objects.pop()

        # Step 1: Try pyphen hyphenation
        pyphen_splits = word.possible_hyphenation()
        valid_splits = []

        if pyphen_splits:
            # Create Text objects for each possible split and check if they fit
            for pair in pyphen_splits:
                first_part_text = pair[0] + "-"
                second_part_text = pair[1]

                # Validate minimum character requirements
                if len(pair[0]) < self._min_chars_before_hyphen:
                    continue
                if len(pair[1]) < self._min_chars_after_hyphen:
                    continue

                # Create Text objects
                first_text = Text(
                    first_part_text,
                    word.style,
                    self._draw,
                    line=self,
                    source=word)
                second_text = Text(
                    second_part_text,
                    word.style,
                    self._draw,
                    line=self,
                    source=word)

                # Check if first part fits
                self._text_objects.append(first_text)
                spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
                    self._text_objects, self._size[0], self._spacing[0], self._spacing[1],
                self._natural_spacing)
                _ = self._text_objects.pop()

                if not overflow:
                    # This split fits! Add it to valid options
                    valid_splits.append((first_text, second_text, spacing, position))

        # Step 2: If we have valid pyphen splits, choose the best one
        if valid_splits:
            # Select the split with the best (minimum) spacing
            best_split = min(valid_splits, key=lambda x: x[2])
            first_text, second_text, spacing, position = best_split

            # Apply the split
            self._text_objects.append(first_text)
            first_text.line = self
            word.add_concete((first_text, second_text))
            self._spacing_render = spacing
            self._position_render = position
            self._words.append(word)
            return True, second_text

        # Step 3: Try brute force hyphenation (only for long words)
        if len(word.text) >= self._min_word_length_for_brute_force:
            # Calculate available space for the word
            word_length = sum([text.width for text in self._text_objects])
            spacing_length = self._spacing[0] * max(0, len(self._text_objects) - 1)
            remaining = self._size[0] - word_length - spacing_length

            if remaining > 0:
                # Create a hyphenated version to measure
                test_text = Text(word.text + "-", word.style, self._draw)

                if test_text.width > 0:
                    # Calculate what fraction of the hyphenated word fits
                    fraction = remaining / test_text.width

                    # Convert fraction to character position
                    # We need at least min_chars_before_hyphen and leave at least
                    # min_chars_after_hyphen
                    max_split_pos = len(word.text) - self._min_chars_after_hyphen
                    min_split_pos = self._min_chars_before_hyphen

                    # Calculate ideal split position based on available space
                    ideal_split = int(fraction * len(word.text))
                    split_pos = max(min_split_pos, min(ideal_split, max_split_pos))

                    # Ensure we meet minimum requirements
                    if (split_pos >= self._min_chars_before_hyphen and
                            len(word.text) - split_pos >= self._min_chars_after_hyphen):

                        # Create the split
                        first_part_text = word.text[:split_pos] + "-"
                        second_part_text = word.text[split_pos:]

                        first_text = Text(
                            first_part_text,
                            word.style,
                            self._draw,
                            line=self,
                            source=word)
                        second_text = Text(
                            second_part_text,
                            word.style,
                            self._draw,
                            line=self,
                            source=word)

                        # Verify the first part actually fits
                        self._text_objects.append(first_text)
                        spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
                            self._text_objects, self._size[0], self._spacing[0], self._spacing[1],
                self._natural_spacing)

                        if not overflow:
                            # Brute force split works!
                            first_text.line = self
                            second_text.line = self
                            word.add_concete((first_text, second_text))
                            self._spacing_render = spacing
                            self._position_render = position
                            self._words.append(word)
                            return True, second_text
                        else:
                            # Doesn't fit, remove it
                            _ = self._text_objects.pop()

        # Step 4: Word cannot be hyphenated or split, move to next line
        return False, None

    def render(self):
        """
        Render the line with all its text objects using the alignment handler system.

        Returns:
            A PIL Image containing the rendered line
        """
        # Recalculate spacing and position for current text objects to ensure
        # accuracy. Word fitting used the paragraph's alignment; rendering uses
        # render_alignment_handler, which differs only for the last line of a
        # justified paragraph.
        handler = self.render_alignment_handler
        if len(self._text_objects) > 0:
            spacing, position, overflow = handler.calculate_spacing_and_position(
                self._text_objects, self._size[0], self._spacing[0], self._spacing[1],
                self._natural_spacing)
            self._spacing_render = spacing
            self._position_render = position

        y_cursor = self._origin[1] + self._baseline

        # Start x_cursor at line origin plus any alignment offset
        x_cursor = self._origin[0] + self._position_render
        for i, text in enumerate(self._text_objects):
            # Update text draw context to current draw context
            text._draw = self._draw
            text.set_origin(np.array([x_cursor, y_cursor]))

            # Determine next text object for continuous decoration
            next_text = self._text_objects[i + 1] if i + \
                1 < len(self._text_objects) else None

            # Get the spacing for this specific gap (variable for justified text)
            if isinstance(handler, JustifyAlignmentHandler) and \
               hasattr(handler, '_gap_spacings') and \
               i < len(handler._gap_spacings):
                current_spacing = handler._gap_spacings[i]
            else:
                current_spacing = self._spacing_render

            # Render with next text information for continuous underline/strikethrough
            text.render(next_text, current_spacing)
            # Add text width, then spacing only if there are more words
            x_cursor += text.width
            if i < len(self._text_objects) - 1:
                x_cursor += current_spacing

    def query_point(self, point: Tuple[int, int]) -> Optional['QueryResult']:
        """
        Find which Text object contains the given point.
        Uses Queriable.in_object() mixin for hit-testing.

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

        Returns:
            QueryResult from the text object at that point, or None
        """
        point_array = np.array(point)

        # Check each text object in this line
        for text_obj in self._text_objects:
            # Use Queriable mixin's in_object() for hit-testing
            if isinstance(text_obj, Queriable) and text_obj.in_object(point_array):
                # Extract metadata based on text type
                origin = text_obj._origin
                size = text_obj.size

                # Text origin is at baseline (anchor="ls"), so visual top is origin[1] - ascent
                # Bounds should be (x, visual_top, width, height) for proper
                # highlighting
                visual_top = int(origin[1] - text_obj._ascent)
                bounds = (
                    int(origin[0]),
                    visual_top,
                    int(size[0]) if hasattr(size, '__getitem__') else 0,
                    int(size[1]) if hasattr(size, '__getitem__') else 0
                )

                # Import here to avoid circular dependency
                from .functional import LinkText, ButtonText

                if isinstance(text_obj, LinkText):
                    result = QueryResult(
                        object=text_obj,
                        object_type="link",
                        bounds=bounds,
                        text=text_obj._text,
                        is_interactive=True,
                        link_target=text_obj._link.location if hasattr(
                            text_obj,
                            '_link') else None)
                elif isinstance(text_obj, ButtonText):
                    result = QueryResult(
                        object=text_obj,
                        object_type="button",
                        bounds=bounds,
                        text=text_obj._text,
                        is_interactive=True,
                        callback=text_obj._callback if hasattr(
                            text_obj,
                            '_callback') else None)
                else:
                    result = QueryResult(
                        object=text_obj,
                        object_type="text",
                        bounds=bounds,
                        text=text_obj._text if hasattr(text_obj, '_text') else None
                    )

                result.parent_line = self
                return result

        return None
