"""Single-file declarative UI toolkit built on top of Streamlit.

Goal
----
Users should be able to create a web app by writing one file and a few lines
of code, with minimal framework concepts exposed publicly.

Typical usage:

    from soigia.ui import ui

    users = [{"id": 1, "name": "Alice", "active": True}]

    @ui.page("Customers")
    def customers_page(ctx):
        ui.text("Customer Admin")
        search = ui.text_input("Search")
        ui.markdown("Use this panel to manage customers.")
        ui.table(lambda: [row for row in users if search.value.lower() in row["name"].lower()])
        with ui.form("Customer form", submit_label="Save", on_submit=lambda: save_customer()):
            ui.text_input("Name")
            ui.checkbox("Active")
        with ui.dialog("Help", open_label="Need help?"):
            ui.markdown("This is a lightweight management UI.")

    ui.run()

Example app.py:

    from soigia.ui import ui

    orders = [
        {"id": 1, "customer": "Alice", "amount": 120.0, "status": "paid"},
        {"id": 2, "customer": "Bob", "amount": 88.5, "status": "pending"},
    ]

    @ui.page("Orders")
    def orders_page(ctx):
        ui.text("Order Dashboard")
        keyword = ui.text_input("Search")
        ui.metric("Total orders", len(orders))
        ui.table(
            lambda: [row for row in orders if keyword.value.lower() in row["customer"].lower()]
        )
        with ui.form("Update order", submit_label="Save"):
            ui.text_input("Order ID")
            ui.select("Status", ["paid", "pending", "cancelled"])
        ui.button("Refresh")

    ui.run()

Design
------
- Public API is declarative and opinionated.
- Widgets register themselves when created.
- Streamlit stays behind the curtain.
- The file is self-contained on purpose.
"""

from __future__ import annotations

import inspect
import itertools
import re
from math import ceil
from collections.abc import Callable, Mapping, Sequence
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Any, Optional

import pandas as pd

try:
    import streamlit as st
except Exception:  # pragma: no cover - streamlit should exist, but keep import safe.
    st = None  # type: ignore[assignment]

__all__ = [
    "App",
    "ui",
    "run",
    "rerun",
    "stop",
    "state_get",
    "state_set",
    "state_delete",
    "page",
    "text",
    "markdown",
    "code",
    "divider",
    "metric",
    "text_input",
    "textbox",
    "text_area",
    "textarea",
    "checkbox",
    "select",
    "button",
    "table",
    "json",
    "success",
    "info",
    "warning",
    "error",
    "file_uploader",
    "download_button",
    "form",
    "dialog",
    "sidebar",
    "columns",
    "tabs",
    "section",
    "expander",
]

_ORDER = itertools.count()


def _slugify(value: str) -> str:
    value = value.strip().lower()
    value = re.sub(r"[^a-z0-9]+", "_", value)
    return value.strip("_") or "field"


def _coerce_callable(value: Any, context: "RenderContext") -> Any:
    if not callable(value):
        return value
    try:
        sig = inspect.signature(value)
    except (TypeError, ValueError):
        return value()
    params = list(sig.parameters.values())
    if not params:
        return value()
    if any(param.kind == inspect.Parameter.VAR_POSITIONAL for param in params):
        return value(context)
    if len(params) == 1:
        return value(context)
    return value()


def _invoke_callback(callback: Optional[Callable[..., Any]], context: "RenderContext") -> None:
    if callback is None:
        return
    try:
        sig = inspect.signature(callback)
    except (TypeError, ValueError):
        callback()
        return
    params = list(sig.parameters.values())
    if not params:
        callback()
        return
    if any(param.kind == inspect.Parameter.VAR_POSITIONAL for param in params):
        callback(context)
        return
    if len(params) == 1:
        callback(context)
        return
    callback()


def _make_key(kind: str, label: str, explicit_key: Optional[str], order: int) -> str:
    return explicit_key or f"{kind}:{_slugify(label)}:{order}"


def _container_width_value(use_container_width: bool) -> str:
    return "stretch" if use_container_width else "content"


@dataclass(slots=True)
class RenderContext:
    app: "App"
    st: Any

    @property
    def session_state(self) -> Any:
        if st is not None:
            return st.session_state
        return self.st.session_state


@dataclass(slots=True)
class Node:
    label: str = ""
    key: Optional[str] = None
    help: Optional[str] = None
    visible: bool = True
    order: int = field(default_factory=lambda: next(_ORDER), init=False)
    app: Optional["App"] = field(default=None, init=False, repr=False, compare=False)

    def render(self, context: RenderContext) -> Any:
        raise NotImplementedError


@dataclass(slots=True)
class TextNode(Node):
    value: Any = ""
    kind: str = "text"
    truncate_at: Optional[int] = None
    expandable: bool = False

    def render(self, context: RenderContext) -> Any:
        if not self.visible:
            return None
        content = _coerce_callable(self.value, context)
        preview = content
        if isinstance(content, str) and self.truncate_at and len(content) > self.truncate_at:
            preview = content[: self.truncate_at].rstrip() + "..."
        if self.expandable and preview != content and isinstance(content, str):
            self._render_value(context, preview)
            with context.st.expander(self.label or "Show more"):
                self._render_value(context, content)
            return content
        self._render_value(context, preview)
        return content

    def _render_value(self, context: RenderContext, content: Any) -> None:
        if self.kind == "title":
            context.st.title(content)
        elif self.kind == "header":
            context.st.header(content)
        elif self.kind == "subheader":
            context.st.subheader(content)
        elif self.kind == "caption":
            context.st.caption(content)
        elif self.kind == "markdown":
            context.st.markdown(content)
        elif self.kind == "code":
            context.st.code(content)
        elif self.kind == "json":
            context.st.json(content)
        elif self.kind == "success":
            context.st.success(content)
        elif self.kind == "info":
            context.st.info(content)
        elif self.kind == "warning":
            context.st.warning(content)
        elif self.kind == "error":
            context.st.error(content)
        else:
            context.st.write(content)


@dataclass(slots=True)
class DividerNode(Node):
    def render(self, context: RenderContext) -> Any:
        if self.visible:
            context.st.divider()
        return None


@dataclass(slots=True)
class MetricNode(Node):
    value: Any = ""
    delta: Any = None
    delta_color: str = "normal"

    def render(self, context: RenderContext) -> Any:
        if not self.visible:
            return None
        value = _coerce_callable(self.value, context)
        delta = _coerce_callable(self.delta, context)
        context.st.metric(self.label or "", value, delta=delta, delta_color=self.delta_color)
        return value


@dataclass(slots=True)
class TableNode(Node):
    data: Any = None
    use_container_width: bool = True
    height: Optional[int] = None
    editable: bool = False
    key_prefix: Optional[str] = None
    paginate: bool = False
    page_size: int = 100
    show_index: bool = False
    empty_message: str = "No rows to display."

    def _to_frame(self, value: Any) -> pd.DataFrame:
        if isinstance(value, pd.DataFrame):
            return value
        if hasattr(value, "to_df") and callable(value.to_df):
            frame = value.to_df()
            if isinstance(frame, pd.DataFrame):
                return frame
        if isinstance(value, dict):
            return pd.DataFrame(value)
        if isinstance(value, list):
            return pd.DataFrame(value)
        return pd.DataFrame([value])

    def render(self, context: RenderContext) -> Any:
        if not self.visible:
            return None
        value = _coerce_callable(self.data, context)
        frame = self._to_frame(value)
        if frame.empty:
            context.st.info(self.empty_message)
            return frame
        if self.paginate or len(frame) > self.page_size:
            total_rows = len(frame)
            total_pages = max(1, ceil(total_rows / max(1, self.page_size)))
            page_state_key = self.key or self.key_prefix or f"table_page:{self.order}"
            page_widget_key = f"{page_state_key}:widget"
            current_page = int(context.session_state.get(page_state_key, 1))
            current_page = min(max(current_page, 1), total_pages)
            context.session_state[page_state_key] = current_page
            start = (current_page - 1) * self.page_size
            end = start + self.page_size
            frame = frame.iloc[start:end].copy()
            context.st.caption(f"Rows {start + 1}-{min(end, total_rows)} of {total_rows}")
            page = context.st.number_input(
                "Page",
                min_value=1,
                max_value=total_pages,
                value=current_page,
                step=1,
                key=page_widget_key,
            )
            context.session_state[page_state_key] = int(page)
        if self.editable:
            data_editor_kwargs = {
                "key": self.key or self.key_prefix or f"table:{self.order}",
                "width": _container_width_value(self.use_container_width),
                "hide_index": not self.show_index,
            }
            if self.height is not None:
                data_editor_kwargs["height"] = self.height
            rendered = context.st.data_editor(frame, **data_editor_kwargs)
        else:
            dataframe_kwargs = {
                "width": _container_width_value(self.use_container_width),
                "hide_index": not self.show_index,
            }
            if self.height is not None:
                dataframe_kwargs["height"] = self.height
            context.st.dataframe(frame, **dataframe_kwargs)
            rendered = frame
        return rendered


@dataclass(slots=True)
class FileUploadNode(Node):
    accept_multiple_files: bool = False
    type: Optional[Sequence[str]] = None
    _value: Any = field(default=None, init=False, repr=False)

    @property
    def value(self) -> Any:
        if self.key and st is not None and self.key in st.session_state:
            return st.session_state[self.key]
        return self._value

    def _widget_key(self) -> str:
        return self.key or f"file_uploader:{self.order}:{_slugify(self.label)}"

    def render(self, context: RenderContext) -> Any:
        if not self.visible:
            return self.value
        key = self._widget_key()
        self.key = key
        self._value = context.st.file_uploader(
            self.label,
            key=key,
            accept_multiple_files=self.accept_multiple_files,
            type=self.type,
            help=self.help,
        )
        return self._value


@dataclass(slots=True)
class DownloadButtonNode(Node):
    data: Any = None
    file_name: str = "download"
    mime: Optional[str] = None
    on_click: Optional[Callable[..., Any]] = None
    use_container_width: bool = False

    def render(self, context: RenderContext) -> Any:
        if not self.visible:
            return False
        payload = _coerce_callable(self.data, context)
        clicked = context.st.download_button(
            self.label,
            data=payload,
            file_name=self.file_name,
            mime=self.mime,
            key=self.key or f"download:{self.order}:{_slugify(self.label)}",
            help=self.help,
            width=_container_width_value(self.use_container_width),
        )
        if clicked:
            _invoke_callback(self.on_click, context)
        return clicked


@dataclass(slots=True)
class InputNode(Node):
    default: Any = ""
    placeholder: Optional[str] = None
    kind: str = "text"
    options: Optional[Sequence[Any]] = None
    min_value: Any = None
    max_value: Any = None
    step: Any = None
    value_type: Optional[str] = None
    _value: Any = field(default=None, init=False, repr=False)

    @property
    def value(self) -> Any:
        if self.key and st is not None and self.key in st.session_state:
            return st.session_state[self.key]
        return self._value if self._value is not None else self.default

    def _widget_key(self) -> str:
        return self.key or f"{self.kind}:{self.order}:{_slugify(self.label)}"

    def render(self, context: RenderContext) -> Any:
        if not self.visible:
            return self.value
        key = self._widget_key()
        self.key = key
        if self.kind == "textarea":
            rendered = context.st.text_area(
                self.label,
                value=self.default,
                key=key,
                placeholder=self.placeholder,
                help=self.help,
            )
        elif self.kind == "number":
            rendered = context.st.number_input(
                self.label,
                value=self.default if self.default is not None else 0,
                key=key,
                min_value=self.min_value,
                max_value=self.max_value,
                step=self.step,
                help=self.help,
            )
        elif self.kind == "checkbox":
            rendered = context.st.checkbox(
                self.label,
                value=bool(self.default),
                key=key,
                help=self.help,
            )
        elif self.kind == "select":
            rendered = context.st.selectbox(
                self.label,
                options=list(self.options or []),
                index=(
                    0
                    if self.default is None
                    else (
                        max(0, list(self.options or []).index(self.default))
                        if self.options and self.default in self.options
                        else 0
                    )
                ),
                key=key,
                help=self.help,
            )
        else:
            rendered = context.st.text_input(
                self.label,
                value="" if self.default is None else str(self.default),
                key=key,
                placeholder=self.placeholder,
                help=self.help,
            )
        self._value = rendered
        return rendered


@dataclass(slots=True)
class ButtonNode(Node):
    on_click: Optional[Callable[..., Any]] = None
    type: str = "secondary"
    use_container_width: bool = False

    def render(self, context: RenderContext) -> Any:
        if not self.visible:
            return False
        clicked = context.st.button(
            self.label,
            key=self.key or f"button:{self.order}:{_slugify(self.label)}",
            help=self.help,
            type=self.type,
            width=_container_width_value(self.use_container_width),
        )
        if clicked:
            _invoke_callback(self.on_click, context)
        return clicked


@dataclass(slots=True)
class ContainerNode(Node):
    children: list[Node] = field(default_factory=list)

    def add(self, node: Node) -> Node:
        self.children.append(node)
        return node

    def render_children(self, context: RenderContext) -> None:
        for child in self.children:
            child.render(context)


@dataclass(slots=True)
class SidebarNode(ContainerNode):
    def __enter__(self):
        if self.app is None:
            raise RuntimeError("sidebar is not attached to an app")
        self.app._stack.append(self)
        return self

    def __exit__(self, exc_type, exc, tb):
        if self.app is None:
            return False
        if self.app._stack and self.app._stack[-1] is self:
            self.app._stack.pop()
        return False

    def render(self, context: RenderContext) -> Any:
        if not self.visible:
            return None
        with context.st.sidebar:
            if self.label:
                context.st.subheader(self.label)
            self.render_children(context)
        return None


@dataclass(slots=True)
class ColumnNode(ContainerNode):
    def __enter__(self):
        if self.app is None:
            raise RuntimeError("column is not attached to an app")
        self.app._stack.append(self)
        return self

    def __exit__(self, exc_type, exc, tb):
        if self.app is None:
            return False
        if self.app._stack and self.app._stack[-1] is self:
            self.app._stack.pop()
        return False


@dataclass(slots=True)
class ColumnsNode(Node):
    specs: Any = 2
    slots: list[ColumnNode] = field(default_factory=list)

    def render(self, context: RenderContext) -> Any:
        if not self.visible:
            return None
        column_renderers = context.st.columns(self.specs)
        for slot, renderer in zip(self.slots, column_renderers):
            slot_context = RenderContext(app=context.app, st=renderer)
            with renderer:
                slot.render_children(slot_context)
        return None


@dataclass(slots=True)
class TabNode(ContainerNode):
    def __enter__(self):
        if self.app is None:
            raise RuntimeError("tab is not attached to an app")
        self.app._stack.append(self)
        return self

    def __exit__(self, exc_type, exc, tb):
        if self.app is None:
            return False
        if self.app._stack and self.app._stack[-1] is self:
            self.app._stack.pop()
        return False


@dataclass(slots=True)
class TabsNode(Node):
    labels: Sequence[str] = field(default_factory=list)
    slots: list[TabNode] = field(default_factory=list)

    def render(self, context: RenderContext) -> Any:
        if not self.visible:
            return None
        tab_renderers = context.st.tabs(list(self.labels))
        for slot, renderer in zip(self.slots, tab_renderers):
            slot_context = RenderContext(app=context.app, st=renderer)
            with renderer:
                slot.render_children(slot_context)
        return None


@dataclass(slots=True)
class PageNode(ContainerNode):
    render_fn: Optional[Callable[..., Any]] = None
    icon: Optional[str] = None
    layout: Optional[str] = None

    def render(self, context: RenderContext) -> Any:
        if not self.visible:
            return None
        if self.label:
            context.st.subheader(self.label)
        if self.render_fn is None:
            for child in self.children:
                child.render(context)
            return None
        try:
            sig = inspect.signature(self.render_fn)
        except (TypeError, ValueError):
            sig = None
        if sig is not None and len(sig.parameters) == 1:
            self.render_fn(context)
        else:
            self.render_fn()
        for child in self.children:
            child.render(context)
        return None


@dataclass(slots=True)
class FormNode(ContainerNode):
    submit_label: str = "Submit"
    on_submit: Optional[Callable[..., Any]] = None

    def __enter__(self):
        if self.app is None:
            raise RuntimeError("form is not attached to an app")
        self.app._stack.append(self)
        return self

    def __exit__(self, exc_type, exc, tb):
        if self.app is None:
            return False
        if self.app._stack and self.app._stack[-1] is self:
            self.app._stack.pop()
        return False

    def render(self, context: RenderContext) -> Any:
        if not self.visible:
            return None
        if self.label:
            context.st.markdown(f"**{self.label}**")
        with context.st.container(border=True):
            for child in self.children:
                child.render(context)
            submitted = context.st.button(
                self.submit_label,
                key=self.key or f"form:{self.order}:{_slugify(self.label)}",
                width="stretch",
            )
        if submitted:
            _invoke_callback(self.on_submit, context)
        return submitted


@dataclass(slots=True)
class DialogNode(ContainerNode):
    open_label: str = "Open"
    button_label: Optional[str] = None
    expanded: bool = False
    on_open: Optional[Callable[..., Any]] = None

    def __enter__(self):
        if self.app is None:
            raise RuntimeError("dialog is not attached to an app")
        self.app._stack.append(self)
        return self

    def __exit__(self, exc_type, exc, tb):
        if self.app is None:
            return False
        if self.app._stack and self.app._stack[-1] is self:
            self.app._stack.pop()
        return False

    def render(self, context: RenderContext) -> Any:
        if not self.visible:
            return None
        open_label = self.button_label or self.open_label
        clicked = context.st.button(
            open_label,
            key=self.key or f"dialog:{self.order}:{_slugify(self.label)}",
            width="content",
        )
        if clicked:
            _invoke_callback(self.on_open, context)
        use_popover = hasattr(context.st, "popover")
        if clicked and use_popover:
            with context.st.popover(self.label or open_label):
                for child in self.children:
                    child.render(context)
        elif clicked:
            with context.st.expander(self.label or open_label, expanded=self.expanded):
                for child in self.children:
                    child.render(context)
        return clicked


@dataclass(slots=True)
class SectionNode(ContainerNode):
    level: int = 2

    def __enter__(self):
        if self.app is None:
            raise RuntimeError("section is not attached to an app")
        self.app._stack.append(self)
        return self

    def __exit__(self, exc_type, exc, tb):
        if self.app is None:
            return False
        if self.app._stack and self.app._stack[-1] is self:
            self.app._stack.pop()
        return False

    def render(self, context: RenderContext) -> Any:
        if not self.visible:
            return None
        if self.level <= 1:
            context.st.title(self.label)
        elif self.level == 2:
            context.st.header(self.label)
        elif self.level == 3:
            context.st.subheader(self.label)
        else:
            context.st.markdown(f"### {self.label}")
        for child in self.children:
            child.render(context)
        return None


@dataclass(slots=True)
class ExpanderNode(ContainerNode):
    expanded: bool = False

    def __enter__(self):
        if self.app is None:
            raise RuntimeError("expander is not attached to an app")
        self.app._stack.append(self)
        return self

    def __exit__(self, exc_type, exc, tb):
        if self.app is None:
            return False
        if self.app._stack and self.app._stack[-1] is self:
            self.app._stack.pop()
        return False

    def render(self, context: RenderContext) -> Any:
        if not self.visible:
            return None
        with context.st.expander(self.label, expanded=self.expanded):
            for child in self.children:
                child.render(context)
        return None


@dataclass(slots=True, init=False)
class App:
    app_title: str
    layout: str
    page_icon: Optional[str]
    _root_nodes: list[Node] = field(default_factory=list, init=False, repr=False)
    _sidebar_nodes: list[SidebarNode] = field(default_factory=list, init=False, repr=False)
    _pages: list[PageNode] = field(default_factory=list, init=False, repr=False)
    _stack: list[ContainerNode] = field(default_factory=list, init=False, repr=False)

    def __init__(
        self,
        title: str = "SoiGia UI",
        layout: str = "wide",
        page_icon: Optional[str] = None,
        *,
        app_title: Optional[str] = None,
    ) -> None:
        self.app_title = title if app_title is None else app_title
        self.layout = layout
        self.page_icon = page_icon
        self._root_nodes = []
        self._sidebar_nodes = []
        self._pages = []
        self._stack = []

    def _register(self, node: Node) -> Node:
        node.app = self
        if self._stack:
            self._stack[-1].add(node)
        else:
            self._root_nodes.append(node)
        return node

    @contextmanager
    def _push(self, container: ContainerNode):
        self._register(container)
        self._stack.append(container)
        try:
            yield container
        finally:
            self._stack.pop()

    def clear(self) -> None:
        global _ORDER
        self._root_nodes.clear()
        self._sidebar_nodes.clear()
        self._pages.clear()
        self._stack.clear()
        _ORDER = itertools.count()

    def text(
        self,
        value: Any,
        *,
        kind: str = "text",
        key: Optional[str] = None,
        help: Optional[str] = None,
        truncate_at: Optional[int] = None,
        expandable: bool = False,
    ) -> TextNode:
        node = TextNode(
            value=value,
            kind=kind,
            key=key,
            help=help,
            truncate_at=truncate_at,
            expandable=expandable,
        )
        return self._register(node)

    def markdown(
        self,
        value: Any,
        *,
        key: Optional[str] = None,
        help: Optional[str] = None,
        truncate_at: Optional[int] = None,
        expandable: bool = False,
    ) -> TextNode:
        node = TextNode(
            value=value, kind="markdown", key=key, help=help, truncate_at=truncate_at, expandable=expandable
        )
        return self._register(node)

    def code(
        self,
        value: Any,
        *,
        key: Optional[str] = None,
        help: Optional[str] = None,
        truncate_at: Optional[int] = None,
        expandable: bool = False,
    ) -> TextNode:
        node = TextNode(value=value, kind="code", key=key, help=help, truncate_at=truncate_at, expandable=expandable)
        return self._register(node)

    def title(self, value: Any, *, key: Optional[str] = None) -> TextNode:
        return self.text(value, kind="title", key=key)

    def header(self, value: Any, *, key: Optional[str] = None) -> TextNode:
        return self.text(value, kind="header", key=key)

    def subheader(self, value: Any, *, key: Optional[str] = None) -> TextNode:
        return self.text(value, kind="subheader", key=key)

    def caption(self, value: Any, *, key: Optional[str] = None) -> TextNode:
        return self.text(value, kind="caption", key=key)

    def json(self, value: Any, *, key: Optional[str] = None, help: Optional[str] = None) -> TextNode:
        node = TextNode(value=value, kind="json", key=key, help=help)
        return self._register(node)

    def success(self, value: Any, *, key: Optional[str] = None, help: Optional[str] = None) -> TextNode:
        node = TextNode(value=value, kind="success", key=key, help=help)
        return self._register(node)

    def info(self, value: Any, *, key: Optional[str] = None, help: Optional[str] = None) -> TextNode:
        node = TextNode(value=value, kind="info", key=key, help=help)
        return self._register(node)

    def warning(self, value: Any, *, key: Optional[str] = None, help: Optional[str] = None) -> TextNode:
        node = TextNode(value=value, kind="warning", key=key, help=help)
        return self._register(node)

    def error(self, value: Any, *, key: Optional[str] = None, help: Optional[str] = None) -> TextNode:
        node = TextNode(value=value, kind="error", key=key, help=help)
        return self._register(node)

    def rerun(self) -> None:
        if st is None:
            return
        st.rerun()

    def stop(self) -> None:
        if st is None:
            return
        st.stop()

    def state_get(self, key: str, default: Any = None) -> Any:
        return st.session_state.get(key, default) if st is not None else default

    def state_set(self, key: str, value: Any) -> Any:
        if st is None:
            return value
        st.session_state[key] = value
        return value

    def state_delete(self, key: str) -> None:
        if st is None:
            return
        if key in st.session_state:
            del st.session_state[key]

    def divider(self, *, key: Optional[str] = None) -> DividerNode:
        return self._register(DividerNode(key=key))

    def metric(
        self,
        label: str,
        value: Any,
        delta: Any = None,
        *,
        delta_color: str = "normal",
        key: Optional[str] = None,
    ) -> MetricNode:
        node = MetricNode(label=label, value=value, delta=delta, delta_color=delta_color, key=key)
        return self._register(node)

    def table(
        self,
        data: Any,
        *,
        key: Optional[str] = None,
        use_container_width: bool = True,
        height: Optional[int] = None,
        editable: bool = False,
        paginate: bool = False,
        page_size: int = 100,
        show_index: bool = False,
        empty_message: str = "No rows to display.",
    ) -> TableNode:
        node = TableNode(
            data=data,
            key=key,
            use_container_width=use_container_width,
            height=height,
            editable=editable,
            paginate=paginate,
            page_size=page_size,
            show_index=show_index,
            empty_message=empty_message,
        )
        return self._register(node)

    def _input(
        self,
        kind: str,
        label: str,
        default: Any = "",
        *,
        key: Optional[str] = None,
        placeholder: Optional[str] = None,
        help: Optional[str] = None,
        options: Optional[Sequence[Any]] = None,
        min_value: Any = None,
        max_value: Any = None,
        step: Any = None,
    ) -> InputNode:
        node = InputNode(
            label=label,
            default=default,
            key=key,
            placeholder=placeholder,
            help=help,
            kind=kind,
            options=options,
            min_value=min_value,
            max_value=max_value,
            step=step,
        )
        return self._register(node)

    def page(
        self,
        title: str,
        *,
        icon: Optional[str] = None,
        layout: Optional[str] = None,
        key: Optional[str] = None,
    ):
        def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
            node = PageNode(label=title, render_fn=fn, icon=icon, layout=layout, key=key)
            self._pages.append(node)
            return fn

        return decorator

    def text_input(
        self,
        label: str,
        default: Any = "",
        *,
        key: Optional[str] = None,
        placeholder: Optional[str] = None,
        help: Optional[str] = None,
    ) -> InputNode:
        return self._input("text", label, default, key=key, placeholder=placeholder, help=help)

    def text_area(
        self,
        label: str,
        default: Any = "",
        *,
        key: Optional[str] = None,
        placeholder: Optional[str] = None,
        help: Optional[str] = None,
    ) -> InputNode:
        return self._input("textarea", label, default, key=key, placeholder=placeholder, help=help)

    def checkbox(
        self,
        label: str,
        default: bool = False,
        *,
        key: Optional[str] = None,
        help: Optional[str] = None,
    ) -> InputNode:
        return self._input("checkbox", label, default, key=key, help=help)

    def select(
        self,
        label: str,
        options: Sequence[Any],
        default: Any = None,
        *,
        key: Optional[str] = None,
        help: Optional[str] = None,
    ) -> InputNode:
        return self._input("select", label, default, key=key, help=help, options=options)

    def number_input(
        self,
        label: str,
        default: Any = 0,
        *,
        key: Optional[str] = None,
        help: Optional[str] = None,
        min_value: Any = None,
        max_value: Any = None,
        step: Any = None,
    ) -> InputNode:
        return self._input(
            "number",
            label,
            default,
            key=key,
            help=help,
            min_value=min_value,
            max_value=max_value,
            step=step,
        )

    def button(
        self,
        label: str,
        on_click: Optional[Callable[..., Any]] = None,
        *,
        key: Optional[str] = None,
        help: Optional[str] = None,
        type: str = "secondary",
        use_container_width: bool = False,
    ) -> ButtonNode:
        node = ButtonNode(
            label=label,
            on_click=on_click,
            key=key,
            help=help,
            type=type,
            use_container_width=use_container_width,
        )
        return self._register(node)

    def file_uploader(
        self,
        label: str,
        *,
        key: Optional[str] = None,
        help: Optional[str] = None,
        accept_multiple_files: bool = False,
        type: Optional[Sequence[str]] = None,
    ) -> FileUploadNode:
        node = FileUploadNode(
            label=label,
            key=key,
            help=help,
            accept_multiple_files=accept_multiple_files,
            type=type,
        )
        return self._register(node)

    def download_button(
        self,
        label: str,
        data: Any,
        *,
        file_name: str = "download",
        mime: Optional[str] = None,
        on_click: Optional[Callable[..., Any]] = None,
        key: Optional[str] = None,
        help: Optional[str] = None,
        use_container_width: bool = False,
    ) -> DownloadButtonNode:
        node = DownloadButtonNode(
            label=label,
            data=data,
            file_name=file_name,
            mime=mime,
            on_click=on_click,
            key=key,
            help=help,
            use_container_width=use_container_width,
        )
        return self._register(node)

    def form(
        self,
        title: str,
        *,
        submit_label: str = "Submit",
        on_submit: Optional[Callable[..., Any]] = None,
        key: Optional[str] = None,
    ) -> FormNode:
        return self._register(FormNode(label=title, submit_label=submit_label, on_submit=on_submit, key=key))

    def section(self, title: str, *, level: int = 2, key: Optional[str] = None) -> SectionNode:
        return self._register(SectionNode(label=title, level=level, key=key))

    def sidebar(self, title: str = "", *, key: Optional[str] = None) -> SidebarNode:
        node = SidebarNode(label=title, key=key)
        node.app = self
        self._sidebar_nodes.append(node)
        return node

    def columns(self, specs: Any = 2, *, key: Optional[str] = None) -> tuple[ColumnNode, ...]:
        if isinstance(specs, int):
            count = specs
            widths = specs
        else:
            widths = list(specs)
            count = len(widths)
        slots = [ColumnNode(label=f"Column {index + 1}") for index in range(count)]
        node = ColumnsNode(label="Columns", key=key, specs=widths, slots=slots)
        self._register(node)
        for slot in slots:
            slot.app = self
        return tuple(slots)

    def tabs(self, labels: Sequence[str], *, key: Optional[str] = None) -> tuple[TabNode, ...]:
        slots = [TabNode(label=label) for label in labels]
        node = TabsNode(label="Tabs", key=key, labels=list(labels), slots=slots)
        self._register(node)
        for slot in slots:
            slot.app = self
        return tuple(slots)

    @contextmanager
    def section_block(self, title: str, *, level: int = 2, key: Optional[str] = None):
        container = self.section(title, level=level, key=key)
        with container:
            yield container

    @contextmanager
    def expander_block(self, title: str, *, expanded: bool = False, key: Optional[str] = None):
        container = self.expander(title, expanded=expanded, key=key)
        with container:
            yield container

    def expander(self, title: str, *, expanded: bool = False, key: Optional[str] = None) -> ExpanderNode:
        return self._register(ExpanderNode(label=title, expanded=expanded, key=key))

    def dialog(
        self,
        title: str,
        *,
        open_label: str = "Open",
        button_label: Optional[str] = None,
        expanded: bool = False,
        on_open: Optional[Callable[..., Any]] = None,
        key: Optional[str] = None,
    ) -> DialogNode:
        return self._register(
            DialogNode(
                label=title,
                open_label=open_label,
                button_label=button_label,
                expanded=expanded,
                on_open=on_open,
                key=key,
            )
        )

    def render(self) -> None:
        if st is None:
            raise RuntimeError("streamlit is not installed; install it to render the UI")
        try:
            st.set_page_config(page_title=self.app_title, page_icon=self.page_icon, layout=self.layout)
            if self.app_title:
                st.title(self.app_title)
            context = RenderContext(app=self, st=st)
            for node in self._sidebar_nodes:
                node.render(context)
            if self._pages:
                page_labels = [page.label or f"Page {index + 1}" for index, page in enumerate(self._pages)]
                selected_label = st.sidebar.selectbox("Page", page_labels, index=0)
                active_page = self._pages[page_labels.index(selected_label)]
                with self._push(active_page):
                    active_page.render(context)
            else:
                for node in self._root_nodes:
                    node.render(context)
        finally:
            self.clear()

    def run(self) -> None:
        self.render()


# Default singleton, so users can just do `from soigia.ui import ui`.
ui = App()


def run() -> None:
    ui.run()


def rerun() -> None:
    ui.rerun()


def stop() -> None:
    ui.stop()


def state_get(key: str, default: Any = None) -> Any:
    return ui.state_get(key, default)


def state_set(key: str, value: Any) -> Any:
    return ui.state_set(key, value)


def state_delete(key: str) -> None:
    ui.state_delete(key)


def page(
    title: str,
    *,
    icon: Optional[str] = None,
    layout: Optional[str] = None,
    key: Optional[str] = None,
):
    return ui.page(title, icon=icon, layout=layout, key=key)


def text(
    value: Any,
    *,
    key: Optional[str] = None,
    help: Optional[str] = None,
    truncate_at: Optional[int] = None,
    expandable: bool = False,
) -> TextNode:
    return ui.text(value, key=key, help=help, truncate_at=truncate_at, expandable=expandable)


def markdown(
    value: Any,
    *,
    key: Optional[str] = None,
    help: Optional[str] = None,
    truncate_at: Optional[int] = None,
    expandable: bool = False,
) -> TextNode:
    return ui.markdown(value, key=key, help=help, truncate_at=truncate_at, expandable=expandable)


def code(
    value: Any,
    *,
    key: Optional[str] = None,
    help: Optional[str] = None,
    truncate_at: Optional[int] = None,
    expandable: bool = False,
) -> TextNode:
    return ui.code(value, key=key, help=help, truncate_at=truncate_at, expandable=expandable)


def divider(*, key: Optional[str] = None) -> DividerNode:
    return ui.divider(key=key)


def json(value: Any, *, key: Optional[str] = None, help: Optional[str] = None) -> TextNode:
    return ui.json(value, key=key, help=help)


def success(value: Any, *, key: Optional[str] = None, help: Optional[str] = None) -> TextNode:
    return ui.success(value, key=key, help=help)


def info(value: Any, *, key: Optional[str] = None, help: Optional[str] = None) -> TextNode:
    return ui.info(value, key=key, help=help)


def warning(value: Any, *, key: Optional[str] = None, help: Optional[str] = None) -> TextNode:
    return ui.warning(value, key=key, help=help)


def error(value: Any, *, key: Optional[str] = None, help: Optional[str] = None) -> TextNode:
    return ui.error(value, key=key, help=help)


def metric(
    label: str,
    value: Any,
    delta: Any = None,
    *,
    delta_color: str = "normal",
    key: Optional[str] = None,
) -> MetricNode:
    return ui.metric(label, value, delta=delta, delta_color=delta_color, key=key)


def text_input(
    label: str,
    default: Any = "",
    *,
    key: Optional[str] = None,
    placeholder: Optional[str] = None,
    help: Optional[str] = None,
) -> InputNode:
    return ui.text_input(label, default=default, key=key, placeholder=placeholder, help=help)


def textbox(
    label: str,
    default: Any = "",
    *,
    key: Optional[str] = None,
    placeholder: Optional[str] = None,
    help: Optional[str] = None,
) -> InputNode:
    return text_input(label, default=default, key=key, placeholder=placeholder, help=help)


def text_area(
    label: str,
    default: Any = "",
    *,
    key: Optional[str] = None,
    placeholder: Optional[str] = None,
    help: Optional[str] = None,
) -> InputNode:
    return ui.text_area(label, default=default, key=key, placeholder=placeholder, help=help)


def textarea(
    label: str,
    default: Any = "",
    *,
    key: Optional[str] = None,
    placeholder: Optional[str] = None,
    help: Optional[str] = None,
) -> InputNode:
    return text_area(label, default=default, key=key, placeholder=placeholder, help=help)


def checkbox(
    label: str,
    default: bool = False,
    *,
    key: Optional[str] = None,
    help: Optional[str] = None,
) -> InputNode:
    return ui.checkbox(label, default=default, key=key, help=help)


def select(
    label: str,
    options: Sequence[Any],
    default: Any = None,
    *,
    key: Optional[str] = None,
    help: Optional[str] = None,
) -> InputNode:
    return ui.select(label, options, default=default, key=key, help=help)


def button(
    label: str,
    on_click: Optional[Callable[..., Any]] = None,
    *,
    key: Optional[str] = None,
    help: Optional[str] = None,
    type: str = "secondary",
    use_container_width: bool = False,
) -> ButtonNode:
    return ui.button(label, on_click=on_click, key=key, help=help, type=type, use_container_width=use_container_width)


def file_uploader(
    label: str,
    *,
    key: Optional[str] = None,
    help: Optional[str] = None,
    accept_multiple_files: bool = False,
    type: Optional[Sequence[str]] = None,
) -> FileUploadNode:
    return ui.file_uploader(
        label,
        key=key,
        help=help,
        accept_multiple_files=accept_multiple_files,
        type=type,
    )


def download_button(
    label: str,
    data: Any,
    *,
    file_name: str = "download",
    mime: Optional[str] = None,
    on_click: Optional[Callable[..., Any]] = None,
    key: Optional[str] = None,
    help: Optional[str] = None,
    use_container_width: bool = False,
) -> DownloadButtonNode:
    return ui.download_button(
        label,
        data,
        file_name=file_name,
        mime=mime,
        on_click=on_click,
        key=key,
        help=help,
        use_container_width=use_container_width,
    )


def form(
    title: str,
    *,
    submit_label: str = "Submit",
    on_submit: Optional[Callable[..., Any]] = None,
    key: Optional[str] = None,
) -> FormNode:
    return ui.form(title, submit_label=submit_label, on_submit=on_submit, key=key)


def dialog(
    title: str,
    *,
    open_label: str = "Open",
    button_label: Optional[str] = None,
    expanded: bool = False,
    on_open: Optional[Callable[..., Any]] = None,
    key: Optional[str] = None,
) -> DialogNode:
    return ui.dialog(
        title,
        open_label=open_label,
        button_label=button_label,
        expanded=expanded,
        on_open=on_open,
        key=key,
    )


def table(
    data: Any,
    *,
    key: Optional[str] = None,
    use_container_width: bool = True,
    height: Optional[int] = None,
    editable: bool = False,
    paginate: bool = False,
    page_size: int = 100,
    show_index: bool = False,
    empty_message: str = "No rows to display.",
) -> TableNode:
    return ui.table(
        data,
        key=key,
        use_container_width=use_container_width,
        height=height,
        editable=editable,
        paginate=paginate,
        page_size=page_size,
        show_index=show_index,
        empty_message=empty_message,
    )


def sidebar(title: str = "", *, key: Optional[str] = None) -> SidebarNode:
    return ui.sidebar(title, key=key)


def columns(specs: Any = 2, *, key: Optional[str] = None) -> tuple[ColumnNode, ...]:
    return ui.columns(specs, key=key)


def tabs(labels: Sequence[str], *, key: Optional[str] = None) -> tuple[TabNode, ...]:
    return ui.tabs(labels, key=key)


def section(title: str, *, level: int = 2, key: Optional[str] = None) -> SectionNode:
    return ui.section(title, level=level, key=key)


def expander(title: str, *, expanded: bool = False, key: Optional[str] = None) -> ExpanderNode:
    return ui.expander(title, expanded=expanded, key=key)


if __name__ == "__main__":
    run()
