from enum import IntEnum
from collections.abc import Callable
from itertools import zip_longest
from typing import Union
import pyray as rl

from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_SIZE, DEFAULT_TEXT_COLOR, FONT_SCALE
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.utils import GuiStyleContext
from openpilot.system.ui.lib.emoji import find_emoji, emoji_tex
from openpilot.system.ui.lib.wrap_text import wrap_text

ICON_PADDING = 15


# TODO: make this common
def _resolve_value(value, default=""):
  if callable(value):
    return value()
  return value if value is not None else default


class ScrollState(IntEnum):
  STARTING = 0
  SCROLLING = 1


# TODO: merge anything new here to master
class MiciLabel(Widget):
  def __init__(self,
               text: str,
               font_size: int = DEFAULT_TEXT_SIZE,
               width: int | None = None,
               color: rl.Color = DEFAULT_TEXT_COLOR,
               font_weight: FontWeight = FontWeight.NORMAL,
               alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
               alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
               spacing: int = 0,
               line_height: int | None = None,
               elide_right: bool = True,
               wrap_text: bool = False,
               scroll: bool = False):
    super().__init__()
    self.text = text
    self.wrapped_text: list[str] = []
    self.font_size = font_size
    self.width = width
    self.color = color
    self.font_weight = font_weight
    self.alignment = alignment
    self.alignment_vertical = alignment_vertical
    self.spacing = spacing
    self.line_height = line_height if line_height is not None else font_size
    self.elide_right = elide_right
    self.wrap_text = wrap_text
    self._height = 0

    # Scroll state
    self.scroll = scroll
    self._needs_scroll = False
    self._scroll_offset = 0
    self._scroll_pause_t: float | None = None
    self._scroll_state: ScrollState = ScrollState.STARTING

    assert not (self.scroll and self.wrap_text), "Cannot enable both scroll and wrap_text"
    assert not (self.scroll and self.elide_right), "Cannot enable both scroll and elide_right"

    self.set_text(text)

  @property
  def text_height(self):
    return self._height

  def set_font_size(self, font_size: int):
    self.font_size = font_size
    self.set_text(self.text)

  def set_width(self, width: int):
    self.width = width
    self._rect.width = width
    self.set_text(self.text)

  def set_text(self, txt: str):
    self.text = txt
    text_size = measure_text_cached(gui_app.font(self.font_weight), self.text, self.font_size, self.spacing)
    if self.width is not None:
      self._rect.width = self.width
    else:
      self._rect.width = text_size.x

    if self.wrap_text:
      self.wrapped_text = wrap_text(gui_app.font(self.font_weight), self.text, self.font_size, int(self._rect.width))
      self._height = len(self.wrapped_text) * self.line_height
    elif self.scroll:
      self._needs_scroll = self.scroll and text_size.x > self._rect.width
      self._rect.height = text_size.y

  def set_color(self, color: rl.Color):
    self.color = color

  def set_font_weight(self, font_weight: FontWeight):
    self.font_weight = font_weight
    self.set_text(self.text)

  def _render(self, rect: rl.Rectangle):
    # Only scissor when we know there is a single scrolling line
    if self._needs_scroll:
      rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))

    font = gui_app.font(self.font_weight)

    text_y_offset = 0
    # Draw the text in the specified rectangle
    lines = self.wrapped_text or [self.text]
    if self.alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM:
      lines = lines[::-1]

    for display_text in lines:
      text_size = measure_text_cached(font, display_text, self.font_size, self.spacing)

      # Elide text to fit within the rectangle
      if self.elide_right and text_size.x > rect.width:
        ellipsis = "..."
        left, right = 0, len(display_text)
        while left < right:
          mid = (left + right) // 2
          candidate = display_text[:mid] + ellipsis
          candidate_size = measure_text_cached(font, candidate, self.font_size, self.spacing)
          if candidate_size.x <= rect.width:
            left = mid + 1
          else:
            right = mid
        display_text = display_text[: left - 1] + ellipsis if left > 0 else ellipsis
        text_size = measure_text_cached(font, display_text, self.font_size, self.spacing)

      # Handle scroll state
      elif self.scroll and self._needs_scroll:
        if self._scroll_state == ScrollState.STARTING:
          if self._scroll_pause_t is None:
            self._scroll_pause_t = rl.get_time() + 2.0
          if rl.get_time() >= self._scroll_pause_t:
            self._scroll_state = ScrollState.SCROLLING
            self._scroll_pause_t = None

        elif self._scroll_state == ScrollState.SCROLLING:
          self._scroll_offset -= 0.8 / 60. * gui_app.target_fps
          # don't fully hide
          if self._scroll_offset <= -text_size.x - self._rect.width / 3:
            self._scroll_offset = 0
            self._scroll_state = ScrollState.STARTING
            self._scroll_pause_t = None

      # Calculate horizontal position based on alignment
      text_x = rect.x + {
        rl.GuiTextAlignment.TEXT_ALIGN_LEFT: 0,
        rl.GuiTextAlignment.TEXT_ALIGN_CENTER: (rect.width - text_size.x) / 2,
        rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: rect.width - text_size.x,
      }.get(self.alignment, 0) + self._scroll_offset

      # Calculate vertical position based on alignment
      text_y = rect.y + {
        rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP: 0,
        rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE: (rect.height - text_size.y) / 2,
        rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: rect.height - text_size.y,
      }.get(self.alignment_vertical, 0)
      text_y += text_y_offset

      rl.draw_text_ex(font, display_text, rl.Vector2(round(text_x), text_y), self.font_size, self.spacing, self.color)
      # Draw 2nd instance for scrolling
      if self._needs_scroll and self._scroll_state != ScrollState.STARTING:
        text2_scroll_offset = text_size.x + self._rect.width / 3
        rl.draw_text_ex(font, display_text, rl.Vector2(round(text_x + text2_scroll_offset), text_y), self.font_size, self.spacing, self.color)
      if self.alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM:
        text_y_offset -= self.line_height
      else:
        text_y_offset += self.line_height

    if self._needs_scroll:
      # draw black fade on left and right
      fade_width = 20
      rl.draw_rectangle_gradient_h(int(rect.x + rect.width - fade_width), int(rect.y), fade_width, int(rect.height), rl.BLANK, rl.BLACK)
      if self._scroll_state != ScrollState.STARTING:
        rl.draw_rectangle_gradient_h(int(rect.x), int(rect.y), fade_width, int(rect.height), rl.BLACK, rl.BLANK)

      rl.end_scissor_mode()


# TODO: This should be a Widget class
def gui_label(
  rect: rl.Rectangle,
  text: str,
  font_size: int = DEFAULT_TEXT_SIZE,
  color: rl.Color = DEFAULT_TEXT_COLOR,
  font_weight: FontWeight = FontWeight.NORMAL,
  alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
  alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
  elide_right: bool = True
):
  font = gui_app.font(font_weight)
  text_size = measure_text_cached(font, text, font_size)
  display_text = text

  # Elide text to fit within the rectangle
  if elide_right and text_size.x > rect.width:
    _ellipsis = "..."
    left, right = 0, len(text)
    while left < right:
      mid = (left + right) // 2
      candidate = text[:mid] + _ellipsis
      candidate_size = measure_text_cached(font, candidate, font_size)
      if candidate_size.x <= rect.width:
        left = mid + 1
      else:
        right = mid
    display_text = text[: left - 1] + _ellipsis if left > 0 else _ellipsis
    text_size = measure_text_cached(font, display_text, font_size)

  # Calculate horizontal position based on alignment
  text_x = rect.x + {
    rl.GuiTextAlignment.TEXT_ALIGN_LEFT: 0,
    rl.GuiTextAlignment.TEXT_ALIGN_CENTER: (rect.width - text_size.x) / 2,
    rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: rect.width - text_size.x,
  }.get(alignment, 0)

  # Calculate vertical position based on alignment
  text_y = rect.y + {
    rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP: 0,
    rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE: (rect.height - text_size.y) / 2,
    rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: rect.height - text_size.y,
  }.get(alignment_vertical, 0)

  # Draw the text in the specified rectangle
  # TODO: add wrapping and proper centering for multiline text
  rl.draw_text_ex(font, display_text, rl.Vector2(text_x, text_y), font_size, 0, color)


def gui_text_box(
  rect: rl.Rectangle,
  text: str,
  font_size: int = DEFAULT_TEXT_SIZE,
  color: rl.Color = DEFAULT_TEXT_COLOR,
  alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
  alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
  font_weight: FontWeight = FontWeight.NORMAL,
  line_scale: float = 1.0,
):
  styles = [
    (rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_COLOR_NORMAL, rl.color_to_int(color)),
    (rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_SIZE, round(font_size * FONT_SCALE)),
    (rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_LINE_SPACING, round(font_size * FONT_SCALE * line_scale)),
    (rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_ALIGNMENT, alignment),
    (rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_ALIGNMENT_VERTICAL, alignment_vertical),
    (rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_WRAP_MODE, rl.GuiTextWrapMode.TEXT_WRAP_WORD)
  ]
  if font_weight != FontWeight.NORMAL:
    rl.gui_set_font(gui_app.font(font_weight))

  with GuiStyleContext(styles):
    rl.gui_label(rect, text)

  if font_weight != FontWeight.NORMAL:
    rl.gui_set_font(gui_app.font(FontWeight.NORMAL))


# Non-interactive text area. Can render emojis and an optional specified icon.
class Label(Widget):
  def __init__(self,
               text: str | Callable[[], str],
               font_size: int = DEFAULT_TEXT_SIZE,
               font_weight: FontWeight = FontWeight.NORMAL,
               text_alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
               text_alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
               text_padding: int = 0,
               text_color: rl.Color = DEFAULT_TEXT_COLOR,
               icon: Union[rl.Texture, None] = None,
               elide_right: bool = False,
               line_scale=1.0,
               ):

    super().__init__()
    self._font_weight = font_weight
    self._font = gui_app.font(self._font_weight)
    self._font_size = font_size
    self._text_alignment = text_alignment
    self._text_alignment_vertical = text_alignment_vertical
    self._text_padding = text_padding
    self._text_color = text_color
    self._icon = icon
    self._elide_right = elide_right
    self._line_scale = line_scale

    self._text = text
    self.set_text(text)

  def set_text(self, text):
    self._text = text
    self._update_text(self._text)

  def set_text_color(self, color):
    self._text_color = color

  def set_font_size(self, size):
    self._font_size = size
    self._update_text(self._text)

  def _update_text(self, text):
    self._emojis = []
    self._text_size = []
    text = _resolve_value(text)

    if self._elide_right:
      display_text = text

      # Elide text to fit within the rectangle
      text_size = measure_text_cached(self._font, text, self._font_size)
      content_width = self._rect.width - self._text_padding * 2
      if self._icon:
        content_width -= self._icon.width + ICON_PADDING
      if text_size.x > content_width:
        _ellipsis = "..."
        left, right = 0, len(text)
        while left < right:
          mid = (left + right) // 2
          candidate = text[:mid] + _ellipsis
          candidate_size = measure_text_cached(self._font, candidate, self._font_size)
          if candidate_size.x <= content_width:
            left = mid + 1
          else:
            right = mid
        display_text = text[: left - 1] + _ellipsis if left > 0 else _ellipsis

      self._text_wrapped = [display_text]
    else:
      self._text_wrapped = wrap_text(self._font, text, self._font_size, round(self._rect.width - (self._text_padding * 2)))

    for t in self._text_wrapped:
      self._emojis.append(find_emoji(t))
      self._text_size.append(measure_text_cached(self._font, t, self._font_size))

  def _render(self, _):
    # Text can be a callable
    # TODO: cache until text changed
    self._update_text(self._text)

    text_size = self._text_size[0] if self._text_size else rl.Vector2(0.0, 0.0)
    if self._text_alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE:
      total_text_height = sum(ts.y for ts in self._text_size) or self._font_size * FONT_SCALE
      text_pos = rl.Vector2(self._rect.x, (self._rect.y + (self._rect.height - total_text_height) // 2))
    else:
      text_pos = rl.Vector2(self._rect.x, self._rect.y)

    if self._icon:
      icon_y = self._rect.y + (self._rect.height - self._icon.height) / 2
      if len(self._text_wrapped) > 0:
        if self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT:
          icon_x = self._rect.x + self._text_padding
          text_pos.x = self._icon.width + ICON_PADDING
        elif self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER:
          total_width = self._icon.width + ICON_PADDING + text_size.x
          icon_x = self._rect.x + (self._rect.width - total_width) / 2
          text_pos.x = self._icon.width + ICON_PADDING
        else:
          icon_x = (self._rect.x + self._rect.width - text_size.x - self._text_padding) - ICON_PADDING - self._icon.width
      else:
        icon_x = self._rect.x + (self._rect.width - self._icon.width) / 2
      rl.draw_texture_v(self._icon, rl.Vector2(icon_x, icon_y), rl.WHITE)

    for text, text_size, emojis in zip_longest(self._text_wrapped, self._text_size, self._emojis, fillvalue=[]):
      line_pos = rl.Vector2(text_pos.x, text_pos.y)
      if self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT:
        line_pos.x += self._text_padding
      elif self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER:
        line_pos.x += (self._rect.width - text_size.x) // 2
      elif self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_RIGHT:
        line_pos.x += self._rect.width - text_size.x - self._text_padding

      prev_index = 0
      for start, end, emoji in emojis:
        text_before = text[prev_index:start]
        width_before = measure_text_cached(self._font, text_before, self._font_size)
        rl.draw_text_ex(self._font, text_before, line_pos, self._font_size, 0, self._text_color)
        line_pos.x += width_before.x

        tex = emoji_tex(emoji)
        rl.draw_texture_ex(tex, line_pos, 0.0, self._font_size / tex.height * FONT_SCALE, self._text_color)
        line_pos.x += self._font_size * FONT_SCALE
        prev_index = end
      rl.draw_text_ex(self._font, text[prev_index:], line_pos, self._font_size, 0, self._text_color)
      text_pos.y += (text_size.y or self._font_size * FONT_SCALE) * self._line_scale


class UnifiedLabel(Widget):
  """
  Unified label widget that combines functionality from gui_label, gui_text_box, Label, and MiciLabel.

  Supports:
  - Emoji rendering
  - Text wrapping
  - Automatic eliding (single-line or multiline)
  - Proper multiline vertical alignment
  - Height calculation for layout purposes
  """
  def __init__(self,
               text: str | Callable[[], str],
               font_size: int = DEFAULT_TEXT_SIZE,
               font_weight: FontWeight = FontWeight.NORMAL,
               text_color: rl.Color = DEFAULT_TEXT_COLOR,
               alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
               alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
               text_padding: int = 0,
               max_width: int | None = None,
               elide: bool = True,
               wrap_text: bool = True,
               scroll: bool = False,
               line_height: float = 1.0,
               letter_spacing: float = 0.0):
    super().__init__()
    self._text = text
    self._font_size = font_size
    self._font_weight = font_weight
    self._font = gui_app.font(self._font_weight)
    self._text_color = text_color
    self._alignment = alignment
    self._alignment_vertical = alignment_vertical
    self._text_padding = text_padding
    self._max_width = max_width
    self._elide = elide
    self._wrap_text = wrap_text
    self._scroll = scroll
    self._line_height = line_height * 0.9
    self._letter_spacing = letter_spacing  # 0.1 = 10%
    self._spacing_pixels = font_size * letter_spacing

    # Scroll state
    self._scroll = scroll
    self._needs_scroll = False
    self._scroll_offset = 0
    self._scroll_pause_t: float | None = None
    self._scroll_state: ScrollState = ScrollState.STARTING

    # Scroll mode does not support eliding or multiline wrapping
    if self._scroll:
      self._elide = False
      self._wrap_text = False

    # Cached data
    self._cached_text: str | None = None
    self._cached_wrapped_lines: list[str] = []
    self._cached_line_sizes: list[rl.Vector2] = []
    self._cached_line_emojis: list[list[tuple[int, int, str]]] = []
    self._cached_total_height: float | None = None
    self._cached_width: int = -1

    # If max_width is set, initialize rect size for Scroller support
    if max_width is not None:
      self._rect.width = max_width
      self._rect.height = self.get_content_height(max_width)

  def set_text(self, text: str | Callable[[], str]):
    """Update the text content."""
    self._text = text
    # No need to update cache here, will be done on next render if needed

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

  def set_text_color(self, color: rl.Color):
    """Update the text color."""
    self._text_color = color

  def set_color(self, color: rl.Color):
    """Update the text color (alias for set_text_color)."""
    self.set_text_color(color)

  def set_font_size(self, size: int):
    """Update the font size."""
    if self._font_size != size:
      self._font_size = size
      self._spacing_pixels = size * self._letter_spacing  # Recalculate spacing
      self._cached_text = None  # Invalidate cache

  def set_letter_spacing(self, letter_spacing: float):
    """Update letter spacing (as percentage, e.g., 0.1 = 10%)."""
    if self._letter_spacing != letter_spacing:
      self._letter_spacing = letter_spacing
      self._spacing_pixels = self._font_size * letter_spacing
      self._cached_text = None  # Invalidate cache

  def set_line_height(self, line_height: float):
    """Update line height (multiplier, e.g., 1.0 = default)."""
    new_line_height = line_height * 0.9
    if self._line_height != new_line_height:
      self._line_height = new_line_height
      self._cached_text = None  # Invalidate cache (affects total height)

  def set_font_weight(self, font_weight: FontWeight):
    """Update the font weight."""
    if self._font_weight != font_weight:
      self._font_weight = font_weight
      self._font = gui_app.font(self._font_weight)
      self._cached_text = None  # Invalidate cache

  def set_alignment(self, alignment: int):
    """Update the horizontal text alignment."""
    self._alignment = alignment

  def set_alignment_vertical(self, alignment_vertical: int):
    """Update the vertical text alignment."""
    self._alignment_vertical = alignment_vertical

  def reset_scroll(self):
    """Reset scroll state to initial position."""
    self._scroll_offset = 0
    self._scroll_pause_t = None
    self._scroll_state = ScrollState.STARTING

  def set_max_width(self, max_width: int | None):
    """Set the maximum width constraint for wrapping/eliding."""
    if self._max_width != max_width:
      self._max_width = max_width
      self._cached_text = None  # Invalidate cache
      # Update rect size for Scroller support
      if max_width is not None:
        self._rect.width = max_width
        self._rect.height = self.get_content_height(max_width)

  def _update_text_cache(self, available_width: int):
    """Update cached text processing data."""
    text = self.text

    # Check if cache is still valid
    if (self._cached_text == text and
        self._cached_width == available_width and
        self._cached_wrapped_lines):
      return

    self._cached_text = text
    self._cached_width = available_width

    # Determine wrapping width
    content_width = available_width - (self._text_padding * 2)
    if content_width <= 0:
      content_width = 1

    # Wrap text if enabled
    if self._wrap_text:
      self._cached_wrapped_lines = wrap_text(self._font, text, self._font_size, content_width, self._spacing_pixels)
    else:
      # Split by newlines but don't wrap
      self._cached_wrapped_lines = text.split('\n') if text else [""]

    # Elide lines if needed (for width constraint)
    self._cached_wrapped_lines = [self._elide_line(line, content_width) for line in self._cached_wrapped_lines]

    if self._scroll:
      self._cached_wrapped_lines = self._cached_wrapped_lines[:1]  # Only first line for scrolling

    # Process each line: measure and find emojis
    self._cached_line_sizes = []
    self._cached_line_emojis = []

    for line in self._cached_wrapped_lines:
      emojis = find_emoji(line)
      self._cached_line_emojis.append(emojis)
      # Empty lines should still have height (use font size as line height)
      if not line:
        size = rl.Vector2(0, self._font_size * FONT_SCALE)
      else:
        size = measure_text_cached(self._font, line, self._font_size, self._spacing_pixels)

      # This is the only line
      if self._scroll:
        self._needs_scroll = size.x > content_width

      self._cached_line_sizes.append(size)

    # Calculate total height
    # Each line contributes its measured height * line_height (matching Label's behavior)
    # This includes spacing to the next line
    if self._cached_line_sizes:
      # Match the rendering logic: first line doesn't get line_height scaling
      total_height = 0.0
      for idx, size in enumerate(self._cached_line_sizes):
        if idx == 0:
          total_height += size.y
        else:
          total_height += size.y * self._line_height
      self._cached_total_height = total_height
    else:
      self._cached_total_height = 0.0

  def _elide_line(self, line: str, max_width: int, force: bool = False) -> str:
    """Elide a single line if it exceeds max_width. If force is True, always elide even if it fits."""
    if not self._elide and not force:
      return line

    text_size = measure_text_cached(self._font, line, self._font_size, self._spacing_pixels)
    if text_size.x <= max_width and not force:
      return line

    ellipsis = "..."
    # If force=True and line fits, just append ellipsis without truncating
    if force and text_size.x <= max_width:
      ellipsis_size = measure_text_cached(self._font, ellipsis, self._font_size, self._spacing_pixels)
      if text_size.x + ellipsis_size.x <= max_width:
        return line + ellipsis
      # If line + ellipsis doesn't fit, need to truncate
      # Fall through to binary search below

    left, right = 0, len(line)
    while left < right:
      mid = (left + right) // 2
      candidate = line[:mid] + ellipsis
      candidate_size = measure_text_cached(self._font, candidate, self._font_size, self._spacing_pixels)
      if candidate_size.x <= max_width:
        left = mid + 1
      else:
        right = mid
    return line[:left - 1] + ellipsis if left > 0 else ellipsis

  def get_content_height(self, max_width: int) -> float:
    """
    Returns the height needed for text at given max_width.
    Similar to HtmlRenderer.get_total_height().
    """
    # Use max_width if provided, otherwise use self._max_width or a default
    width = max_width if max_width > 0 else (self._max_width if self._max_width else 1000)
    self._update_text_cache(width)

    if self._cached_total_height is not None:
      return self._cached_total_height
    return 0.0

  def _render(self, _):
    """Render the label."""
    if self._rect.width <= 0 or self._rect.height <= 0:
      return

    # Determine available width
    available_width = self._rect.width
    if self._max_width is not None:
      available_width = min(available_width, self._max_width)

    # Update text cache
    self._update_text_cache(int(available_width))

    if not self._cached_wrapped_lines:
      return

    # Calculate which lines fit in the available height
    visible_lines: list[str] = []
    visible_sizes: list[rl.Vector2] = []
    visible_emojis: list[list[tuple[int, int, str]]] = []

    current_height = 0.0
    broke_early = False
    for line, size, emojis in zip(
      self._cached_wrapped_lines,
      self._cached_line_sizes,
      self._cached_line_emojis,
      strict=True):

      # Calculate height needed for this line
      # Each line contributes its height * line_height (matching Label's behavior)
      line_height_needed = size.y * self._line_height

      # Check if this line fits
      if current_height + line_height_needed > self._rect.height:
        # This line doesn't fit
        if len(visible_lines) == 0:
          # First line doesn't fit by height - still show it (will be clipped by scissor if needed)
          # Continue to add this line below
          pass
        else:
          # We have visible lines and this one doesn't fit - mark that we broke early
          broke_early = True
          break

      visible_lines.append(line)
      visible_sizes.append(size)
      visible_emojis.append(emojis)

      current_height += line_height_needed

    # If we broke early (there are more lines that don't fit) and elide is enabled, elide the last visible line
    if broke_early and len(visible_lines) > 0 and self._elide:
      content_width = int(available_width - (self._text_padding * 2))
      if content_width <= 0:
        content_width = 1

      last_line_idx = len(visible_lines) - 1
      last_line = visible_lines[last_line_idx]
      # Force elide the last line to show "..." even if it fits in width (to indicate more content)
      elided = self._elide_line(last_line, content_width, force=True)
      visible_lines[last_line_idx] = elided
      visible_sizes[last_line_idx] = measure_text_cached(self._font, elided, self._font_size, self._spacing_pixels)

    if not visible_lines:
      return

    # Calculate total visible text block height
    # First line is not changed by line_height scaling
    total_visible_height = 0.0
    for idx, size in enumerate(visible_sizes):
      if idx == 0:
        total_visible_height += size.y
      else:
        total_visible_height += size.y * self._line_height

    # Calculate vertical alignment offset
    if self._alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP:
      start_y = self._rect.y
    elif self._alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM:
      start_y = self._rect.y + self._rect.height - total_visible_height
    else:  # TEXT_ALIGN_MIDDLE
      start_y = self._rect.y + (self._rect.height - total_visible_height) / 2

    # Only scissor when we know there is a single scrolling line
    # Pad a little since descenders like g or j may overflow below rect from font_scale
    if self._needs_scroll:
      rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y - self._font_size / 2), int(self._rect.width), int(self._rect.height + self._font_size))

    # Render each line
    current_y = start_y
    for idx, (line, size, emojis) in enumerate(zip(visible_lines, visible_sizes, visible_emojis, strict=True)):
      if self._needs_scroll:
        if self._scroll_state == ScrollState.STARTING:
          if self._scroll_pause_t is None:
            self._scroll_pause_t = rl.get_time() + 2.0
          if rl.get_time() >= self._scroll_pause_t:
            self._scroll_state = ScrollState.SCROLLING
            self._scroll_pause_t = None

        elif self._scroll_state == ScrollState.SCROLLING:
          self._scroll_offset -= 0.8 / 60. * gui_app.target_fps
          # don't fully hide
          if self._scroll_offset <= -size.x - self._rect.width / 3:
            self._scroll_offset = 0
            self._scroll_state = ScrollState.STARTING
            self._scroll_pause_t = None
      else:
        self.reset_scroll()

      self._render_line(line, size, emojis, current_y)

      # Draw 2nd instance for scrolling
      if self._needs_scroll and self._scroll_state != ScrollState.STARTING:
        text2_scroll_offset = size.x + self._rect.width / 3
        self._render_line(line, size, emojis, current_y, text2_scroll_offset)

      # Move to next line (if not last line)
      if idx < len(visible_lines) - 1:
        # Use current line's height * line_height for spacing to next line
        current_y += size.y * self._line_height

    if self._needs_scroll:
      # draw black fade on left and right
      fade_width = 20
      rl.draw_rectangle_gradient_h(int(self._rect.x + self._rect.width - fade_width), int(self._rect.y), fade_width, int(self._rect.height), rl.BLANK, rl.BLACK)

      # stop drawing left fade once text scrolls past
      text_width = visible_sizes[0].x if visible_sizes else 0
      first_copy_in_view = self._scroll_offset + text_width > 0
      draw_left_fade = self._scroll_state != ScrollState.STARTING and first_copy_in_view
      if draw_left_fade:
        rl.draw_rectangle_gradient_h(int(self._rect.x), int(self._rect.y), fade_width, int(self._rect.height), rl.BLACK, rl.BLANK)

      rl.end_scissor_mode()

  def _render_line(self, line, size, emojis, current_y, x_offset=0.0):
    # Calculate horizontal position
    if self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT:
      line_x = self._rect.x + self._text_padding
    elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER:
      line_x = self._rect.x + (self._rect.width - size.x) / 2
    elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_RIGHT:
      line_x = self._rect.x + self._rect.width - size.x - self._text_padding
    else:
      line_x = self._rect.x + self._text_padding
    line_x += self._scroll_offset + x_offset

    # Render line with emojis
    line_pos = rl.Vector2(line_x, current_y)
    prev_index = 0

    for start, end, emoji in emojis:
      # Draw text before emoji
      text_before = line[prev_index:start]
      if text_before:
        rl.draw_text_ex(self._font, text_before, line_pos, self._font_size, self._spacing_pixels, self._text_color)
        width_before = measure_text_cached(self._font, text_before, self._font_size, self._spacing_pixels)
        line_pos.x += width_before.x

      # Draw emoji
      tex = emoji_tex(emoji)
      emoji_scale = self._font_size / tex.height * FONT_SCALE
      rl.draw_texture_ex(tex, line_pos, 0.0, emoji_scale, self._text_color)
      # Emoji width is font_size * FONT_SCALE (as per measure_text_cached)
      line_pos.x += self._font_size * FONT_SCALE
      prev_index = end

    # Draw remaining text after last emoji
    text_after = line[prev_index:]
    if text_after:
      rl.draw_text_ex(self._font, text_after, line_pos, self._font_size, self._spacing_pixels, self._text_color)
