"""Data table with pagination, search, sorting, and inline actions"""

# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/05_datatable.ipynb.

# %% auto #0
__all__ = ['PAGE_SIZES', 'TABLE_STYLES', 'TABLE_SPACES', 'TABLE_ALIGNS', 'logger', 'table_state_from_request', 'DataTable',
           'CrudContext', 'DataTableResource']

# %% ../nbs/05_datatable.ipynb #d7f65962
from fastcore.utils import *
from fasthtml.common import *
from fasthtml.jupyter import *
from fastlite import *
import fasthtml.components as fc
from fasthtml.common import A, Button as FhButton, I, Span
from .foundations import normalize_tokens, stringify, VEnum, dedupe_preserve_order
from .core import *
from nbdev.showdoc import show_doc
from .components import *

# %% ../nbs/05_datatable.ipynb #3a9c1b0c
#| code-fold: true
#| code-fold: true
from math import ceil
from urllib.parse import urlencode
from typing import Callable, Optional, Any, Union
from dataclasses import asdict, is_dataclass
from collections import OrderedDict

# Default page size options
PAGE_SIZES = [5, 10, 20, 50]

# BeerCSS table styles
TABLE_STYLES = ['border', 'stripes', 'min', 'fixed']  # Can combine: 'border stripes'
TABLE_SPACES = ['no-space', 'small-space', 'medium-space', 'large-space']
TABLE_ALIGNS = ['left-align', 'right-align', 'center-align']


def _to_dict(obj: Any) -> dict:
    """
    Convert any record type to dict.
    
    Handles:
    - dict -> pass through
    - dataclass -> asdict()
    - namedtuple -> _asdict()
    - Pydantic model -> model_dump() or dict()
    - ORM/object with __dict__ -> vars() filtered
    - dict-like with keys -> dict()
    - None -> {}
    """
    if obj is None:
        return {}
    if isinstance(obj, dict):
        return obj
    # Dataclass
    if is_dataclass(obj) and not isinstance(obj, type):
        return asdict(obj)
    # Namedtuple
    if hasattr(obj, '_asdict'):
        return obj._asdict()
    # Pydantic v2
    if hasattr(obj, 'model_dump'):
        return obj.model_dump()
    # Pydantic v1
    if hasattr(obj, 'dict') and callable(getattr(obj, 'dict')):
        return obj.dict()
    # ORM-style objects with __dict__
    if hasattr(obj, "__dict__"):
        return {k: v for k, v in obj.__dict__.items() if not k.startswith("_")}
    # Dict-like
    if hasattr(obj, 'keys'):
        return dict(obj)
    return {}


def _safe_int(value, default):
    """Safely convert to positive int or return default."""
    try:
        number = int(value)
        return number if number > 0 else default
    except (TypeError, ValueError):
        return default


def _group_data(data: list[dict], group_key: str) -> list[tuple[Any, list[dict]]]:
    """
    Group data by a column value, preserving input order.
    
    Args:
        data: List of row dicts
        group_key: Column key to group by
        
    Returns:
        List of (group_value, [rows...]) tuples in order of first appearance
    """
    groups = OrderedDict()
    for row in data:
        val = row.get(group_key)
        groups.setdefault(val, []).append(row)
    return list(groups.items())


def table_state_from_request(req, page_sizes=None):
    """
    Extract pagination state from request query params.
    
    Returns dict with: search, page, page_size
    """
    page_sizes = page_sizes or PAGE_SIZES
    params = getattr(req, "query_params", {})
    getter = params.get if hasattr(params, "get") else (lambda key, default=None: params[key] if key in params else default)
    
    search = (getter("search", "") or "").strip()
    page = _safe_int(getter("page", 1), 1)
    page_size = _safe_int(getter("page_size", 10), 10)
    
    if page_size not in page_sizes:
        page_size = page_sizes[0] if page_sizes else 10
    
    return {"search": search, "page": page, "page_size": page_size}


def _page_size_select(current_size: int, search: str, base_route: str, container_id: str, page_sizes: list):
    """Build page size dropdown selector."""
    menu_id = f"{container_id}-page-size-menu"
    options = []
    for size in page_sizes:
        params = urlencode({"search": search, "page_size": size, "page": 1})
        option_cls = "active" if size == current_size else None
        options.append(
            Li(
                f"Show {size}",
                hx_get=f"{base_route}?{params}",
                hx_target=f"#{container_id}",
                hx_push_url="true",
                cls=option_cls
            )
        )
    return Button(
        Span(f"Show {current_size}", cls="small-text grey-text"),
        Icon("arrow_drop_down", cls="small grey-text"),
        Menu(*options, cls="border", id=menu_id),
        cls="transparent small",
        data_ui=f"#{menu_id}"
    )


def _action_menu(
    row: dict,
    row_id: Any,
    crud_ops: dict,
    crud_enabled: dict,
    base_route: str,
    search: str,
    page: int,
    page_size: int,
    container_id: str,
    feedback_id: str
):
    """Build per-row action menu based on enabled CRUD operations."""
    menu_id = f"crud-actions-{row_id}"
    base_query = {"id": row_id, "search": search, "page": page, "page_size": page_size}
    
    def action_item(label: str, icon: str, action: str, confirm: str = None):
        query = urlencode({**base_query, "action": action})
        attrs = {
            "hx_get": f"{base_route}/action?{query}",
            "hx_target": f"#{feedback_id}",
            "hx_swap": "outerHTML"
        }
        if confirm:
            attrs["hx_confirm"] = confirm
        return Li(
            A(
                Icon(icon, cls="tiny"),
                Span(label, cls="max"),
                cls="row middle-align",
                **attrs
            )
        )
    
    items = []
    # View is conditional (read operation) - use crud_enabled for callable support
    if crud_enabled.get("view", True):
        items.append(action_item("View", "visibility", "view"))
    
    if crud_enabled.get("update", False):
        items.append(action_item("Edit", "edit", "edit"))
    
    if crud_enabled.get("delete", False):
        items.append(action_item("Delete", "delete", "delete", confirm="Delete this record?"))
    
    # Custom actions
    for custom_action in crud_ops.get("custom_actions", []):
        # Check per-row condition if provided
        condition = custom_action.get("condition")
        if condition and not condition(row):
            continue
        
        items.append(action_item(
            label=custom_action["label"],
            icon=custom_action["icon"],
            action=custom_action["name"],
            confirm=custom_action.get("confirm")
        ))
    
    return Div(
        Button(
            Icon("more_vert"),
            cls=(ButtonT.text, "circle"),
            data_ui=f"#{menu_id}",
            title="Row actions"
        ),
        Menu(*items, id=menu_id),
        cls="relative"
    )


def DataTable(
    data: list[dict],
    total: int,
    page: int = 1,
    page_size: int = 10,
    search: str = '',
    columns: list[dict] = None,
    crud_ops: dict = None,
    crud_enabled: dict = None,
    base_route: str = '',
    row_id_field: str = 'id',
    title: str = 'Records',
    container_id: str = None,
    page_sizes: list = None,
    search_placeholder: str = 'Search...',
    create_label: str = 'New Record',
    empty_message: str = 'No records match the current filters.',
    # BeerCSS table styling options
    table_style: str = 'border',      # border, stripes, min, fixed (can combine: 'border stripes')
    space: str = 'small-space',       # no-space, small-space, medium-space, large-space
    align: str = None,                # left-align, right-align, center-align (None = default left)
    # Grouping options
    group_by: str = None,             # Column key to group rows by
    group_header_format: Callable[[Any], str] = None,  # Format group header text
    group_header_cls: str = 'surface-container',       # CSS classes for group header row
    group_header_icon: Union[str, Callable[[Any], str]] = None,  # Icon name or callable
    group_header_space: str = 'small-space'  # no-space, small-space, medium-space, large-space
):
    """
    Generic data table with server-side pagination, search, and row actions.
    
    Table Styling (BeerCSS):
        table_style: Table visual style - 'border', 'stripes', 'min', 'fixed' or combine like 'border stripes'
        space: Row spacing - 'no-space', 'small-space', 'medium-space', 'large-space'  
        align: Text alignment - 'left-align', 'right-align', 'center-align' (None for default)
    
    Grouping:
        group_by: Column key to group rows by (e.g., 'transaction_date')
        group_header_format: Callable to format group value for display (e.g., lambda d: d.strftime('%B %d, %Y'))
        group_header_cls: CSS classes for group header rows (default: 'surface-container')
        group_header_space: Spacing for group header cells - 'no-space', 'small-space', 'medium-space', 'large-space'
    """
    # Defaults
    crud_ops = crud_ops or {"create": False, "update": False, "delete": False}
    crud_enabled = crud_enabled or {k: bool(v) for k, v in crud_ops.items() if k != 'custom_actions'}
    page_sizes = page_sizes or PAGE_SIZES
    container_id = container_id or f"crud-table-{base_route.replace('/', '-').strip('-')}"
    feedback_id = f"{container_id}-feedback"
    
    # Auto-convert data records to dicts (supports dataclass, namedtuple, Pydantic, ORM)
    data = [_to_dict(r) for r in data]
    
    # Auto-generate columns from first data row if not provided
    if columns is None and data:
        columns = [{"key": k, "label": k.replace("_", " ").title()} for k in data[0].keys()]
    columns = columns or []
    
    # Calculate pagination metadata
    total_pages = max(1, ceil(total / page_size)) if total > 0 else 1
    page = min(max(1, page), total_pages)
    start_index = (page - 1) * page_size + 1 if total else 0
    end_index = min(start_index + page_size - 1, total) if total else 0
    summary = f"{start_index}-{end_index} of {total} records" if total else "No matching records"
    base_query = urlencode({"search": search, "page_size": page_size})
    
    # Build table header keys and labels
    header_keys = [col["key"] for col in columns] + ["actions"]
    header_labels = [col.get("label", col["key"]) for col in columns] + [""]
    
    # Calculate colspan for group headers (all visible columns)
    group_colspan = len(header_keys)
    
    # Helper to build a single data row
    def build_data_row(row: dict) -> Tr:
        row_id = row.get(row_id_field)
        row_dict = {}
        
        for col in columns:
            key = col["key"]
            value = row.get(key, "")
            renderer = col.get("renderer")
            
            if renderer and callable(renderer):
                row_dict[key] = renderer(value, row)
            else:
                row_dict[key] = value
        
        # Add actions column
        row_dict["actions"] = _action_menu(
            row=row,
            row_id=row_id,
            crud_ops=crud_ops,
            crud_enabled=crud_enabled,
            base_route=base_route,
            search=search,
            page=page,
            page_size=page_size,
            container_id=container_id,
            feedback_id=feedback_id
        )
        return Tr(*[Td(row_dict.get(key, '')) for key in header_keys])
    
    # Helper to build a group header row
    def build_group_header(group_value: Any) -> Tr:
        # Format the group label
        if group_header_format and callable(group_header_format):
            label = group_header_format(group_value)
        else:
            label = str(group_value) if group_value is not None else "Ungrouped"
        
        # Get icon if specified
        icon_content = None
        if group_header_icon:
            if callable(group_header_icon):
                icon_name = group_header_icon(group_value)
            else:
                icon_name = group_header_icon
            if icon_name:
                icon_content = Icon(icon_name, cls="small")
        
        # Build header cell content
        cell_content = []
        if icon_content:
            cell_content.append(icon_content)
        cell_content.append(Span(label, cls="bold"))
        
        return Tr(
            Td(*cell_content, colspan=group_colspan, cls=group_header_space),
            cls=f"group-header {group_header_cls}"
        )
    
    # Build table rows - grouped or flat
    if group_by and data:
        # Grouped rendering
        grouped = _group_data(data, group_by)
        tbody_rows = []
        for group_value, group_rows in grouped:
            tbody_rows.append(build_group_header(group_value))
            for row in group_rows:
                tbody_rows.append(build_data_row(row))
    else:
        # Flat rendering (original behavior)
        tbody_rows = [build_data_row(row) for row in data]
    
    # Create button - use crud_enabled for callable support
    create_button = None
    if crud_enabled.get("create", False):
        query = urlencode({"action": "create", "search": search, "page": page, "page_size": page_size})
        create_button = Button(
            Icon("add"),
            create_label,
            hx_get=f"{base_route}/action?{query}",
            hx_target=f"#{feedback_id}",
            hx_swap="outerHTML",
            cls=ButtonT.primary
        )
    
    # Pagination controls (using reusable Pagination component)
    pagination = None
    if total_pages > 1:
        pagination_params = urlencode({"search": search, "page_size": page_size})
        pagination = Pagination(
            current_page=page,
            total_pages=total_pages,
            hx_get=f"{base_route}?{pagination_params}",
            hx_target=f"#{container_id}"
        )
    
    # Search form
    search_form = Form(
        Div(
            Icon("search", cls="small"),
            Input(
                type="search",
                name="search",
                value=search,
                placeholder=search_placeholder,
                hx_get=base_route,
                hx_trigger="input changed delay:300ms, search",
                hx_target=f"#{container_id}",
                hx_push_url="true",
                hx_include="[name='page_size']"
            ),
            cls="field prefix"
        ),
        Input(type="hidden", name="page_size", value=page_size),
        cls="max"
    )
    
    # Compose table classes from BeerCSS styling options
    table_classes = []
    if table_style:
        table_classes.append(table_style)
    if space:
        table_classes.append(space)
    if align:
        table_classes.append(align)
    table_cls = ' '.join(table_classes) if table_classes else None
    
    # Build table with proper Thead/Tbody structure
    data_table = Table(
        Thead(Tr(*[Th(label) for label in header_labels])),
        Tbody(*tbody_rows),
        cls=table_cls
    ) if data else Div(empty_message, cls="center-align padding")
    
    # Footer section with page size selector and pagination
    footer = None
    if total > 0:
        page_info_row = Div(
            _page_size_select(page_size, search, base_route, container_id, page_sizes),
            Span(summary, cls="small-text grey-text"),
            cls="row"
        )
        if pagination:
            # Two-row layout: info on top, centered pagination below
            footer = Div(
                page_info_row,
                Div(pagination, cls="row center-align"),
            )
        else:
            # Single row when no pagination needed
            footer = page_info_row
    
    return Div(
        Div(id=feedback_id),  # Feedback container for modals/toasts
        Div(
            search_form,
            create_button,
            cls="row"
        ),
        data_table,
        footer,
        id=container_id,
        hx_trigger=f"{container_id}-refresh from:body",
        hx_get=f"{base_route}?{base_query}",
        hx_target=f"#{container_id}",
        hx_swap="outerHTML"

    )    

# %% ../nbs/05_datatable.ipynb #b84c744d
import asyncio
import logging
from dataclasses import dataclass
from typing import Callable, Any, Optional
from starlette.responses import HTMLResponse

logger = logging.getLogger(__name__)

@dataclass
class CrudContext:
    """
    🎯 Context object passed to enhanced CRUD operation hooks.
    
    Provides rich access to request state, user info, database, and record data.
    Perfect for implementing complex business logic and external API integration.
    
    ## 📦 Fields
    
    - `request`: Full Starlette request object (headers, query params, session, state)
    - `user`: Current user dict from `request.state.user` (if available)
    - `db`: Database instance from `request.state.tenant_db` (if available)
    - `tbl`: Table instance from `request.state.tables[table_name]` (if available)
    - `record`: Form data dict with field values
    - `record_id`: Record ID for update/delete operations (None for create)
    
    ## 💡 Usage in Hooks
    
    ### Example: Create with External API
    ```python
    async def quiltt_create_connection(ctx: CrudContext) -> dict:
        # Access user info
        user_id = ctx.user['user_id']
        
        # Call external API
        api = QuilttClient()
        response = await api.create_connection(
            institution=ctx.record['institution_name'],
            user_id=user_id
        )
        
        # Enrich record with API response
        ctx.record['connection_id'] = response['id']
        ctx.record['account_id'] = response['account_id']
        ctx.record['status'] = 'pending'
        
        return ctx.record  # DataTableResource will insert this
    
    DataTableResource(
        ...,
        on_create=quiltt_create_connection  # 🆕 Enhanced hook
    )
    ```
    
    ### Example: Soft Delete
    ```python
    def soft_delete_budget(ctx: CrudContext) -> None:
        # Access table directly
        ctx.tbl.update({
            'id': ctx.record_id,
            'is_deleted': True,
            'deleted_at': datetime.now().isoformat(),
            'deleted_by': ctx.user['user_id']
        })
        # No return needed for delete hooks
    
    DataTableResource(
        ...,
        on_delete=soft_delete_budget,  # 🆕 Custom delete logic
        get_table=lambda req: req.state.tables['budgets']
    )
    ```
    
    ### Example: Update with Sync
    ```python
    async def sync_transaction_update(ctx: CrudContext) -> dict:
        # Update external API first
        api = TransactionAPI()
        await api.update_transaction(
            transaction_id=ctx.record_id,
            data=ctx.record
        )
        
        # Add sync timestamp
        ctx.record['last_synced'] = datetime.now().isoformat()
        return ctx.record
    ```
    """
    request: Any                     # Full Starlette request
    user: Optional[dict] = None      # request.state.user (if available)
    db: Optional[Any] = None         # request.state.tenant_db (if available)
    tbl: Optional[Any] = None        # request.state.tables[table_name] (if available)
    record: dict = None              # Form data dict
    record_id: Optional[Any] = None  # ID for update/delete (None for create)
    feedback_id: Optional[str] = None  # Target div ID for HTMX swap (for override handlers)

# %% ../nbs/05_datatable.ipynb #63468220
from typing import Callable, Optional, Any, Union
from dataclasses import asdict, is_dataclass
from datetime import datetime
import uuid

def _to_dict(obj: Any) -> dict:
    """Convert dataclass, ORM object, or dict to plain dict."""
    if obj is None:
        return {}
    if isinstance(obj, dict):
        return obj
    if is_dataclass(obj):
        return asdict(obj)
    # ORM-style objects with __dict__
    if hasattr(obj, "__dict__"):
        return {k: v for k, v in obj.__dict__.items() if not k.startswith("_")}
    return dict(obj)


def _is_htmx_request(req) -> bool:
    """Check if request is an HTMX partial request."""
    headers = getattr(req, 'headers', {})
    return headers.get('HX-Request') == 'true'


class DataTableResource:
    """
    🔧 High-level resource that auto-registers all routes for a data table.
    
    **Features:**
    - All callbacks receive `request` for multi-tenant support
    - Custom CRUD hooks with `CrudContext` for rich business logic
    - Async/sync hook support for external API integration
    - Auto-refresh table via HX-Trigger after mutations
    - Layout wrapper for full-page (non-HTMX) responses
    - Optional `get_count` for efficient DB-level pagination
    - **Row grouping** with `group_by` for visual organization
    
    **Auto-registers 3 routes:**
    - `GET {base_route}` → DataTable list view
    - `GET {base_route}/action` → FormModal for create/edit/view/delete
    - `POST {base_route}/save` → Save handler with hooks
    
    **Row Grouping:**
    
    Group rows by a column value with formatted section headers:
    
    ```python
    DataTableResource(
        ...,
        group_by="transaction_date",
        group_header_format=lambda d: d.strftime("%B %d, %Y"),
        group_header_icon="calendar_today"
    )
    ```
    
    **DB-Level Pagination (Recommended for large datasets):**
    
    For efficient pagination, provide both `get_all` (returning paginated rows) 
    and `get_count` (returning total count):
    
    ```python
    def get_products(req):
        page = int(req.query_params.get('page', 1))
        page_size = int(req.query_params.get('page_size', 10))
        offset = (page - 1) * page_size
        search = req.query_params.get('search', '')
        # SQL-level pagination
        return list(tbl(limit=page_size, offset=offset))
    
    def get_product_count(req):
        search = req.query_params.get('search', '')
        return db.execute("SELECT COUNT(*) FROM products").scalar()
    
    DataTableResource(
        get_all=get_products,      # Returns page_size rows
        get_count=get_product_count,  # Returns total count
        ...
    )
    ```
    
    If `get_count` is not provided, the library filters and paginates in Python
    (requires `get_all` to return ALL rows - inefficient for large datasets).
    """
    
    def __init__(
        self,
        app,
        base_route: str,
        columns: list[dict],
        # Data callbacks - ALL receive request as first param
        get_all: Callable[[Any], list],                    # (req) -> list (can be paginated)
        get_by_id: Callable[[Any, Any], Any],              # (req, id) -> record
        get_count: Callable[[Any], int] = None,            # (req) -> total count for pagination
        create: Callable[[Any, dict], Any] = None,         # (req, data) -> record
        update: Callable[[Any, Any, dict], Any] = None,    # (req, id, data) -> record
        delete: Callable[[Any, Any], bool] = None,         # (req, id) -> bool
        # Display options
        title: str = "Records",
        row_id_field: str = "id",
        crud_ops: dict = None,
        page_sizes: list = None,
        search_placeholder: str = "Search...",
        create_label: str = "New Record",
        empty_message: str = "No records found.",
        # CRUD Hooks (with CrudContext)
        on_create: Callable[[CrudContext], dict] = None,
        on_update: Callable[[CrudContext], dict] = None,
        on_delete: Callable[[CrudContext], None] = None,
        # Layout wrapper for full-page responses
        layout_wrapper: Callable[[Any, Any], Any] = None,  # (content, req) -> wrapped
        # Custom generators
        id_generator: Callable[[], Any] = None,
        timestamp_fields: dict = None,
        # Grouping options
        group_by: str = None,                              # Column key to group rows by
        group_header_format: Callable[[Any], str] = None,  # Format group header text
        group_header_cls: str = "surface-container",       # CSS classes for group header row
        group_header_icon: Union[str, Callable[[Any], str]] = None,  # Icon name or callable
        group_header_space: str = "small-space",           # no-space, small-space, medium-space, large-space
        # Table styling options
        space: str = "small-space"                         # Row density: "no-space", "small-space", or None
    ):
        self.app = app
        self.base_route = base_route.rstrip("/")
        self.columns = columns
        self.get_all = get_all
        self.get_by_id = get_by_id
        self.get_count = get_count
        self.create_fn = create
        self.update_fn = update
        self.delete_fn = delete
        self.title = title
        self.row_id_field = row_id_field
        self.page_sizes = page_sizes or PAGE_SIZES
        self.search_placeholder = search_placeholder
        self.create_label = create_label
        self.empty_message = empty_message
        
        # Grouping options
        self.group_by = group_by
        self.group_header_format = group_header_format
        self.group_header_cls = group_header_cls
        self.group_header_icon = group_header_icon
        self.group_header_space = group_header_space
        
        # Table styling
        self.space = space
        
        # Determine CRUD ops from provided functions
        if crud_ops is None:
            self.crud_ops = {
                "create": create is not None or on_create is not None,
                "update": update is not None or on_update is not None,
                "delete": delete is not None or on_delete is not None
            }
        else:
            self.crud_ops = crud_ops
            
            # Validate custom_actions if provided
            custom_actions = crud_ops.get("custom_actions", [])
            if custom_actions:
                reserved_names = {"view", "edit", "delete", "create"}
                for action in custom_actions:
                    # Check required fields
                    if "name" not in action:
                        raise ValueError("Custom action missing required 'name' field")
                    if "label" not in action:
                        raise ValueError(f"Custom action '{action['name']}' missing required 'label' field")
                    if "icon" not in action:
                        raise ValueError(f"Custom action '{action['name']}' missing required 'icon' field")
                    if "handler" not in action:
                        raise ValueError(f"Custom action '{action['name']}' missing required 'handler' field")
                    
                    # Check for reserved name collisions
                    if action["name"] in reserved_names:
                        raise ValueError(f"Custom action name '{action['name']}' conflicts with reserved action name")
                    
                    # Validate handler is callable
                    if not callable(action["handler"]):
                        raise ValueError(f"Custom action '{action['name']}' handler must be callable")
        
        # Parse crud_ops for callable overrides vs boolean enable/disable
        # Callable = override handler (action is enabled)
        # True = use default handler (action is enabled)
        # False = action is disabled
        self.crud_overrides = {}
        self.crud_enabled = {}
        for action_name in ['create', 'update', 'delete', 'view']:
            value = self.crud_ops.get(action_name, action_name == 'view')  # view defaults to True
            if callable(value):
                self.crud_overrides[action_name] = value
                self.crud_enabled[action_name] = True  # Callable means action is enabled
            else:
                self.crud_enabled[action_name] = bool(value)
        
        # CRUD hooks
        self.on_create_hook = on_create
        self.on_update_hook = on_update
        self.on_delete_hook = on_delete
        
        # Layout wrapper
        self.layout_wrapper = layout_wrapper
        
        # Generators
        self.id_generator = id_generator
        self.timestamp_fields = timestamp_fields or {}
        
        # Derived IDs
        self.container_id = f"crud-table-{base_route.replace('/', '-').strip('-')}"
        self.feedback_id = f"{self.container_id}-feedback"
        self.modal_id = f"{self.container_id}-modal"
        self.refresh_trigger = f"{self.container_id}-refresh"
        
        # Register routes
        self._register_routes()
    
    async def _call_hook(self, hook, ctx: CrudContext):
        """🔄 Call hook function, handling both sync and async."""
        if hook is None:
            return None
        if asyncio.iscoroutinefunction(hook):
            return await hook(ctx)
        return hook(ctx)
    
    def _build_context(self, req, record: dict = None, record_id: Any = None, include_feedback_id: bool = False) -> CrudContext:
        """🏗️ Build CrudContext from request."""
        user = None
        db = None
        tbl = None
        
        # Extract user from request.state if available
        try:
            if hasattr(req, 'state') and hasattr(req.state, 'user'):
                user = req.state.user
        except AttributeError:
            pass
        
        # Extract db from request.state if available
        try:
            if hasattr(req, 'state') and hasattr(req.state, 'tenant_db'):
                db = req.state.tenant_db
        except AttributeError:
            pass
        
        return CrudContext(
            request=req,
            user=user,
            db=db,
            tbl=tbl,
            record=record or {},
            record_id=record_id,
            feedback_id=self.feedback_id if include_feedback_id else None
        )
    
    def _wrap_response(self, content, req):
        """Wrap content with layout_wrapper if not an HTMX request."""
        if self.layout_wrapper and not _is_htmx_request(req):
            return self.layout_wrapper(content, req)
        return content
    
    def _register_routes(self):
        """Register all data table routes with the app."""
        rt = self.app.route
        
        @rt(self.base_route)
        def _table_handler(req):
            return self._handle_table(req)
        
        @rt(f"{self.base_route}/action")
        async def _action_handler(req):
            return await self._handle_action(req)
        
        @rt(f"{self.base_route}/save")
        async def _save_handler(req):
            return await self._handle_save(req)
    
    def _get_filtered_data(self, req) -> list:
        """Get all data from user's callback."""
        all_data = self.get_all(req)
        return [_to_dict(item) for item in all_data]
    
    def _filter_by_search(self, data: list, search: str) -> list:
        """Filter data by search term across searchable columns."""
        if not search:
            return data
        
        needle = search.lower()
        searchable_keys = [
            col["key"] for col in self.columns 
            if col.get("searchable", False)
        ]
        
        if not searchable_keys:
            searchable_keys = [col["key"] for col in self.columns]
        
        return [
            row for row in data
            if any(needle in str(row.get(key, "")).lower() for key in searchable_keys)
        ]
    
    def _paginate(self, data: list, page: int, page_size: int) -> tuple:
        """Paginate data list. Returns (page_rows, total, adjusted_page)."""
        total = len(data)
        total_pages = max(1, ceil(total / page_size))
        page = min(max(1, page), total_pages)
        start = (page - 1) * page_size
        end = start + page_size
        return data[start:end], total, page
    
    def _handle_table(self, req):
        """Handle main table route."""
        state = table_state_from_request(req, page_sizes=self.page_sizes)
        search, page, page_size = state["search"], state["page"], state["page_size"]
        # Get data from user callback
        data = self._get_filtered_data(req)
        
        # Determine total count:
        # - If get_count provided: use it (efficient DB-level count)
        # - Otherwise: filter and count in Python (assumes get_all returns all rows)
        if self.get_count:
            # User handles pagination in get_all, we just get count separately
            total = self.get_count(req)
            page_data = data  # Already paginated by user
            total_pages = max(1, ceil(total / page_size)) if total > 0 else 1
            page = min(max(1, page), total_pages)
        else:
            # Legacy behavior: filter and paginate in Python
            filtered = self._filter_by_search(data, search)
            page_data, total, page = self._paginate(filtered, page, page_size)
        
        table = DataTable(
            data=page_data,
            total=total,
            page=page,
            page_size=page_size,
            search=search,
            columns=self.columns,
            crud_ops=self.crud_ops,
            crud_enabled=self.crud_enabled,
            base_route=self.base_route,
            row_id_field=self.row_id_field,
            title=self.title,
            container_id=self.container_id,
            page_sizes=self.page_sizes,
            search_placeholder=self.search_placeholder,
            create_label=self.create_label,
            empty_message=self.empty_message,
            # Grouping options
            group_by=self.group_by,
            group_header_format=self.group_header_format,
            group_header_cls=self.group_header_cls,
            group_header_icon=self.group_header_icon,
            group_header_space=self.group_header_space,
            # Table styling
            space=self.space
        )
        
        # Wrap table in auto-refresh container
        params = urlencode({"search": search, "page": page, "page_size": page_size})
        table_container = Div(
            table,
            id=self.container_id,
            hx_trigger=f"{self.refresh_trigger} from:body",
            hx_get=f"{self.base_route}?{params}",
            hx_target=f"#{self.container_id}",
            hx_swap="outerHTML"
        )
        
        feedback = Div(id=self.feedback_id)
        content = Div(feedback, table_container)
        
        # Wrap with layout if full-page request
        return self._wrap_response(content, req)
    
    async def _handle_action(self, req):
        """Handle action route (view/edit/create/delete) with override support."""
        params = getattr(req, "query_params", {})
        getter = params.get if hasattr(params, "get") else (lambda k, d=None: params[k] if k in params else d)
        
        if getter("dismiss") is not None:
            return Div(id=self.feedback_id)
        
        record_id = getter("id")
        if record_id:
            try:
                record_id = int(record_id)
            except (TypeError, ValueError):
                pass
        
        action = (getter("action", "view") or "view").lower()
        search = getter("search", "") or ""
        page = _safe_int(getter("page", 1), 1)
        page_size = _safe_int(getter("page_size", 10), 10)
        
        return_params = urlencode({"search": search, "page": page, "page_size": page_size})
        cancel_url = f"{self.base_route}/action?dismiss=1"
        save_url = f"{self.base_route}/save?{return_params}"
        
        # Get record for actions that need it (not create)
        record = None
        if record_id:
            raw_record = self.get_by_id(req, record_id)
            record = _to_dict(raw_record) if raw_record else None
        
        # Map action names to internal action names for overrides
        action_map = {'edit': 'update'}  # 'edit' action uses 'update' override
        override_key = action_map.get(action, action)
        
        # Check for CRUD override FIRST (callable in crud_ops)
        if override_key in self.crud_overrides:
            try:
                ctx = self._build_context(req, record=record, record_id=record_id, include_feedback_id=True)
                handler = self.crud_overrides[override_key]
                
                # Execute handler (sync or async)
                if asyncio.iscoroutinefunction(handler):
                    result = await handler(ctx)
                else:
                    result = handler(ctx)
                
                # Handle return value:
                # - FT component: Return directly (full control to user)
                # - str: Wrap in success toast
                # - None: Default success message
                if result is None:
                    return self._success_toast(f"{action.title()} completed successfully.")
                elif isinstance(result, str):
                    return self._success_toast(result)
                else:
                    # FT component - wrap in feedback div
                    return Div(result, id=self.feedback_id)
                    
            except Exception as e:
                logger.error(f"CRUD override '{action}' failed: {e}", exc_info=True)
                return self._error_toast(f"{action.title()} failed: {str(e)}")
        
        # Handle CREATE (default behavior)
        if action == "create":
            if not self.crud_enabled.get("create"):
                return self._error_toast("Create operation not enabled.")
            
            modal = FormModal(
                columns=self.columns,
                mode="create",
                record=None,
                modal_id=self.modal_id,
                title=f"New {self.title.rstrip('s')}",
                save_url=save_url,
                save_target=f"#{self.feedback_id}",
                cancel_url=cancel_url,
                cancel_target=f"#{self.feedback_id}"
            )
            return self._wrap_modal(modal)
        
        # Record required for remaining actions
        if not record:
            return self._error_toast("Record not found.")
            return self._wrap_modal(modal)
        # Handle VIEW (default behavior)
        if action == "view":
            modal = FormModal(
                columns=self.columns,
                mode="view",
                record=record,
                modal_id=self.modal_id,
                title=f"View {self.title.rstrip('s')}",
                cancel_url=cancel_url,
                cancel_target=f"#{self.feedback_id}"
            )
            return self._wrap_modal(modal)
        
        # Handle EDIT (default behavior)
        if action == "edit":
            if not self.crud_enabled.get("update"):
                return self._error_toast("Update operation not enabled.")
            
            modal = FormModal(
                columns=self.columns,
                mode="edit",
                record=record,
                modal_id=self.modal_id,
                title=f"Edit {self.title.rstrip('s')}",
                save_url=save_url,
                save_target=f"#{self.feedback_id}",
                cancel_url=cancel_url,
                cancel_target=f"#{self.feedback_id}"
            )
            return self._wrap_modal(modal)
        
        # Handle DELETE (default behavior)
        if action == "delete":
            if not self.crud_enabled.get("delete"):
                return self._error_toast("Delete operation not enabled.")
            
            try:
                ctx = self._build_context(req, record=record, record_id=record_id)
                
                # Use on_delete hook if provided
                if self.on_delete_hook:
                    await self._call_hook(self.on_delete_hook, ctx)
                else:
                    # Default: use delete_fn
                    self.delete_fn(req, record_id)
                
                return self._success_toast("Record deleted successfully.")
            except Exception as e:
                logger.error(f"Delete failed: {e}", exc_info=True)
                return self._error_toast(f"Delete failed: {str(e)}")
        
        # Handle CUSTOM ACTIONS
        custom_actions = self.crud_ops.get("custom_actions", [])
        for custom_action in custom_actions:
            if action == custom_action["name"]:
                try:
                    ctx = self._build_context(req, record=record, record_id=record_id, include_feedback_id=True)
                    handler = custom_action["handler"]
                    
                    # Execute handler (sync or async)
                    if asyncio.iscoroutinefunction(handler):
                        result = await handler(ctx)
                    else:
                        result = handler(ctx)
                    
                    # Handle return value same as overrides
                    if result is None:
                        return self._success_toast(f"{custom_action['label']} completed successfully.")
                    elif isinstance(result, str):
                        return self._success_toast(result)
                    else:
                        return Div(result, id=self.feedback_id)
                    
                except Exception as e:
                    logger.error(f"Custom action '{action}' failed: {e}", exc_info=True)
                    return self._error_toast(f"{custom_action['label']} failed: {str(e)}")
        
        return Div(id=self.feedback_id)
    
    async def _handle_save(self, req):
        """Handle save route (create/update form submission)."""
        try:
            form_data = await req.form()
            record_id = form_data.get(self.row_id_field)
            
            if record_id:
                try:
                    record_id = int(record_id)
                except (TypeError, ValueError):
                    pass
            
            # Build data dict from form
            data = {}
            for col in self.columns:
                key = col["key"]
                if key == self.row_id_field:
                    continue
                
                form_cfg = col.get("form", {})
                if form_cfg.get("hidden"):
                    continue
                
                value = form_data.get(key)
                
                field_type = form_cfg.get("type", "text")
                if field_type == "number" and value:
                    try:
                        value = float(value) if "." in str(value) else int(value)
                    except (TypeError, ValueError):
                        pass
                elif field_type == "checkbox":
                    value = value == "on" or value == "true" or value == True
                
                data[key] = value
            
            # Add timestamps
            now = datetime.now()
            for field, ts_type in self.timestamp_fields.items():
                if ts_type == "updated":
                    data[field] = now
                elif ts_type == "created" and not record_id:
                    data[field] = now
            
            # CREATE or UPDATE
            if record_id:
                # ===== UPDATE =====
                ctx = self._build_context(req, record=data, record_id=record_id)
                
                if self.on_update_hook:
                    data = await self._call_hook(self.on_update_hook, ctx)
                    if data is not None:
                        self.update_fn(req, record_id, data)
                else:
                    self.update_fn(req, record_id, data)
                
                return self._success_toast("Record updated successfully.")
            else:
                # ===== CREATE =====
                # Generate ID if needed
                if self.id_generator and self.row_id_field not in data:
                    data[self.row_id_field] = self.id_generator()
                
                ctx = self._build_context(req, record=data, record_id=None)
                
                if self.on_create_hook:
                    data = await self._call_hook(self.on_create_hook, ctx)
                    if data is not None:
                        self.create_fn(req, data)
                else:
                    self.create_fn(req, data)
                
                return self._success_toast("Record created successfully.")
        
        except Exception as e:
            logger.error(f"Save failed: {e}", exc_info=True)
            return self._error_toast(f"Save failed: {str(e)}")
    
    def _wrap_modal(self, modal):
        """Wrap modal content in feedback div."""
        if isinstance(modal, list):
            return Div(*modal, id=self.feedback_id)
        return Div(modal, id=self.feedback_id)
    
    def _success_toast(self, message: str):
        """✅ Return a success toast with auto-refresh trigger."""
        toast = Toast(
            message,
            variant="success",
            position="top",
            action=A(
                "Dismiss",
                cls="inverse-link",
                hx_get=f"{self.base_route}/action?dismiss=1",
                hx_target=f"#{self.feedback_id}",
                hx_swap="outerHTML"
            ),
            active=True
        )
        
        # Return as HTMLResponse with HX-Trigger for auto-refresh
        from fasthtml.common import to_xml
        html_content = to_xml(Div(toast, id=self.feedback_id))
        response = HTMLResponse(content=html_content)
        response.headers['HX-Trigger'] = self.refresh_trigger
        return response
    
    def _error_toast(self, message: str):
        """❌ Return an error toast (no auto-refresh on errors)."""
        toast = Toast(
            message,
            variant="error",
            position="top",
            action=A(
                "Dismiss",
                cls="inverse-link",
                hx_get=f"{self.base_route}/action?dismiss=1",
                hx_target=f"#{self.feedback_id}",
                hx_swap="outerHTML"
            ),
            active=True
        )
        return Div(toast, id=self.feedback_id)
