import uuid
from datetime import datetime

from app.schemas.actions import ActionStep, PageVariableDef, check_flow
from typing import Literal

from pydantic import BaseModel, ConfigDict, Field, model_validator

from app.schemas.public_site import PageSeo
from app.schemas.reporting import DatasetAggregateFn, DatasetFilter, DatasetFilterItem
from app.schemas.styles import CustomCss, ElementStyles
from app.schemas.workflow import FormFieldDef

MenuStatus = Literal["active", "inactive"]
MenuLinkType = Literal["existing_feature", "new_feature"]
MenuFeatureStatus = Literal["coming_soon", "external_link"]
MenuTargetType = Literal["master", "form", "page", "dashboard"]
MenuAudience = Literal["internal", "vendor", "both"]
PublishStatus = Literal["draft", "published"]
# The first six are the originals; the rest were added with the enterprise
# designer (db/schema.sql's widget_type enum carries the same list).
WidgetType = Literal[
    "kpi_card", "bar_chart", "line_chart", "pie_chart", "table", "filter",
    "area_chart", "combo_chart", "funnel_chart", "radar_chart", "treemap", "gauge", "leaderboard", "text",
]
WidgetHeight = Literal["compact", "normal", "tall", "xl"]
WidgetSortOrder = Literal["none", "value_desc", "value_asc", "label_asc", "label_desc"]
TrendPeriod = Literal["day", "week", "month", "quarter", "year"]
WidgetSize = Literal["small", "medium", "large"]
RefreshInterval = Literal["real_time", "every_15_min", "hourly"]
WidgetDateBucket = Literal["none", "day", "week", "month"]
# "all_time" applies no date limit (the viewer can still pick a period).
WidgetFilterMode = Literal["all_time", "daily", "weekly", "monthly", "custom_range"]


class MenuCreate(BaseModel):
    label: str
    icon: str
    # Short blurb shown under the label in the Public Portal's top-bar mega
    # menu — optional, purely presentational (see Menu.description).
    description: str | None = None
    link_type: MenuLinkType
    linked_screen: str | None = None
    target_type: MenuTargetType | None = None  # links to one specific Master/Form/Page record instead of a whole screen
    target_id: uuid.UUID | None = None
    group_label: str | None = None  # items sharing a label render under one collapsible sidebar section
    feature_name: str | None = None
    feature_status: MenuFeatureStatus | None = None
    external_link_url: str | None = None
    sort_order: int = 0
    status: MenuStatus = "active"
    audience: MenuAudience = "internal"

    @model_validator(mode="after")
    def _check_link_type_fields(self) -> "MenuCreate":
        if self.link_type == "existing_feature":
            if self.feature_name or self.feature_status:
                raise ValueError("feature_name/feature_status must be empty when link_type is existing_feature")
            has_screen = bool(self.linked_screen)
            has_target = bool(self.target_type) or bool(self.target_id)
            if has_screen == has_target:  # neither set, or both set
                raise ValueError("existing_feature needs exactly one of linked_screen or (target_type + target_id)")
            if has_target and (not self.target_type or not self.target_id):
                raise ValueError("target_type and target_id must be set together")
        else:
            if not self.feature_name or not self.feature_status:
                raise ValueError("feature_name and feature_status are required when link_type is new_feature")
            if self.linked_screen or self.target_type or self.target_id:
                raise ValueError("linked_screen/target_type/target_id must be empty when link_type is new_feature")
        return self


class MenuUpdate(BaseModel):
    label: str | None = None
    icon: str | None = None
    description: str | None = None
    sort_order: int | None = None
    status: MenuStatus | None = None
    audience: MenuAudience | None = None
    linked_screen: str | None = None
    target_type: MenuTargetType | None = None
    target_id: uuid.UUID | None = None
    group_label: str | None = None
    feature_name: str | None = None
    feature_status: MenuFeatureStatus | None = None
    external_link_url: str | None = None


class MenuRead(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: uuid.UUID
    tenant_id: uuid.UUID
    label: str
    icon: str
    description: str | None
    link_type: MenuLinkType
    linked_screen: str | None
    target_type: MenuTargetType | None
    target_id: uuid.UUID | None
    group_label: str | None
    feature_name: str | None
    feature_status: MenuFeatureStatus | None
    external_link_url: str | None
    sort_order: int
    status: MenuStatus
    audience: MenuAudience
    created_at: datetime
    updated_at: datetime


class MenuRoleSet(BaseModel):
    visible: bool


class MenuRoleRead(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    menu_id: uuid.UUID
    role_id: uuid.UUID
    visible: bool


class DashboardParameterDef(BaseModel):
    """A dashboard-wide filter, shown in the filter bar above the widgets and
    applied to every widget bound to a datasource or form (DashboardDetail.
    tsx / DashboardRunner.tsx):
      - kind "date_range" on a date `field` — created_at / updated_at (every
        record has them) or any date field; `default` is a preset such as
        "this_month", "last_30_days" or "all".
      - kind "user" on created_by / updated_by — `default` "anyone" or "me"
        (resolved per viewer as {{current_user.id}}).
    A saved-dataset widget isn't affected (its dataset carries its own
    filters). Older dashboards stored only {name, kind} here — still valid."""

    name: str
    kind: Literal["date_range", "user"] = "date_range"
    field: str = "created_at"
    label: str | None = None
    default: str | None = None


class DashboardCreate(BaseModel):
    name: str
    parameters: list[DashboardParameterDef] = []


class DashboardUpdate(BaseModel):
    name: str | None = None
    status: PublishStatus | None = None
    parameters: list[DashboardParameterDef] | None = None


class DashboardRead(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: uuid.UUID
    tenant_id: uuid.UUID
    name: str
    status: PublishStatus
    parameters: list[DashboardParameterDef]
    created_by: uuid.UUID | None
    created_at: datetime
    updated_at: datetime
    accessible: bool = True  # false when zero roles are assigned (FR-DSH-006); set by the router, not the ORM row


class DashboardRoleRead(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    dashboard_id: uuid.UUID
    role_id: uuid.UUID


class WidgetSeriesDef(BaseModel):
    """One Y-series on a bar/line chart widget — either a plain field+
    aggregate (the common case) or a custom `expression` (Phase 4) combining
    aggregates over the same group's rows, e.g. "SUM(revenue)/SUM(orders)".
    Exactly one of (field+aggregate) or expression should be set; `label` is
    what shows in the chart legend/tooltip, defaulting to the field/aggregate
    pairing when unset."""

    field: str | None = None
    aggregate: DatasetAggregateFn | None = None
    label: str | None = None
    expression: str | None = None
    # combo_chart only — how this series is drawn (default bar).
    chart: Literal["bar", "line", "area"] | None = None

    @model_validator(mode="after")
    def _check_expression(self) -> "WidgetSeriesDef":
        if self.expression:
            from app.crud.expr_eval import parse_expression

            parse_expression(self.expression)  # raises ValueError on bad grammar
        return self


class WidgetNumberFormat(BaseModel):
    """How a KPI / gauge / chart value is shown."""

    style: Literal["number", "currency", "percent"] = "number"
    decimals: int | None = None
    currency: str = "INR"
    prefix: str = ""
    suffix: str = ""
    compact: bool = False  # 12.3K / 4.5M / 1.2Cr style


class WidgetConfig(BaseModel):
    """Everything type-specific about a Widget that doesn't fit the flat
    columns on the Widget model itself (see models/navigation.py::Widget.
    config). Every field is optional and absence means "fall back to the
    pre-Designer legacy behavior" — see DashboardWidgetView.tsx's groupRows
    fallback — so a widget saved before this schema existed (config == {})
    keeps rendering exactly as it always has."""

    model_config = ConfigDict(from_attributes=True)

    # All widget types — an author-chosen display title shown in the card
    # header instead of the data source's own name (e.g. "Monthly Revenue"
    # instead of "Orders"). Absent/blank means "fall back to the data
    # source's name", the pre-title behavior — see DashboardWidgetView.tsx.
    title: str | None = None

    # kpi_card — lucide-react icon name, from IconPicker.tsx's curated allowlist
    icon: str | None = None
    # kpi_card / filter click-through (Phase 5) — reuses PageAction's shape
    on_click: "PageAction | None" = None

    # bar_chart / line_chart — X axis field + optional date bucketing, one or
    # more Y series. Absent x_field/series means "fall back to group-by-
    # first-column-and-count" (the pre-axis-config behavior).
    x_field: str | None = None
    x_bucket: WidgetDateBucket = "none"
    series: list[WidgetSeriesDef] = []

    # pie_chart — explicit slice field + optional value field/aggregate
    # (absent value_field/value_aggregate means "count rows per slice",
    # the pre-axis-config behavior).
    slice_field: str | None = None
    value_field: str | None = None
    value_aggregate: DatasetAggregateFn | None = None

    # kpi_card — single computed value: either value_field+value_aggregate,
    # or a custom expression (Phase 4, mutually exclusive with those two).
    # Absent entirely means "show the record/submission count" (legacy).
    expression: str | None = None

    # table — explicit column list; empty means "first 4 keys" (legacy).
    columns: list[str] = []

    # kpi_card / table / bar_chart / line_chart / pie_chart — narrows a
    # master/form-bound widget's own count/rows/series to records matching
    # every filter (e.g. a Form KPI counting only "status equals Approved"
    # submissions). Applied alongside whatever a linked "filter" widget
    # separately contributes — see DashboardWidgetView.tsx's
    # useWidgetRows/useWidgetSeries. Meaningless when data_source is a saved
    # Dataset, which already carries its own filters.
    filters: list[DatasetFilterItem] = []

    # canvas drag-and-drop grid position (Phase 2) — absent means the client
    # computes a one-time default from the widget's legacy size/sort_order.
    layout: dict[str, int] | None = None

    # filter widgets only (type == "filter")
    filter_mode: WidgetFilterMode | None = None
    target_field: str | None = None
    default_range: str | None = None
    targets: list[str] = []  # ids of the other widgets on this dashboard this filter drives
    # "date" narrows its targets to a day/week/month/range of target_field;
    # "values" offers "All" plus one choice per value (filter_options, or —
    # when empty — the values target_field holds in the widget's own source)
    # and narrows its targets to target_field equal to the one picked.
    filter_kind: Literal["date", "values"] = "date"
    filter_options: list[str] = []

    # ---- Enterprise designer (all optional; absent = the earlier look) ----
    subtitle: str | None = None
    # Width in grid columns (1-6), overriding `size`; card height.
    col_span: int | None = Field(default=None, ge=1, le=6)
    height: WidgetHeight | None = None
    # Charts — palette name (see frontend lib/widgetTheme.ts), legend /
    # grid / value labels, bar orientation and stacking, line/area curve,
    # pie as donut, ordering and "top N + Other".
    color_scheme: str | None = None
    show_legend: bool | None = None
    show_grid: bool = True
    show_values: bool = False
    orientation: Literal["vertical", "horizontal"] = "vertical"
    stacked: bool = False
    curve: Literal["smooth", "linear", "step"] = "smooth"
    donut: bool = True
    sort: WidgetSortOrder = "none"
    top_n: int | None = Field(default=None, ge=1, le=100)
    number_format: WidgetNumberFormat | None = None
    # kpi_card / gauge — a goal (progress bar on a KPI; the gauge's marker)
    # and the gauge's full scale (defaults to the target, else 100).
    target: float | None = None
    max_value: float | None = None
    # kpi_card — "this period vs the previous one" on a date field
    # (created_at by default), and a small trend line of the same value.
    trend_field: str | None = None
    trend_period: TrendPeriod | None = None
    sparkline: bool = False
    # text — a note / heading block (plain text; blank lines = paragraphs).
    text: str | None = None

    @model_validator(mode="after")
    def _check_expression(self) -> "WidgetConfig":
        if self.expression:
            from app.crud.expr_eval import parse_expression

            parse_expression(self.expression)  # raises ValueError on bad grammar
        return self


class WidgetCreate(BaseModel):
    type: WidgetType
    data_source: str
    size: WidgetSize = "medium"
    color: str
    refresh_interval: RefreshInterval = "hourly"
    connector_id: uuid.UUID | None = None
    sort_order: int = 0
    # default_factory (not a plain `= WidgetConfig()` default) defers
    # instantiation past class-body-execution time — WidgetConfig.on_click's
    # forward ref to PageAction (defined later in this file) isn't resolved
    # until WidgetConfig.model_rebuild() runs near the bottom of this module,
    # so an eager default here would fail at import time.
    config: WidgetConfig = Field(default_factory=WidgetConfig)

    @model_validator(mode="after")
    def _check_realtime_needs_connector(self) -> "WidgetCreate":
        if self.refresh_interval == "real_time" and self.connector_id is None:
            raise ValueError("connector_id is required when refresh_interval is real_time")
        return self


class AiWidgetRequest(BaseModel):
    prompt: str = Field(min_length=3, max_length=2000)


class AiWidgetSuggestion(BaseModel):
    """A validated widget the AI proposed (app/crud/dashboard_ai.py) — ready
    to POST as a WidgetCreate once the user accepts it."""

    type: WidgetType
    data_source: str
    source_name: str
    size: WidgetSize = "medium"
    config: WidgetConfig = Field(default_factory=lambda: WidgetConfig())


class AiWidgetResult(BaseModel):
    message: str = ""
    widgets: list[AiWidgetSuggestion] = []
    error: str | None = None
    provider_ok: bool = True


class WidgetUpdate(BaseModel):
    size: WidgetSize | None = None
    color: str | None = None
    refresh_interval: RefreshInterval | None = None
    connector_id: uuid.UUID | None = None
    sort_order: int | None = None
    config: WidgetConfig | None = None


class WidgetRead(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: uuid.UUID
    tenant_id: uuid.UUID
    dashboard_id: uuid.UUID
    type: WidgetType
    data_source: str
    size: WidgetSize
    color: str
    refresh_interval: RefreshInterval
    connector_id: uuid.UUID | None
    sort_order: int
    config: WidgetConfig
    created_at: datetime
    updated_at: datetime


# ---- Pages (Page Designer Studio) -------------------------------------------------------------

PageBlockType = Literal[
    # Content / data
    "data_list", "button", "text", "image", "form",
    # One record's selected fields, each on its own "Label: Value" line —
    # same data_source/columns/filters shape as data_list, just rendered as
    # the single first-matched row instead of a table (see
    # PageBlockRenderer.tsx's PreviewDetailView). Typically paired with a
    # data_list row action or Menu that passes ?recordId= (or any other
    # page parameter) so `filters` can narrow to exactly one row, e.g.
    # {"field": "id", "operator": "equals", "value": "{{recordId}}"}.
    "detail_view",
    # One single field's value, with its own caption — same data_source/
    # filters convention as detail_view, but `columns` holds exactly one
    # field name rather than a whole record's worth. Meant for placing a
    # single value inline in a layout (e.g. next to a heading) rather than
    # a full record card. A file/attachment-type field renders as a
    # filename + Download link instead of its raw stored value (an
    # uploaded file's id — see app/routers/uploads.py) since that raw id
    # means nothing to an end user.
    "label",
    # A file/attachment field's file(s) shown in place — image, PDF, video,
    # text/CSV, Word, Excel, PowerPoint (frontend/src/components/
    # FileViewer.tsx) — same data_source/filters/columns[0] convention as
    # "label"; or, with no data_source, value_template naming a file id
    # (e.g. "{{fileId}}"). Several files (a multi-attachment field) get a
    # picker above the viewer.
    "file_viewer",
    # Historical block type, kept ONLY so pages authored before Data List
    # and Data Grid merged into one control still parse and render exactly
    # as before — no longer offered in the Page Designer palette (see
    # frontend/src/lib/types.ts::PAGE_BLOCK_TYPE_GROUPS). A "data_grid"
    # block always behaves as if PageBlockDef.live_pagination were true,
    # same as it always has; see that field's own doc for what that means.
    # New pages should use "data_list" with live_pagination set instead.
    "data_grid",
    # A read-only, Gmail-inbox-styled list — same data_source/filters/
    # row_click_action/row_actions convention as data_list, rendered as
    # stacked rows (title/subtitle/snippet/date, guessed from the source's
    # own column names/order — see PageBlockRenderer.tsx's guessInboxFields)
    # instead of a table, plus a runtime field/operator filter bar the page
    # VIEWER can use (ANDed with this block's own author-configured
    # `filters`), and a "Load more" pager (re-fetches with a larger `limit`
    # — no backend offset/pagination support needed, same as data_list).
    "inbox",
    # Filters the page's VIEWER sets — search, dropdowns, date ranges,
    # yes/no — that narrow other blocks on the server (PageBlockDef
    # filter_controls / filter_targets; frontend components/FilterBar.tsx).
    "filter_bar",
    # Work views over a datasource / form: cards in columns by a choice
    # field, dragged across to change it ("kanban"); records on a month or
    # week calendar by a date field ("calendar"); and a template of blocks
    # repeated for each record, its values as {{item.<field>}} ("repeater").
    "kanban", "calendar", "repeater",
    # The workflow steps waiting on the viewer, to approve / reject / route
    # from the page (app/routers/approvals.py).
    "approvals",
    # Chart widgets — same three types + data-source convention as Dashboard's Widget
    "kpi_card", "bar_chart", "pie_chart",
    # Layout containers — real nested drop targets, not visual markers (unlike
    # the Form Designer's LAYOUT types): "row" lays its children out
    # horizontally, "section"/"column"/"panel" stack theirs vertically (panel
    # additionally gets a bordered box), "tabs"/"accordion" hold named
    # sub-groups of children instead of one flat list.
    # "row"/"column" are kept only for pages authored before the free-form
    # canvas rewrite — every container (including these two) now positions
    # its children via PageBlockDef.layout instead of flex, so a legacy row/
    # column renders as an undecorated free-form box (see PreviewBlock).
    # "grid" is the one deliberate exception: its children ignore their own
    # layout.x/y and instead auto-arrange into grid_columns evenly-spaced
    # columns (see PageBlockDef.grid_columns).
    "section", "row", "column", "panel", "grid", "tabs", "accordion",
    # "layout" — a responsive container: its children flow (CSS grid/flex)
    # instead of sitting at fixed x/y, so they re-arrange for each screen size
    # (flow_* settings below), and it grows to fit them at runtime.
    "layout",
    # An embeddable nav widget — lists the same Menu items the sidebar
    # already shows for the current viewer (active, audience-appropriate,
    # portal-item-granted), optionally narrowed to one group_label. See
    # PageBlockDef.menu_group and PageBlockRenderer.tsx's PreviewMenu.
    "menu",
    # Divider/spacer — pure visual spacing, no data binding.
    "divider", "spacer",
    # Tiptap-authored formatted text (PageBlockDef.html) — see
    # frontend/src/components/RichTextEditor.tsx, already used for Form's
    # rich_text field type. A genuinely different trust level than "html"
    # below: this HTML only ever comes from that in-app editor, never pasted
    # raw by the author, so it's rendered inline (dangerouslySetInnerHTML)
    # rather than sandboxed.
    "rich_text",
    # YouTube/Vimeo URL -> iframe embed, or a direct file URL -> <video>.
    "video",
    # A single lucide-react icon (PageBlockDef.icon_name), from IconPicker's
    # curated allowlist.
    "icon",
    # Raw author-supplied HTML/JS (e.g. a Calendly/Typeform embed snippet).
    # Pages can be viewed by external Portal (vendor) accounts, so this is
    # rendered inside a sandboxed <iframe srcdoc sandbox="allow-scripts">
    # with same-origin access deliberately NOT granted — a script inside
    # cannot read the parent page's cookies/session or call the API as the
    # viewer. Gated by the "pages" module's "edit_html" action (see
    # app/routers/pages.py) — not every Studio editor gets this by default.
    "html",
    # Mirrors Dashboard's axis-config-driven line_chart (WidgetSeriesDef/
    # x_field/x_bucket below) rather than the page's own hand-rolled
    # bar_chart/pie_chart — see PageBlockRenderer.tsx.
    "line_chart",
    # A static, author-entered table (PageBlockDef.table_columns/table_rows)
    # — NOT bound to a live data source, unlike data_list/data_grid.
    "table",
    # payment_initiator/payment_callback — a generic "any payment gateway"
    # pair built on the payment_gateway Connector type and POST /payments/
    # initiate|verify (app/routers/payments.py, app/crud/payments.py) —
    # the same Connector contract Task Designer's "Payment — Initiate" node
    # already uses (_exec_payment_initiate), just callable directly from a
    # Page without a Task in between. payment_initiator renders a button
    # that starts a payment and redirects the browser to the gateway's
    # checkout_url; payment_callback goes on whichever page the gateway
    # redirects back to, reads its query params (optionally verified
    # server-side), and fires a success/failure PageAction. See
    # PageBlockDef's payment_* fields below.
    "payment_initiator", "payment_callback",
    # A modal overlay — same container shape as panel/section (holds
    # `children`, laid out on its own free-form canvas), but NEVER rendered
    # inline in the page flow: PageBlockRenderer.tsx's PreviewBlock always
    # returns null for one in the normal render pass. Instead it's opened
    # by a PageAction of type "open_popup" (button click, data_list row
    # click, ...) naming this block's own id as popup_block_id, and shown
    # as a fixed-position overlay with a close button and a maximize/
    # restore toggle (both always present — not author-configurable).
    # `label` is the popup's title bar text; `layout.w`/`layout.h` size it
    # when not maximized — the same fields every other block already uses,
    # just reinterpreted. pass_row_id (PageAction, already used by navigate)
    # doubles here as "expose the clicked row's id to the popup's own
    # children as {{recordId}}", since a popup has no query string of its
    # own to carry one the way a page navigation does.
    "popup",
    # One standalone Form Designer input control (PageBlockDef.form_field,
    # the exact same FormFieldDef shape a Form's own field_definitions use —
    # see app/schemas/workflow.py), bound to one of this Page's own declared
    # `parameters` (PageBlockDef.param_name) rather than a Form submission.
    # Reads its initial value from the page's live ?param_name= (via
    # buildPageContext, same as every other block's "{{param_name}}"
    # template token); writing it re-queries every sibling block whose
    # filters/record_filter_field already reference that parameter, no
    # navigation — the same effect a set_page_value PageAction already has,
    # just driven by typing/picking instead of a click. For collecting a
    # value that should be written back onto a record, use a Form block
    # (still the only way to actually submit/persist a value) — this is for
    # building a custom filter bar or standalone input directly on a page.
    "form_field",
    # Panels lifted from the sample UI screens (see frontend/src/lib/types.ts
    # for the full per-type doc): "page_header" (title + subtitle + optional
    # back link), "alert" (an info/success/warning/error banner), "stat_cards"
    # (a row of clickable count tiles, each with its own filter, that can set
    # a page parameter so sibling lists narrow live), and "stepper" (a
    # numbered progress bar). All four are configured through the new fields
    # below (subtitle/variant/stat_items/steps/current_step/...) — none needs
    # a new table.
    "page_header", "alert", "stat_cards", "stepper",
    # Two more data-bound displays from the same sample screens. "tree" — a
    # collapsible hierarchy built from a self-referencing field
    # (tree_parent_field holds the parent row's id, e.g. a Master's reference
    # field pointing back at itself); labels/pills use the same card_*_field
    # roles data_list's List/Cards views do. "timeline" — a dated activity
    # log (newest first), each entry expandable, with an optional before/
    # after text pair rendered as a side-by-side diff (the sample "change
    # log" screen).
    "tree", "timeline",
    # A text hyperlink (label) that runs `action` — usually "navigate" to
    # another page with PageAction.params as its query parameters, or
    # "open_external_url". Rendered as a real <a href> for those two, so
    # the viewer can also open it in a new tab / copy the link.
    "link",
]
# run_flow: an ordered list of steps (app/schemas/actions.py) instead of one action.
# print: prints print_block_ids (blank = the whole page) — see PageAction.
PageActionType = Literal["navigate", "open_form", "trigger_workflow", "open_external_url", "set_page_value", "open_popup", "run_flow", "print"]
# Same target-entity convention Menu/Widget already use (target_type + target_id);
# "page" additionally targets another Page's own designer route (FR- there's no
# published end-user page viewer yet, so "navigate to a page" opens its designer).
PageNavigateTargetType = Literal["page", "master", "form", "dashboard"]
TextBlockStyle = Literal["heading", "subheading", "paragraph"]
ButtonStyle = Literal["primary", "secondary", "danger"]


# Whether a form shows its "review before submit" summary when a Page embeds
# or opens it: "form" follows the Form's own setting (FormSettings.
# review_before_submit), "always"/"never" override it for this Page.
FormReviewMode = Literal["form", "always", "never"]


class PageActionParam(BaseModel):
    """One named value an action passes along. `value` is literal text or a
    template — "{{recordId}}", "{{current_user.id}}", "{{session.office_id}}",
    one of the page's own parameters, or (row actions) "{{row_field}}" —
    resolved in the browser when the action runs; an empty result is left
    out. navigate: a query parameter on the target (?name=value, read there
    as a page parameter or a form's prefill). open_form: pre-fills the field
    of that name. trigger_workflow: a key in the new instance's context.
    open_popup: readable by the popup's children as {{name}}.
    open_external_url: appended to the URL as a query parameter."""

    name: str = Field(min_length=1, max_length=100)
    value: str = Field(default="", max_length=2000)


class PageAction(BaseModel):
    type: PageActionType
    # See PageActionParam — every type but set_page_value uses them.
    params: list[PageActionParam] = []
    # navigate
    target_type: PageNavigateTargetType | None = None
    target_id: uuid.UUID | None = None
    pass_row_id: bool = False  # append the clicked row's record id as ?recordId= (data_list row-click or a row action button; meaningless elsewhere)
    # open_form — always opens the target form fresh, for a new submission
    form_id: uuid.UUID | None = None
    form_review: FormReviewMode = "form"  # open_form — see FormReviewMode
    # trigger_workflow — starts an instance at the workflow's own "start" step
    workflow_id: uuid.UUID | None = None
    # open_external_url — sends the browser off-site (a new tab, not an
    # in-app navigation) e.g. to a payment gateway's checkout_url; url can
    # itself be "{{field_name}}" and gets rendered against the block's own
    # row/context the same way a Task node's templates are (see
    # PageBlockRenderer.tsx's runAction for the render step, since this
    # schema only validates the raw template string, not its resolved value)
    url: str | None = None
    # set_page_value — updates this SAME page's own `{{target_param}}`
    # (must name one of the page's own declared `parameters`) in place, no
    # navigation: every sibling block whose `filters`/`record_filter_field`
    # already reference "{{target_param}}" re-queries live off the new
    # value. source_field names which field of the clicked row supplies the
    # value; unset means the row's own id (same convention pass_row_id
    # already uses for "the record's own id").
    target_param: str | None = None
    source_field: str | None = None
    # open_popup — names a "popup" block's own id (PageBlockDef.id), not a
    # separate stored entity, so it's a plain str rather than the uuid.UUID
    # every other *_id field here points at a real table row with. Reuses
    # pass_row_id above for "expose the clicked row's id to the popup's own
    # children as {{recordId}}" — see PageBlockType's "popup" doc.
    popup_block_id: str | None = None
    steps: list[ActionStep] = []  # run_flow only
    # print — the blocks (PageBlockDef.id) to print, in page order; blank =
    # the whole page. print_title heads the printout (a template, e.g.
    # "Application {{application_number}}"); blank = none.
    print_block_ids: list[str] = []
    print_title: str | None = Field(default=None, max_length=200)

    @model_validator(mode="after")
    def _check_action_fields(self) -> "PageAction":
        if self.type == "run_flow":
            if not self.steps:
                raise ValueError("run_flow actions need at least one step")
            check_flow(self.steps)
        if self.type == "navigate" and (not self.target_type or not self.target_id):
            raise ValueError("navigate actions require target_type and target_id")
        if self.type == "open_form" and not self.form_id:
            raise ValueError("open_form actions require form_id")
        if self.type == "trigger_workflow" and not self.workflow_id:
            raise ValueError("trigger_workflow actions require workflow_id")
        if self.type == "open_external_url" and not self.url:
            raise ValueError("open_external_url actions require url")
        if self.type == "set_page_value" and not self.target_param:
            raise ValueError("set_page_value actions require target_param")
        if self.type == "open_popup" and not self.popup_block_id:
            raise ValueError("open_popup actions require popup_block_id")
        return self


WidgetConfig.model_rebuild()  # resolves the forward-ref on_click: "PageAction | None" above


# on_interval: every interval_seconds while the page is open (e.g. refresh a queue).
PageEventTrigger = Literal["on_load", "on_interval"]
# Same action vocabulary as forms.events (FormEventAction) — see
# app/schemas/workflow.py::FormEventActionType. A "run_task" on_load event's
# resulting context is what the frontend stashes into session.data (see
# PageRunner.tsx), making it available afterward as "{{session.<key>}}"
# anywhere else a template gets resolved.
PageEventActionType = Literal["run_task", "trigger_workflow", "navigate", "show_message", "open_external_url", "run_flow"]


class PageEventAction(BaseModel):
    trigger: PageEventTrigger
    action_type: PageEventActionType
    task_id: uuid.UUID | None = None
    workflow_id: uuid.UUID | None = None
    target_type: PageNavigateTargetType | None = None
    target_id: uuid.UUID | None = None
    message: str | None = None
    url: str | None = None
    steps: list[ActionStep] = []  # run_flow only
    interval_seconds: int | None = Field(default=None, ge=5, le=86400)  # on_interval only

    @model_validator(mode="after")
    def _check_event_fields(self) -> "PageEventAction":
        if self.action_type == "run_flow":
            if not self.steps:
                raise ValueError("run_flow events need at least one step")
            check_flow(self.steps)
        if self.trigger == "on_interval" and not self.interval_seconds:
            raise ValueError("on_interval events need interval_seconds")
        if self.action_type == "run_task" and not self.task_id:
            raise ValueError("run_task events require task_id")
        if self.action_type == "trigger_workflow" and not self.workflow_id:
            raise ValueError("trigger_workflow events require workflow_id")
        if self.action_type == "navigate" and (not self.target_type or not self.target_id):
            raise ValueError("navigate events require target_type and target_id")
        if self.action_type == "show_message" and not self.message:
            raise ValueError("show_message events require message")
        if self.action_type == "open_external_url" and not self.url:
            raise ValueError("open_external_url events require url")
        return self


class PageBlockLayout(BaseModel):
    """One block's position/size on its parent's free-form canvas — pixels,
    relative to that parent's own top-left (the page root's canvas, or the
    interior of any container block). Absent (PageBlockDef.layout is None)
    means "unpositioned" — every renderer computes a deterministic default
    from document order instead (see frontend/src/lib/pageLayout.ts's
    computeDefaultLayout), so pages authored before this existed keep
    rendering unchanged with no backend backfill required."""

    x: int
    y: int
    w: int
    h: int


class PageBlockGroup(BaseModel):
    """One named child list inside a Tabs/Accordion container — one tab, or
    one accordion section. Each group's own children area is itself a
    free-form canvas, same as any other container's."""

    id: str
    label: str
    children: list["PageBlockDef"] = []


class PageParameterDef(BaseModel):
    """A named value this Page reads from its own URL query string when it
    loads (e.g. a parameter named "officeId" reads ?officeId=... ). Declaring
    it here doesn't gate anything server-side — it's so the Page Designer can
    offer it by name when authoring a block's `filters` or a PageRowAction's
    outgoing `param_mappings`, instead of the author having to know and spell
    a raw query-string key. See PageBlockDef.filters."""

    name: str


class PageRowActionParamMapping(BaseModel):
    """One row column -> named parameter mapping for a PageRowAction. What
    param_name means depends on the row action's own action.type:
    navigate -> appended to the destination page's URL as ?param_name=value
    (read there via a PageParameterDef of the same name); open_form -> must
    match a field name on the target form, pre-filling it; trigger_workflow
    -> becomes a key in the new instance's initial context. Meaningless (and
    ignored) for open_external_url, which already supports "{{row_field}}"
    directly in its url template."""

    row_field: str
    param_name: str


class PageRowAction(BaseModel):
    """One named button in a data_list or inbox row's Actions area — lets a
    single row offer several distinct actions (e.g. "View", "Approve",
    "Reject") instead of the whole row sharing the one row_click_action
    below."""

    id: str
    label: str
    button_style: ButtonStyle = "secondary"
    action: PageAction
    param_mappings: list[PageRowActionParamMapping] = []


DataListViewMode = Literal["table", "list", "cards"]
AlertVariant = Literal["info", "success", "warning", "error"]
PopupDisplayMode = Literal["modal", "drawer"]


class PageFilterControl(BaseModel):
    """One control of a "filter_bar" block. An empty control filters nothing.
      search       — text matched (contains) in `fields`, any of them
      select       — one value of `field` (its values on the source, or `options`)
      multiselect  — any of several values of `field`
      date_range   — `field` within a preset ("this month"…) or a from–to range
      number_range — `field` from–to
      yes_no       — `field` is yes / true, when ticked
      mine         — `field` (created_by unless set) is the viewer, when ticked"""

    id: str = Field(min_length=1, max_length=60)
    kind: Literal["search", "select", "multiselect", "date_range", "number_range", "yes_no", "mine"]
    label: str = Field(default="", max_length=120)
    field: str | None = Field(default=None, max_length=200)
    fields: list[str] = []
    options: list[str] = []


class DataListFormatRule(BaseModel):
    """Colours a Data List row (or one cell) when `field` meets the condition —
    worked out in the browser on the rows shown. before_today / within_days
    read the field as a date (within_days: value = number of days ahead)."""

    id: str = Field(min_length=1, max_length=60)
    field: str = Field(min_length=1, max_length=200)
    operator: Literal["equals", "not_equals", "contains", "gt", "gte", "lt", "lte", "is_empty", "is_not_empty", "before_today", "within_days"]
    value: str | None = Field(default=None, max_length=200)
    target: Literal["row", "cell"] = "row"
    # target "cell": which column's cell is coloured (blank = `field`'s own).
    cell_column: str | None = Field(default=None, max_length=200)
    color: Literal["green", "amber", "red", "blue", "violet", "grey"] = "amber"


class DataListBulkAction(BaseModel):
    """A button for the rows a viewer has ticked in a Data List:
      delete    — delete them (needs the list's own delete permission)
      export    — download them as a spreadsheet (CSV)
      set_field — set `field` to `value` on each (e.g. status -> Closed)
      run_task  — run task `task_id` once, with record_ids in its context"""

    id: str = Field(min_length=1, max_length=60)
    label: str = Field(default="", max_length=80)
    kind: Literal["delete", "export", "set_field", "run_task"]
    field: str | None = Field(default=None, max_length=200)
    value: str | None = Field(default=None, max_length=500)
    task_id: uuid.UUID | None = None


class PageStatItem(BaseModel):
    """One tile of a "stat_cards" block — its own label, filters (ANDed with
    the block's own `filters`) that decide what it counts, and an accent
    color. If param_name is set (one of the page's own declared parameters),
    clicking the tile sets it to param_value (clicking the active tile again
    clears it) so a sibling data_list filtered on "{{param_name}}" narrows
    live — same effect as a set_page_value PageAction, without the author
    having to build one per tile."""

    id: str
    label: str
    filters: list[DatasetFilterItem] = []
    color: str | None = None
    param_name: str | None = None
    param_value: str | None = None


class PageStepDef(BaseModel):
    """One step of a "stepper" block."""

    id: str
    label: str


class PageBlockDef(BaseModel):
    id: str  # client-generated, stable key for reordering — not a DB row id
    type: PageBlockType
    # Shown only while this expression (app/core/expr.py) is true — over the
    # page's vars, URL parameters, user and session, e.g.
    # "vars.tab == 'open' and HASROLE('Supervisor')". Blank = always shown.
    visible_when: str | None = None
    # text
    text: str | None = None
    text_style: TextBlockStyle | None = None
    # image
    image_url: str | None = None
    # image — what it shows, for screen readers; decorative = says nothing.
    alt_text: str | None = Field(default=None, max_length=500)
    decorative: bool = False
    # html — the component SDK: props handed to the sandboxed component and
    # the page parameters it may set (frontend lib/componentSdk.ts).
    component_props: dict[str, str] | None = None
    component_outputs: list[str] | None = None
    # button — also doubles as the optional heading text for section/panel
    label: str | None = None
    button_style: ButtonStyle | None = None
    action: PageAction | None = None
    # data_list — data_source follows Widget's "master:<id>" / "form:<id>" /
    # "dataset:<id>" convention (see app/routers/dashboards.py's parse).
    # form — same field, always "form:<id>": which Form to embed inline.
    data_source: str | None = None
    columns: list[str] | None = None  # None/empty = show every column
    page_size: int | None = None
    row_click_action: PageAction | None = None
    # inbox only — explicitly picks which of the source's fields fill each
    # of the four stacked-row roles, overriding PageBlockRenderer.tsx's
    # guessInboxFields heuristic for that role only (unset roles keep being
    # auto-guessed) — an author can pin down one or two roles the guesser
    # gets wrong without having to configure all four.
    inbox_title_field: str | None = None
    inbox_subtitle_field: str | None = None
    inbox_preview_field: str | None = None
    inbox_date_field: str | None = None
    # data_list (either mode — see live_pagination) only — names a
    # (non-multi) "reference"-type field on this block's own Master that
    # should be filtered to equal the page's incoming ?recordId= query
    # param, if present (see PageAction's pass_row_id doc above). Lets a
    # page act as a detail view for a parent page's row-click — e.g. an
    # Application's Work Items filtered to that Application's id — instead
    # of only ever showing every row. No value (or no recordId present at
    # runtime) means the block shows its normal unfiltered list, same as
    # before this field existed. In live_pagination mode this is applied
    # server-side (GET .../records?filter_field=...&filter_value=... — see
    # app/routers/masters.py), same as everywhere else it's applied client-
    # side before the query runs.
    record_filter_field: str | None = None
    # data_list / kpi_card / bar_chart / pie_chart — applied before this
    # block's own data_source is queried, same DatasetFilter shape Data
    # Explorer/Datasets use. A filter's value may contain "{{param_name}}"
    # (matching one of this Page's own `parameters`) or
    # "{{current_user.field}}" (field one of id/name/email/vendor_id/
    # tenant_id) — rendered client-side against the live page context right
    # before the query runs (see PageBlockRenderer.tsx's renderTemplate). A
    # literal value with no "{{...}}" is used as-is. Independent of
    # record_filter_field above, which stays the dedicated recordId path.
    filters: list[DatasetFilterItem] = []
    # data_list (either mode — see live_pagination) or inbox — one or more
    # distinct action buttons per row, shown alongside (not instead of)
    # row_click_action: a trailing Actions column for data_list, a trailing
    # button cluster for inbox. Each button's own action (PageAction) can
    # navigate, open a form, open a popup with the clicked row's id
    # (pass_row_id + open_popup), trigger a workflow, or open an external
    # URL — the same action vocabulary a Button block's own click already has.
    row_actions: list[PageRowAction] = []
    # kpi_card / bar_chart / pie_chart — reuse data_source above; accent color
    color: str | None = None
    # section / row / column / panel — direct ordered children
    children: list["PageBlockDef"] | None = None
    # tabs / accordion — named child groups instead of one flat list
    groups: list[PageBlockGroup] | None = None
    # data_list only, Master source only (a Form has no update/delete
    # endpoint and a Dataset is a computed query) — which CRUD affordances
    # this block's rendered table offers. Purely a page-author convenience
    # toggle, NOT a security boundary: the viewer's own masters:create/
    # edit/delete permission (and portal-item grant) is what the backend
    # actually enforces on each attempted mutation, same as every other
    # write in this app — a viewer without rights who clicks Add/Edit/
    # Delete just gets a normal error, the same as attempting any other
    # action they lack permission for.
    allow_create: bool = False
    allow_edit: bool = False
    allow_delete: bool = False
    # data_list, Master or Form source — an Export button (its rows as
    # Excel, POST /datasets/export.xlsx) and, Master only, an Import button
    # (POST /masters/{id}/bulk-import). Like the three above, a convenience
    # toggle: a portal viewer also needs their role's export / import
    # permission on the source, which the API enforces.
    allow_export: bool = False
    allow_import: bool = False
    # file_viewer — Download and Print buttons for the file it shows
    # (frontend/src/lib/filePrint.ts prints it); off unless turned on.
    file_download: bool = False
    file_print: bool = False
    # data_list only, Master source only — swaps the block's read path from
    # the generic dataset query (used for every other source, "Load more"
    # pagination client-side) to the Master's own dedicated REST endpoints
    # (GET/POST/PATCH/DELETE .../records — app/routers/masters.py), which
    # adds a search box and real page-by-page pagination (via GET
    # .../records/count). This is the one behavioral difference that used
    # to be a separate block type ("data_grid", now legacy — see
    # PageBlockType's own doc); a "data_grid" block always behaves as if
    # this were true, regardless of its own stored value.
    live_pagination: bool = False
    # menu only — narrows to one Menu.group_label; unset shows every
    # active, audience-appropriate, portal-item-granted item the current
    # viewer can already see in the sidebar (see PortalLayout.tsx's own
    # PortalNavItems filter chain, mirrored in PreviewMenu).
    menu_group: str | None = None

    # Every block's position/size on its parent's free-form canvas — see
    # PageBlockLayout. layout_mobile optionally overrides it below the
    # mobile breakpoint; unset falls back to `layout`, then to an
    # auto-stacked default (see frontend/src/lib/pageLayout.ts).
    layout: PageBlockLayout | None = None
    layout_mobile: PageBlockLayout | None = None

    # grid containers only — children auto-arrange into this many evenly
    # spaced columns instead of reading their own layout.x/y.
    grid_columns: int | None = None
    grid_gap: int | None = None
    # Meaningful only for a legacy "row" block's direct children (CSS
    # `flex: <width_ratio>`), and a "layout" container's children in its
    # "row" mode (each one's share of the row) — a no-op everywhere else.
    width_ratio: float | None = None

    # "layout" containers only — how children are arranged (see
    # frontend/src/lib/flowLayout.ts): "auto" fills each line with as many
    # items as fit at flow_min_item_width, "columns" uses a column count per
    # screen size, "row" shares one line (stacking below flow_stack_below),
    # "stack" puts them one under another.
    flow_mode: Literal["auto", "columns", "row", "stack"] | None = None
    flow_min_item_width: int | None = Field(default=None, ge=40, le=2000)
    flow_columns: int | None = Field(default=None, ge=1, le=12)
    flow_columns_tablet: int | None = Field(default=None, ge=1, le=12)
    flow_columns_mobile: int | None = Field(default=None, ge=1, le=12)
    flow_gap: int | None = Field(default=None, ge=0, le=200)
    flow_align_x: Literal["stretch", "start", "center", "end", "between"] | None = None
    flow_align_y: Literal["stretch", "start", "center", "end"] | None = None
    flow_stack_below: Literal["never", "tablet", "mobile"] | None = None
    # A "layout" container's children only — per screen size: how many
    # columns to span, whether to show at all, order on tablet/phone, and
    # whether the height is fixed (layout.h) or fits the content.
    flow_span: int | None = Field(default=None, ge=1, le=12)
    flow_span_tablet: int | None = Field(default=None, ge=1, le=12)
    flow_span_mobile: int | None = Field(default=None, ge=1, le=12)
    flow_hide_on: list[Literal["desktop", "tablet", "mobile"]] | None = None
    flow_order_mobile: int | None = Field(default=None, ge=-100, le=100)
    flow_height: Literal["fixed", "auto"] | None = None

    # Container styling — section/row/column/panel/grid.
    background_color: str | None = None
    padding: int | None = None
    border_color: str | None = None
    border_width: int | None = None

    # Free-form styling — every block type (Page Designer's block settings
    # "Style" tab). font_family/font_size/font_color are applied to this
    # block's own positioned wrapper so they cascade via normal CSS
    # inheritance into whatever the block renders, with no per-block-type
    # rendering change needed. background_image layers under
    # background_color (same wrapper) — a URL, not an upload id, same as
    # the "image" block's own image_url above.
    font_family: str | None = None
    font_size: int | None = None
    font_color: str | None = None
    background_image: str | None = None
    # Style tab › parts — the block's own box plus what's inside it (labels,
    # inputs, buttons, links, a table's th/tr/td), and free CSS scoped to the
    # block (frontend lib/elementStyle.ts). font_*/background_color above
    # stay the block box's text and fill.
    styles: ElementStyles | None = None
    custom_css: CustomCss | None = None

    # rich_text (Tiptap-authored, trusted) AND html (raw author HTML,
    # sandboxed — see PageBlockType's "html" doc above) both store their
    # markup here; which rendering path applies is purely a function of
    # `type`.
    html: str | None = None

    # video
    video_url: str | None = None

    # icon — a lucide-react name from IconPicker's curated allowlist
    icon_name: str | None = None

    # line_chart — mirrors WidgetConfig's axis-config shape exactly so the
    # same query resolver (queryDatasetSeries) and Recharts rendering can be
    # reused verbatim from the Dashboard widget of the same name.
    x_field: str | None = None
    x_bucket: WidgetDateBucket = "none"
    series: list[WidgetSeriesDef] = []

    # table — a static, author-entered grid; not bound to a live data source.
    table_columns: list[str] | None = None
    table_rows: list[list[str]] | None = None

    # payment_initiator / payment_callback — see PageBlockType's own comment
    # above. Both reuse `label`/`button_style` above for their button/
    # heading text.
    payment_connector_id: uuid.UUID | None = None
    # payment_initiator only — each may contain "{{param_name}}"/
    # "{{current_user.field}}", rendered client-side against the page
    # context (same convention as `filters` above) right before POST
    # /payments/initiate. amount is in the gateway's minor currency units.
    payment_amount: str | None = None
    # What payment_amount is in: "minor" units (paise — 1000 = ₹10.00, the
    # gateway's own unit) or "major" (rupees — 10 = ₹10.00, handy when the
    # amount comes from a fee stored in rupees, e.g. "{{fee}}").
    payment_amount_unit: Literal["minor", "major"] = "minor"
    payment_currency: str | None = None
    payment_reference: str | None = None
    payment_checkout_path: str | None = None
    # payment_initiator only — where the gateway should redirect back to
    # once payment completes; same target-entity convention PageAction's
    # navigate uses. payment_pass_record_id appends this page's own
    # incoming ?recordId= (if present) onto that redirect, so the callback
    # page knows which record the payment was for.
    payment_callback_target_type: PageNavigateTargetType | None = None
    payment_callback_target_id: uuid.UUID | None = None
    payment_pass_record_id: bool = False
    # payment_initiator only — a published Task run when Pay Now is clicked,
    # BEFORE the gateway is contacted (e.g. a Data — Update node inserting
    # the payment row). It gets the page context plus payment_amount (in
    # payment_amount_unit — rupees for "major"), payment_amount_minor (always
    # paise), payment_currency and payment_label; a failed run stops the
    # payment. Its
    # outputs join the context payment_amount / payment_reference are
    # rendered from (so the reference can be a number the task generated),
    # and an output named "recordId" is what payment_pass_record_id passes
    # on to the callback page.
    payment_before_task_id: uuid.UUID | None = None

    # payment_callback only — which query params the gateway's redirect
    # carries. reference_param is always read. status_param/success_value
    # are the no-connector fallback (trust the gateway's own redirect
    # params as-is); when payment_connector_id is set, POST /payments/verify
    # is called instead and its authoritative `status` is compared against
    # payment_success_value, ignoring status_param.
    payment_reference_param: str | None = None
    payment_status_param: str | None = None
    payment_success_value: str | None = None
    payment_status_path: str | None = None
    payment_success_action: PageAction | None = None
    payment_failure_action: PageAction | None = None
    # payment_callback only — a published Task run once the result is known,
    # before the success/failure action (e.g. a Data — Update node writing
    # the status onto the payment row). It gets the page context plus
    # payment_reference / payment_status / payment_label / payment_amount /
    # … — those are replaced server-side with the stored transaction's own
    # values (app/routers/tasks.py::_trusted_payment_context), so a caller
    # can't claim "paid". If the task navigates (a Navigate node), that
    # replaces the success/failure action.
    payment_after_task_id: uuid.UUID | None = None

    # label only — show the field's value as HTML (a rich-text form answer, say)
    # instead of plain text. The value is untrusted data, so the client runs it
    # through an allow-list sanitizer first (frontend/src/lib/sanitizeHtml.ts);
    # this flag only opts a Label in.
    label_render_html: bool = False
    # data_list / detail_view — show a value holding markup as HTML (cleaned
    # by the same client-side sanitizer as a Label's); plain text as usual.
    render_html: bool = False
    # data_list / detail_view — cut a longer text value to this many
    # characters, with a "More" link that shows all of it. None = show it all.
    max_chars: int | None = Field(default=None, ge=1, le=100000)
    # detail_view — how each line's field name and value sit: aligned left,
    # centred or right within their own column (unset: name left, value
    # right), the name column's share of the width in percent (unset: the
    # name takes what it needs), or the name above the value ("stacked").
    detail_label_align: Literal["left", "center", "right"] | None = None
    detail_value_align: Literal["left", "center", "right"] | None = None
    detail_label_width: int | None = Field(default=None, ge=10, le=80)
    detail_layout: Literal["side_by_side", "stacked"] | None = None
    # label only — also share the shown value with the rest of the page as
    # "{{output_name}}" (e.g. a Pay Now block's amount "{{fee}}" or
    # reference "{{application_number}}"), once the record has loaded.
    output_name: str | None = Field(default=None, max_length=60, pattern=r"^[A-Za-z_][A-Za-z0-9_]*$")
    # label (and file_viewer: a file id), with no data_source — the value shown instead: a template
    # like "{{session.office_id}}" or "{{current_user.name}}", resolved in the
    # browser against the page context (frontend/src/lib/contextTemplates.ts).
    # Text, header, alert and label captions resolve such tokens too.
    value_template: str | None = None

    # data_list only — how the rows are drawn. None behaves as "table" (the
    # original and still-default look). "list" is a stacked row per record
    # (title/subtitle, status pill, a few meta chips, actions on the right);
    # "cards" is a responsive card grid (title, status pill, description, a
    # facts strip, actions in the footer) — the sample UI's audit/KPI/fleet
    # cards. allow_view_switch shows a small Table/List/Cards toggle so the
    # viewer can flip between them at runtime. Roles left unset are guessed
    # from column names/order (PageBlockRenderer.tsx's guessCardFields), the
    # same convention inbox_*_field already uses.
    view_mode: DataListViewMode | None = None
    allow_view_switch: bool = False
    # data_list — viewers may hide columns and keep named views of the list
    # (sort, column filters, hidden columns, display, Filter Bar values) —
    # app/routers/saved_views.py.
    allow_saved_views: bool = False
    # data_list — colour rows/cells by rules; a totals footer (column -> sum,
    # avg, min, max or count); rows grouped under a column's values with
    # subtotals; ticking rows for bulk_actions; editing plain fields in the
    # table; the first column kept in view while scrolling sideways.
    format_rules: list[DataListFormatRule] | None = None
    totals: dict[str, Literal["sum", "avg", "min", "max", "count"]] | None = None
    group_rows_by: str | None = None
    allow_select: bool = False
    bulk_actions: list[DataListBulkAction] | None = None
    inline_edit: bool = False
    freeze_first_column: bool = False
    # kanban — the choice field whose values are the columns, and their
    # order (blank = the field's own options).
    kanban_field: str | None = Field(default=None, max_length=200)
    kanban_columns: list[str] | None = None
    # calendar — the date (and optional end date) placing each record, a
    # field whose value colours it, and month or week to start on.
    calendar_date_field: str | None = Field(default=None, max_length=200)
    calendar_end_field: str | None = Field(default=None, max_length=200)
    calendar_color_field: str | None = Field(default=None, max_length=200)
    calendar_view: Literal["month", "week"] | None = None
    # repeater — copies side by side (1 = a column of them).
    repeater_columns: int | None = Field(default=None, ge=1, le=6)
    # approvals — only these workflows (none = all), and which of the
    # instance's values to show on each item.
    approvals_workflow_ids: list[str] | None = None
    approvals_fields: list[str] | None = None
    card_title_field: str | None = None
    card_subtitle_field: str | None = None
    card_badge_field: str | None = None
    card_description_field: str | None = None
    card_columns: int | None = None  # cards view only — 1..4 columns
    # data_list (table view) — click-to-sort column headers plus a per-column
    # value filter. Applied client-side to the rows already loaded, so on a
    # live-paginated list it sorts/filters the current page only.
    allow_column_tools: bool = False
    # data_list — the toolbar's wording. record_noun names one row
    # ("Country"): the Add button, the add/edit dialog's title and its save
    # button read "Add Country", "Edit Country", "Save Country".
    # search_placeholder is the search box's hint. Blank = the generic
    # "Add" / "Search…".
    record_noun: str | None = None
    search_placeholder: str | None = None
    # form, and a data_list over a Form (its add/edit dialog) — see FormReviewMode.
    form_review: FormReviewMode = "form"
    # label, detail_view, data_list — "Fit to content": as tall as what it
    # shows (smaller or larger than layout.h), moving what's under it; off =
    # layout.h.
    auto_height: bool = False
    # form — the page parameter holding the id of a submission of this form
    # to open for editing (e.g. "recordId"); blank, or no such submission =
    # a new one.
    edit_record_param: str | None = Field(default=None, max_length=100)
    # tree — the field holding each row's PARENT row id (blank = a root)
    tree_parent_field: str | None = None
    tree_expand_all: bool = False
    # timeline — the entry's date, and an optional before/after text pair
    timeline_date_field: str | None = None
    timeline_before_field: str | None = None
    timeline_after_field: str | None = None

    # page_header / alert — `label` is the title, `text` the body/subtitle
    subtitle: str | None = None
    back_label: str | None = None
    back_action: PageAction | None = None
    variant: AlertVariant | None = None
    # stat_cards
    stat_items: list[PageStatItem] | None = None
    # filter_bar — its controls, and the blocks (by id) they narrow; none
    # listed = every block on the page reading the bar's own data_source.
    filter_controls: list[PageFilterControl] | None = None
    filter_targets: list[str] | None = None
    # filter_bar — a link field filtered by its record's shown name: field ->
    # the linked source's display field ("office" -> "name" filters
    # "office.name"), so a dropdown's names match.
    filter_link_display: dict[str, str] | None = None
    # stepper — current_step is a 1-based index, or "{{param}}" resolved from
    # the page's own parameters so a wizard page can drive it
    steps: list[PageStepDef] | None = None
    current_step: str | None = None
    # popup only — "drawer" slides in from the right edge full-height instead
    # of centering as a modal; still closable, and layout.w sets its width
    popup_mode: PopupDisplayMode | None = None

    # form_field only — see PageBlockType's own doc above. form_field.name
    # is unused (nothing reads it outside a Form's own field_values), kept
    # only because FormFieldDef requires one; the Designer sets it to
    # "value" and never surfaces it as an author-editable name.
    form_field: FormFieldDef | None = None
    param_name: str | None = None

    @model_validator(mode="after")
    def _check_visible_when(self) -> "PageBlockDef":
        self.visible_when = (self.visible_when or "").strip() or None
        if self.visible_when:
            from app.core.expr import expression_error

            error = expression_error(self.visible_when)
            if error:
                raise ValueError(f"block '{self.id}' show-when: {error}")
        return self

    @model_validator(mode="after")
    def _check_data_grid_source(self) -> "PageBlockDef":
        # The legacy "data_grid" type predates Form support here and always
        # meant Master specifically — kept exactly as strict as before. A
        # current "data_list" block with live_pagination on now also allows
        # a Form source (see PageBlockRenderer.tsx's PreviewDataGrid), so
        # only its own check is loosened to accept either prefix.
        if self.type == "data_grid" and self.data_source and not self.data_source.startswith("master:"):
            raise ValueError(
                "live_pagination (and the legacy data_grid type) require a \"master:<id>\" data_source — "
                "live search/pagination and CRUD need a real Master, never a form or a saved dataset"
            )
        if (
            self.type == "data_list"
            and self.live_pagination
            and self.data_source
            and not (self.data_source.startswith("master:") or self.data_source.startswith("form:"))
        ):
            raise ValueError(
                "live_pagination requires a \"master:<id>\" or \"form:<id>\" data_source — "
                "live search/pagination and CRUD need a real Master or Form, never a saved dataset"
            )
        return self


PageBlockDef.model_rebuild()


# How a page fits a screen narrower than its canvas_width: "scale" shrinks the
# whole canvas proportionally (text gets smaller); "fluid" reflows it — block
# positions and widths follow the screen, text-like blocks and Responsive
# Layouts grow taller to fit, and what is below them moves down. Never wider
# than canvas_width either way.
PageFitMode = Literal["scale", "fluid"]
# "blocks": the designer's canvas. "html": the page is one whole HTML
# document (Page.html_document), shown exactly as written — sanitized on
# save (app/core/html_sanitize.py). See app/routers/public_site.py.
PageContentMode = Literal["blocks", "html"]


class PageCreate(BaseModel):
    name: str
    blocks: list[PageBlockDef] = []
    parameters: list[PageParameterDef] = []
    events: list[PageEventAction] = []
    variables: list[PageVariableDef] = []
    # Authored pixel width of the page root's free-form canvas — narrower
    # viewports scale the whole canvas down (see PageRunner.tsx) rather than
    # reflowing it.
    canvas_width: int = 1200
    fit_mode: PageFitMode = "scale"
    content_mode: PageContentMode = "blocks"
    html_document: str | None = Field(default=None, max_length=2_000_000)
    seo: PageSeo | None = None


class PageUpdate(BaseModel):
    name: str | None = None
    blocks: list[PageBlockDef] | None = None
    parameters: list[PageParameterDef] | None = None
    events: list[PageEventAction] | None = None
    variables: list[PageVariableDef] | None = None
    canvas_width: int | None = None
    fit_mode: PageFitMode | None = None
    content_mode: PageContentMode | None = None
    html_document: str | None = Field(default=None, max_length=2_000_000)
    seo: PageSeo | None = None
    # The updated_at last read — a save over a newer change is refused (409).
    expected_updated_at: datetime | None = None


class PageDuplicate(BaseModel):
    # Blank/omitted -> "<source name> (copy)".
    name: str | None = Field(default=None, max_length=200)


class PageRead(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: uuid.UUID
    tenant_id: uuid.UUID
    name: str
    blocks: list[dict]
    parameters: list[dict]
    events: list[dict]
    variables: list[dict] = []
    canvas_width: int
    fit_mode: PageFitMode = "scale"
    content_mode: PageContentMode = "blocks"
    html_document: str | None = None
    seo: dict = {}
    # On the public website (app/routers/public_site.py), and its address there.
    is_public: bool = False
    slug: str | None = None
    status: PublishStatus
    # The page_versions row visitors see; None = never published (the
    # working copy is shown). has_unpublished_changes: the working copy
    # differs from it. See app/routers/pages.py.
    published_version: int | None = None
    has_unpublished_changes: bool = False
    created_by: uuid.UUID | None
    created_at: datetime
    updated_at: datetime


class PagePublish(BaseModel):
    note: str | None = Field(default=None, max_length=500)


class PageVersionRead(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    version: int
    name: str
    blocks: list[dict]
    parameters: list[dict]
    events: list[dict]
    variables: list[dict] = []
    canvas_width: int
    fit_mode: PageFitMode = "scale"
    content_mode: PageContentMode = "blocks"
    html_document: str | None = None
    seo: dict = {}
    note: str | None
    published_by: uuid.UUID | None
    published_by_name: str | None = None
    published_at: datetime
