from __future__ import annotations

import re
from enum import Enum
from typing import Any

from pydantic import AliasChoices, Field, model_validator

from .button_style_catalog import resolve_button_style
from ..solution.spec_models import StrictModel


def _normalize_builder_view_key_value(value: Any) -> Any:
    if not isinstance(value, str):
        return value
    raw = value.strip()
    if raw.startswith("custom:"):
        return raw.split(":", 1)[1].strip()
    return raw


def _normalize_builder_view_key_payload(payload: dict[str, Any]) -> dict[str, Any]:
    for key in ("view_key", "viewKey", "viewgraphKey", "viewGraphKey"):
        if key in payload:
            payload[key] = _normalize_builder_view_key_value(payload[key])
    return payload


class PublicFieldType(str, Enum):
    text = "text"
    long_text = "long_text"
    number = "number"
    amount = "amount"
    date = "date"
    datetime = "datetime"
    member = "member"
    department = "department"
    single_select = "single_select"
    multi_select = "multi_select"
    phone = "phone"
    email = "email"
    address = "address"
    attachment = "attachment"
    boolean = "boolean"
    q_linker = "q_linker"
    code_block = "code_block"
    relation = "relation"
    subtable = "subtable"


class PublicRelationMode(str, Enum):
    single = "single"
    multiple = "multiple"


class PublicDepartmentScopeMode(str, Enum):
    all = "all"
    custom = "custom"


class PublicVisibilityMode(str, Enum):
    workspace = "workspace"
    everyone = "everyone"
    specific = "specific"


class PublicExternalVisibilityMode(str, Enum):
    not_ = "not"
    workspace = "workspace"
    specific = "specific"


FIELD_TYPE_ALIASES: dict[str, PublicFieldType] = {
    "multiline": PublicFieldType.long_text,
    "multiline_text": PublicFieldType.long_text,
    "multi_line": PublicFieldType.long_text,
    "multi_line_text": PublicFieldType.long_text,
    "textarea": PublicFieldType.long_text,
    "longtext": PublicFieldType.long_text,
    "long-text": PublicFieldType.long_text,
    "amount": PublicFieldType.amount,
    "currency": PublicFieldType.amount,
    "mobile": PublicFieldType.phone,
    "user": PublicFieldType.member,
    "users": PublicFieldType.member,
    "select": PublicFieldType.single_select,
    "single_choice": PublicFieldType.single_select,
    "single-choice": PublicFieldType.single_select,
    "single choice": PublicFieldType.single_select,
    "choice": PublicFieldType.single_select,
    "dropdown": PublicFieldType.single_select,
    "radio": PublicFieldType.single_select,
    "checkbox": PublicFieldType.multi_select,
    "multi_select": PublicFieldType.multi_select,
    "multi-select": PublicFieldType.multi_select,
    "multi select": PublicFieldType.multi_select,
    "multiselect": PublicFieldType.multi_select,
    "multi_choice": PublicFieldType.multi_select,
    "multi-choice": PublicFieldType.multi_select,
    "multi choice": PublicFieldType.multi_select,
    "multiple_choice": PublicFieldType.multi_select,
    "multiple-choice": PublicFieldType.multi_select,
    "multiple choice": PublicFieldType.multi_select,
    "departments": PublicFieldType.department,
    "qlinker": PublicFieldType.q_linker,
    "q_linker": PublicFieldType.q_linker,
    "codeblock": PublicFieldType.code_block,
    "code_block": PublicFieldType.code_block,
}

FIELD_TYPE_ID_ALIASES: dict[int, PublicFieldType] = {
    2: PublicFieldType.text,
    3: PublicFieldType.long_text,
    4: PublicFieldType.date,
    5: PublicFieldType.member,
    6: PublicFieldType.email,
    7: PublicFieldType.phone,
    8: PublicFieldType.number,
    10: PublicFieldType.boolean,
    11: PublicFieldType.single_select,
    12: PublicFieldType.multi_select,
    13: PublicFieldType.attachment,
    20: PublicFieldType.q_linker,
    26: PublicFieldType.code_block,
    18: PublicFieldType.subtable,
    21: PublicFieldType.address,
    22: PublicFieldType.department,
    25: PublicFieldType.relation,
}


class PublicViewType(str, Enum):
    table = "table"
    card = "card"
    board = "board"
    gantt = "gantt"


class PublicButtonTriggerAction(str, Enum):
    add_data = "addData"
    link = "link"
    qrobot = "qRobot"
    wings = "wings"


class PublicViewButtonType(str, Enum):
    system = "SYSTEM"
    custom = "CUSTOM"


class PublicViewButtonConfigType(str, Enum):
    top = "TOP"
    detail = "DETAIL"
    list = "INSIDE"


class PublicButtonPlacement(str, Enum):
    header = "header"
    detail = "detail"
    list = "list"


class PublicChartType(str, Enum):
    target = "target"
    indicator = "indicator"
    summary = "summary"
    pie = "pie"
    bar = "bar"
    columnar = "columnar"
    line = "line"
    table = "table"
    detail = "detail"
    area = "area"
    stacked_area = "stacked_area"
    pct_stack_area = "pct_stack_area"
    funnel = "funnel"
    waterfall = "waterfall"
    gauge = "gauge"
    heatmap = "heatmap"
    histogram = "histogram"
    treemap = "treemap"
    radar = "radar"
    stacked_bar = "stacked_bar"
    pct_stack_bar = "pct_stack_bar"
    stacked_column = "stacked_column"
    pct_stack_col = "pct_stack_col"
    scatter = "scatter"
    ring = "ring"
    rose = "rose"
    dualaxes = "dualaxes"
    map = "map"
    timeline = "timeline"


class LayoutApplyMode(str, Enum):
    merge = "merge"
    replace = "replace"


class LayoutPreset(str, Enum):
    balanced = "balanced"
    compact = "compact"
    single_section = "single_section"


class FlowPreset(str, Enum):
    basic_approval = "basic_approval"
    basic_fill_then_approve = "basic_fill_then_approve"


class ViewsPreset(str, Enum):
    default_table = "default_table"
    status_board = "status_board"
    default_gantt = "default_gantt"


class PublicFlowNodeType(str, Enum):
    start = "start"
    approve = "approve"
    fill = "fill"
    copy = "copy"
    branch = "branch"
    condition = "condition"
    webhook = "webhook"
    end = "end"


class FlowConditionOperator(str, Enum):
    eq = "eq"
    neq = "neq"
    in_ = "in"
    contains = "contains"
    gte = "gte"
    lte = "lte"
    is_empty = "is_empty"
    not_empty = "not_empty"


class ViewFilterOperator(str, Enum):
    eq = "eq"
    neq = "neq"
    in_ = "in"
    contains = "contains"
    gte = "gte"
    lte = "lte"
    is_empty = "is_empty"
    not_empty = "not_empty"


_PUBLIC_CONDITION_OPERATOR_ALIASES: dict[str, str] = {
    "eq": "eq",
    "equal": "eq",
    "equals": "eq",
    "=": "eq",
    "==": "eq",
    "neq": "neq",
    "ne": "neq",
    "not_eq": "neq",
    "not_equal": "neq",
    "not_equals": "neq",
    "noteq": "neq",
    "notequal": "neq",
    "notequals": "neq",
    "!=": "neq",
    "in": "in",
    "any": "in",
    "any_of": "in",
    "anyof": "in",
    "one_of": "in",
    "oneof": "in",
    "between_any": "in",
    "betweenany": "in",
    "equal_any": "in",
    "equalany": "in",
    "any_match": "in",
    "anymatch": "in",
    "contains": "contains",
    "contain": "contains",
    "include": "contains",
    "includes": "contains",
    "like": "contains",
    "fuzzy": "contains",
    "fuzzy_match": "contains",
    "fuzzymatch": "contains",
    "gte": "gte",
    "ge": "gte",
    ">=": "gte",
    "greater_or_equal": "gte",
    "greaterorequal": "gte",
    "greater_than_or_equal": "gte",
    "greaterthanorequal": "gte",
    "lte": "lte",
    "le": "lte",
    "<=": "lte",
    "less_or_equal": "lte",
    "lessorequal": "lte",
    "less_than_or_equal": "lte",
    "lessthanorequal": "lte",
    "is_empty": "is_empty",
    "isempty": "is_empty",
    "empty": "is_empty",
    "blank": "is_empty",
    "is_blank": "is_empty",
    "isblank": "is_empty",
    "null": "is_empty",
    "is_null": "is_empty",
    "isnull": "is_empty",
    "not_empty": "not_empty",
    "notempty": "not_empty",
    "is_not_empty": "not_empty",
    "isnotempty": "not_empty",
    "not_blank": "not_empty",
    "notblank": "not_empty",
    "not_null": "not_empty",
    "notnull": "not_empty",
    "is_not_null": "not_empty",
    "isnotnull": "not_empty",
}


def normalize_public_condition_operator(raw_operator: Any, *, extra_aliases: dict[str, str] | None = None) -> Any:
    if not isinstance(raw_operator, str):
        return raw_operator
    text = raw_operator.strip().lower()
    if not text:
        return text
    lookup = dict(_PUBLIC_CONDITION_OPERATOR_ALIASES)
    if extra_aliases:
        lookup.update(extra_aliases)
    candidates = [
        text,
        re.sub(r"[\s-]+", "_", text),
        re.sub(r"[\s_-]+", "", text),
    ]
    for candidate in candidates:
        if candidate in lookup:
            return lookup[candidate]
    return text


def _normalize_public_condition_payload(value: Any) -> Any:
    if not isinstance(value, dict):
        return value
    payload = dict(value)
    if "value" in payload and "values" not in payload:
        raw_value = payload.pop("value")
        payload["values"] = list(raw_value) if isinstance(raw_value, list) else [raw_value]
    elif "values" in payload and not isinstance(payload.get("values"), list):
        payload["values"] = [payload.get("values")]
    raw_operator = payload.get("operator", payload.get("op"))
    if raw_operator is None:
        payload["operator"] = "eq"
        raw_operator = "eq"
    if isinstance(raw_operator, str):
        payload["operator"] = normalize_public_condition_operator(raw_operator)
    payload.pop("op", None)
    return payload


class FlowAssigneePatch(StrictModel):
    role_ids: list[int] = Field(default_factory=list)
    role_names: list[str] = Field(default_factory=list)
    member_uids: list[int] = Field(default_factory=list)
    member_emails: list[str] = Field(default_factory=list)
    member_names: list[str] = Field(default_factory=list)
    include_sub_departs: bool | None = None


class FlowNodePermissionsPatch(StrictModel):
    editable_fields: list[str] = Field(default_factory=list)


class FlowConditionRulePatch(StrictModel):
    field_name: str = Field(validation_alias=AliasChoices("field_name", "fieldName", "field", "name"))
    operator: FlowConditionOperator = Field(default=FlowConditionOperator.eq, validation_alias=AliasChoices("operator", "op"))
    values: list[Any] = Field(default_factory=list)

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        return _normalize_public_condition_payload(value)

    @model_validator(mode="after")
    def validate_shape(self) -> "FlowConditionRulePatch":
        if self.operator in {FlowConditionOperator.is_empty, FlowConditionOperator.not_empty}:
            self.values = []
            return self
        if not self.values:
            raise ValueError("condition rule requires values")
        return self


class ViewFilterRulePatch(StrictModel):
    field_name: str = Field(validation_alias=AliasChoices("field_name", "fieldName", "field", "name"))
    operator: ViewFilterOperator = Field(default=ViewFilterOperator.eq, validation_alias=AliasChoices("operator", "op"))
    values: list[Any] = Field(default_factory=list)

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        return _normalize_public_condition_payload(value)

    @model_validator(mode="after")
    def validate_shape(self) -> "ViewFilterRulePatch":
        if self.operator in {ViewFilterOperator.is_empty, ViewFilterOperator.not_empty}:
            self.values = []
            return self
        if not self.values:
            raise ValueError("view filter rule requires values")
        return self


class FieldSelector(StrictModel):
    field_id: str | None = None
    que_id: int | None = None
    name: str | None = Field(default=None, validation_alias=AliasChoices("name", "title", "label"))

    @model_validator(mode="after")
    def validate_selector(self) -> "FieldSelector":
        if self.field_id is None and self.que_id is None and self.name is None:
            raise ValueError("selector must include field_id, que_id, or name")
        return self


class DepartmentSelectorPatch(StrictModel):
    dept_id: int | None = Field(default=None, validation_alias=AliasChoices("dept_id", "deptId", "id"))
    dept_name: str | None = Field(default=None, validation_alias=AliasChoices("dept_name", "deptName", "name", "value"))

    @model_validator(mode="after")
    def validate_selector(self) -> "DepartmentSelectorPatch":
        if self.dept_id is None and not str(self.dept_name or "").strip():
            raise ValueError("department selector must include dept_id or dept_name")
        return self


class DepartmentScopePatch(StrictModel):
    mode: PublicDepartmentScopeMode | None = None
    departments: list[DepartmentSelectorPatch] = Field(
        default_factory=list,
        validation_alias=AliasChoices("departments", "departs", "depart"),
    )
    include_sub_departs: bool | None = Field(
        default=None,
        validation_alias=AliasChoices("include_sub_departs", "includeSubDeparts"),
    )

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        if "depart" in payload and "departments" not in payload:
            payload["departments"] = payload.pop("depart")
        if "departs" in payload and "departments" not in payload:
            payload["departments"] = payload.pop("departs")
        normalized_mode = _normalize_public_department_scope_mode(payload.get("mode"))
        if normalized_mode is None:
            if "departments" in payload:
                normalized_mode = PublicDepartmentScopeMode.custom.value
            else:
                normalized_mode = PublicDepartmentScopeMode.all.value
        payload["mode"] = normalized_mode
        return payload

    @model_validator(mode="after")
    def validate_shape(self) -> "DepartmentScopePatch":
        if self.mode == PublicDepartmentScopeMode.custom and not self.departments:
            raise ValueError("custom department scope requires departments")
        if self.mode == PublicDepartmentScopeMode.custom and any(item.dept_id is None for item in self.departments):
            raise ValueError("custom department scope requires departments[].dept_id")
        return self


class VisibilitySelectorsPatch(StrictModel):
    member_uids: list[int] = Field(default_factory=list, validation_alias=AliasChoices("member_uids", "memberUids"))
    member_emails: list[str] = Field(default_factory=list, validation_alias=AliasChoices("member_emails", "memberEmails"))
    member_names: list[str] = Field(default_factory=list, validation_alias=AliasChoices("member_names", "memberNames"))
    dept_ids: list[int] = Field(default_factory=list, validation_alias=AliasChoices("dept_ids", "deptIds"))
    dept_names: list[str] = Field(default_factory=list, validation_alias=AliasChoices("dept_names", "deptNames"))
    role_ids: list[int] = Field(default_factory=list, validation_alias=AliasChoices("role_ids", "roleIds"))
    role_names: list[str] = Field(default_factory=list, validation_alias=AliasChoices("role_names", "roleNames"))
    include_sub_departs: bool | None = Field(
        default=None,
        validation_alias=AliasChoices("include_sub_departs", "includeSubDeparts"),
    )

    def has_any_selector(self) -> bool:
        return any(
            (
                self.member_uids,
                self.member_emails,
                self.member_names,
                self.dept_ids,
                self.dept_names,
                self.role_ids,
                self.role_names,
            )
        )


class ExternalVisibilitySelectorsPatch(StrictModel):
    member_ids: list[int] = Field(default_factory=list, validation_alias=AliasChoices("member_ids", "memberIds"))
    member_emails: list[str] = Field(default_factory=list, validation_alias=AliasChoices("member_emails", "memberEmails"))
    dept_ids: list[int] = Field(default_factory=list, validation_alias=AliasChoices("dept_ids", "deptIds"))

    def has_any_selector(self) -> bool:
        return any((self.member_ids, self.member_emails, self.dept_ids))


class VisibilityPatch(StrictModel):
    mode: PublicVisibilityMode | None = None
    selectors: VisibilitySelectorsPatch = Field(default_factory=VisibilitySelectorsPatch)
    external_mode: PublicExternalVisibilityMode | None = Field(
        default=None,
        validation_alias=AliasChoices("external_mode", "externalMode"),
    )
    external_selectors: ExternalVisibilitySelectorsPatch = Field(
        default_factory=ExternalVisibilitySelectorsPatch,
        validation_alias=AliasChoices("external_selectors", "externalSelectors"),
    )

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        selectors = payload.get("selectors")
        external_selectors = payload.get("external_selectors", payload.get("externalSelectors"))
        if payload.get("mode") is None:
            if isinstance(selectors, dict) and selectors:
                payload["mode"] = PublicVisibilityMode.specific.value
            else:
                payload["mode"] = PublicVisibilityMode.workspace.value
        if payload.get("mode") == PublicVisibilityMode.everyone.value and payload.get("external_mode", payload.get("externalMode")) is None:
            payload["external_mode"] = PublicExternalVisibilityMode.workspace.value
        if payload.get("external_mode", payload.get("externalMode")) is None:
            if isinstance(external_selectors, dict) and external_selectors:
                payload["external_mode"] = PublicExternalVisibilityMode.specific.value
            else:
                payload["external_mode"] = PublicExternalVisibilityMode.not_.value
        return payload

    @model_validator(mode="after")
    def validate_shape(self) -> "VisibilityPatch":
        if self.mode == PublicVisibilityMode.specific and not self.selectors.has_any_selector():
            raise ValueError("specific visibility requires selectors")
        if self.mode != PublicVisibilityMode.specific and self.selectors.has_any_selector():
            raise ValueError("selectors are only allowed when mode=specific")
        if self.mode == PublicVisibilityMode.everyone and self.external_mode != PublicExternalVisibilityMode.workspace:
            raise ValueError("mode=everyone requires external_mode=workspace")
        if self.mode == PublicVisibilityMode.everyone and self.external_selectors.has_any_selector():
            raise ValueError("external_selectors are not allowed when mode=everyone")
        if self.external_mode == PublicExternalVisibilityMode.specific and not self.external_selectors.has_any_selector():
            raise ValueError("external_mode=specific requires external_selectors")
        if self.external_mode != PublicExternalVisibilityMode.specific and self.external_selectors.has_any_selector():
            raise ValueError("external_selectors are only allowed when external_mode=specific")
        return self


class CodeBlockAliasPathPatch(StrictModel):
    alias_name: str = Field(validation_alias=AliasChoices("alias_name", "aliasName"))
    alias_path: str = Field(validation_alias=AliasChoices("alias_path", "aliasPath"))
    alias_type: int = Field(default=1, validation_alias=AliasChoices("alias_type", "aliasType"))
    alias_id: int | None = Field(default=None, validation_alias=AliasChoices("alias_id", "aliasId"))
    sub_alias: list["CodeBlockAliasPathPatch"] = Field(
        default_factory=list,
        validation_alias=AliasChoices("sub_alias", "subAlias"),
    )


class CodeBlockConfigPatch(StrictModel):
    config_mode: int = Field(default=1, validation_alias=AliasChoices("config_mode", "configMode"))
    code_content: str = Field(default="", validation_alias=AliasChoices("code_content", "codeContent"))
    being_hide_on_form: bool = Field(
        default=False,
        validation_alias=AliasChoices("being_hide_on_form", "beingHideOnForm"),
    )
    result_alias_path: list[CodeBlockAliasPathPatch] = Field(
        default_factory=list,
        validation_alias=AliasChoices("result_alias_path", "resultAliasPath", "alias_config", "aliasConfig"),
    )


class CodeBlockInputBindingPatch(StrictModel):
    field: FieldSelector
    var: str | None = None

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        if isinstance(value, str):
            return {"field": {"name": value}}
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        raw_field = payload.get("field")
        if isinstance(raw_field, str):
            payload["field"] = {"name": raw_field}
        elif raw_field is None:
            for key in ("field_name", "fieldName", "name", "title", "label"):
                raw_value = payload.get(key)
                if isinstance(raw_value, str) and raw_value.strip():
                    payload["field"] = {"name": raw_value}
                    break
        return payload


class QLinkerInputSource(str, Enum):
    query_param = "query_param"
    header = "header"
    url_encoded = "url_encoded"
    json_path = "json_path"


class QLinkerKeyValuePatch(StrictModel):
    key: str
    value: str | None = None


class QLinkerAliasPathPatch(StrictModel):
    alias_name: str = Field(validation_alias=AliasChoices("alias_name", "aliasName"))
    alias_path: str = Field(validation_alias=AliasChoices("alias_path", "aliasPath"))
    alias_id: int | None = Field(default=None, validation_alias=AliasChoices("alias_id", "aliasId"))


class RemoteLookupConfigPatch(StrictModel):
    config_mode: int = Field(default=1, validation_alias=AliasChoices("config_mode", "configMode"))
    url: str = ""
    method: str = "GET"
    headers: list[QLinkerKeyValuePatch] = Field(default_factory=list)
    body_type: int = Field(default=1, validation_alias=AliasChoices("body_type", "bodyType"))
    url_encoded_value: list[QLinkerKeyValuePatch] = Field(
        default_factory=list,
        validation_alias=AliasChoices("url_encoded_value", "urlEncodedValue"),
    )
    json_value: str | None = Field(default=None, validation_alias=AliasChoices("json_value", "jsonValue"))
    xml_value: str | None = Field(default=None, validation_alias=AliasChoices("xml_value", "xmlValue"))
    result_type: int = Field(default=1, validation_alias=AliasChoices("result_type", "resultType"))
    result_format_path: list[QLinkerAliasPathPatch] = Field(
        default_factory=list,
        validation_alias=AliasChoices("result_format_path", "resultFormatPath"),
    )
    query_params: list[QLinkerKeyValuePatch] = Field(
        default_factory=list,
        validation_alias=AliasChoices("query_params", "queryParams"),
    )
    auto_trigger: bool | None = Field(default=None, validation_alias=AliasChoices("auto_trigger", "autoTrigger"))
    custom_button_text_enabled: bool | None = Field(
        default=None,
        validation_alias=AliasChoices("custom_button_text_enabled", "customButtonTextEnabled", "custom_btn_text_status", "customBtnTextStatus"),
    )
    custom_button_text: str | None = Field(
        default=None,
        validation_alias=AliasChoices("custom_button_text", "customButtonText", "custom_btn_text", "customBtnText"),
    )
    being_insert_value_directly: bool | None = Field(
        default=None,
        validation_alias=AliasChoices("being_insert_value_directly", "beingInsertValueDirectly"),
    )
    being_hide_on_form: bool | None = Field(
        default=None,
        validation_alias=AliasChoices("being_hide_on_form", "beingHideOnForm"),
    )


class QLinkerInputBindingPatch(StrictModel):
    field: FieldSelector
    key: str
    source: QLinkerInputSource

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        if isinstance(value, str):
            return {"field": {"name": value}, "key": value, "source": QLinkerInputSource.query_param.value}
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        raw_field = payload.get("field")
        if isinstance(raw_field, str):
            payload["field"] = {"name": raw_field}
        elif raw_field is None:
            for key in ("field_name", "fieldName", "name", "title", "label"):
                raw_value = payload.get(key)
                if isinstance(raw_value, str) and raw_value.strip():
                    payload["field"] = {"name": raw_value}
                    break
        raw_source = payload.get("source")
        if isinstance(raw_source, str):
            payload["source"] = raw_source.strip().lower()
        return payload


class QLinkerOutputBindingPatch(StrictModel):
    alias: str
    path: str
    target_field: FieldSelector = Field(validation_alias=AliasChoices("target_field", "targetField"))

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        if "alias_name" in payload and "alias" not in payload:
            payload["alias"] = payload.pop("alias_name")
        if "aliasName" in payload and "alias" not in payload:
            payload["alias"] = payload.pop("aliasName")
        if "alias_path" in payload and "path" not in payload:
            payload["path"] = payload.pop("alias_path")
        if "aliasPath" in payload and "path" not in payload:
            payload["path"] = payload.pop("aliasPath")
        raw_target = payload.get("target_field", payload.get("targetField"))
        if isinstance(raw_target, str):
            payload["target_field"] = {"name": raw_target}
        return payload


class QLinkerRequestPatch(StrictModel):
    url: str = ""
    method: str = "GET"
    headers: list[QLinkerKeyValuePatch] = Field(default_factory=list)
    query_params: list[QLinkerKeyValuePatch] = Field(
        default_factory=list,
        validation_alias=AliasChoices("query_params", "queryParams"),
    )
    body_type: int = Field(default=1, validation_alias=AliasChoices("body_type", "bodyType"))
    url_encoded_value: list[QLinkerKeyValuePatch] = Field(
        default_factory=list,
        validation_alias=AliasChoices("url_encoded_value", "urlEncodedValue"),
    )
    json_value: str | None = Field(default=None, validation_alias=AliasChoices("json_value", "jsonValue"))
    xml_value: str | None = Field(default=None, validation_alias=AliasChoices("xml_value", "xmlValue"))
    result_type: int = Field(default=1, validation_alias=AliasChoices("result_type", "resultType"))
    auto_trigger: bool | None = Field(default=None, validation_alias=AliasChoices("auto_trigger", "autoTrigger"))
    custom_button_text_enabled: bool | None = Field(
        default=None,
        validation_alias=AliasChoices("custom_button_text_enabled", "customButtonTextEnabled", "custom_btn_text_status", "customBtnTextStatus"),
    )
    custom_button_text: str | None = Field(
        default=None,
        validation_alias=AliasChoices("custom_button_text", "customButtonText", "custom_btn_text", "customBtnText"),
    )
    being_insert_value_directly: bool | None = Field(
        default=None,
        validation_alias=AliasChoices("being_insert_value_directly", "beingInsertValueDirectly"),
    )
    being_hide_on_form: bool | None = Field(
        default=None,
        validation_alias=AliasChoices("being_hide_on_form", "beingHideOnForm"),
    )


class QLinkerBindingPatch(StrictModel):
    inputs: list[QLinkerInputBindingPatch] = Field(default_factory=list)
    request: QLinkerRequestPatch
    outputs: list[QLinkerOutputBindingPatch] = Field(default_factory=list)


class CodeBlockOutputBindingPatch(StrictModel):
    alias: str
    path: str
    target_field: FieldSelector = Field(
        validation_alias=AliasChoices("target_field", "targetField"),
    )

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        if "alias_name" in payload and "alias" not in payload:
            payload["alias"] = payload.pop("alias_name")
        if "aliasName" in payload and "alias" not in payload:
            payload["alias"] = payload.pop("aliasName")
        if "alias_path" in payload and "path" not in payload:
            payload["path"] = payload.pop("alias_path")
        if "aliasPath" in payload and "path" not in payload:
            payload["path"] = payload.pop("aliasPath")
        raw_target = payload.get("target_field", payload.get("targetField"))
        if isinstance(raw_target, str):
            payload["target_field"] = {"name": raw_target}
        return payload


class CodeBlockBindingPatch(StrictModel):
    inputs: list[CodeBlockInputBindingPatch] = Field(default_factory=list)
    code: str = ""
    auto_trigger: bool | None = Field(default=None, validation_alias=AliasChoices("auto_trigger", "autoTrigger"))
    custom_button_text_enabled: bool | None = Field(
        default=None,
        validation_alias=AliasChoices("custom_button_text_enabled", "customButtonTextEnabled", "custom_btn_text_status", "customBtnTextStatus"),
    )
    custom_button_text: str | None = Field(
        default=None,
        validation_alias=AliasChoices("custom_button_text", "customButtonText", "custom_btn_text", "customBtnText"),
    )
    outputs: list[CodeBlockOutputBindingPatch] = Field(default_factory=list)

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        if "code_content" in payload and "code" not in payload:
            payload["code"] = payload.pop("code_content")
        if "codeContent" in payload and "code" not in payload:
            payload["code"] = payload.pop("codeContent")
        return payload


class FieldPatch(StrictModel):
    name: str = Field(validation_alias=AliasChoices("name", "title", "label"))
    type: PublicFieldType
    required: bool = False
    description: str | None = None
    options: list[str] = Field(default_factory=list)
    target_app_key: str | None = None
    display_field: FieldSelector | None = None
    visible_fields: list[FieldSelector] = Field(default_factory=list)
    relation_mode: PublicRelationMode | None = Field(default=None, validation_alias=AliasChoices("relation_mode", "relationMode", "selection_mode", "selectionMode"))
    department_scope: DepartmentScopePatch | None = Field(
        default=None,
        validation_alias=AliasChoices("department_scope", "departmentScope"),
    )
    remote_lookup_config: RemoteLookupConfigPatch | None = Field(
        default=None,
        validation_alias=AliasChoices("remote_lookup_config", "remoteLookupConfig"),
    )
    q_linker_binding: QLinkerBindingPatch | None = Field(
        default=None,
        validation_alias=AliasChoices("q_linker_binding", "qLinkerBinding"),
    )
    code_block_config: CodeBlockConfigPatch | None = Field(
        default=None,
        validation_alias=AliasChoices("code_block_config", "codeBlockConfig"),
    )
    code_block_binding: CodeBlockBindingPatch | None = Field(
        default=None,
        validation_alias=AliasChoices("code_block_binding", "codeBlockBinding"),
    )
    auto_trigger: bool | None = Field(default=None, validation_alias=AliasChoices("auto_trigger", "autoTrigger"))
    custom_button_text_enabled: bool | None = Field(
        default=None,
        validation_alias=AliasChoices("custom_button_text_enabled", "customButtonTextEnabled", "custom_btn_text_status", "customBtnTextStatus"),
    )
    custom_button_text: str | None = Field(
        default=None,
        validation_alias=AliasChoices("custom_button_text", "customButtonText", "custom_btn_text", "customBtnText"),
    )
    as_data_title: bool | None = Field(default=None, validation_alias=AliasChoices("as_data_title", "asDataTitle"))
    as_data_cover: bool | None = Field(default=None, validation_alias=AliasChoices("as_data_cover", "asDataCover"))
    subfields: list["FieldPatch"] = Field(default_factory=list)

    @model_validator(mode="after")
    def validate_shape(self) -> "FieldPatch":
        if self.type == PublicFieldType.relation and not self.target_app_key:
            raise ValueError("relation field requires target_app_key")
        if self.type == PublicFieldType.relation and self.display_field is None:
            raise ValueError("relation field requires display_field")
        if self.type == PublicFieldType.relation and not self.visible_fields:
            raise ValueError("relation field requires visible_fields")
        if self.type != PublicFieldType.relation and self.target_app_key:
            raise ValueError("target_app_key is only allowed for relation fields")
        if self.type != PublicFieldType.relation and (self.display_field is not None or self.visible_fields or self.relation_mode is not None):
            raise ValueError("display_field, visible_fields, and relation_mode are only allowed for relation fields")
        if self.type != PublicFieldType.department and self.department_scope is not None:
            raise ValueError("department_scope is only allowed for department fields")
        if self.type != PublicFieldType.q_linker and (
            self.remote_lookup_config is not None
            or self.q_linker_binding is not None
        ):
            raise ValueError("remote_lookup_config and q_linker_binding are only allowed for q_linker fields")
        if self.type != PublicFieldType.code_block and (
            self.code_block_config is not None
            or self.code_block_binding is not None
            or self.auto_trigger is not None
            or self.custom_button_text_enabled is not None
            or self.custom_button_text is not None
        ):
            raise ValueError("code_block_config, code_block_binding, auto_trigger, custom_button_text_enabled, and custom_button_text are only allowed for code_block fields")
        if self.type == PublicFieldType.subtable and not self.subfields:
            raise ValueError("subtable field requires subfields")
        if self.type != PublicFieldType.subtable and self.subfields:
            raise ValueError("subfields are only allowed for subtable fields")
        return self

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        return _normalize_field_payload(value)


class FieldMutation(StrictModel):
    name: str | None = Field(default=None, validation_alias=AliasChoices("name", "title", "label"))
    type: PublicFieldType | None = None
    required: bool | None = None
    description: str | None = None
    options: list[str] | None = None
    target_app_key: str | None = None
    display_field: FieldSelector | None = None
    visible_fields: list[FieldSelector] | None = None
    relation_mode: PublicRelationMode | None = Field(default=None, validation_alias=AliasChoices("relation_mode", "relationMode", "selection_mode", "selectionMode"))
    department_scope: DepartmentScopePatch | None = Field(
        default=None,
        validation_alias=AliasChoices("department_scope", "departmentScope"),
    )
    remote_lookup_config: RemoteLookupConfigPatch | None = Field(
        default=None,
        validation_alias=AliasChoices("remote_lookup_config", "remoteLookupConfig"),
    )
    q_linker_binding: QLinkerBindingPatch | None = Field(
        default=None,
        validation_alias=AliasChoices("q_linker_binding", "qLinkerBinding"),
    )
    code_block_config: CodeBlockConfigPatch | None = Field(
        default=None,
        validation_alias=AliasChoices("code_block_config", "codeBlockConfig"),
    )
    code_block_binding: CodeBlockBindingPatch | None = Field(
        default=None,
        validation_alias=AliasChoices("code_block_binding", "codeBlockBinding"),
    )
    auto_trigger: bool | None = Field(default=None, validation_alias=AliasChoices("auto_trigger", "autoTrigger"))
    custom_button_text_enabled: bool | None = Field(
        default=None,
        validation_alias=AliasChoices("custom_button_text_enabled", "customButtonTextEnabled", "custom_btn_text_status", "customBtnTextStatus"),
    )
    custom_button_text: str | None = Field(
        default=None,
        validation_alias=AliasChoices("custom_button_text", "customButtonText", "custom_btn_text", "customBtnText"),
    )
    as_data_title: bool | None = Field(default=None, validation_alias=AliasChoices("as_data_title", "asDataTitle"))
    as_data_cover: bool | None = Field(default=None, validation_alias=AliasChoices("as_data_cover", "asDataCover"))
    subfields: list[FieldPatch] | None = None
    subfield_updates: list["FieldUpdatePatch"] | None = Field(
        default=None,
        validation_alias=AliasChoices("subfield_updates", "subfieldUpdates"),
    )

    @model_validator(mode="after")
    def validate_shape(self) -> "FieldMutation":
        if self.type == PublicFieldType.relation and not self.target_app_key:
            raise ValueError("relation field requires target_app_key")
        relation_patch = (
            self.type == PublicFieldType.relation
            or self.target_app_key is not None
            or self.display_field is not None
            or self.visible_fields is not None
            or self.relation_mode is not None
        )
        if self.type is not None and self.type != PublicFieldType.relation and (self.display_field is not None or self.visible_fields or self.relation_mode is not None):
            raise ValueError("display_field, visible_fields, and relation_mode are only allowed for relation fields")
        if self.type is not None and self.type != PublicFieldType.department and self.department_scope is not None:
            raise ValueError("department_scope is only allowed for department fields")
        if self.type is not None and self.type != PublicFieldType.q_linker and (
            self.remote_lookup_config is not None
            or self.q_linker_binding is not None
        ):
            raise ValueError("remote_lookup_config and q_linker_binding are only allowed for q_linker fields")
        if self.type is not None and self.type != PublicFieldType.code_block and (
            self.code_block_config is not None
            or self.code_block_binding is not None
            or self.auto_trigger is not None
            or self.custom_button_text_enabled is not None
            or self.custom_button_text is not None
        ):
            raise ValueError("code_block_config, code_block_binding, auto_trigger, custom_button_text_enabled, and custom_button_text are only allowed for code_block fields")
        if self.type == PublicFieldType.subtable and not self.subfields and not self.subfield_updates:
            raise ValueError("subtable field requires subfields or subfield_updates")
        if self.type is not None and self.type != PublicFieldType.subtable and self.subfield_updates:
            raise ValueError("subfield_updates are only allowed for subtable fields")
        if self.subfields and self.subfield_updates:
            raise ValueError("subfields and subfield_updates cannot be used together")
        return self

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        return _normalize_field_payload(value)


class FieldUpdatePatch(StrictModel):
    selector: FieldSelector
    set: FieldMutation


class FieldRemovePatch(StrictModel):
    field_id: str | None = None
    que_id: int | None = None
    name: str | None = Field(default=None, validation_alias=AliasChoices("name", "title", "label"))

    @model_validator(mode="after")
    def validate_shape(self) -> "FieldRemovePatch":
        if not any((self.field_id, self.que_id, self.name)):
            raise ValueError("remove patch must include field_id, que_id, or name")
        return self


def _coerce_layout_columns(value: Any) -> int | None:
    if isinstance(value, bool):
        return None
    if isinstance(value, int):
        return value if value > 0 else None
    if isinstance(value, str):
        stripped = value.strip()
        if stripped.isdigit():
            parsed = int(stripped)
            return parsed if parsed > 0 else None
    return None


def _normalize_layout_rows(value: Any, *, columns: int | None = None) -> Any:
    if not isinstance(value, list):
        return value
    if value and all(isinstance(item, list) for item in value):
        return value
    if not value:
        return []
    width = columns if columns and columns > 0 else None
    if width is None:
        return [list(value)]
    return [list(value[index : index + width]) for index in range(0, len(value), width) if value[index : index + width]]


class LayoutSectionPatch(StrictModel):
    type: str | None = Field(default=None, validation_alias=AliasChoices("type", "kind", "block_type", "blockType"))
    section_id: str | None = Field(default=None, validation_alias=AliasChoices("section_id", "sectionId", "paragraph_id", "paragraphId"))
    title: str = Field(validation_alias=AliasChoices("title", "name", "paragraph_title", "paragraphTitle"))
    rows: list[list[Any]] = Field(default_factory=list)

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        if "name" in payload and "title" not in payload:
            payload["title"] = payload.pop("name")
        if "paragraph_title" in payload and "title" not in payload:
            payload["title"] = payload.pop("paragraph_title")
        if "paragraphTitle" in payload and "title" not in payload:
            payload["title"] = payload.pop("paragraphTitle")
        shorthand: Any | None = None
        if "rows" not in payload:
            if "fields" in payload:
                shorthand = payload.pop("fields")
            elif "field_ids" in payload:
                shorthand = payload.pop("field_ids")
            if shorthand is not None:
                payload["rows"] = _normalize_layout_rows(
                    shorthand,
                    columns=_coerce_layout_columns(payload.pop("columns", None)),
                )
        return payload

    @model_validator(mode="after")
    def validate_rows(self) -> "LayoutSectionPatch":
        if self.type is not None and str(self.type).strip().lower() != "paragraph":
            raise ValueError("layout section type must be 'paragraph'")
        if not self.rows:
            raise ValueError("section rows must be a non-empty list")
        for row in self.rows:
            if not isinstance(row, list) or not row:
                raise ValueError("section rows must be a non-empty list")
        if not self.section_id:
            self.section_id = _slugify_title(self.title)
        return self


class FlowNodePatch(StrictModel):
    id: str
    type: PublicFlowNodeType
    name: str
    assignees: FlowAssigneePatch = Field(default_factory=FlowAssigneePatch)
    permissions: FlowNodePermissionsPatch = Field(default_factory=FlowNodePermissionsPatch)
    conditions: list[FlowConditionRulePatch] = Field(default_factory=list)
    condition_groups: list[list[FlowConditionRulePatch]] = Field(default_factory=list)
    config: dict[str, Any] = Field(default_factory=dict)

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        assignees = dict(payload.get("assignees") or {})
        permissions = dict(payload.get("permissions") or {})

        for key in ("role_ids", "role_names", "member_uids", "member_emails", "member_names", "include_sub_departs"):
            if key in payload and key not in assignees:
                assignees[key] = payload.pop(key)
        for key in ("editable_fields",):
            if key in payload and key not in permissions:
                permissions[key] = payload.pop(key)
        if "filters" in payload and "conditions" not in payload:
            payload["conditions"] = payload.pop("filters")
        if "rules" in payload and "conditions" not in payload:
            payload["conditions"] = payload.pop("rules")
        if "conditionRules" in payload and "condition_groups" not in payload:
            payload["condition_groups"] = payload.pop("conditionRules")
        if "conditionGroups" in payload and "condition_groups" not in payload:
            payload["condition_groups"] = payload.pop("conditionGroups")
        if "owners" in payload and "member_names" not in assignees:
            assignees["member_names"] = payload.pop("owners")
        if "approvers" in payload and "role_names" not in assignees:
            assignees["role_names"] = payload.pop("approvers")
        if assignees:
            payload["assignees"] = assignees
        if permissions:
            payload["permissions"] = permissions
        return payload

    @model_validator(mode="after")
    def validate_branch_conditions(self) -> "FlowNodePatch":
        if self.conditions:
            self.condition_groups = [list(self.conditions), *self.condition_groups]
            self.conditions = []
        if self.type != PublicFlowNodeType.condition and self.condition_groups:
            raise ValueError("condition_groups are only allowed on condition nodes")
        return self


class FlowTransitionPatch(StrictModel):
    source: str = Field(alias="from")
    target: str = Field(alias="to")


class ViewQueryConditionsPatch(StrictModel):
    enabled: bool = Field(default=True, validation_alias=AliasChoices("enabled", "queryConditionStatus", "status"))
    exact: bool = Field(default=False, validation_alias=AliasChoices("exact", "queryConditionExact"))
    hide_before_query: bool = Field(default=False, validation_alias=AliasChoices("hide_before_query", "hideBeforeQuery", "hideBeforeQueryCondition"))
    rows: list[list[str | int]] = Field(default_factory=list, validation_alias=AliasChoices("rows", "fields", "queryCondition"))


class ViewAssociatedResourcesPatch(StrictModel):
    visible: bool = Field(default=True, validation_alias=AliasChoices("visible", "enabled", "asosChartVisible"))
    limit_type: str | None = Field(default=None, validation_alias=AliasChoices("limit_type", "limitType"))
    associated_item_ids: list[Any] = Field(
        default_factory=list,
        validation_alias=AliasChoices(
            "associated_item_ids",
            "associatedItemIds",
            "asosChartIdList",
            "items",
        ),
    )


class ViewActionButtonPatch(StrictModel):
    button_id: int | None = Field(default=None, validation_alias=AliasChoices("button_id", "buttonId", "id"))
    client_key: str | None = Field(default=None, validation_alias=AliasChoices("client_key", "clientKey"))
    text: str = Field(validation_alias=AliasChoices("text", "button_text", "buttonText", "name"))
    action: PublicButtonTriggerAction = Field(validation_alias=AliasChoices("action", "trigger_action", "triggerAction"))
    url: str | None = Field(default=None, validation_alias=AliasChoices("url", "link_url", "linkUrl", "trigger_link_url", "triggerLinkUrl"))
    target_app_key: str | None = Field(default=None, validation_alias=AliasChoices("target_app_key", "targetAppKey", "related_app_key", "relatedAppKey"))
    target_app_name: str | None = Field(default=None, validation_alias=AliasChoices("target_app_name", "targetAppName", "related_app_name", "relatedAppName"))
    field_mappings: list[dict[str, Any]] = Field(default_factory=list, validation_alias=AliasChoices("field_mappings", "fieldMappings", "mappings"))
    default_values: dict[str, Any] = Field(default_factory=dict, validation_alias=AliasChoices("default_values", "defaultValues", "defaults"))
    placement: PublicButtonPlacement = Field(default=PublicButtonPlacement.detail, validation_alias=AliasChoices("placement", "position"))
    primary: bool = Field(default=False, validation_alias=AliasChoices("primary", "being_main", "beingMain"))
    visible_when: list[list[ViewFilterRulePatch]] = Field(
        default_factory=list,
        validation_alias=AliasChoices("visible_when", "visibleWhen", "button_limit", "buttonLimit"),
    )
    button_formula: str | None = Field(default=None, validation_alias=AliasChoices("button_formula", "buttonFormula"))
    button_formula_type: int = Field(default=1, validation_alias=AliasChoices("button_formula_type", "buttonFormulaType"))
    print_tpls: list[Any] = Field(default_factory=list, validation_alias=AliasChoices("print_tpls", "printTpls"))
    style_preset: str | None = Field(default=None, validation_alias=AliasChoices("style_preset", "stylePreset"))
    background_color: str | None = Field(default=None, validation_alias=AliasChoices("background_color", "backgroundColor"))
    text_color: str | None = Field(default=None, validation_alias=AliasChoices("text_color", "textColor"))
    button_icon: str | None = Field(default=None, validation_alias=AliasChoices("button_icon", "buttonIcon"))
    external_qrobot_config: dict[str, Any] | None = Field(
        default=None,
        validation_alias=AliasChoices(
            "external_qrobot_config",
            "externalQrobotConfig",
            "externalQRobotConfig",
            "custom_button_external_qrobot_relation_vo",
            "customButtonExternalQRobotRelationVO",
        ),
    )
    trigger_wings_config: dict[str, Any] | None = Field(default=None, validation_alias=AliasChoices("trigger_wings_config", "triggerWingsConfig"))

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        raw_action = payload.get("action", payload.get("trigger_action", payload.get("triggerAction")))
        if isinstance(raw_action, str):
            normalized = raw_action.strip()
            aliases = {
                "add_data": PublicButtonTriggerAction.add_data.value,
                "add-data": PublicButtonTriggerAction.add_data.value,
                "adddata": PublicButtonTriggerAction.add_data.value,
                "addData": PublicButtonTriggerAction.add_data.value,
                "link": PublicButtonTriggerAction.link.value,
                "url": PublicButtonTriggerAction.link.value,
                "qrobot": PublicButtonTriggerAction.qrobot.value,
                "qRobot": PublicButtonTriggerAction.qrobot.value,
                "wings": PublicButtonTriggerAction.wings.value,
            }
            payload["action"] = aliases.get(normalized, aliases.get(normalized.lower(), normalized))
        raw_placement = payload.get("placement", payload.get("position", payload.get("config_type", payload.get("configType"))))
        if isinstance(raw_placement, str):
            normalized = raw_placement.strip().lower()
            if normalized in {"top", "header"}:
                payload["placement"] = PublicButtonPlacement.header.value
            elif normalized in {"detail", "data_detail"}:
                payload["placement"] = PublicButtonPlacement.detail.value
            elif normalized in {"list", "row", "row_action", "inside"}:
                payload["placement"] = PublicButtonPlacement.list.value
        raw_limits = payload.get("visible_when", payload.get("visibleWhen", payload.get("button_limit", payload.get("buttonLimit"))))
        if isinstance(raw_limits, list) and raw_limits and all(isinstance(item, dict) for item in raw_limits):
            payload["visible_when"] = [raw_limits]
        return payload

    @model_validator(mode="after")
    def validate_shape(self) -> "ViewActionButtonPatch":
        if not str(self.text or "").strip():
            raise ValueError("action_buttons[].text is required")
        if self.action == PublicButtonTriggerAction.link and not str(self.url or "").strip():
            raise ValueError("link action_buttons require url")
        if self.action == PublicButtonTriggerAction.add_data and not str(self.target_app_key or "").strip():
            raise ValueError("add_data action_buttons require target_app_key")
        if self.action == PublicButtonTriggerAction.qrobot and self.external_qrobot_config is None:
            raise ValueError("qRobot action_buttons require external_qrobot_config")
        if self.action == PublicButtonTriggerAction.wings and self.trigger_wings_config is None:
            raise ValueError("wings action_buttons require trigger_wings_config")
        return self


class ViewUpsertPatch(StrictModel):
    name: str
    view_key: str | None = Field(default=None, validation_alias=AliasChoices("view_key", "viewKey"))
    type: PublicViewType
    columns: list[str] = Field(default_factory=list)
    group_by: str | None = None
    filters: list[ViewFilterRulePatch] = Field(default_factory=list)
    start_field: str | None = Field(default=None, validation_alias=AliasChoices("start_field", "startField"))
    end_field: str | None = Field(default=None, validation_alias=AliasChoices("end_field", "endField"))
    title_field: str | None = Field(default=None, validation_alias=AliasChoices("title_field", "titleField"))
    buttons: list["ViewButtonBindingPatch"] | None = None
    action_buttons: list[ViewActionButtonPatch] | None = Field(default=None, validation_alias=AliasChoices("action_buttons", "actionButtons"))
    action_buttons_mode: str = Field(default="merge", validation_alias=AliasChoices("action_buttons_mode", "actionButtonsMode", "button_mode", "buttonMode"))
    visibility: VisibilityPatch | None = None
    query_conditions: ViewQueryConditionsPatch | None = Field(default=None, validation_alias=AliasChoices("query_conditions", "queryConditions", "query_condition", "queryCondition"))
    associated_resources: ViewAssociatedResourcesPatch | None = Field(
        default=None,
        validation_alias=AliasChoices(
            "associated_resources",
            "associatedResources",
            "associated_reports",
            "associatedReports",
            "asosChartConfig",
        ),
    )
    partial_update: bool = Field(default=False, exclude=True, validation_alias=AliasChoices("_partial_update", "partial_update", "partialUpdate"))
    preserve_filters: bool = Field(default=False, exclude=True, validation_alias=AliasChoices("_preserve_filters", "preserve_filters", "preserveFilters"))
    preserve_buttons: bool = Field(default=False, exclude=True, validation_alias=AliasChoices("_preserve_buttons", "preserve_buttons", "preserveButtons"))
    preserve_query_conditions: bool = Field(default=False, exclude=True, validation_alias=AliasChoices("_preserve_query_conditions", "preserve_query_conditions", "preserveQueryConditions"))
    preserve_associated_resources: bool = Field(default=False, exclude=True, validation_alias=AliasChoices("_preserve_associated_resources", "preserve_associated_resources", "preserveAssociatedResources"))

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        payload = _normalize_builder_view_key_payload(payload)
        if "fields" in payload and "columns" not in payload:
            payload["columns"] = payload.pop("fields")
        if "column_names" in payload and "columns" not in payload:
            payload["columns"] = payload.pop("column_names")
        if "columnNames" in payload and "columns" not in payload:
            payload["columns"] = payload.pop("columnNames")
        if "filter_rules" in payload and "filters" not in payload:
            payload["filters"] = payload.pop("filter_rules")
        if "filterRules" in payload and "filters" not in payload:
            payload["filters"] = payload.pop("filterRules")
        if "queryConditions" in payload and "query_conditions" not in payload:
            payload["query_conditions"] = payload.pop("queryConditions")
        if "query_condition" in payload and "query_conditions" not in payload:
            payload["query_conditions"] = payload.pop("query_condition")
        if "queryCondition" in payload and "query_conditions" not in payload:
            payload["query_conditions"] = payload.pop("queryCondition")
        if "associatedResources" in payload and "associated_resources" not in payload:
            payload["associated_resources"] = payload.pop("associatedResources")
        if "associated_reports" in payload and "associated_resources" not in payload:
            payload["associated_resources"] = payload.pop("associated_reports")
        if "associatedReports" in payload and "associated_resources" not in payload:
            payload["associated_resources"] = payload.pop("associatedReports")
        if "asosChartConfig" in payload and "associated_resources" not in payload:
            payload["associated_resources"] = payload.pop("asosChartConfig")
        if "actionButtons" in payload and "action_buttons" not in payload:
            payload["action_buttons"] = payload.pop("actionButtons")
        if "actionButtonsMode" in payload and "action_buttons_mode" not in payload:
            payload["action_buttons_mode"] = payload.pop("actionButtonsMode")
        if "buttonMode" in payload and "action_buttons_mode" not in payload:
            payload["action_buttons_mode"] = payload.pop("buttonMode")
        raw_type = payload.get("type")
        if isinstance(raw_type, str):
            normalized = raw_type.strip().lower()
            if normalized == "tableview":
                payload["type"] = "table"
            elif normalized == "cardview":
                payload["type"] = "card"
            elif normalized == "kanban":
                payload["type"] = "board"
            elif normalized == "ganttview":
                payload["type"] = "gantt"
        return payload

    @model_validator(mode="after")
    def validate_shape(self) -> "ViewUpsertPatch":
        if self.type in {PublicViewType.table, PublicViewType.card} and not self.columns:
            raise ValueError("table/card views require columns")
        if self.type == PublicViewType.board and not self.group_by:
            raise ValueError("board view requires group_by")
        if self.type == PublicViewType.gantt and not (self.start_field and self.end_field):
            raise ValueError("gantt view requires start_field and end_field")
        normalized_mode = str(self.action_buttons_mode or "merge").strip().lower()
        if normalized_mode not in {"merge", "replace"}:
            raise ValueError("action_buttons_mode must be merge or replace")
        self.action_buttons_mode = normalized_mode
        return self


class ViewPartialPatch(StrictModel):
    view_key: str | None = Field(default=None, validation_alias=AliasChoices("view_key", "viewKey", "viewgraphKey", "viewGraphKey"))
    name: str | None = Field(default=None, validation_alias=AliasChoices("name", "view_name", "viewName"))
    set: dict[str, Any] = Field(default_factory=dict, validation_alias=AliasChoices("set", "update", "values"))
    unset: list[str] = Field(default_factory=list, validation_alias=AliasChoices("unset", "clear", "remove"))

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        return _normalize_builder_view_key_payload(dict(value))

    @model_validator(mode="after")
    def validate_shape(self) -> "ViewPartialPatch":
        if not str(self.view_key or "").strip() and not str(self.name or "").strip():
            raise ValueError("patch_views[] requires view_key or unique name")
        if not self.set and not self.unset:
            raise ValueError("patch_views[] requires set or unset")
        return self


def public_button_trigger_action_value(value: Any) -> str:
    raw = value.value if isinstance(value, PublicButtonTriggerAction) else str(value or "").strip()
    normalized = raw.lower()
    if raw == PublicButtonTriggerAction.add_data.value or normalized in {"add_data", "add-data", "adddata"}:
        return "add_data"
    if normalized in {PublicButtonTriggerAction.link.value, "url"}:
        return "link"
    if raw == PublicButtonTriggerAction.qrobot.value or normalized == "qrobot":
        return "qRobot"
    if normalized == PublicButtonTriggerAction.wings.value:
        return "wings"
    return raw


def _drop_empty_public_payload_values(payload: dict[str, Any]) -> dict[str, Any]:
    compact: dict[str, Any] = {}
    for key, item in payload.items():
        if item is None or item == "" or item == [] or item == {}:
            continue
        if key == "primary" and item is False:
            continue
        compact[key] = item
    return compact


def public_view_action_button_payload(button: Any, *, compact: bool = False) -> dict[str, Any]:
    if isinstance(button, ViewActionButtonPatch):
        payload = button.model_dump(mode="json")
    elif isinstance(button, dict):
        payload = dict(button)
    else:
        return {}
    raw_action = payload.get("action", payload.get("trigger_action", payload.get("triggerAction")))
    if raw_action is not None:
        payload["action"] = public_button_trigger_action_value(raw_action)
        payload.pop("trigger_action", None)
        payload.pop("triggerAction", None)
    if compact:
        return _drop_empty_public_payload_values(payload)
    return payload


def public_view_upsert_payload(view: ViewUpsertPatch) -> dict[str, Any]:
    payload = view.model_dump(mode="json")
    if view.action_buttons is not None:
        payload["action_buttons"] = [
            public_view_action_button_payload(button, compact=True)
            for button in view.action_buttons
        ]
    return payload


def public_view_partial_payload(patch: ViewPartialPatch) -> dict[str, Any]:
    payload = patch.model_dump(mode="json")
    patch_set = payload.get("set")
    if isinstance(patch_set, dict):
        raw_buttons = patch_set.get("action_buttons")
        if raw_buttons is None:
            raw_buttons = patch_set.get("actionButtons")
        if isinstance(raw_buttons, list):
            patch_set = dict(patch_set)
            patch_set.pop("actionButtons", None)
            patch_set["action_buttons"] = [
                public_view_action_button_payload(button, compact=True)
                for button in raw_buttons
            ]
            payload["set"] = patch_set
    return payload


class CustomButtonJudgeValuePatch(StrictModel):
    id: int | str | None = None
    value: Any | None = None

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        if "opt_id" in payload and "id" not in payload:
            payload["id"] = payload.pop("opt_id")
        if "member_id" in payload and "id" not in payload:
            payload["id"] = payload.pop("member_id")
        return payload


class CustomButtonQuestionRefPatch(StrictModel):
    que_id: int = Field(validation_alias=AliasChoices("que_id", "queId"))
    que_title: str | None = Field(default=None, validation_alias=AliasChoices("que_title", "queTitle"))
    que_type: int | None = Field(default=None, validation_alias=AliasChoices("que_type", "queType"))


class CustomButtonMatchRulePatch(StrictModel):
    que_id: int = Field(validation_alias=AliasChoices("que_id", "queId"))
    que_title: str | None = Field(default=None, validation_alias=AliasChoices("que_title", "queTitle"))
    que_type: int | None = Field(default=None, validation_alias=AliasChoices("que_type", "queType"))
    date_type: int | None = Field(default=None, validation_alias=AliasChoices("date_type", "dateType"))
    judge_type: int | None = Field(default=None, validation_alias=AliasChoices("judge_type", "judgeType"))
    match_type: int | None = Field(default=None, validation_alias=AliasChoices("match_type", "matchType"))
    judge_values: list[str] = Field(default_factory=list, validation_alias=AliasChoices("judge_values", "judgeValues"))
    judge_que_type: int | None = Field(default=None, validation_alias=AliasChoices("judge_que_type", "judgeQueType"))
    judge_que_id: int | None = Field(default=None, validation_alias=AliasChoices("judge_que_id", "judgeQueId"))
    judge_que_detail: CustomButtonQuestionRefPatch | None = Field(
        default=None,
        validation_alias=AliasChoices("judge_que_detail", "judgeQueDetail"),
    )
    judge_value_details: list[CustomButtonJudgeValuePatch] = Field(
        default_factory=list,
        validation_alias=AliasChoices("judge_value_details", "judgeValueDetails"),
    )
    path_value: str | None = Field(default=None, validation_alias=AliasChoices("path_value", "pathValue"))
    table_update_type: int | None = Field(default=None, validation_alias=AliasChoices("table_update_type", "tableUpdateType"))
    multi_value: bool | None = Field(default=None, validation_alias=AliasChoices("multi_value", "multiValue"))
    add_rule: str | None = Field(default=None, validation_alias=AliasChoices("add_rule", "addRule"))
    filter_condition: list[list["CustomButtonMatchRulePatch"]] = Field(
        default_factory=list,
        validation_alias=AliasChoices("filter_condition", "filterCondition"),
    )
    field_id_prefix: str | None = Field(default=None, validation_alias=AliasChoices("field_id_prefix", "fieldIdPrefix"))

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        if "judgeValue" in payload and "judge_values" not in payload and "judgeValues" not in payload:
            payload["judge_values"] = [payload.pop("judgeValue")]
        return payload


class CustomButtonFieldMappingPatch(StrictModel):
    source_field: Any = Field(validation_alias=AliasChoices("source_field", "sourceField", "source"))
    target_field: Any = Field(validation_alias=AliasChoices("target_field", "targetField", "target"))


class FieldMatchMappingPatch(StrictModel):
    target_field: Any = Field(validation_alias=AliasChoices("target_field", "targetField", "target", "field", "field_name", "fieldName", "name"))
    source_field: Any | None = Field(default=None, validation_alias=AliasChoices("source_field", "sourceField", "source"))
    value: Any | None = Field(default=None, validation_alias=AliasChoices("value", "values", "static_value", "staticValue"))
    operator: str = Field(default="eq", validation_alias=AliasChoices("operator", "op", "judge_type", "judgeType"))

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        if "values" in payload and "value" not in payload:
            raw_values = payload.pop("values")
            if isinstance(raw_values, list) and len(raw_values) == 1:
                payload["value"] = raw_values[0]
            else:
                payload["value"] = raw_values
        raw_operator = payload.get("operator", payload.get("op", payload.get("judge_type", payload.get("judgeType"))))
        if isinstance(raw_operator, str):
            payload["operator"] = normalize_public_condition_operator(
                raw_operator,
                extra_aliases={"include_any": "include_any", "includes_any": "include_any", "includeany": "include_any"},
            )
        for alias in ("op", "judge_type", "judgeType"):
            payload.pop(alias, None)
        return payload

    @model_validator(mode="after")
    def validate_shape(self) -> "FieldMatchMappingPatch":
        if str(self.operator or "").strip().lower() in {"is_empty", "not_empty"}:
            self.source_field = None
            self.value = None
            return self
        has_source = self.source_field is not None and str(self.source_field).strip() != ""
        has_value = self.value is not None
        if has_source == has_value:
            raise ValueError("match_mappings[] requires exactly one of source_field or value")
        return self


class CustomButtonAddDataConfigPatch(StrictModel):
    related_app_key: str | None = Field(
        default=None,
        validation_alias=AliasChoices("related_app_key", "relatedAppKey", "target_app_key", "targetAppKey"),
    )
    related_app_name: str | None = Field(default=None, validation_alias=AliasChoices("related_app_name", "relatedAppName"))
    que_relation: list[CustomButtonMatchRulePatch] = Field(
        default_factory=list,
        validation_alias=AliasChoices("que_relation", "queRelation"),
    )
    field_mappings: list[CustomButtonFieldMappingPatch] = Field(
        default_factory=list,
        validation_alias=AliasChoices("field_mappings", "fieldMappings", "mappings"),
    )
    default_values: dict[str, Any] = Field(
        default_factory=dict,
        validation_alias=AliasChoices("default_values", "defaultValues", "defaults"),
    )


class CustomButtonExternalQRobotConfigPatch(StrictModel):
    external_qrobot_config_id: int | None = Field(
        default=None,
        validation_alias=AliasChoices("external_qrobot_config_id", "externalQRobotConfigId"),
    )
    triggered_text: str | None = Field(default=None, validation_alias=AliasChoices("triggered_text", "triggeredText"))


class CustomButtonWingsConfigPatch(StrictModel):
    wings_agent_id: int | None = Field(default=None, validation_alias=AliasChoices("wings_agent_id", "wingsAgentId"))
    wings_agent_name: str | None = Field(default=None, validation_alias=AliasChoices("wings_agent_name", "wingsAgentName"))
    bind_que_id_list: list[int] = Field(default_factory=list, validation_alias=AliasChoices("bind_que_id_list", "bindQueIdList"))
    bind_file_que_id_list: list[int] = Field(
        default_factory=list,
        validation_alias=AliasChoices("bind_file_que_id_list", "bindFileQueIdList"),
    )
    default_prompt: str | None = Field(default=None, validation_alias=AliasChoices("default_prompt", "defaultPrompt"))
    being_auto_send: bool | None = Field(default=None, validation_alias=AliasChoices("being_auto_send", "beingAutoSend"))

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        raw_agent_id = payload.get("wings_agent_id", payload.get("wingsAgentId"))
        if isinstance(raw_agent_id, str) and raw_agent_id.strip().isdigit():
            payload["wings_agent_id"] = int(raw_agent_id.strip())
        return payload


def _is_white_button_color(value: str | None) -> bool:
    normalized = str(value or "").strip().lower().replace(" ", "")
    return normalized in {
        "#fff",
        "#ffffff",
        "white",
        "rgb(255,255,255)",
        "rgba(255,255,255,1)",
    }


class CustomButtonPatch(StrictModel):
    button_text: str = Field(validation_alias=AliasChoices("button_text", "buttonText"))
    background_color: str | None = Field(default=None, validation_alias=AliasChoices("background_color", "backgroundColor"))
    text_color: str | None = Field(default=None, validation_alias=AliasChoices("text_color", "textColor"))
    button_icon: str | None = Field(default=None, validation_alias=AliasChoices("button_icon", "buttonIcon"))
    style_preset: str | None = Field(default=None, validation_alias=AliasChoices("style_preset", "stylePreset"))
    trigger_action: PublicButtonTriggerAction = Field(validation_alias=AliasChoices("trigger_action", "triggerAction"))
    trigger_link_url: str | None = Field(default=None, validation_alias=AliasChoices("trigger_link_url", "triggerLinkUrl"))
    trigger_add_data_config: CustomButtonAddDataConfigPatch | None = Field(
        default=None,
        validation_alias=AliasChoices("trigger_add_data_config", "triggerAddDataConfig"),
    )
    external_qrobot_config: CustomButtonExternalQRobotConfigPatch | None = Field(
        default=None,
        validation_alias=AliasChoices(
            "external_qrobot_config",
            "externalQrobotConfig",
            "custom_button_external_qrobot_relation_vo",
            "customButtonExternalQRobotRelationVO",
        ),
    )
    trigger_wings_config: CustomButtonWingsConfigPatch | None = Field(
        default=None,
        validation_alias=AliasChoices("trigger_wings_config", "triggerWingsConfig"),
    )

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        if "icon_color" in payload or "iconColor" in payload:
            raise ValueError("icon_color is not supported; icon color follows text_color")
        return payload

    @model_validator(mode="after")
    def validate_shape(self) -> "CustomButtonPatch":
        resolved = resolve_button_style(
            style_preset=self.style_preset,
            button_icon=self.button_icon,
            background_color=self.background_color,
            text_color=self.text_color,
            require_complete_style=True,
        )
        self.style_preset = resolved["style_preset"]
        self.button_icon = resolved["button_icon"]
        self.background_color = resolved["background_color"]
        self.text_color = resolved["text_color"]
        if self.trigger_action == PublicButtonTriggerAction.link and not str(self.trigger_link_url or "").strip():
            raise ValueError("link buttons require trigger_link_url")
        if self.trigger_action == PublicButtonTriggerAction.add_data and self.trigger_add_data_config is None:
            raise ValueError("addData buttons require trigger_add_data_config")
        if self.trigger_action == PublicButtonTriggerAction.qrobot and self.external_qrobot_config is None:
            raise ValueError("qRobot buttons require external_qrobot_config")
        if self.trigger_action == PublicButtonTriggerAction.wings and self.trigger_wings_config is None:
            raise ValueError("wings buttons require trigger_wings_config")
        if _is_white_button_color(self.background_color) and _is_white_button_color(self.text_color):
            raise ValueError("background_color and text_color cannot both be white")
        return self


class CustomButtonUpsertPatch(CustomButtonPatch):
    button_id: int | None = Field(default=None, validation_alias=AliasChoices("button_id", "buttonId", "id"))
    client_key: str | None = Field(default=None, validation_alias=AliasChoices("client_key", "clientKey"))


class CustomButtonPartialPatch(StrictModel):
    button_id: int | None = Field(default=None, validation_alias=AliasChoices("button_id", "buttonId", "id"))
    button_text: str | None = Field(default=None, validation_alias=AliasChoices("button_text", "buttonText", "name"))
    client_key: str | None = Field(default=None, validation_alias=AliasChoices("client_key", "clientKey"))
    set: dict[str, Any] = Field(default_factory=dict, validation_alias=AliasChoices("set", "update", "values"))
    unset: list[str] = Field(default_factory=list, validation_alias=AliasChoices("unset", "clear", "remove"))

    @model_validator(mode="after")
    def validate_shape(self) -> "CustomButtonPartialPatch":
        if self.button_id is None and not str(self.button_text or "").strip():
            raise ValueError("patch_buttons[] requires button_id or unique button_text")
        if not self.set and not self.unset:
            raise ValueError("patch_buttons[] requires set or unset")
        return self


class CustomButtonRemovePatch(StrictModel):
    button_id: int | None = Field(default=None, validation_alias=AliasChoices("button_id", "buttonId", "id"))
    button_text: str | None = Field(default=None, validation_alias=AliasChoices("button_text", "buttonText", "name"))

    @model_validator(mode="after")
    def validate_selector(self) -> "CustomButtonRemovePatch":
        if self.button_id is None and not str(self.button_text or "").strip():
            raise ValueError("remove_buttons[] requires button_id or button_text")
        return self


class CustomButtonViewButtonBindingPatch(StrictModel):
    button_ref: Any = Field(validation_alias=AliasChoices("button_ref", "buttonRef", "button_id", "buttonId", "id"))
    placement: PublicButtonPlacement = Field(default=PublicButtonPlacement.detail, validation_alias=AliasChoices("placement", "position"))
    primary: bool = Field(default=False, validation_alias=AliasChoices("primary", "being_main", "beingMain"))
    button_limit: list[list[ViewFilterRulePatch]] = Field(
        default_factory=list,
        validation_alias=AliasChoices("button_limit", "buttonLimit", "visible_when", "visibleWhen"),
    )
    button_formula: str | None = Field(default=None, validation_alias=AliasChoices("button_formula", "buttonFormula"))
    button_formula_type: int = Field(default=1, validation_alias=AliasChoices("button_formula_type", "buttonFormulaType"))
    print_tpls: list[Any] = Field(default_factory=list, validation_alias=AliasChoices("print_tpls", "printTpls"))

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        raw_placement = payload.get("placement", payload.get("position", payload.get("config_type", payload.get("configType"))))
        if isinstance(raw_placement, str):
            normalized = raw_placement.strip().lower()
            if normalized in {"top", "header"}:
                payload["placement"] = PublicButtonPlacement.header.value
            elif normalized in {"detail", "data_detail"}:
                payload["placement"] = PublicButtonPlacement.detail.value
            elif normalized in {"list", "row", "row_action"}:
                payload["placement"] = PublicButtonPlacement.list.value
        raw_limits = payload.get("button_limit", payload.get("buttonLimit", payload.get("visible_when", payload.get("visibleWhen"))))
        if isinstance(raw_limits, list) and raw_limits and all(isinstance(item, dict) for item in raw_limits):
            payload["button_limit"] = [raw_limits]
        return payload


class CustomButtonViewConfigPatch(StrictModel):
    view_key: str = Field(validation_alias=AliasChoices("view_key", "viewKey", "viewgraphKey", "viewGraphKey"))
    mode: str = Field(default="merge", validation_alias=AliasChoices("mode", "apply_mode", "applyMode"))
    buttons: list[CustomButtonViewButtonBindingPatch] = Field(default_factory=list)

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        return _normalize_builder_view_key_payload(dict(value))

    @model_validator(mode="after")
    def validate_mode(self) -> "CustomButtonViewConfigPatch":
        normalized = str(self.mode or "").strip().lower()
        if normalized not in {"merge", "replace"}:
            raise ValueError("view_configs[].mode must be merge or replace")
        self.mode = normalized
        if normalized == "merge" and "buttons" not in getattr(self, "model_fields_set", set()):
            raise ValueError("view_configs[] in merge mode requires buttons; pass buttons: [] or mode=replace to clear existing bindings")
        return self


class CustomButtonsApplyRequest(StrictModel):
    app_key: str
    upsert_buttons: list[CustomButtonUpsertPatch] = Field(default_factory=list)
    patch_buttons: list[CustomButtonPartialPatch] = Field(default_factory=list)
    remove_buttons: list[CustomButtonRemovePatch] = Field(default_factory=list)
    view_configs: list[CustomButtonViewConfigPatch] = Field(default_factory=list)

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        if "buttons" in payload and "upsert_buttons" not in payload:
            payload["upsert_buttons"] = payload.pop("buttons")
        if "upsertButtons" in payload and "upsert_buttons" not in payload:
            payload["upsert_buttons"] = payload.pop("upsertButtons")
        if "patchButtons" in payload and "patch_buttons" not in payload:
            payload["patch_buttons"] = payload.pop("patchButtons")
        if "removeButtons" in payload and "remove_buttons" not in payload:
            payload["remove_buttons"] = payload.pop("removeButtons")
        if "viewConfigs" in payload and "view_configs" not in payload:
            payload["view_configs"] = payload.pop("viewConfigs")
        return payload

    @model_validator(mode="after")
    def validate_shape(self) -> "CustomButtonsApplyRequest":
        if not self.upsert_buttons and not self.patch_buttons and not self.remove_buttons and not self.view_configs:
            raise ValueError("custom button apply requires at least one upsert, patch, remove, or view config operation")
        return self


class AssociatedResourceUpsertPatch(StrictModel):
    client_key: str | None = Field(default=None, validation_alias=AliasChoices("client_key", "clientKey"))
    associated_item_id: int | None = Field(
        default=None,
        validation_alias=AliasChoices("associated_item_id", "associatedItemId", "asosChartId", "id"),
    )
    graph_type: str = Field(validation_alias=AliasChoices("graph_type", "graphType"))
    target_app_key: str = Field(validation_alias=AliasChoices("target_app_key", "targetAppKey", "app_key", "appKey"))
    chart_key: str | None = Field(default=None, validation_alias=AliasChoices("chart_key", "chartKey"))
    view_key: str | None = Field(default=None, validation_alias=AliasChoices("view_key", "viewKey", "viewgraphKey", "viewGraphKey"))
    report_source: str | None = Field(default=None, validation_alias=AliasChoices("report_source", "reportSource"))
    source_type: str | None = Field(default=None, validation_alias=AliasChoices("source_type", "sourceType"))
    match_rules: list[CustomButtonMatchRulePatch] = Field(default_factory=list, validation_alias=AliasChoices("match_rules", "matchRules"))
    match_mappings: list[FieldMatchMappingPatch] = Field(
        default_factory=list,
        validation_alias=AliasChoices("match_mappings", "matchMappings"),
    )

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        payload = _normalize_builder_view_key_payload(payload)
        if "chart_id" in payload and "chart_key" not in payload and "chartKey" not in payload:
            payload["chart_key"] = str(payload.pop("chart_id"))
        raw_graph_type = str(payload.get("graph_type", payload.get("graphType", "")) or "").strip().lower()
        raw_source_type = payload.get("source_type", payload.get("sourceType"))
        has_report_source = "report_source" in payload or "reportSource" in payload
        if isinstance(raw_source_type, str):
            normalized_source = raw_source_type.strip().upper()
            if normalized_source == "BI_QINGFLOW" and not has_report_source:
                payload["report_source"] = "app"
                payload.pop("source_type", None)
                payload.pop("sourceType", None)
            elif normalized_source == "BI_DATASET" and not has_report_source:
                payload["report_source"] = "dataset"
                payload.pop("source_type", None)
                payload.pop("sourceType", None)
            elif normalized_source == "QINGFLOW" and raw_graph_type in {"view", "viewgraph"}:
                payload.pop("source_type", None)
                payload.pop("sourceType", None)
        return payload


class AssociatedResourcePartialPatch(StrictModel):
    associated_item_id: int = Field(validation_alias=AliasChoices("associated_item_id", "associatedItemId", "asosChartId", "id"))
    client_key: str | None = Field(default=None, validation_alias=AliasChoices("client_key", "clientKey"))
    set: dict[str, Any] = Field(default_factory=dict, validation_alias=AliasChoices("set", "update", "values"))
    unset: list[str] = Field(default_factory=list, validation_alias=AliasChoices("unset", "clear", "remove"))

    @model_validator(mode="after")
    def validate_shape(self) -> "AssociatedResourcePartialPatch":
        if self.associated_item_id <= 0:
            raise ValueError("patch_resources[].associated_item_id must be a positive integer")
        if not self.set and not self.unset:
            raise ValueError("patch_resources[] requires set or unset")
        return self


class AssociatedResourceViewConfigPatch(StrictModel):
    view_key: str = Field(validation_alias=AliasChoices("view_key", "viewKey", "viewgraphKey", "viewGraphKey"))
    visible: bool = Field(default=True, validation_alias=AliasChoices("visible", "enabled", "asosChartVisible"))
    limit_type: str | None = Field(default=None, validation_alias=AliasChoices("limit_type", "limitType"))
    associated_item_ids: list[Any] = Field(
        default_factory=list,
        validation_alias=AliasChoices("associated_item_ids", "associatedItemIds", "asosChartIdList"),
    )
    associated_item_refs: list[str] = Field(default_factory=list, validation_alias=AliasChoices("associated_item_refs", "associatedItemRefs", "refs"))

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        return _normalize_builder_view_key_payload(dict(value))


class AssociatedResourcesApplyRequest(StrictModel):
    app_key: str
    upsert_resources: list[AssociatedResourceUpsertPatch] = Field(default_factory=list)
    patch_resources: list[AssociatedResourcePartialPatch] = Field(default_factory=list)
    remove_associated_item_ids: list[Any] = Field(default_factory=list)
    reorder_associated_item_ids: list[Any] = Field(default_factory=list)
    view_configs: list[AssociatedResourceViewConfigPatch] = Field(default_factory=list)

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        default_target_app_key = str(payload.get("app_key", payload.get("appKey", "")) or "").strip()
        if "upsertResources" in payload and "upsert_resources" not in payload:
            payload["upsert_resources"] = payload.pop("upsertResources")
        if "patchResources" in payload and "patch_resources" not in payload:
            payload["patch_resources"] = payload.pop("patchResources")
        if "resources" in payload and "upsert_resources" not in payload:
            payload["upsert_resources"] = payload.pop("resources")
        if "removeAssociatedItemIds" in payload and "remove_associated_item_ids" not in payload:
            payload["remove_associated_item_ids"] = payload.pop("removeAssociatedItemIds")
        if "reorderAssociatedItemIds" in payload and "reorder_associated_item_ids" not in payload:
            payload["reorder_associated_item_ids"] = payload.pop("reorderAssociatedItemIds")
        if "viewConfigs" in payload and "view_configs" not in payload:
            payload["view_configs"] = payload.pop("viewConfigs")
        if default_target_app_key and isinstance(payload.get("upsert_resources"), list):
            normalized_resources = []
            for item in payload["upsert_resources"]:
                if isinstance(item, dict) and not any(
                    str(item.get(key) or "").strip()
                    for key in ("target_app_key", "targetAppKey", "app_key", "appKey")
                ):
                    item = {**item, "target_app_key": default_target_app_key}
                normalized_resources.append(item)
            payload["upsert_resources"] = normalized_resources
        return payload

    @model_validator(mode="after")
    def validate_shape(self) -> "AssociatedResourcesApplyRequest":
        if not self.upsert_resources and not self.patch_resources and not self.remove_associated_item_ids and not self.reorder_associated_item_ids and not self.view_configs:
            raise ValueError("associated resources apply requires at least one upsert, patch, remove, reorder, or view config operation")
        return self


class ViewButtonBindingPatch(StrictModel):
    button_type: PublicViewButtonType = Field(validation_alias=AliasChoices("button_type", "buttonType"))
    config_type: PublicViewButtonConfigType = Field(validation_alias=AliasChoices("config_type", "configType"))
    button_id: int = Field(validation_alias=AliasChoices("button_id", "buttonId", "id"))
    button_text: str | None = Field(default=None, validation_alias=AliasChoices("button_text", "buttonText"))
    button_icon: str | None = Field(default=None, validation_alias=AliasChoices("button_icon", "buttonIcon"))
    style_preset: str | None = Field(default=None, validation_alias=AliasChoices("style_preset", "stylePreset"))
    background_color: str | None = Field(default=None, validation_alias=AliasChoices("background_color", "backgroundColor"))
    text_color: str | None = Field(default=None, validation_alias=AliasChoices("text_color", "textColor"))
    trigger_action: str | None = Field(default=None, validation_alias=AliasChoices("trigger_action", "triggerAction"))
    print_tpls: list[Any] = Field(default_factory=list, validation_alias=AliasChoices("print_tpls", "printTpls"))
    being_main: bool = Field(default=False, validation_alias=AliasChoices("being_main", "beingMain"))
    button_limit: list[list[ViewFilterRulePatch]] = Field(
        default_factory=list,
        validation_alias=AliasChoices("button_limit", "buttonLimit"),
    )
    button_formula: str | None = Field(default=None, validation_alias=AliasChoices("button_formula", "buttonFormula"))
    button_formula_type: int = Field(default=1, validation_alias=AliasChoices("button_formula_type", "buttonFormulaType"))

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        if "icon_color" in payload or "iconColor" in payload:
            raise ValueError("icon_color is not supported; icon color follows text_color")
        raw_button_type = payload.get("button_type", payload.get("buttonType"))
        if isinstance(raw_button_type, str):
            normalized_type = raw_button_type.strip().lower()
            if normalized_type == "system":
                payload["button_type"] = "SYSTEM"
            elif normalized_type == "custom":
                payload["button_type"] = "CUSTOM"
        raw_config_type = payload.get("config_type", payload.get("configType"))
        if isinstance(raw_config_type, str):
            normalized_config = raw_config_type.strip().lower()
            if normalized_config in {"top", "header"}:
                payload["config_type"] = "TOP"
            elif normalized_config == "detail":
                payload["config_type"] = "DETAIL"
            elif normalized_config in {"inside", "list", "row", "row_action"}:
                payload["config_type"] = "INSIDE"
        raw_limits = payload.get("button_limit", payload.get("buttonLimit"))
        if isinstance(raw_limits, list) and raw_limits and all(isinstance(item, dict) for item in raw_limits):
            payload["button_limit"] = [raw_limits]
        return payload

    @model_validator(mode="after")
    def validate_shape(self) -> "ViewButtonBindingPatch":
        require_complete_style = self.button_type == PublicViewButtonType.system or any(
            str(value or "").strip()
            for value in (self.style_preset, self.button_icon, self.background_color, self.text_color)
        )
        resolved = resolve_button_style(
            style_preset=self.style_preset,
            button_icon=self.button_icon,
            background_color=self.background_color,
            text_color=self.text_color,
            require_complete_style=require_complete_style,
        )
        self.style_preset = resolved["style_preset"]
        self.button_icon = resolved["button_icon"]
        self.background_color = resolved["background_color"]
        self.text_color = resolved["text_color"]
        if self.button_type == PublicViewButtonType.system:
            missing = [
                field_name
                for field_name, value in (
                    ("button_icon", self.button_icon),
                    ("background_color", self.background_color),
                    ("text_color", self.text_color),
                    ("trigger_action", self.trigger_action),
                )
                if not str(value or "").strip()
            ]
            if missing:
                raise ValueError(f"system button bindings require {', '.join(missing)}")
        if _is_white_button_color(self.background_color) and _is_white_button_color(self.text_color):
            raise ValueError("background_color and text_color cannot both be white")
        return self


class ChartFilterRulePatch(StrictModel):
    field_name: str = Field(validation_alias=AliasChoices("field_name", "fieldName", "field", "name"))
    operator: ViewFilterOperator = Field(default=ViewFilterOperator.eq, validation_alias=AliasChoices("operator", "op"))
    values: list[Any] = Field(default_factory=list)

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        return _normalize_public_condition_payload(value)

    @model_validator(mode="after")
    def validate_shape(self) -> "ChartFilterRulePatch":
        if self.operator in {ViewFilterOperator.is_empty, ViewFilterOperator.not_empty}:
            self.values = []
            return self
        if not self.values:
            raise ValueError("chart filter rule requires values")
        return self


class ChartMetricPatch(StrictModel):
    op: str = "count"
    field_name: str | None = Field(default=None, validation_alias=AliasChoices("field", "field_name", "fieldName", "name"))
    alias: str | None = None

    @model_validator(mode="before")
    @classmethod
    def normalize_metric(cls, value: Any) -> Any:
        if isinstance(value, str):
            raw = value.strip()
            match = re.fullmatch(r"([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*(.*?)\s*\)", raw)
            if match:
                op = match.group(1).strip().lower()
                field = match.group(2).strip()
                payload: dict[str, Any] = {"op": op}
                if field and field != "*":
                    payload["field_name"] = field
                return payload
            if raw:
                return {"op": "sum", "field_name": raw}
            return {"op": "count"}
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        if "operation" in payload and "op" not in payload:
            payload["op"] = payload.pop("operation")
        if "agg" in payload and "op" not in payload:
            payload["op"] = payload.pop("agg")
        if "aggregate" in payload and "op" not in payload:
            payload["op"] = payload.pop("aggregate")
        if "aggregation" in payload and "op" not in payload:
            payload["op"] = payload.pop("aggregation")
        return payload

    @model_validator(mode="after")
    def validate_metric(self) -> "ChartMetricPatch":
        normalized_op = str(self.op or "count").strip().lower()
        op_aliases = {
            "average": "avg",
            "mean": "avg",
            "total": "sum",
            "cnt": "count",
            "count_all": "count",
        }
        self.op = op_aliases.get(normalized_op, normalized_op)
        if self.field_name is not None:
            field_name = str(self.field_name).strip()
            self.field_name = field_name or None
        if self.alias is not None:
            alias = str(self.alias).strip()
            self.alias = alias or None
        supported = {"count", "sum", "avg", "max", "min"}
        if self.op not in supported:
            raise ValueError(f"chart metric op must be one of {sorted(supported)}")
        if self.op != "count" and not self.field_name:
            raise ValueError(f"chart metric op '{self.op}' requires field")
        return self


class ChartUpsertPatch(StrictModel):
    chart_id: str | None = None
    name: str
    chart_type: PublicChartType
    dimension_field_ids: list[str] = Field(default_factory=list)
    indicator_field_ids: list[str] = Field(default_factory=list)
    filters: list[ChartFilterRulePatch] = Field(default_factory=list)
    group_by: list[str] = Field(default_factory=list)
    rows: list[str] = Field(default_factory=list)
    columns: list[str] = Field(default_factory=list)
    metric: ChartMetricPatch | None = None
    metrics: list[ChartMetricPatch] = Field(default_factory=list)
    x_metric: ChartMetricPatch | None = None
    y_metric: ChartMetricPatch | None = None
    left_metric: ChartMetricPatch | None = None
    right_metric: ChartMetricPatch | None = None
    value_metric: ChartMetricPatch | None = None
    target_metric: ChartMetricPatch | None = None
    question_config: list[dict[str, Any]] = Field(default_factory=list)
    user_config: list[dict[str, Any]] = Field(default_factory=list)
    config: dict[str, Any] = Field(default_factory=dict)
    visibility: VisibilityPatch | None = None

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        if "id" in payload and "chart_id" not in payload:
            payload["chart_id"] = payload.pop("id")
        if "type" in payload and "chart_type" not in payload:
            payload["chart_type"] = payload.pop("type")
        if "dimension_fields" in payload and "dimension_field_ids" not in payload:
            payload["dimension_field_ids"] = payload.pop("dimension_fields")
        if "dimensions" in payload and "dimension_field_ids" not in payload and "group_by" not in payload:
            payload["group_by"] = payload.pop("dimensions")
        if "groupBy" in payload and "group_by" not in payload:
            payload["group_by"] = payload.pop("groupBy")
        if "where" in payload and "filters" not in payload:
            payload["filters"] = payload.pop("where")
        if "filter_rules" in payload and "filters" not in payload:
            payload["filters"] = payload.pop("filter_rules")
        if "filterRules" in payload and "filters" not in payload:
            payload["filters"] = payload.pop("filterRules")
        if "indicator_fields" in payload and "indicator_field_ids" not in payload:
            payload["indicator_field_ids"] = payload.pop("indicator_fields")
        if "metric_field_ids" in payload and "indicator_field_ids" not in payload:
            payload["indicator_field_ids"] = payload.pop("metric_field_ids")
        metric_slots: list[Any] = []
        generic_metric_keys = ("metric", "metrics")
        axis_metric_keys = (
            "x_metric",
            "xMetric",
            "y_metric",
            "yMetric",
            "left_metric",
            "leftMetric",
            "right_metric",
            "rightMetric",
            "value_metric",
            "valueMetric",
            "target_metric",
            "targetMetric",
        )

        def has_metric_value(key: str) -> bool:
            entry = payload.get(key)
            return entry is not None and entry != "" and entry != []

        if any(has_metric_value(key) for key in generic_metric_keys) and any(has_metric_value(key) for key in axis_metric_keys):
            raise ValueError(
                "chart metric input is ambiguous: use either metric/metrics or axis-specific "
                "x_metric/y_metric, left_metric/right_metric, value_metric/target_metric, not both"
            )
        if "metric" in payload and "metrics" not in payload:
            payload["metrics"] = [payload["metric"]]
        for key in ("x_metric", "xMetric", "left_metric", "leftMetric", "value_metric", "valueMetric"):
            if key in payload:
                slot_value = payload.get(key)
                if slot_value is not None:
                    metric_slots.append(slot_value)
                canonical = {
                    "xMetric": "x_metric",
                    "leftMetric": "left_metric",
                    "valueMetric": "value_metric",
                }.get(key)
                if canonical and canonical not in payload:
                    payload[canonical] = payload.pop(key)
        for key in ("y_metric", "yMetric", "right_metric", "rightMetric", "target_metric", "targetMetric"):
            if key in payload:
                slot_value = payload.get(key)
                if slot_value is not None:
                    metric_slots.append(slot_value)
                canonical = {
                    "yMetric": "y_metric",
                    "rightMetric": "right_metric",
                    "targetMetric": "target_metric",
                }.get(key)
                if canonical and canonical not in payload:
                    payload[canonical] = payload.pop(key)
        if metric_slots and "metrics" not in payload:
            payload["metrics"] = metric_slots
        raw_type = payload.get("chart_type")
        if isinstance(raw_type, str):
            normalized = raw_type.strip().lower()
            aliases = {
                "targetchart": PublicChartType.target.value,
                "indicatorchart": PublicChartType.indicator.value,
                "summarychart": PublicChartType.summary.value,
                "piechart": PublicChartType.pie.value,
                "barchart": PublicChartType.bar.value,
                "columnchart": PublicChartType.columnar.value,
                "columnarchart": PublicChartType.columnar.value,
                "linechart": PublicChartType.line.value,
                "tablechart": PublicChartType.table.value,
                "detailchart": PublicChartType.detail.value,
                "percent_stacked_column": PublicChartType.pct_stack_col.value,
                "percent_stacked_bar": PublicChartType.pct_stack_bar.value,
                "percent_stacked_area": PublicChartType.pct_stack_area.value,
            }
            if normalized in aliases:
                payload["chart_type"] = aliases[normalized]
        if isinstance(payload.get("chart_id"), int):
            payload["chart_id"] = str(payload["chart_id"])
        if isinstance(payload.get("dimension_field_ids"), list):
            payload["dimension_field_ids"] = [str(item) for item in payload["dimension_field_ids"] if item is not None and str(item).strip()]
        if isinstance(payload.get("indicator_field_ids"), list):
            payload["indicator_field_ids"] = [str(item) for item in payload["indicator_field_ids"] if item is not None and str(item).strip()]
        for key in ("group_by", "rows", "columns"):
            if isinstance(payload.get(key), str):
                payload[key] = [payload[key]]
            if isinstance(payload.get(key), list):
                payload[key] = [str(item) for item in payload[key] if item is not None and str(item).strip()]
        return payload

    @model_validator(mode="after")
    def apply_semantic_chart_fields(self) -> "ChartUpsertPatch":
        if self.group_by and not self.dimension_field_ids:
            self.dimension_field_ids = list(self.group_by)
        if self.rows and not self.dimension_field_ids:
            self.dimension_field_ids = list(self.rows)
        semantic_metrics: list[ChartMetricPatch] = []
        if self.metrics:
            semantic_metrics.extend(self.metrics)
        for metric in (self.x_metric, self.y_metric, self.left_metric, self.right_metric, self.value_metric, self.target_metric):
            if metric is not None and metric not in semantic_metrics:
                semantic_metrics.append(metric)
        if semantic_metrics and not self.metrics:
            self.metrics = semantic_metrics
        if self.metrics and not self.metric:
            self.metric = self.metrics[0]
        return self


class ChartPartialPatch(StrictModel):
    chart_id: str | None = None
    name: str | None = None
    set: dict[str, Any] = Field(default_factory=dict, validation_alias=AliasChoices("set", "update", "values"))
    unset: list[str] = Field(default_factory=list, validation_alias=AliasChoices("unset", "clear", "remove"))

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        if "id" in payload and "chart_id" not in payload:
            payload["chart_id"] = str(payload.pop("id"))
        if "chart_name" in payload and "name" not in payload:
            payload["name"] = payload.pop("chart_name")
        if "chartName" in payload and "name" not in payload:
            payload["name"] = payload.pop("chartName")
        return payload

    @model_validator(mode="after")
    def validate_shape(self) -> "ChartPartialPatch":
        if not str(self.chart_id or "").strip() and not str(self.name or "").strip():
            raise ValueError("patch_charts[] requires chart_id or unique name")
        if not self.set and not self.unset:
            raise ValueError("patch_charts[] requires set or unset")
        return self


class ChartApplyRequest(StrictModel):
    app_key: str
    upsert_charts: list[ChartUpsertPatch] = Field(default_factory=list)
    patch_charts: list[ChartPartialPatch] = Field(default_factory=list)
    remove_chart_ids: list[str] = Field(default_factory=list)
    reorder_chart_ids: list[str] = Field(default_factory=list)

    @model_validator(mode="before")
    @classmethod
    def normalize_ids(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        if "upsertCharts" in payload and "upsert_charts" not in payload:
            payload["upsert_charts"] = payload.pop("upsertCharts")
        if "patchCharts" in payload and "patch_charts" not in payload:
            payload["patch_charts"] = payload.pop("patchCharts")
        if "removeChartIds" in payload and "remove_chart_ids" not in payload:
            payload["remove_chart_ids"] = payload.pop("removeChartIds")
        if "reorderChartIds" in payload and "reorder_chart_ids" not in payload:
            payload["reorder_chart_ids"] = payload.pop("reorderChartIds")
        for key in ("remove_chart_ids", "reorder_chart_ids"):
            raw = payload.get(key)
            if isinstance(raw, list):
                payload[key] = [str(item) for item in raw if item is not None and str(item).strip()]
        return payload

    @model_validator(mode="after")
    def validate_shape(self) -> "ChartApplyRequest":
        if not self.upsert_charts and not self.patch_charts and not self.remove_chart_ids and not self.reorder_chart_ids:
            raise ValueError("chart apply requires at least one upsert, patch, remove, or reorder operation")
        return self


class PortalInlineChartPatch(ChartUpsertPatch):
    app_key: str = Field(validation_alias=AliasChoices("app_key", "appKey"))

    @model_validator(mode="before")
    @classmethod
    def normalize_inline_aliases(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        if "chart_name" in payload and "name" not in payload:
            payload["name"] = payload.pop("chart_name")
        if "chartName" in payload and "name" not in payload:
            payload["name"] = payload.pop("chartName")
        return payload


class PortalComponentPositionPatch(StrictModel):
    pc_x: int = Field(default=0, validation_alias=AliasChoices("pc_x", "pcX", "x"))
    pc_y: int = Field(default=0, validation_alias=AliasChoices("pc_y", "pcY", "y"))
    pc_w: int = Field(default=12, validation_alias=AliasChoices("pc_w", "pcW", "w"))
    pc_h: int = Field(default=8, validation_alias=AliasChoices("pc_h", "pcH", "h"))
    mobile_x: int = Field(default=0, validation_alias=AliasChoices("mobile_x", "mobileX"))
    mobile_y: int = Field(default=0, validation_alias=AliasChoices("mobile_y", "mobileY"))
    mobile_w: int = Field(default=6, validation_alias=AliasChoices("mobile_w", "mobileW"))
    mobile_h: int = Field(default=8, validation_alias=AliasChoices("mobile_h", "mobileH"))
    mobile_provided: bool = Field(default=False, exclude=True)

    @model_validator(mode="before")
    @classmethod
    def normalize_nested_layout(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        pc = payload.pop("pc", None)
        mobile = payload.pop("mobile", None)
        mobile_keys = {"mobile_x", "mobileX", "mobile_y", "mobileY", "mobile_w", "mobileW", "mobile_h", "mobileH"}
        mobile_provided = isinstance(mobile, dict) or any(key in payload for key in mobile_keys)
        if isinstance(pc, dict):
            if "pc_x" not in payload and "x" in pc:
                payload["pc_x"] = pc.get("x")
            if "pc_y" not in payload and "y" in pc:
                payload["pc_y"] = pc.get("y")
            if "pc_w" not in payload and "cols" in pc:
                payload["pc_w"] = pc.get("cols")
            if "pc_h" not in payload and "rows" in pc:
                payload["pc_h"] = pc.get("rows")
        if isinstance(mobile, dict):
            if "mobile_x" not in payload and "x" in mobile:
                payload["mobile_x"] = mobile.get("x")
            if "mobile_y" not in payload and "y" in mobile:
                payload["mobile_y"] = mobile.get("y")
            if "mobile_w" not in payload and "cols" in mobile:
                payload["mobile_w"] = mobile.get("cols")
            if "mobile_h" not in payload and "rows" in mobile:
                payload["mobile_h"] = mobile.get("rows")
        payload["mobile_provided"] = mobile_provided
        return payload


class PortalChartRefPatch(StrictModel):
    app_key: str | None = None
    chart_id: str | None = None
    chart_name: str | None = None

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        if "chartId" in payload and "chart_id" not in payload:
            payload["chart_id"] = payload.pop("chartId")
        if "chart_key" in payload and "chart_id" not in payload:
            payload["chart_id"] = payload.pop("chart_key")
        if "chartKey" in payload and "chart_id" not in payload:
            payload["chart_id"] = payload.pop("chartKey")
        if "chartName" in payload and "chart_name" not in payload:
            payload["chart_name"] = payload.pop("chartName")
        if "name" in payload and "chart_name" not in payload:
            payload["chart_name"] = payload.pop("name")
        if "appKey" in payload and "app_key" not in payload:
            payload["app_key"] = payload.pop("appKey")
        return payload

    @model_validator(mode="after")
    def validate_target(self) -> "PortalChartRefPatch":
        if not (self.chart_id or self.chart_name):
            raise ValueError("chart_ref requires chart_id or chart_name")
        if self.chart_name and not self.chart_id and not self.app_key:
            raise ValueError("chart_ref.app_key is required when resolving by chart_name")
        return self


class PortalViewRefPatch(StrictModel):
    app_key: str
    view_key: str | None = None
    view_name: str | None = None

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        return _normalize_builder_view_key_payload(dict(value))

    @model_validator(mode="after")
    def validate_target(self) -> "PortalViewRefPatch":
        if not (self.view_key or self.view_name):
            raise ValueError("view_ref requires view_key or view_name")
        return self


class PortalSectionPatch(StrictModel):
    title: str
    source_type: str = Field(validation_alias=AliasChoices("source_type", "sourceType"))
    role: str | None = Field(default=None, validation_alias=AliasChoices("role", "zone", "section_role", "sectionRole"))
    position: PortalComponentPositionPatch | None = None
    dash_style_config: dict[str, Any] | None = Field(default=None, validation_alias=AliasChoices("dash_style_config", "dashStyleConfigBO"))
    config: dict[str, Any] = Field(default_factory=dict)
    chart_ref: PortalChartRefPatch | None = None
    chart: PortalInlineChartPatch | None = Field(default=None, validation_alias=AliasChoices("chart", "inline_chart", "inlineChart", "create_chart", "createChart"))
    view_ref: PortalViewRefPatch | None = None
    text: str | None = None
    url: str | None = None

    @model_validator(mode="before")
    @classmethod
    def normalize_aliases(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        raw_type = payload.get("source_type", payload.get("sourceType"))
        if isinstance(raw_type, str):
            payload["source_type"] = raw_type.strip().lower()
        raw_role = payload.get("role", payload.get("zone", payload.get("section_role", payload.get("sectionRole"))))
        if isinstance(raw_role, str):
            payload["role"] = raw_role.strip().lower()
        if "chartRef" in payload and "chart_ref" not in payload:
            payload["chart_ref"] = payload.pop("chartRef")
        if "inlineChart" in payload and "chart" not in payload:
            payload["chart"] = payload.pop("inlineChart")
        if "inline_chart" in payload and "chart" not in payload:
            payload["chart"] = payload.pop("inline_chart")
        if "createChart" in payload and "chart" not in payload:
            payload["chart"] = payload.pop("createChart")
        if "create_chart" in payload and "chart" not in payload:
            payload["chart"] = payload.pop("create_chart")
        raw_chart = payload.get("chart")
        if isinstance(raw_chart, dict):
            chart_payload = dict(raw_chart)
            has_ref_id = any(key in chart_payload for key in ("chart_id", "chartId", "chart_key", "chartKey"))
            has_chart_type = any(key in chart_payload for key in ("chart_type", "chartType"))
            has_create_config = has_chart_type or any(
                key in chart_payload
                for key in ("metric", "metrics", "group_by", "groupBy", "where", "filters", "dimensions")
            )
            has_ref_name = any(key in chart_payload for key in ("chart_name", "chartName", "name"))
            if has_ref_id or (has_ref_name and not has_create_config):
                payload["chart_ref"] = chart_payload
                payload.pop("chart", None)
        if "viewRef" in payload and "view_ref" not in payload:
            payload["view_ref"] = payload.pop("viewRef")
        if "dashStyleConfigBO" in payload and "dash_style_config" not in payload:
            payload["dash_style_config"] = payload.pop("dashStyleConfigBO")
        return payload

    @model_validator(mode="after")
    def validate_shape(self) -> "PortalSectionPatch":
        supported = {"chart", "view", "grid", "filter", "text", "link"}
        if self.source_type not in supported:
            raise ValueError(f"unsupported portal source_type '{self.source_type}'")
        if self.source_type == "chart" and self.chart_ref is None and self.chart is None:
            raise ValueError("chart section requires chart_ref or chart")
        if self.source_type == "view" and self.view_ref is None:
            raise ValueError("view section requires view_ref")
        if self.source_type == "text" and self.text is None:
            raise ValueError("text section requires text")
        if self.source_type == "link" and self.url is None:
            raise ValueError("link section requires url")
        return self


class PortalApplyRequest(StrictModel):
    dash_key: str | None = None
    dash_name: str | None = None
    package_tag_id: int | None = Field(default=None, validation_alias=AliasChoices("package_tag_id", "packageTagId", "package_id", "packageId"))
    publish: bool = True
    sections: list[PortalSectionPatch] = Field(default_factory=list)
    layout_preset: str | None = Field(default=None, validation_alias=AliasChoices("layout_preset", "layoutPreset"))
    visibility: VisibilityPatch | None = None
    auth: dict[str, Any] | None = None
    icon: str | None = None
    color: str | None = None
    hide_copyright: bool | None = Field(default=None, validation_alias=AliasChoices("hide_copyright", "hideCopyright"))
    dash_global_config: dict[str, Any] | None = Field(default=None, validation_alias=AliasChoices("dash_global_config", "dashGlobalConfig"))
    config: dict[str, Any] = Field(default_factory=dict)

    @model_validator(mode="before")
    @classmethod
    def normalize_compat_payload(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        if "dash_name" not in payload and "dashName" not in payload and "name" in payload:
            payload["dash_name"] = payload.pop("name")
        if "sections" not in payload and "pages" in payload:
            pages = payload.pop("pages")
            if not isinstance(pages, list):
                raise ValueError("portal pages must be a list")
            if len(pages) != 1:
                raise ValueError("portal_apply currently supports a single page; pass one page or flatten components into sections")
            page = pages[0]
            if not isinstance(page, dict):
                raise ValueError("portal pages[0] must be an object")
            components = page.get("components")
            if not isinstance(components, list) or not components:
                raise ValueError("portal pages[0].components must be a non-empty list")
            payload["sections"] = components
        if "theme" in payload:
            payload.pop("theme")
        if "type" in payload:
            payload.pop("type")
        return payload

    @model_validator(mode="after")
    def validate_shape(self) -> "PortalApplyRequest":
        if not self.dash_key and not self.package_tag_id:
            raise ValueError("package_tag_id is required when dash_key is empty")
        if not self.dash_key and not self.dash_name:
            raise ValueError("dash_name is required when creating a portal")
        if not self.dash_key and not self.sections:
            raise ValueError("portal apply requires a non-empty sections list when creating a portal")
        if self.visibility is not None and self.auth is not None:
            raise ValueError("visibility and auth cannot be provided together")
        if self.layout_preset is not None and self.layout_preset not in {"auto", "dashboard_2col", "dashboard_3col"}:
            raise ValueError("layout_preset must be one of: auto, dashboard_2col, dashboard_3col")
        return self


FieldPatch.model_rebuild()
FieldMutation.model_rebuild()
FieldUpdatePatch.model_rebuild()


class AppGetResponse(StrictModel):
    app_key: str
    app_name: str | None = None
    name: str | None = None
    title: str | None = None
    app_icon: str | None = None
    icon_config: dict[str, Any] = Field(default_factory=dict)
    visibility: dict[str, Any] = Field(default_factory=dict)
    tag_ids: list[int] = Field(default_factory=list)
    publish_status: int | None = None
    field_count: int = 0
    layout_section_count: int = 0
    view_count: int = 0
    chart_count: int = 0
    associated_resource_count: int = 0
    custom_button_count: int = 0
    workflow_enabled: bool = False
    counts: dict[str, int] = Field(default_factory=dict)
    views: list[dict[str, Any]] = Field(default_factory=list)
    charts: list[dict[str, Any]] = Field(default_factory=list)
    associated_resources: list[dict[str, Any]] = Field(default_factory=list)
    custom_buttons: list[dict[str, Any]] = Field(default_factory=list)
    verification_hints: list[str] = Field(default_factory=list)
    editability: dict[str, bool | None] = Field(default_factory=dict)
    form_settings: dict[str, Any] = Field(default_factory=dict)


class AppGetFieldsResponse(StrictModel):
    app_key: str
    fields: list[dict[str, Any]] = Field(default_factory=list)
    field_count: int = 0
    chart_fields: list[dict[str, Any]] = Field(default_factory=list)
    chart_field_count: int = 0
    form_settings: dict[str, Any] = Field(default_factory=dict)


class AppGetLayoutResponse(StrictModel):
    app_key: str
    sections: list[dict[str, Any]] = Field(default_factory=list)
    unplaced_fields: list[str] = Field(default_factory=list)
    layout_mode_detected: str = "empty"


class AppGetViewsResponse(StrictModel):
    app_key: str
    views: list[dict[str, Any]] = Field(default_factory=list)


class AppGetFlowResponse(StrictModel):
    app_key: str
    enabled: bool = False
    nodes: list[dict[str, Any]] = Field(default_factory=list)
    transitions: list[dict[str, Any]] = Field(default_factory=list)


class AppGetChartsResponse(StrictModel):
    app_key: str
    charts: list[dict[str, Any]] = Field(default_factory=list)
    chart_count: int = 0


AppReadSummaryResponse = AppGetResponse
AppFieldsReadResponse = AppGetFieldsResponse
AppLayoutReadResponse = AppGetLayoutResponse
AppViewsReadResponse = AppGetViewsResponse
AppFlowReadResponse = AppGetFlowResponse
AppChartsReadResponse = AppGetChartsResponse


class PortalListResponse(StrictModel):
    items: list[dict[str, Any]] = Field(default_factory=list)
    total: int = 0


class PortalReadSummaryResponse(StrictModel):
    dash_key: str
    being_draft: bool = True
    dash_name: str | None = None
    package_tag_ids: list[int] = Field(default_factory=list)
    dash_icon: str | None = None
    icon_config: dict[str, Any] = Field(default_factory=dict)
    hide_copyright: bool | None = None
    config_keys: list[str] = Field(default_factory=list)
    dash_global_config_keys: list[str] = Field(default_factory=list)
    section_count: int = 0
    sections: list[dict[str, Any]] = Field(default_factory=list)


class PortalGetResponse(StrictModel):
    dash_key: str
    being_draft: bool = True
    dash_name: str | None = None
    package_tag_ids: list[int] = Field(default_factory=list)
    dash_icon: str | None = None
    icon_config: dict[str, Any] = Field(default_factory=dict)
    hide_copyright: bool | None = None
    visibility: dict[str, Any] = Field(default_factory=dict)
    auth: dict[str, Any] = Field(default_factory=dict)
    config: dict[str, Any] = Field(default_factory=dict)
    dash_global_config: dict[str, Any] = Field(default_factory=dict)
    component_count: int = 0
    components: list[dict[str, Any]] = Field(default_factory=list)


class ViewGetResponse(StrictModel):
    view_key: str
    base_info: dict[str, Any] = Field(default_factory=dict)
    visibility: dict[str, Any] = Field(default_factory=dict)
    config: dict[str, Any] = Field(default_factory=dict)
    questions: list[dict[str, Any]] = Field(default_factory=list)
    associations: list[dict[str, Any]] = Field(default_factory=list)
    buttons_config: dict[str, Any] = Field(default_factory=dict)
    associated_resources_config: dict[str, Any] = Field(default_factory=dict)


class ChartGetResponse(StrictModel):
    chart_id: str
    base: dict[str, Any] = Field(default_factory=dict)
    visibility: dict[str, Any] = Field(default_factory=dict)
    filters: list[list[dict[str, Any]]] = Field(default_factory=list)
    group_by: list[str] = Field(default_factory=list)
    metrics: list[dict[str, Any]] = Field(default_factory=list)
    config: dict[str, Any] = Field(default_factory=dict)


class SchemaPlanRequest(StrictModel):
    app_key: str = ""
    package_tag_id: int | None = None
    app_name: str = Field(default="", validation_alias=AliasChoices("app_name", "app_title", "title"))
    icon: str | None = None
    color: str | None = None
    visibility: VisibilityPatch | None = None
    add_fields: list[FieldPatch] = Field(default_factory=list)
    update_fields: list[FieldUpdatePatch] = Field(default_factory=list)
    remove_fields: list[FieldRemovePatch] = Field(default_factory=list)


class LayoutPlanRequest(StrictModel):
    app_key: str
    mode: LayoutApplyMode = LayoutApplyMode.merge
    sections: list[LayoutSectionPatch] = Field(default_factory=list)
    preset: LayoutPreset | None = None

    @model_validator(mode="before")
    @classmethod
    def normalize_mode_alias(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        if str(payload.get("mode") or "").strip().lower() == "overwrite":
            payload["mode"] = "replace"
        return payload


class FlowPlanRequest(StrictModel):
    app_key: str
    mode: str = "replace"
    nodes: list[FlowNodePatch] = Field(default_factory=list)
    transitions: list[FlowTransitionPatch] = Field(default_factory=list)
    preset: FlowPreset | None = None

    @model_validator(mode="before")
    @classmethod
    def normalize_mode_alias(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        payload = dict(value)
        if str(payload.get("mode") or "").strip().lower() == "overwrite":
            payload["mode"] = "replace"
        raw_preset = payload.get("preset")
        if raw_preset is None and isinstance(payload.get("base_preset"), str):
            raw_preset = payload["base_preset"]
            payload["preset"] = raw_preset
        if isinstance(raw_preset, str):
            normalized_preset = raw_preset.strip().lower()
            preset_aliases = {
                "default_approval": FlowPreset.basic_approval.value,
                "approval": FlowPreset.basic_approval.value,
                "basic approval": FlowPreset.basic_approval.value,
                "default_fill_then_approve": FlowPreset.basic_fill_then_approve.value,
                "default-fill-then-approve": FlowPreset.basic_fill_then_approve.value,
                "fill_then_approve": FlowPreset.basic_fill_then_approve.value,
            }
            if normalized_preset in preset_aliases:
                payload["preset"] = preset_aliases[normalized_preset]
        return payload


class ViewsPlanRequest(StrictModel):
    app_key: str
    upsert_views: list[ViewUpsertPatch] = Field(default_factory=list)
    patch_views: list[ViewPartialPatch] = Field(default_factory=list)
    remove_views: list[str] = Field(default_factory=list)
    preset: ViewsPreset | None = None


class OperationResultEnvelope(StrictModel):
    status: str
    error_code: str | None = None
    recoverable: bool = False
    message: str
    normalized_args: dict[str, Any] = Field(default_factory=dict)
    missing_fields: list[str] = Field(default_factory=list)
    allowed_values: dict[str, Any] = Field(default_factory=dict)
    details: dict[str, Any] = Field(default_factory=dict)
    request_id: str | None = None
    suggested_next_call: dict[str, Any] | None = None
    noop: bool = False
    verification: dict[str, Any] = Field(default_factory=dict)


def _normalize_field_payload(value: Any) -> Any:
    if not isinstance(value, dict):
        return value
    payload = dict(value)
    if "data_title" in payload and "as_data_title" not in payload and "asDataTitle" not in payload:
        payload["as_data_title"] = payload.pop("data_title")
    if "dataTitle" in payload and "as_data_title" not in payload and "asDataTitle" not in payload:
        payload["as_data_title"] = payload.pop("dataTitle")
    if "data_cover" in payload and "as_data_cover" not in payload and "asDataCover" not in payload:
        payload["as_data_cover"] = payload.pop("data_cover")
    if "dataCover" in payload and "as_data_cover" not in payload and "asDataCover" not in payload:
        payload["as_data_cover"] = payload.pop("dataCover")
    if "fields" in payload and "subfields" not in payload:
        payload["subfields"] = payload.pop("fields")
    if "options" in payload:
        payload["options"] = _normalize_field_options(payload.get("options"))
    raw_type = payload.get("type")
    if isinstance(raw_type, int):
        normalized_from_id = FIELD_TYPE_ID_ALIASES.get(raw_type)
        if normalized_from_id is not None:
            payload["type"] = normalized_from_id.value
    if isinstance(raw_type, str):
        normalized = FIELD_TYPE_ALIASES.get(raw_type.strip().lower())
        if normalized is not None:
            payload["type"] = normalized.value
    normalized_relation_mode = _normalize_public_relation_mode(
        payload.get("relation_mode", payload.get("relationMode", payload.get("selection_mode", payload.get("selectionMode"))))
    )
    if normalized_relation_mode is None:
        for alias_key in ("optional_data_num", "optionalDataNum", "multiple", "allow_multiple"):
            if alias_key in payload:
                normalized_relation_mode = _normalize_public_relation_mode(payload.get(alias_key))
                break
    if normalized_relation_mode is not None:
        payload["relation_mode"] = normalized_relation_mode
    for alias_key in (
        "relationMode",
        "selection_mode",
        "selectionMode",
        "optional_data_num",
        "optionalDataNum",
        "multiple",
        "allow_multiple",
    ):
        payload.pop(alias_key, None)
    return payload


def _normalize_field_options(value: Any) -> Any:
    if not isinstance(value, list):
        return value
    normalized: list[str] = []
    for item in value:
        if isinstance(item, str):
            normalized.append(item)
            continue
        if isinstance(item, (int, float, bool)):
            normalized.append(str(item))
            continue
        if isinstance(item, dict):
            label = (
                item.get("label")
                or item.get("name")
                or item.get("title")
                or item.get("value")
                or item.get("text")
                or item.get("optValue")
                or item.get("optName")
            )
            if label is not None:
                normalized.append(str(label))
                continue
        normalized.append(str(item))
    return normalized


def _slugify_title(title: str) -> str:
    normalized = "".join(ch.lower() if ch.isalnum() else "_" for ch in str(title or ""))
    collapsed = "_".join(part for part in normalized.split("_") if part)
    return collapsed or "section"


def _normalize_public_relation_mode(value: Any) -> str | None:
    if value is None:
        return None
    if isinstance(value, bool):
        return PublicRelationMode.multiple.value if value else PublicRelationMode.single.value
    if isinstance(value, int):
        if value == 0:
            return PublicRelationMode.multiple.value
        if value == 1:
            return PublicRelationMode.single.value
        return None
    if isinstance(value, str):
        normalized = value.strip().lower()
        aliases = {
            "single": PublicRelationMode.single.value,
            "single_select": PublicRelationMode.single.value,
            "single-select": PublicRelationMode.single.value,
            "one": PublicRelationMode.single.value,
            "1": PublicRelationMode.single.value,
            "multiple": PublicRelationMode.multiple.value,
            "multi": PublicRelationMode.multiple.value,
            "multi_select": PublicRelationMode.multiple.value,
            "multi-select": PublicRelationMode.multiple.value,
            "many": PublicRelationMode.multiple.value,
            "0": PublicRelationMode.multiple.value,
        }
        return aliases.get(normalized, normalized or None)
    return None


def _normalize_public_department_scope_mode(value: Any) -> str | None:
    if value is None:
        return None
    if isinstance(value, int):
        if value == 1:
            return PublicDepartmentScopeMode.all.value
        if value == 2:
            return PublicDepartmentScopeMode.custom.value
        return None
    if isinstance(value, str):
        normalized = value.strip().lower()
        aliases = {
            "all": PublicDepartmentScopeMode.all.value,
            "workspace_all": PublicDepartmentScopeMode.all.value,
            "workspace-all": PublicDepartmentScopeMode.all.value,
            "default": PublicDepartmentScopeMode.all.value,
            "default_all": PublicDepartmentScopeMode.all.value,
            "default-all": PublicDepartmentScopeMode.all.value,
            "1": PublicDepartmentScopeMode.all.value,
            "custom": PublicDepartmentScopeMode.custom.value,
            "explicit": PublicDepartmentScopeMode.custom.value,
            "selected": PublicDepartmentScopeMode.custom.value,
            "2": PublicDepartmentScopeMode.custom.value,
        }
        return aliases.get(normalized, normalized or None)
    return None


CustomButtonMatchRulePatch.model_rebuild()
CustomButtonFieldMappingPatch.model_rebuild()
CustomButtonAddDataConfigPatch.model_rebuild()
CustomButtonViewButtonBindingPatch.model_rebuild()
CustomButtonViewConfigPatch.model_rebuild()
CodeBlockAliasPathPatch.model_rebuild()
ViewButtonBindingPatch.model_rebuild()
ViewUpsertPatch.model_rebuild()
