"""µFrame parser — .uf source text → IR tree.

Line-oriented, indentation-based (2-space). Each line is parsed as:
  (indent_level, keyword, arguments)

Nesting is determined by indentation: children are indented deeper
than their parent.
"""

from __future__ import annotations

import re
import shlex

from uframe.errors import ParseError
from uframe.ir import (
    IRNode, Page, Box, Row, Col, Spacer, Pad,
    Heading, Text, Label, Divider, Link, ListNode, ListItem,
    Gauge, Sparkline, Status, Table, TextSpan,
    Form, Field, Password, Radio, Checkbox, FormButton,
    Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
    BigTitle, ImageNode, HNav, VNav, NavItem,
    ComponentDef, ComponentUse,
    SourceType,
    BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, Style,
)

# ---------------------------------------------------------------------------
# Tokenisation helpers
# ---------------------------------------------------------------------------

_INDENT_RE = re.compile(r"^( *)")
_MODIFIER_RE = re.compile(r"@(\w+)\{([^}]*)\}")


def _indent_level(line: str) -> int:
    """Count leading spaces and return indent level (2 spaces = 1 level)."""
    spaces = len(_INDENT_RE.match(line).group(1))  # type: ignore[union-attr]
    return spaces // 2


def _split_args(text: str) -> list[str]:
    """Split argument string respecting quoted tokens."""
    try:
        return shlex.split(text)
    except ValueError:
        return text.split()


def parse_inline(content: str) -> list[TextSpan]:
    """Parse @modifier{text} syntax into a list of TextSpan objects.

    Supported modifiers: @bold{}, @italic{}, @under{}, @color{hex}{},
    @bg{hex}{}.
    """
    spans: list[TextSpan] = []
    pos = 0

    # Match @modifier{content} — including nested @color{hex}{text}
    pattern = re.compile(
        r"@(bold|italic|under|color|bg)"
        r"(?:\{([0-9a-fA-F]{3})\})?"  # optional hex arg for color/bg
        r"\{([^}]*)\}"
    )

    for m in pattern.finditer(content):
        # Add plain text before this modifier
        if m.start() > pos:
            spans.append(TextSpan(text=content[pos:m.start()]))

        mod = m.group(1)
        hex_arg = m.group(2)
        inner = m.group(3)

        span = TextSpan(text=inner)
        if mod == "bold":
            span.bold = True
        elif mod == "italic":
            span.italic = True
        elif mod == "under":
            span.underline = True
        elif mod == "color" and hex_arg:
            span.fg = hex_arg
        elif mod == "bg" and hex_arg:
            span.bg = hex_arg

        spans.append(span)
        pos = m.end()

    # Trailing plain text
    if pos < len(content):
        spans.append(TextSpan(text=content[pos:]))

    # If no modifiers found, return single plain span
    if not spans:
        spans.append(TextSpan(text=content))

    return spans


# ---------------------------------------------------------------------------
# Line-level parsing — keyword dispatch
# ---------------------------------------------------------------------------

def _parse_border_weight(s: str) -> BorderWeight:
    return {
        "light": BorderWeight.LIGHT,
        "heavy": BorderWeight.HEAVY,
        "double": BorderWeight.DOUBLE,
        "rounded": BorderWeight.ROUNDED,
    }.get(s.lower(), BorderWeight.LIGHT)


def _parse_divider_style(s: str) -> DividerStyle:
    return {
        "light": DividerStyle.LIGHT,
        "heavy": DividerStyle.HEAVY,
        "double": DividerStyle.DOUBLE,
        "dash": DividerStyle.DASH,
        "dot": DividerStyle.DOT,
    }.get(s.lower(), DividerStyle.LIGHT)


def _parse_heading_level(s: str) -> HeadingLevel:
    return {
        "1": HeadingLevel.H1,
        "2": HeadingLevel.H2,
        "3": HeadingLevel.H3,
    }.get(s, HeadingLevel.H1)


def _parse_list_style(s: str) -> ListStyle:
    return {
        "bullet": ListStyle.BULLET,
        "dash": ListStyle.DASH,
        "number": ListStyle.NUMBER,
        "arrow": ListStyle.ARROW,
    }.get(s.lower(), ListStyle.BULLET)


def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
    """Parse a single line into an IR node based on the keyword."""

    if keyword == "page":
        title = args[0] if args else "Untitled"
        width = int(args[1]) if len(args) > 1 else 64
        return Page(title=title, width=width, source_line=line_num)

    elif keyword == "box":
        # box [weight] "title"
        if len(args) >= 2:
            weight = _parse_border_weight(args[0])
            title = args[1]
        elif len(args) == 1:
            # Could be weight or title
            if args[0].lower() in ("light", "heavy", "double", "rounded"):
                weight = _parse_border_weight(args[0])
                title = ""
            else:
                weight = BorderWeight.LIGHT
                title = args[0]
        else:
            weight = BorderWeight.LIGHT
            title = ""
        return Box(title=title, weight=weight, source_line=line_num)

    elif keyword == "row":
        # Table row (has | separators) vs layout Row (has gap number or nothing)
        raw = " ".join(args)
        if "|" in raw:
            # Split by | and strip quotes from each cell, preserving @modifiers
            cells: list[str] = []
            for part in raw.split("|"):
                cell = part.strip().strip('"')
                cells.append(cell)
            return _TableRow(cells, line_num)
        gap = int(args[0]) if args else 1
        return Row(gap=gap, source_line=line_num)

    elif keyword == "col":
        w = int(args[0]) if args else None
        return Col(col_width=w, source_line=line_num)

    elif keyword == "spacer":
        lines = int(args[0]) if args else 1
        return Spacer(lines=lines, source_line=line_num)

    elif keyword == "pad":
        vals = [int(a) for a in args[:4]]
        while len(vals) < 4:
            vals.append(0)
        return Pad(top=vals[0], right=vals[1], bottom=vals[2], left=vals[3],
                    source_line=line_num)

    elif keyword == "heading":
        level_str = args[0] if args else "1"
        text = args[1] if len(args) > 1 else ""
        return Heading(level=_parse_heading_level(level_str), text=text,
                       source_line=line_num)

    elif keyword == "text":
        content = args[0] if args else ""
        spans = parse_inline(content)
        return Text(content=content, spans=spans, source_line=line_num)

    elif keyword == "label":
        key = args[0] if args else ""
        value = args[1] if len(args) > 1 else ""
        return Label(key=key, value=value, source_line=line_num)

    elif keyword == "divider":
        style = _parse_divider_style(args[0]) if args else DividerStyle.LIGHT
        return Divider(divider_style=style, source_line=line_num)

    elif keyword == "link":
        display = args[0] if args else ""
        dest = args[1] if len(args) > 1 else ""
        return Link(display=display, dest=dest, source_line=line_num)

    elif keyword == "list":
        style = _parse_list_style(args[0]) if args else ListStyle.BULLET
        return ListNode(list_style=style, source_line=line_num)

    elif keyword == "item":
        # Could be a list item or a nav item — disambiguated by parent in tree builder
        if len(args) >= 2 and ("/" in args[1] or ":" in args[1]):
            # Nav item: item "Label" "/dest.mu" [active]
            label = args[0]
            dest = args[1]
            active = "active" in args[2:] if len(args) > 2 else False
            return _NavItemNode("item", label, dest, active, line_num)
        content = args[0] if args else ""
        return ListItem(content=content, source_line=line_num)

    elif keyword == "separator":
        return _NavItemNode("separator", "", "", False, line_num)

    # Style modifiers (applied to parent)
    elif keyword == "align":
        val = args[0].lower() if args else "left"
        align = {"left": Align.LEFT, "center": Align.CENTER, "right": Align.RIGHT}.get(val, Align.LEFT)
        return _StyleDirective("align", align, line_num)

    elif keyword == "color":
        return _StyleDirective("fg", args[0] if args else None, line_num)

    elif keyword == "bg":
        return _StyleDirective("bg", args[0] if args else None, line_num)

    elif keyword == "bold":
        return _StyleDirective("bold", True, line_num)

    elif keyword == "italic":
        return _StyleDirective("italic", True, line_num)

    elif keyword == "underline":
        return _StyleDirective("underline", True, line_num)

    # Phase 4 placeholders
    elif keyword == "gauge":
        label = args[0] if args else ""
        val_str = args[1] if len(args) > 1 else "0"
        max_str = args[2] if len(args) > 2 else "100"
        bw_str = args[3] if len(args) > 3 else "28"
        try:
            value = float(val_str)
        except ValueError:
            value = 0  # $variable — resolved at runtime
        try:
            max_val = float(max_str)
        except ValueError:
            max_val = 100
        try:
            bar_width = int(bw_str)
        except ValueError:
            bar_width = 28
        # Parse warn=N crit=N from remaining args
        warn = crit = None
        for a in args[4:]:
            if a.startswith("warn="):
                warn = float(a[5:])
            elif a.startswith("crit="):
                crit = float(a[5:])
        node = Gauge(label=label, value=value, max_val=max_val,
                     bar_width=bar_width, warn=warn, crit=crit,
                     source_line=line_num)
        # Store raw strings for deferred component expansion
        if "$" in val_str:
            node._raw_value = val_str  # type: ignore[attr-defined]
        if "$" in max_str:
            node._raw_max_val = max_str  # type: ignore[attr-defined]
        return node

    elif keyword == "sparkline":
        label = args[0] if args else ""
        vals_str = args[1] if len(args) > 1 else ""
        values: list[float] = []
        if vals_str and "$" not in vals_str:
            values = [float(v) for v in vals_str.split(",") if v.strip()]
        try:
            width = int(args[2]) if len(args) > 2 else 20
        except ValueError:
            width = 20
        return Sparkline(label=label, values=values, spark_width=width,
                         source_line=line_num)

    elif keyword == "status":
        label = args[0] if args else ""
        state = args[1] if len(args) > 1 else "unknown"
        return Status(label=label, state=state, source_line=line_num)

    elif keyword == "table":
        title = args[0] if args else ""
        return Table(title=title, source_line=line_num)

    elif keyword == "columns":
        # columns "Name" 24 | "Hops" 6 | "Status" 10
        # Re-join args and split by |
        raw = " ".join(args)
        cols: list[tuple[str, int]] = []
        for part in raw.split("|"):
            tokens = _split_args(part.strip())
            if tokens:
                col_name = tokens[0]
                col_w = int(tokens[1]) if len(tokens) > 1 else 0
                cols.append((col_name, col_w))
        return _TableColumns(cols, line_num)

    # Forms
    elif keyword == "form":
        form_name = args[0] if args else ""
        return Form(form_name=form_name, source_line=line_num)

    elif keyword == "field":
        name = args[0] if args else ""
        width = int(args[1]) if len(args) > 1 else 24
        placeholder = args[2] if len(args) > 2 else ""
        return Field(field_name=name, field_width=width, placeholder=placeholder,
                     source_line=line_num)

    elif keyword == "password":
        name = args[0] if args else ""
        width = int(args[1]) if len(args) > 1 else 24
        placeholder = args[2] if len(args) > 2 else ""
        return Password(field_name=name, field_width=width, placeholder=placeholder,
                        source_line=line_num)

    elif keyword == "radio":
        group = args[0] if args else ""
        raw = " ".join(args[1:]) if len(args) > 1 else ""
        options = [o.strip().strip('"') for o in raw.split("|")] if raw else []
        return Radio(group=group, options=options, source_line=line_num)

    elif keyword == "checkbox":
        name = args[0] if args else ""
        label_text = args[1] if len(args) > 1 else ""
        return Checkbox(field_name=name, checkbox_label=label_text, source_line=line_num)

    elif keyword == "button":
        label_text = args[0] if args else ""
        dest = args[1] if len(args) > 1 else ""
        return FormButton(button_label=label_text, dest=dest, source_line=line_num)

    # Dynamic features
    elif keyword == "let":
        # let name = "value" or let name = 1,2,3
        raw = " ".join(args)
        eq = raw.find("=")
        if eq != -1:
            var_name = raw[:eq].strip()
            var_value = raw[eq + 1:].strip().strip('"')
        else:
            var_name = args[0] if args else ""
            var_value = args[1] if len(args) > 1 else ""
        return Let(var_name=var_name, var_value=var_value, source_line=line_num)

    elif keyword == "source":
        # source cpu : shell "grep 'cpu' /proc/stat"
        # source name : type "command"
        raw = " ".join(args)
        colon = raw.find(":")
        if colon != -1:
            var_name = raw[:colon].strip()
            rest = raw[colon + 1:].strip()
            parts = _split_args(rest)
            src_type_str = parts[0] if parts else "shell"
            command = parts[1] if len(parts) > 1 else ""
            src_type = {
                "shell": SourceType.SHELL,
                "file": SourceType.FILE,
                "json": SourceType.JSON,
                "python": SourceType.PYTHON,
                "rns": SourceType.RNS,
                "param": SourceType.PARAM,
            }.get(src_type_str.lower(), SourceType.SHELL)
            # Parse optional timeout
            timeout = 5
            for p in parts[2:]:
                if p.startswith("timeout"):
                    try:
                        timeout = int(p.split("=")[1]) if "=" in p else int(parts[parts.index(p) + 1])
                    except (ValueError, IndexError):
                        pass
            return Source(var_name=var_name, source_type=src_type,
                         command=command, timeout=timeout, source_line=line_num)
        else:
            return Source(var_name=args[0] if args else "", source_line=line_num)

    elif keyword == "if":
        condition = " ".join(args)
        return IfBlock(condition=condition, source_line=line_num)

    elif keyword == "elif":
        condition = " ".join(args)
        return _ElifBranch(condition, line_num)

    elif keyword == "else":
        return _ElseBranch(line_num)

    elif keyword == "for":
        # for item in $collection
        var_name = args[0] if args else "item"
        # Skip "in" keyword
        iterable = args[2] if len(args) > 2 else (args[1] if len(args) > 1 else "")
        return ForLoop(var_name=var_name, iterable=iterable, source_line=line_num)

    elif keyword == "cache":
        seconds = int(args[0]) if args else 0
        return CacheControl(seconds=seconds, source_line=line_num)

    elif keyword == "on_submit":
        form_name = args[0] if args else ""
        return OnSubmit(form_name=form_name, source_line=line_num)

    elif keyword == "state":
        state_name = args[0] if args else ""
        path = args[1] if len(args) > 1 else ""
        return StateDecl(state_name=state_name, path=path, source_line=line_num)

    elif keyword in ("set", "append", "prepend"):
        # State operations — store as text nodes with metadata for the compiler
        content = " ".join([keyword] + args)
        return Text(content=content, source_line=line_num)

    elif keyword == "hnav":
        nav_style = args[0] if args else "bar"
        return HNav(nav_style=nav_style, source_line=line_num)

    elif keyword == "vnav":
        nav_style = args[0] if args else "list"
        nav_width = int(args[1]) if len(args) > 1 and args[1].isdigit() else 0
        return VNav(nav_style=nav_style, nav_width=nav_width, source_line=line_num)

    elif keyword == "image":
        path = args[0] if args else ""
        mode = args[1] if len(args) > 1 else "braille"
        img_width = int(args[2]) if len(args) > 2 else 30
        return ImageNode(path=path, mode=mode, img_width=img_width, source_line=line_num)

    elif keyword == "dither":
        # Style directive for image node
        return _StyleDirective("dither_val", args[0] if args else "floyd", line_num)

    elif keyword == "invert":
        return _StyleDirective("invert_val", True, line_num)

    elif keyword == "caption":
        return _StyleDirective("caption_val", args[0] if args else "", line_num)

    elif keyword == "bigtitle":
        text = args[0] if args else ""
        font = args[1] if len(args) > 1 else "block"
        return BigTitle(text=text, font=font, source_line=line_num)

    elif keyword == "theme":
        # theme "name" — sets the page theme (handled as _StyleDirective on Page)
        theme_name = args[0] if args else "default"
        return _ThemeDirective(theme_name, line_num)

    elif keyword == "component":
        # component name(arg1, arg2)
        raw = " ".join(args)
        paren = raw.find("(")
        if paren != -1:
            comp_name = raw[:paren].strip()
            params_str = raw[paren + 1:].rstrip(")")
            params = [p.strip() for p in params_str.split(",") if p.strip()]
        else:
            comp_name = args[0] if args else ""
            params = []
        return ComponentDef(comp_name=comp_name, params=params, source_line=line_num)

    elif keyword == "use":
        # use std/dashboard — load a library (handled at parse level)
        lib_path = args[0] if args else ""
        return _UseDirective(lib_path, line_num)

    else:
        # Try as component invocation: name "arg1" "arg2"
        # Only if keyword isn't a known keyword — handled by the tree builder
        return ComponentUse(comp_name=keyword, args=args, source_line=line_num)


class _NavItemNode(IRNode):
    """Temporary node — absorbed by parent HNav/VNav during tree building."""
    def __init__(self, kind: str, label: str, dest: str, active: bool, line_num: int):
        super().__init__(source_line=line_num)
        self.kind = kind
        self.label = label
        self.dest = dest
        self.active = active


class _ThemeDirective(IRNode):
    """Temporary node — sets theme_name on the Page during tree building."""
    def __init__(self, theme_name: str, line_num: int):
        super().__init__(source_line=line_num)
        self.theme_name = theme_name


class _UseDirective(IRNode):
    """Temporary node — triggers library loading during tree building."""
    def __init__(self, lib_path: str, line_num: int):
        super().__init__(source_line=line_num)
        self.lib_path = lib_path


class _ElifBranch(IRNode):
    """Temporary node — absorbed by parent IfBlock during tree building."""
    def __init__(self, condition: str, line_num: int):
        super().__init__(source_line=line_num)
        self.condition = condition


class _ElseBranch(IRNode):
    """Temporary node — absorbed by parent IfBlock during tree building."""
    def __init__(self, line_num: int):
        super().__init__(source_line=line_num)


class _TableColumns(IRNode):
    """Temporary node — absorbed by parent Table during tree building."""
    def __init__(self, columns: list[tuple[str, int]], line_num: int):
        super().__init__(source_line=line_num)
        self.columns = columns


class _TableRow(IRNode):
    """Temporary node — absorbed by parent Table during tree building."""
    def __init__(self, cells: list[str], line_num: int):
        super().__init__(source_line=line_num)
        self.cells = cells


# ---------------------------------------------------------------------------
# Style directives — pseudo-nodes that modify their parent's style
# ---------------------------------------------------------------------------

class _StyleDirective(IRNode):
    """Temporary node representing a style modifier (align, color, bold, etc.).

    These are absorbed by the parent during tree building and never appear
    in the final IR tree.
    """
    def __init__(self, attr: str, value: object, line_num: int):
        super().__init__(source_line=line_num)
        self.attr = attr
        self.value = value


# ---------------------------------------------------------------------------
# Component expansion
# ---------------------------------------------------------------------------

def _expand_component(comp_def: ComponentDef, args: list[str]) -> list[IRNode]:
    """Expand a component use into a list of IR nodes by substituting $params.

    Clones the component body and replaces $param references with provided args.
    """
    import copy

    # Build param → arg mapping
    param_map: dict[str, str] = {}
    for i, param in enumerate(comp_def.params):
        param_map[param] = args[i] if i < len(args) else ""

    # Deep copy the children and substitute
    expanded: list[IRNode] = []
    for child in comp_def.children:
        clone = copy.deepcopy(child)
        _substitute_vars(clone, param_map)
        expanded.append(clone)

    return expanded


def _substitute_vars(node: IRNode, var_map: dict[str, str]) -> None:
    """Recursively substitute $param references in an IR node tree."""
    # Substitute in string fields
    for attr_name in ("text", "content", "title", "label", "key", "value",
                      "display", "dest", "field_name", "placeholder",
                      "button_label", "checkbox_label", "var_name", "var_value",
                      "condition", "iterable", "command", "form_name",
                      "state_name", "path", "group", "state", "comp_name"):
        val = getattr(node, attr_name, None)
        if isinstance(val, str) and "$" in val:
            for param, arg in var_map.items():
                val = val.replace(f"${param}", arg)
            setattr(node, attr_name, val)

    # Substitute in list fields
    for attr_name in ("options", "args"):
        val = getattr(node, attr_name, None)
        if isinstance(val, list):
            for i, item in enumerate(val):
                if isinstance(item, str) and "$" in item:
                    for param, arg in var_map.items():
                        item = item.replace(f"${param}", arg)
                    val[i] = item

    # Substitute in TextSpan list
    spans = getattr(node, "spans", None)
    if isinstance(spans, list):
        for span in spans:
            if hasattr(span, "text") and "$" in span.text:
                for param, arg in var_map.items():
                    span.text = span.text.replace(f"${param}", arg)

    # After string substitution, resolve deferred numeric fields
    for num_attr in ("value", "max_val", "bar_width"):
        raw = getattr(node, f"_raw_{num_attr}", None)
        if raw is not None:
            for param, arg in var_map.items():
                raw = raw.replace(f"${param}", arg)
            try:
                setattr(node, num_attr, float(raw) if "." in raw else int(raw))
            except (ValueError, TypeError):
                pass

    # Recurse
    for child in node.children:
        _substitute_vars(child, var_map)


# ---------------------------------------------------------------------------
# Standard library loader
# ---------------------------------------------------------------------------

# Built-in component libraries
_STD_LIBRARIES: dict[str, str] = {
    "std/dashboard": '''\
component banner(title, subtitle)
  box double "$title"
    align center
    text "$subtitle"

component resources(cpu, mem)
  heading 1 "Resources"
  gauge "CPU" $cpu 100 28 warn=75 crit=90
  gauge "MEM" $mem 100 28 warn=80 crit=95

component peer_status(name, state)
  status "$name" $state

component info_box(title, content)
  box light "$title"
    text "$content"

component alert_box(title, content)
  box heavy "$title"
    color f00
    text "$content"

component metric(label, value, max, width)
  gauge "$label" $value $max $width
''',
    "std/status-bar": '''\
component status_bar(label, value, max)
  gauge "$label" $value $max 28

component status_item(name, state)
  status "$name" $state

component status_row(name1, state1, name2, state2)
  row 2
    col 28
      status "$name1" $state1
    col 28
      status "$name2" $state2
''',
    "std/nav": '''\
component nav_link(label, dest)
  link "$label" "$dest"

component nav_divider()
  divider light

component nav_bar(label1, dest1, label2, dest2)
  row 2
    col 28
      link "$label1" "$dest1"
    col 28
      link "$label2" "$dest2"
''',
    "std/network": '''\
component route_table(title)
  table "$title"

component peer_list(title)
  heading 2 "$title"

component traffic(label_in, vals_in, label_out, vals_out)
  sparkline "$label_in" "$vals_in" 20
  sparkline "$label_out" "$vals_out" 20
''',
    "std/form": '''\
component search_form(name, action)
  form "$name"
    field "query" 30 "Search..."
    button "Search" "$action"

component login_form(action)
  form "login"
    field "username" 24 "Username"
    password "password" 24 "Password"
    button "Login" "$action"
''',
}


def _load_library(lib_path: str) -> dict[str, ComponentDef]:
    """Load a standard library and return its component definitions."""
    lib_source = _STD_LIBRARIES.get(lib_path, "")
    if not lib_source:
        return {}

    # Parse the library source to extract ComponentDef nodes
    comps: dict[str, ComponentDef] = {}
    # Use a mini-parse: just extract component definitions
    lines = lib_source.split("\n")
    current_comp: ComponentDef | None = None
    comp_indent = 0

    for raw_line in lines:
        stripped = raw_line.strip()
        if not stripped or stripped.startswith("#"):
            continue

        indent = _indent_level(raw_line)
        parts = stripped.split(None, 1)
        keyword = parts[0].lower()
        arg_str = parts[1] if len(parts) > 1 else ""
        args = _split_args(arg_str)

        if keyword == "component":
            raw = " ".join(args)
            paren = raw.find("(")
            if paren != -1:
                comp_name = raw[:paren].strip()
                params_str = raw[paren + 1:].rstrip(")")
                params = [p.strip() for p in params_str.split(",") if p.strip()]
            else:
                comp_name = args[0] if args else ""
                params = []
            current_comp = ComponentDef(comp_name=comp_name, params=params)
            comp_indent = indent
            comps[comp_name] = current_comp
        elif current_comp and indent > comp_indent:
            # Parse child node and attach to current component
            try:
                child = _parse_line(keyword, args, 0)
                if not isinstance(child, (_StyleDirective, _TableColumns, _TableRow,
                                          _ElifBranch, _ElseBranch, _UseDirective)):
                    current_comp.children.append(child)
            except ParseError:
                pass
        else:
            current_comp = None

    return comps


# ---------------------------------------------------------------------------
# Tree builder
# ---------------------------------------------------------------------------

def parse(source: str, components: dict[str, ComponentDef] | None = None) -> Page:
    """Parse a .uf source string into an IR tree rooted at a Page node.

    Args:
        source: the .uf DSL source text
        components: optional pre-loaded component registry (from `use` directives)

    Returns the Page node with all children attached.
    """
    lines = source.split("\n")

    # Component registry: name → ComponentDef (with children as template)
    comp_registry: dict[str, ComponentDef] = dict(components or {})

    # Stack: list of (indent_level, node)
    stack: list[tuple[int, IRNode]] = []
    root: Page | None = None

    for line_num, raw_line in enumerate(lines, start=1):
        # Skip blank lines and comments
        stripped = raw_line.strip()
        if not stripped or stripped.startswith("#"):
            continue

        indent = _indent_level(raw_line)

        # Split into keyword + arguments
        parts = stripped.split(None, 1)
        keyword = parts[0].lower()
        arg_str = parts[1] if len(parts) > 1 else ""
        args = _split_args(arg_str)

        # Parse this line into a node
        node = _parse_line(keyword, args, line_num)

        # Pop stack back to find the parent (parent indent < this indent)
        while stack and stack[-1][0] >= indent:
            stack.pop()

        if isinstance(node, _StyleDirective):
            if stack:
                parent = stack[-1][1]
                # Image-specific directives
                if node.attr == "dither_val" and isinstance(parent, ImageNode):
                    parent.dither = node.value
                elif node.attr == "invert_val" and isinstance(parent, ImageNode):
                    parent.invert = node.value
                elif node.attr == "caption_val" and isinstance(parent, ImageNode):
                    parent.caption = node.value
                elif node.attr in ("dither_val", "invert_val", "caption_val"):
                    pass  # ignore if not on ImageNode
                else:
                    setattr(parent.style, node.attr, node.value)
            continue

        if isinstance(node, _ThemeDirective):
            # Set theme on the root Page
            if root and isinstance(root, Page):
                root.theme_name = node.theme_name
            continue

        # Table children: columns and rows are absorbed by the Table node
        if isinstance(node, _TableColumns):
            if stack and isinstance(stack[-1][1], Table):
                stack[-1][1].columns = node.columns
            continue

        if isinstance(node, _TableRow):
            if stack and isinstance(stack[-1][1], Table):
                stack[-1][1].rows.append(node.cells)
            continue

        # elif/else branches are absorbed by the nearest IfBlock ancestor
        if isinstance(node, _ElifBranch):
            # Find the IfBlock in the stack
            for si in range(len(stack) - 1, -1, -1):
                if isinstance(stack[si][1], IfBlock):
                    # Collect subsequent children under this elif
                    stack[si][1].elif_branches.append((node.condition, []))
                    break
            continue

        if isinstance(node, _ElseBranch):
            # Find the IfBlock in the stack — mark it for else collection
            for si in range(len(stack) - 1, -1, -1):
                if isinstance(stack[si][1], IfBlock):
                    stack[si][1].else_children = []  # will be filled by subsequent children
                    break
            continue

        # Nav items — absorbed by parent HNav/VNav
        if isinstance(node, _NavItemNode):
            for si in range(len(stack) - 1, -1, -1):
                parent = stack[si][1]
                if isinstance(parent, (HNav, VNav)):
                    parent.items.append(NavItem(
                        kind=node.kind, label=node.label,
                        dest=node.dest, active=node.active))
                    break
            continue

        # Headings inside vnav become nav headings
        if isinstance(node, Heading):
            for si in range(len(stack) - 1, -1, -1):
                if isinstance(stack[si][1], VNav):
                    stack[si][1].items.append(NavItem(
                        kind="heading", label=node.text))
                    break
            else:
                # Not inside a vnav — proceed as normal heading
                pass
            if any(isinstance(stack[si][1], VNav) for si in range(len(stack))):
                continue

        # Use directive — load standard library components
        if isinstance(node, _UseDirective):
            lib_comps = _load_library(node.lib_path)
            comp_registry.update(lib_comps)
            continue

        # Component definition — register in the component registry
        if isinstance(node, ComponentDef):
            comp_registry[node.comp_name] = node
            stack.append((indent, node))  # push so children attach to it
            continue

        # Component use — expand inline by cloning the template with args substituted
        if isinstance(node, ComponentUse) and node.comp_name in comp_registry:
            comp_def = comp_registry[node.comp_name]
            expanded = _expand_component(comp_def, node.args)
            if stack:
                parent = stack[-1][1]
                parent.children.extend(expanded)
            continue
        elif isinstance(node, ComponentUse) and node.comp_name not in comp_registry:
            # Unknown component — treat as unknown keyword error
            # But be lenient: just skip it with a warning
            continue

        # Attach to parent
        if stack:
            parent = stack[-1][1]
            parent.children.append(node)
        elif isinstance(node, Page):
            root = node
        else:
            # Auto-wrap in a default Page if source doesn't start with `page`
            root = Page(title="Untitled", width=64, source_line=0)
            root.children.append(node)
            stack.append((-1, root))

        # Push onto stack
        if isinstance(node, Page) and root is node:
            stack.append((-1, node))
        else:
            stack.append((indent, node))

    if root is None:
        root = Page(title="Untitled", width=64, source_line=0)

    return root
