import re
import uuid
from datetime import date, datetime
from typing import Literal

from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator

from app.schemas.workflow import FormValidationRule

TenantStatus = Literal["active", "inactive", "suspended"]
AccountStatus = Literal["active", "inactive"]
RoleScope = Literal["internal", "portal"]

# Deliberately a small, fixed subset of MasterFieldType (app/schemas/master.py)
# — no reference/formula/file/user, since none of that infra (other Masters,
# uploads, a logged-in user to pick from) is available to an unauthenticated
# public registration form.
RegistrationFieldType = Literal["text", "number", "boolean", "date", "single_select"]

# Which Form-field validation rules (app/schemas/workflow.py::
# FormValidationRule) a registration field of each type may carry. No
# "equals_field" — the registration form has no cross-field rules.
_TEXT_RULES = {"min_length", "max_length", "pattern", "email", "url", "phone"}
REGISTRATION_RULES_BY_TYPE: dict[str, set[str]] = {
    "text": _TEXT_RULES,
    "number": {"min_value", "max_value"},
    "date": {"min_date", "max_date"},
    "boolean": set(),
    "single_select": set(),
}


def _check_rule_types(label: str, rules: list[FormValidationRule], allowed: set[str]) -> None:
    for rule in rules:
        if rule.type not in allowed:
            raise ValueError(f"'{label}': the '{rule.type}' rule doesn't apply to this kind of field")


class RegistrationFieldDef(BaseModel):
    key: str  # storage key in User.extra_fields — stable, not shown to the registrant
    label: str  # what the registrant sees
    type: RegistrationFieldType
    required: bool = False
    options: list[str] | None = None  # single_select only
    # Shown instead of "This field is required." when blank.
    required_message: str | None = None
    # Checked when the field has a value — same rules/messages as a Form
    # field's (Form Designer's Validation section), limited per type by
    # REGISTRATION_RULES_BY_TYPE. Enforced in Register.tsx and again in
    # POST /auth/register.
    validation_rules: list[FormValidationRule] = []

    @model_validator(mode="after")
    def _check_options(self) -> "RegistrationFieldDef":
        if self.type == "single_select" and not self.options:
            raise ValueError("single_select fields need at least one option")
        if self.options is not None and self.type != "single_select":
            raise ValueError("options is only meaningful for single_select fields")
        _check_rule_types(self.label or self.key, self.validation_rules, REGISTRATION_RULES_BY_TYPE[self.type])
        return self


RegistrationVerification = Literal["none", "email", "sms", "both"]


class BuiltinRegistrationField(BaseModel):
    """Settings for one of the Register page's built-in text fields
    (organization_name, name, phone). `required`/`hidden` are honoured for
    phone only — an organization and a person's name are always needed to
    create the vendor account."""

    required: bool = True
    hidden: bool = False
    required_message: str | None = None
    validation_rules: list[FormValidationRule] = []


class RegistrationSettings(BaseModel):
    """Tenant Admin's Public Portal registration settings, beside the extra
    fields list (Users page → "Registration settings"; PATCH /tenants/me).
    {} = all defaults, which reproduce the original form: every built-in
    field required, no rules, no verification."""

    organization_name: BuiltinRegistrationField = BuiltinRegistrationField()
    name: BuiltinRegistrationField = BuiltinRegistrationField()
    phone: BuiltinRegistrationField = BuiltinRegistrationField()
    # Verification every self-registered account must complete — not part
    # of the registration form itself: "email" emails a link + code right
    # after registering; "sms" texts a code at first sign-in; "both" does
    # the email first, then the SMS. See app/crud/user_verification.py.
    verification: RegistrationVerification = "none"

    @model_validator(mode="after")
    def _check(self) -> "RegistrationSettings":
        for key in ("organization_name", "name", "phone"):
            field: BuiltinRegistrationField = getattr(self, key)
            _check_rule_types(key, field.validation_rules, _TEXT_RULES)
        if not self.organization_name.required or self.organization_name.hidden or not self.name.required or self.name.hidden:
            raise ValueError("organization name and name are always required on the registration form")
        if self.phone.hidden:
            self.phone.required = False
        if self.verification in ("sms", "both") and (self.phone.hidden or not self.phone.required):
            raise ValueError("SMS verification needs the phone number to be a required registration field")
        return self


PortalMenuPosition = Literal["left", "right", "top"]
PortalMenuItemStyle = Literal["classic", "pill", "bar", "underline", "filled"]
PortalMenuDensity = Literal["comfortable", "compact"]
PortalMenuTooltips = Literal["off", "collapsed", "always"]

_HEX_COLOR = r"^#[0-9a-fA-F]{6}$"


# ---- Session data after login ------------------------------------------

# The signed-in user's own attributes a session value can read or match on;
# any other name is looked up in the user's registration answers
# (User.extra_fields).
LOGIN_SESSION_USER_FIELDS = ("id", "email", "name", "phone", "vendor_id")
_SESSION_KEY_RE = r"^[A-Za-z_][A-Za-z0-9_]*$"


class LoginSessionValue(BaseModel):
    """One value computed at every sign-in and stored in the browser's
    session data, readable afterwards as "{{session.<key>}}" (dropdown
    option filters, Page filters, Form/Page events, task context). See
    app/crud/login_session.py.

    source "user": the signed-in user's own `user_field`.
    source "datasource": the first record of a Master (or a Form's
    submissions) whose `match_field` equals `match_on`, storing its
    `value_field`. `match_on` names a user field, or "session.<key>" of a
    value defined EARLIER in the list (so lookups can chain, e.g. office ->
    region). "id" as match_field/value_field means the record's own id."""

    key: str = Field(pattern=_SESSION_KEY_RE, max_length=60)
    source: Literal["user", "datasource"] = "datasource"
    user_field: str | None = None
    source_type: Literal["master", "form"] = "master"
    source_id: uuid.UUID | None = None
    match_field: str | None = None
    match_on: str = "id"
    value_field: str | None = None

    @model_validator(mode="after")
    def _check_source(self) -> "LoginSessionValue":
        if self.source == "user" and not self.user_field:
            raise ValueError(f"session value '{self.key}': pick which user field to store")
        if self.source == "datasource" and not (self.source_id and self.match_field and self.value_field and self.match_on):
            raise ValueError(f"session value '{self.key}': pick the datasource, its match field, what to match on and the field to store")
        return self


class LoginSessionSettings(BaseModel):
    """Tenant Admin config (PATCH /tenants/me/login-session): what to put in
    a user's session data right after every successful sign-in."""

    values: list[LoginSessionValue] = []
    # Optionally run a published Task after the values above; every context
    # key it writes joins the session data too (same rule as a Page's
    # on_load task). It starts with current_user.* and the values above.
    task_id: uuid.UUID | None = None

    @model_validator(mode="after")
    def _check_keys(self) -> "LoginSessionSettings":
        seen: set[str] = set()
        for v in self.values:
            if v.key in seen:
                raise ValueError(f"session key '{v.key}' is defined twice")
            if v.source == "datasource" and v.match_on.startswith("session.") and v.match_on[len("session."):] not in seen:
                raise ValueError(f"session value '{v.key}' matches on '{v.match_on}', which isn't defined above it")
            seen.add(v.key)
        return self


class LoginSessionPreview(BaseModel):
    session_data: dict


class PortalMenuSettings(BaseModel):
    """How the Public Portal's navigation menu looks and behaves (Menus screen
    -> "Portal appearance"; PATCH /tenants/me/portal-menu). Every field has a
    default that reproduces the original look, so an empty stored object
    ({}) means "unchanged". A color left null falls back to the portal
    theme's own color for that slot."""

    # where the menu sits: a left or right sidebar, or a bar across the top
    position: PortalMenuPosition = "left"
    # how an item is drawn: classic = rounded highlight, pill = fully rounded,
    # bar = accent bar on the inner edge, underline = bottom rule (best on
    # top), filled = solid active color
    item_style: PortalMenuItemStyle = "classic"
    density: PortalMenuDensity = "comfortable"
    width: int = Field(256, ge=180, le=360)  # sidebar width in px (left/right only)
    show_icons: bool = True
    show_group_labels: bool = True

    background_color: str | None = Field(None, pattern=_HEX_COLOR)
    text_color: str | None = Field(None, pattern=_HEX_COLOR)
    hover_background: str | None = Field(None, pattern=_HEX_COLOR)
    active_background: str | None = Field(None, pattern=_HEX_COLOR)
    active_text: str | None = Field(None, pattern=_HEX_COLOR)
    group_label_color: str | None = Field(None, pattern=_HEX_COLOR)
    border_color: str | None = Field(None, pattern=_HEX_COLOR)

    # tooltip on an item: never, only while the sidebar is collapsed to icons,
    # or always
    tooltips: PortalMenuTooltips = "collapsed"

    search_enabled: bool = False
    search_placeholder: str | None = Field(None, max_length=60)

    # The portal's logo, shown in the menu's header instead of the default
    # icon: either an uploaded image (logo_file_id, a tenant upload — see
    # routers/tenants.py for the type/size checks) or an external image URL.
    # An uploaded file wins when both are set.
    logo_file_id: uuid.UUID | None = None
    logo_url: str | None = Field(None, max_length=500, pattern=r"^https?://\S+$")
    logo_height: int = Field(32, ge=20, le=64)  # px
    logo_show_title: bool = True  # keep the portal title beside the logo

    # a sidebar can fold down to an icon rail; groups can fold their items
    collapsible: bool = True
    default_collapsed: bool = False
    collapsible_groups: bool = True
    groups_default_collapsed: bool = False


ThemeFont = Literal["inter", "system", "serif", "rounded", "mono"]
ThemeRadius = Literal["none", "small", "medium", "large"]


_HEX = r"^#[0-9a-fA-F]{6}$"


class TenantTheme(BaseModel):
    """The tenant's look in the Studio and the Portal (frontend
    lib/theme.ts turns it into CSS variables). Every field is optional —
    unset keeps FlowFabric's default."""

    # The brand color — buttons, links, selection. The full shade ramp is
    # derived from it at fixed lightness steps, so text on it stays readable.
    brand_color: str | None = Field(default=None, pattern=r"^#[0-9a-fA-F]{6}$")
    font: ThemeFont | None = None
    radius: ThemeRadius | None = None
    # The Portal's controls (frontend lib/portalTheme.ts) — what every page,
    # form and list there looks like unless a control's own Style settings
    # say otherwise. Unset = FlowFabric's default.
    background_color: str | None = Field(default=None, pattern=_HEX)
    text_color: str | None = Field(default=None, pattern=_HEX)
    heading_color: str | None = Field(default=None, pattern=_HEX)
    label_color: str | None = Field(default=None, pattern=_HEX)
    input_background: str | None = Field(default=None, pattern=_HEX)
    input_border_color: str | None = Field(default=None, pattern=_HEX)
    input_text_color: str | None = Field(default=None, pattern=_HEX)
    table_header_background: str | None = Field(default=None, pattern=_HEX)
    table_header_text_color: str | None = Field(default=None, pattern=_HEX)
    card_background: str | None = Field(default=None, pattern=_HEX)
    card_border_color: str | None = Field(default=None, pattern=_HEX)


class TenantLicense(BaseModel):
    """What a tenant is licensed for (app/crud/licensing.py) — set by the
    Super Admin only. Every field optional: unset = no limit."""

    # Active internal (Studio) accounts.
    max_users: int | None = Field(default=None, ge=1)
    # Active Public Portal accounts.
    max_portal_users: int | None = Field(default=None, ge=0)
    # Valid from the start of starts_on through the end of ends_on (UTC).
    starts_on: date | None = None
    ends_on: date | None = None

    @model_validator(mode="after")
    def _check_dates(self) -> "TenantLicense":
        if self.starts_on and self.ends_on and self.ends_on < self.starts_on:
            raise ValueError("The license end date is before its start date")
        return self


LicenseState = Literal["unlicensed", "unlimited", "active", "not_started", "expired"]


class LicenseUsage(BaseModel):
    users: int
    portal_users: int


class TenantLicenseStatus(BaseModel):
    """A tenant's license with what it uses of it right now."""

    license: TenantLicense
    usage: LicenseUsage
    # "unlicensed": none yet. "unlimited": licensed, no dates. "active": within its dates.
    state: LicenseState
    # Days left through ends_on, counting today; None without an end date.
    days_left: int | None = None


# A license file's state, for the Super Admin's list: not applied yet,
# applied, revoked, or never applied before a newer one was.
LicenseFileStatus = Literal["waiting", "applied", "revoked", "superseded"]


class LicenseFileRead(BaseModel):
    """A license file the Super Admin issued (app/crud/license_files.py)."""

    model_config = ConfigDict(from_attributes=True)
    id: uuid.UUID
    license: TenantLicense
    issued_at: datetime
    applied_at: datetime | None = None
    applied_by_name: str | None = None
    revoked_at: datetime | None = None
    status: LicenseFileStatus = "waiting"


class IssuedLicenseFile(BaseModel):
    file: LicenseFileRead
    # What to save and send to the tenant.
    filename: str
    content: str


class LicenseFileInput(BaseModel):
    """A license file's text, as the tenant uploads it."""

    content: str = Field(min_length=1, max_length=20_000)


class LicenseFileCheck(BaseModel):
    """What a license file would change, before it's applied."""

    tenant_name: str
    license: TenantLicense
    current: TenantLicense
    issued_at: datetime


LicenseRequestStatus = Literal["open", "fulfilled", "declined", "cancelled"]


class LicenseRequestCreate(BaseModel):
    """A tenant administrator asking for a renewal or more seats. Every
    term optional: blank = whatever the Super Admin decides."""

    max_users: int | None = Field(default=None, ge=1)
    max_portal_users: int | None = Field(default=None, ge=0)
    ends_on: date | None = None
    message: str = Field(default="", max_length=2000)

    @model_validator(mode="after")
    def _says_something(self) -> "LicenseRequestCreate":
        self.message = self.message.strip()
        if self.max_users is None and self.max_portal_users is None and self.ends_on is None and not self.message:
            raise ValueError("Say what you need — seats, an end date, or a message")
        return self


class LicenseRequestRead(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: uuid.UUID
    tenant_id: uuid.UUID
    tenant_name: str | None = None
    max_users: int | None
    max_portal_users: int | None
    ends_on: date | None
    message: str
    status: LicenseRequestStatus
    created_at: datetime
    requested_by_name: str | None = None
    resolved_at: datetime | None = None
    response: str | None = None
    license_file_id: uuid.UUID | None = None
    # The license file answering it — and whether it's been applied.
    license: TenantLicense | None = None
    file_applied: bool = False


class LicenseRequestDecline(BaseModel):
    response: str = Field(min_length=1, max_length=2000)


class TenantRead(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: uuid.UUID
    name: str
    status: TenantStatus
    license: TenantLicense = TenantLicense()
    # Whether it has a license at all — without one it can't be used.
    licensed: bool = False
    registration_field_schema: list[RegistrationFieldDef]
    registration_settings: RegistrationSettings = RegistrationSettings()
    portal_menu_settings: PortalMenuSettings = PortalMenuSettings()
    login_session_settings: LoginSessionSettings = LoginSessionSettings()
    theme: TenantTheme = TenantTheme()
    # The time zone portal users see dates in (app/core/timezones.py).
    timezone: str = "Asia/Calcutta"
    created_at: datetime
    updated_at: datetime


class RegistrationFieldsUpdate(BaseModel):
    """Tenant Admin self-service config for the Public Portal registration
    form (PATCH /tenants/me) — deliberately its own narrow schema rather than
    reusing TenantUpdate, so a tenant admin can never rename/suspend their own
    tenant through this route (that stays Super-Admin-only, see
    routers/platform_admin.py)."""

    registration_field_schema: list[RegistrationFieldDef]
    # Omitted = leave the stored settings unchanged.
    registration_settings: RegistrationSettings | None = None


class TenantRegistrationFieldsRead(BaseModel):
    """Public — what Register.tsx fetches (GET /auth/register/fields) before
    rendering the form, so it never needs to guess the field set."""

    tenant_name: str
    fields: list[RegistrationFieldDef]
    settings: RegistrationSettings = RegistrationSettings()


# Super Admin only (app/routers/platform_admin.py) — a regular tenant
# member never creates/updates a Tenant, so these didn't exist before.
class TenantCreate(BaseModel):
    name: str
    license: TenantLicense | None = None
    timezone: str | None = None

    @field_validator("timezone")
    @classmethod
    def _known_zone(cls, v: str | None) -> str | None:
        from app.core.timezones import check_timezone

        return None if v is None else check_timezone(v)


class TenantUpdate(BaseModel):
    name: str | None = None
    status: TenantStatus | None = None
    timezone: str | None = None

    @field_validator("timezone")
    @classmethod
    def _known_zone(cls, v: str | None) -> str | None:
        from app.core.timezones import check_timezone

        return None if v is None else check_timezone(v)


class VendorCreate(BaseModel):
    organization_name: str
    status: AccountStatus = "active"


class VendorRegisterRequest(BaseModel):
    """Public Portal self-registration (FR-PUB-*) — unauthenticated, so the
    caller supplies the workspace id itself, same as /auth/login."""

    tenant_id: uuid.UUID
    organization_name: str
    name: str
    email: EmailStr
    password: str = Field(min_length=1, max_length=256)  # the workspace's password policy decides
    # Stored on User.phone (where SMS verification and the Users page read
    # it). Whether it's required is the tenant's RegistrationSettings.phone
    # — enforced in routers/auth.py, which can see those settings.
    phone: str | None = None
    # Answers to the tenant's registration_field_schema, keyed by each field
    # def's `key` — validated against that schema in routers/auth.py before
    # being stored on the new User's extra_fields (unvalidated here since
    # this schema has no DB access to look the tenant's schema up).
    extra_fields: dict[str, object] = {}

    @model_validator(mode="after")
    def _check_phone(self) -> "VendorRegisterRequest":
        self.phone = _clean_phone(self.phone)
        return self


class VendorRegisterResponse(BaseModel):
    detail: str
    # The tenant's RegistrationSettings.verification the new account must
    # complete ("none" when not required), and — for email/both — whether
    # the verification email actually went out.
    verification: RegistrationVerification = "none"
    verification_email_sent: bool | None = None


class VendorUpdate(BaseModel):
    organization_name: str | None = None
    status: AccountStatus | None = None


class VendorRead(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: uuid.UUID
    tenant_id: uuid.UUID
    organization_name: str
    status: AccountStatus
    created_at: datetime
    updated_at: datetime


VerificationMethod = Literal["email", "sms", "both"]
# Loose on purpose (country formats vary): optional +, then digits/spaces/
# dashes/parentheses/dots, with at least 7 digits overall.
_PHONE_RE = re.compile(r"\+?[0-9\s\-().]{7,20}")


def _clean_phone(phone: str | None) -> str | None:
    if phone is None or not phone.strip():
        return None
    phone = phone.strip()
    if not _PHONE_RE.fullmatch(phone) or sum(c.isdigit() for c in phone) < 7:
        raise ValueError("phone must be a valid phone number, e.g. +91 98470 12345")
    return phone


class UserCreate(BaseModel):
    name: str
    email: EmailStr
    password: str = Field(min_length=1, max_length=256)  # the workspace's password policy decides
    vendor_id: uuid.UUID | None = None
    external_auth_id: str | None = None
    status: AccountStatus = "active"
    phone: str | None = None
    # Set to require the new user to verify before using the account — see
    # app/crud/user_verification.py. "sms" needs `phone`.
    verification_method: VerificationMethod | None = None

    @model_validator(mode="after")
    def _check_verification(self) -> "UserCreate":
        self.phone = _clean_phone(self.phone)
        if self.verification_method in ("sms", "both") and not self.phone:
            raise ValueError("SMS verification needs a phone number")
        return self


class SetPasswordInput(BaseModel):
    password: str = Field(min_length=1, max_length=256)  # the workspace's password policy decides


class UserUpdate(BaseModel):
    name: str | None = None
    email: EmailStr | None = None
    status: AccountStatus | None = None
    phone: str | None = None

    @model_validator(mode="after")
    def _check_phone(self) -> "UserUpdate":
        if "phone" in self.model_fields_set:
            self.phone = _clean_phone(self.phone)
        return self


class UserRead(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: uuid.UUID
    tenant_id: uuid.UUID
    vendor_id: uuid.UUID | None
    name: str
    email: str
    external_auth_id: str | None
    status: AccountStatus
    last_login_at: datetime | None
    extra_fields: dict[str, object]
    phone: str | None = None
    verification_method: VerificationMethod | None = None
    verified_at: datetime | None = None  # when every required channel was done
    email_verified_at: datetime | None = None
    phone_verified_at: datetime | None = None
    verification_pending: bool = False
    # Two-factor sign-in is on since then (None: off); locked after failed attempts until then.
    mfa_enabled_at: datetime | None = None
    locked_until: datetime | None = None
    created_at: datetime
    updated_at: datetime


class UserCreateRead(UserRead):
    # Email verification only: what happened when the link + code were sent
    # ("sent" or why it couldn't be). The account exists either way — a
    # failed send can be retried from the Users page (resend-verification).
    verification_delivery: str | None = None


class UserPickerRead(BaseModel):
    """Narrow projection of a User for the "user" field-type picker (Master/
    Form fields that link to a system account) — deliberately omits email,
    status, vendor_id, etc. so filling in a form never leaks that PII."""

    id: uuid.UUID
    name: str


def _check_default_page(scope: RoleScope, action_matrix: dict[str, list[str]], default_page_id: uuid.UUID | None) -> None:
    """default_page_id (see Role model) only makes sense for a portal role,
    and only pointing at a Page that role's own action_matrix already grants
    — otherwise a holder would land somewhere require_portal_item then
    rejects. Compares as strings since action_matrix ids are already
    lower-cased strings (see resolve_portal_grants)."""
    default_dashboards = action_matrix.get("default_dashboard") or []
    if default_page_id is None and not default_dashboards:
        return
    if scope != "portal":
        raise ValueError("default_page_id / default_dashboard are only meaningful for portal-scope roles")
    if default_page_id is not None and default_dashboards:
        raise ValueError("A role can land on a Page or a Dashboard, not both — clear one of them")
    if default_page_id is not None:
        granted_pages = {str(i).lower() for i in action_matrix.get("pages", [])}
        if str(default_page_id).lower() not in granted_pages:
            raise ValueError("default_page_id must be one of this role's own granted pages (action_matrix['pages'])")
    if default_dashboards:
        # A Dashboard landing is stored as action_matrix["default_dashboard"] = [id].
        if len(default_dashboards) != 1:
            raise ValueError("default_dashboard takes exactly one dashboard id")
        granted_dashboards = {str(i).lower() for i in action_matrix.get("dashboard", [])}
        if str(default_dashboards[0]).lower() not in granted_dashboards:
            raise ValueError("default_dashboard must be one of this role's own granted dashboards (action_matrix['dashboard'])")


def _check_role_menu_settings(value: dict | None) -> dict | None:
    """A portal role's own menu settings: only PortalMenuSettings' fields,
    each a valid value for it (checked over the defaults), kept as given —
    the role stores just what differs from the tenant's. {} = None."""
    if not value:
        return None
    unknown = set(value) - set(PortalMenuSettings.model_fields)
    if unknown:
        raise ValueError(f"Unknown menu settings: {', '.join(sorted(unknown))}")
    checked = PortalMenuSettings.model_validate(value).model_dump(mode="json")
    return {k: checked[k] for k in value}


# What a portal role can do with each granted record, beyond opening it
# ("view", always included). Enforced by the API (app/core/permissions.py::
# require_portal_action / check_portal_action). An item with no entry in
# portal_permissions gets what roles had before these settings existed —
# see default_portal_actions.
PORTAL_ITEM_ACTIONS: dict[str, tuple[str, ...]] = {
    "masters": ("view", "create", "edit", "delete", "import", "export"),
    "forms": ("view", "submit", "edit", "delete", "export"),
    "report_templates": ("view", "export"),
}


def default_portal_actions(module: str, action_matrix: dict) -> set[str]:
    """A granted item's actions when the role has no detailed setting for
    it: everything roles could do before — never import (portal users
    couldn't); a datasource's or form's rows exported only with the Report
    Builder ("reports"), the only place they could be; a saved report's
    export, as before, always."""
    actions = set(PORTAL_ITEM_ACTIONS.get(module, ("view",))) - {"import"}
    if module in ("masters", "forms") and not (action_matrix or {}).get("reports"):
        actions.discard("export")
    return actions


# portal_permissions["hidden_buttons"][page_id] — keys of that page's
# buttons the role doesn't see: a block id (button, link, Pay Now), or
# "<block id>:row:<row action id>" / "<block id>:add|edit|delete|export|
# import" for a data list's, "<block id>:download|print" for a File
# Viewer's. Interface only — what the button would do is still checked by
# the API.
PORTAL_HIDDEN_BUTTONS = "hidden_buttons"
_BUTTON_KEY = re.compile(r"^[A-Za-z0-9_.-]{1,100}(:(add|edit|delete|export|import|download|print|row:[A-Za-z0-9_.-]{1,100}))?$")


def _uuid_key(value: str) -> str:
    try:
        return str(uuid.UUID(str(value)))
    except ValueError:
        raise ValueError(f"Not a record id: {value}") from None


def check_portal_permissions(value: dict | None) -> dict | None:
    """A portal role's detailed permissions, normalized: per module, record
    id -> its sorted allowed actions ("view" always among them); per page
    id -> its hidden button keys. Unknown modules or actions are errors.
    Nothing set = None."""
    if not value:
        return None
    out: dict[str, dict[str, list[str]]] = {}
    for module, items in value.items():
        if not isinstance(items, dict):
            raise ValueError(f"portal_permissions['{module}'] must map record ids to a list")
        if module == PORTAL_HIDDEN_BUTTONS:
            pages = {}
            for page_id, keys in items.items():
                bad = [k for k in keys if not isinstance(k, str) or not _BUTTON_KEY.match(k)]
                if bad:
                    raise ValueError(f"Not a button key: {bad[0]}")
                if keys:
                    pages[_uuid_key(page_id)] = sorted(set(keys))
            if pages:
                out[module] = pages
            continue
        allowed = PORTAL_ITEM_ACTIONS.get(module)
        if allowed is None:
            raise ValueError(f"No detailed permissions for '{module}' — only {', '.join(sorted(PORTAL_ITEM_ACTIONS))} and {PORTAL_HIDDEN_BUTTONS}")
        records = {}
        for record_id, actions in items.items():
            unknown = set(actions) - set(allowed)
            if unknown:
                raise ValueError(f"Unknown {module} action: {', '.join(sorted(unknown))}")
            records[_uuid_key(record_id)] = [a for a in allowed if a in {*actions, "view"}]
        if records:
            out[module] = records
    return out or None


class RoleCreate(BaseModel):
    name: str
    scope: RoleScope = "internal"
    action_matrix: dict[str, list[str]] = {}
    default_page_id: uuid.UUID | None = None
    # Portal roles only — see Role.theme / Role.menu_settings.
    theme: TenantTheme | None = None
    menu_settings: dict | None = None
    portal_permissions: dict | None = None

    @model_validator(mode="after")
    def _check_default_page(self) -> "RoleCreate":
        self.menu_settings = _check_role_menu_settings(self.menu_settings)
        self.portal_permissions = check_portal_permissions(self.portal_permissions)
        if self.portal_permissions is not None and self.scope != "portal":
            raise ValueError("Only portal roles have detailed portal permissions")
        if self.menu_settings is not None and self.scope != "portal":
            raise ValueError("Only portal roles have their own menu settings")
        if self.theme is not None and self.scope != "portal":
            raise ValueError("Only portal roles have their own theme")
        _check_default_page(self.scope, self.action_matrix, self.default_page_id)
        return self


class RoleUpdate(BaseModel):
    # scope is intentionally not updatable here — changing it after creation
    # would silently move a role between "assignable to internal users" and
    # "assignable to portal users". Delete and recreate under the right
    # scope instead.
    name: str | None = None
    action_matrix: dict[str, list[str]] | None = None
    default_page_id: uuid.UUID | None = None
    # Portal roles only (checked in routers/roles.py) — see Role.theme /
    # Role.menu_settings.
    theme: TenantTheme | None = None
    menu_settings: dict | None = None
    # Portal roles only — see Role.portal_permissions; {} clears them.
    portal_permissions: dict | None = None

    @model_validator(mode="after")
    def _check_default_page(self) -> "RoleUpdate":
        self.menu_settings = _check_role_menu_settings(self.menu_settings)
        if self.portal_permissions is not None:
            self.portal_permissions = check_portal_permissions(self.portal_permissions) or {}
        # Only enforced when action_matrix is present in the same payload —
        # same trust posture as MasterUpdate's _check_scope_field, since an
        # update that sends default_page_id without resending action_matrix
        # would need a DB round-trip to know the role's current grants. The
        # Portal Roles editor always resends both together (one "Save
        # changes" button), so this covers every real call site. The
        # scope=="portal" half of _check_default_page is passed literally
        # here rather than looked up — RoleUpdate can't change scope, and
        # only the Portal Roles screen ever sends default_page_id, so an
        # update payload reaching this validator is trusted to already be
        # for a portal role (same trust level as everything else here).
        if self.action_matrix is not None:
            _check_default_page("portal", self.action_matrix, self.default_page_id)
        return self


class RoleRead(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: uuid.UUID
    tenant_id: uuid.UUID
    name: str
    is_system_role: bool
    scope: RoleScope
    action_matrix: dict[str, list[str]]
    default_page_id: uuid.UUID | None
    # Derived from action_matrix["default_dashboard"] — see Role.default_dashboard_id
    default_dashboard_id: uuid.UUID | None = None
    # Portal roles — the role's own theme over the tenant's (None = the tenant's).
    theme: TenantTheme | None = None
    # Portal roles — the role's menu settings that differ from the tenant's.
    menu_settings: dict | None = None
    # Portal roles — see Role.portal_permissions.
    portal_permissions: dict | None = None
    created_at: datetime
    updated_at: datetime


class UserRoleRead(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    user_id: uuid.UUID
    role_id: uuid.UUID
