"""Theme & layout engine application services — Phase 11.3."""
from __future__ import annotations

from datetime import date, datetime, timezone
from typing import Any
from uuid import UUID

from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession

from app.events.publisher import get_event_publisher
from app.events.types import ExperienceEventType
from app.models.themes import (
    ExperienceLayout,
    ExperienceLayoutVersion,
    ExperienceTheme,
    ExperienceThemeVersion,
    PageLayoutBinding,
    SiteThemeBinding,
)
from app.models.types import (
    AuditAction,
    LayoutStatus,
    LayoutVersionStatus,
    ThemeStatus,
    ThemeVersionStatus,
)
from app.policies.themes import (
    LayoutLifecyclePolicy,
    LayoutVersionPolicy,
    PageLayoutBindingPolicy,
    SiteThemeBindingPolicy,
    ThemeLifecyclePolicy,
    ThemeVersionPolicy,
)
from app.repositories.foundation import ExperienceWorkspaceRepository
from app.repositories.sites import ExperiencePageRepository, ExperienceSiteRepository
from app.repositories.themes import (
    ExperienceLayoutRepository,
    ExperienceLayoutVersionRepository,
    ExperienceThemeRepository,
    ExperienceThemeVersionRepository,
    PageLayoutBindingRepository,
    SiteThemeBindingRepository,
)
from app.schemas.themes import (
    ExperienceLayoutCreate,
    ExperienceLayoutUpdate,
    ExperienceLayoutVersionCreate,
    ExperienceLayoutVersionUpdate,
    ExperienceThemeCreate,
    ExperienceThemeUpdate,
    ExperienceThemeVersionCreate,
    ExperienceThemeVersionUpdate,
    PageLayoutBindingCreate,
    PageLayoutBindingUpdate,
    SiteThemeBindingCreate,
    SiteThemeBindingUpdate,
)
from app.services.audit_service import AuditService
from app.specifications.themes import (
    LayoutListSpec,
    LayoutVersionListSpec,
    PageLayoutBindingListSpec,
    SiteThemeBindingListSpec,
    ThemeListSpec,
    ThemeVersionListSpec,
)
from app.validators import ensure_optimistic_version, validate_code, validate_non_empty
from app.validators.themes import (
    ensure_regions_payload,
    ensure_shell_payload,
    ensure_tokens_payload,
    validate_direction,
    validate_layout_kind,
)
from shared.exceptions import AppError, NotFoundError
from shared.security import CurrentUser


def _apply_update(entity, data: dict) -> None:
    for key, value in data.items():
        setattr(entity, key, value)


def _jsonable_changes(data: dict[str, Any]) -> dict[str, Any]:
    out: dict[str, Any] = {}
    for key, value in data.items():
        if isinstance(value, UUID):
            out[key] = str(value)
        elif isinstance(value, (datetime, date)):
            out[key] = value.isoformat()
        elif hasattr(value, "value"):
            out[key] = value.value
        else:
            out[key] = value
    return out


def _actor_id(actor: CurrentUser | None) -> str | None:
    return actor.user_id if actor else None


class ExperienceThemeService:
    def __init__(self, session: AsyncSession) -> None:
        self.session = session
        self.repo = ExperienceThemeRepository(session)
        self.versions = ExperienceThemeVersionRepository(session)
        self.layouts = ExperienceLayoutRepository(session)
        self.layout_versions = ExperienceLayoutVersionRepository(session)
        self.site_bindings = SiteThemeBindingRepository(session)
        self.page_bindings = PageLayoutBindingRepository(session)
        self.workspaces = ExperienceWorkspaceRepository(session)
        self.sites = ExperienceSiteRepository(session)
        self.pages = ExperiencePageRepository(session)
        self.audit = AuditService(session)
        self.events = get_event_publisher()
        self.theme_lifecycle_policy = ThemeLifecyclePolicy()
        self.theme_version_policy = ThemeVersionPolicy()
        self.layout_lifecycle_policy = LayoutLifecyclePolicy()
        self.layout_version_policy = LayoutVersionPolicy()
        self.site_theme_policy = SiteThemeBindingPolicy()
        self.page_layout_policy = PageLayoutBindingPolicy()

    # --- Themes ---

    async def create_theme(
        self,
        tenant_id: UUID,
        body: ExperienceThemeCreate,
        *,
        actor: CurrentUser | None = None,
    ) -> ExperienceTheme:
        ws = await self.workspaces.get(tenant_id, body.workspace_id)
        if ws is None:
            raise NotFoundError("فضای کاری یافت نشد")
        code = validate_code(body.code)
        name = validate_non_empty(body.name, "name")
        if await self.repo.get_by_code(tenant_id, body.workspace_id, code):
            raise AppError(
                "کد تم تکراری است",
                error_code="duplicate_code",
                status_code=409,
            )
        entity = ExperienceTheme(
            tenant_id=tenant_id,
            workspace_id=body.workspace_id,
            code=code,
            name=name,
            description=body.description,
            status=body.status,
            direction_mode=body.direction_mode,
            category=body.category,
            tags=body.tags,
            metadata_json=body.metadata_json,
            created_by=_actor_id(actor),
            updated_by=_actor_id(actor),
        )
        try:
            await self.repo.add(entity)
        except IntegrityError as exc:
            raise AppError(
                "کد تم تکراری است",
                error_code="duplicate_code",
                status_code=409,
            ) from exc
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="theme",
            entity_id=entity.id,
            action=AuditAction.CREATE,
            actor_user_id=_actor_id(actor),
            changes=_jsonable_changes(body.model_dump()),
        )
        self.events.publish(
            event_type=ExperienceEventType.THEME_CREATED,
            aggregate_type="theme",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={
                "code": entity.code,
                "workspace_id": str(entity.workspace_id),
            },
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def get_theme(self, tenant_id: UUID, theme_id: UUID) -> ExperienceTheme:
        entity = await self.repo.get(tenant_id, theme_id)
        if entity is None:
            raise NotFoundError("تم یافت نشد")
        return entity

    async def list_themes(
        self,
        tenant_id: UUID,
        *,
        offset: int,
        limit: int,
        spec: ThemeListSpec | None = None,
    ) -> tuple[list[ExperienceTheme], int]:
        items, total = await self.repo.list_filtered(
            tenant_id, offset=offset, limit=limit, spec=spec
        )
        return list(items), total

    async def update_theme(
        self,
        tenant_id: UUID,
        theme_id: UUID,
        body: ExperienceThemeUpdate,
        *,
        actor: CurrentUser | None = None,
    ) -> ExperienceTheme:
        entity = await self.get_theme(tenant_id, theme_id)
        ensure_optimistic_version(entity, body.version)
        data = body.model_dump(exclude_unset=True, exclude={"version"})
        if "name" in data and data["name"] is not None:
            data["name"] = validate_non_empty(data["name"], "name")
        if "status" in data and data["status"] is not None:
            self.theme_lifecycle_policy.assert_status_change(
                current=entity.status, target=data["status"]
            )
        _apply_update(entity, data)
        entity.updated_by = _actor_id(actor)
        entity.version += 1
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="theme",
            entity_id=entity.id,
            action=AuditAction.UPDATE,
            actor_user_id=_actor_id(actor),
            changes=_jsonable_changes(data),
        )
        self.events.publish(
            event_type=ExperienceEventType.THEME_UPDATED,
            aggregate_type="theme",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={"code": entity.code, "status": entity.status.value},
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def activate_theme(
        self, tenant_id: UUID, theme_id: UUID, *, actor: CurrentUser | None = None
    ) -> ExperienceTheme:
        return await self._set_theme_status(
            tenant_id,
            theme_id,
            ThemeStatus.ACTIVE,
            ExperienceEventType.THEME_ACTIVATED,
            actor=actor,
        )

    async def deprecate_theme(
        self, tenant_id: UUID, theme_id: UUID, *, actor: CurrentUser | None = None
    ) -> ExperienceTheme:
        return await self._set_theme_status(
            tenant_id,
            theme_id,
            ThemeStatus.DEPRECATED,
            ExperienceEventType.THEME_DEPRECATED,
            actor=actor,
        )

    async def archive_theme(
        self, tenant_id: UUID, theme_id: UUID, *, actor: CurrentUser | None = None
    ) -> ExperienceTheme:
        return await self._set_theme_status(
            tenant_id,
            theme_id,
            ThemeStatus.ARCHIVED,
            ExperienceEventType.THEME_ARCHIVED,
            actor=actor,
        )

    async def _set_theme_status(
        self,
        tenant_id: UUID,
        theme_id: UUID,
        target: ThemeStatus,
        event_type: ExperienceEventType,
        *,
        actor: CurrentUser | None,
    ) -> ExperienceTheme:
        entity = await self.get_theme(tenant_id, theme_id)
        self.theme_lifecycle_policy.assert_status_change(current=entity.status, target=target)
        entity.status = target
        entity.updated_by = _actor_id(actor)
        entity.version += 1
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="theme",
            entity_id=entity.id,
            action=AuditAction.STATUS_CHANGE,
            actor_user_id=_actor_id(actor),
            changes={"status": target.value},
        )
        self.events.publish(
            event_type=event_type,
            aggregate_type="theme",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={"code": entity.code, "status": target.value},
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def soft_delete_theme(
        self, tenant_id: UUID, theme_id: UUID, *, actor: CurrentUser | None = None
    ) -> ExperienceTheme:
        entity = await self.get_theme(tenant_id, theme_id)
        usage = await self.site_bindings.count_active_by_theme(tenant_id, theme_id)
        if usage > 0:
            raise AppError(
                "تم دارای تخصیص فعال به سایت است",
                error_code="theme_in_use",
                status_code=409,
                details={"binding_count": usage},
            )
        await self.repo.soft_delete(entity, deleted_by=_actor_id(actor))
        entity.updated_by = _actor_id(actor)
        entity.version += 1
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="theme",
            entity_id=entity.id,
            action=AuditAction.DELETE,
            actor_user_id=_actor_id(actor),
            changes={"is_deleted": True},
        )
        self.events.publish(
            event_type=ExperienceEventType.THEME_UPDATED,
            aggregate_type="theme",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={"code": entity.code, "is_deleted": True},
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    # --- Theme Versions ---

    async def create_theme_version(
        self,
        tenant_id: UUID,
        body: ExperienceThemeVersionCreate,
        *,
        actor: CurrentUser | None = None,
    ) -> ExperienceThemeVersion:
        theme = await self.get_theme(tenant_id, body.theme_id)
        if theme.workspace_id != body.workspace_id:
            raise AppError(
                "workspace_id با تم هم‌خوان نیست",
                error_code="workspace_mismatch",
                status_code=422,
            )
        next_number = (
            await self.versions.max_version_number(tenant_id, body.theme_id) + 1
        )
        entity = ExperienceThemeVersion(
            tenant_id=tenant_id,
            workspace_id=body.workspace_id,
            theme_id=body.theme_id,
            version_number=next_number,
            status=ThemeVersionStatus.DRAFT,
            tokens=ensure_tokens_payload(body.tokens, "tokens"),
            brand_shell=ensure_shell_payload(body.brand_shell, "brand_shell"),
            direction_mode=validate_direction(body.direction_mode),
            changelog=body.changelog,
            metadata_json=body.metadata_json,
            is_immutable=False,
            created_by=_actor_id(actor),
            updated_by=_actor_id(actor),
        )
        await self.versions.add(entity)
        theme.latest_version_number = next_number
        theme.updated_by = _actor_id(actor)
        theme.version += 1
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="theme_version",
            entity_id=entity.id,
            action=AuditAction.CREATE,
            actor_user_id=_actor_id(actor),
            changes=_jsonable_changes(
                {"theme_id": body.theme_id, "version_number": next_number}
            ),
        )
        self.events.publish(
            event_type=ExperienceEventType.THEME_VERSION_CREATED,
            aggregate_type="theme_version",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={
                "theme_id": str(body.theme_id),
                "version_number": next_number,
            },
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def get_theme_version(
        self, tenant_id: UUID, version_id: UUID
    ) -> ExperienceThemeVersion:
        entity = await self.versions.get(tenant_id, version_id)
        if entity is None:
            raise NotFoundError("نسخه تم یافت نشد")
        return entity

    async def list_theme_versions(
        self,
        tenant_id: UUID,
        *,
        offset: int,
        limit: int,
        spec: ThemeVersionListSpec | None = None,
    ) -> tuple[list[ExperienceThemeVersion], int]:
        items, total = await self.versions.list_filtered(
            tenant_id, offset=offset, limit=limit, spec=spec
        )
        return list(items), total

    async def update_theme_version(
        self,
        tenant_id: UUID,
        version_id: UUID,
        body: ExperienceThemeVersionUpdate,
        *,
        actor: CurrentUser | None = None,
    ) -> ExperienceThemeVersion:
        entity = await self.get_theme_version(tenant_id, version_id)
        self.theme_version_policy.assert_can_update(entity)
        ensure_optimistic_version(entity, body.version)
        data = body.model_dump(exclude_unset=True, exclude={"version"})
        if "tokens" in data:
            data["tokens"] = ensure_tokens_payload(data["tokens"], "tokens")
        if "brand_shell" in data:
            data["brand_shell"] = ensure_shell_payload(data["brand_shell"], "brand_shell")
        if "direction_mode" in data:
            data["direction_mode"] = validate_direction(data["direction_mode"])
        _apply_update(entity, data)
        entity.updated_by = _actor_id(actor)
        entity.version += 1
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="theme_version",
            entity_id=entity.id,
            action=AuditAction.UPDATE,
            actor_user_id=_actor_id(actor),
            changes=_jsonable_changes(data),
        )
        self.events.publish(
            event_type=ExperienceEventType.THEME_VERSION_UPDATED,
            aggregate_type="theme_version",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={
                "theme_id": str(entity.theme_id),
                "version_number": entity.version_number,
            },
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def publish_theme_version(
        self, tenant_id: UUID, version_id: UUID, *, actor: CurrentUser | None = None
    ) -> ExperienceThemeVersion:
        entity = await self.get_theme_version(tenant_id, version_id)
        self.theme_version_policy.assert_publish(current=entity.status)
        theme = await self.get_theme(tenant_id, entity.theme_id)
        now = datetime.now(timezone.utc)
        entity.status = ThemeVersionStatus.PUBLISHED
        entity.is_immutable = True
        entity.published_at = now
        entity.updated_by = _actor_id(actor)
        entity.version += 1
        theme.published_version_id = entity.id
        if theme.status == ThemeStatus.DRAFT:
            theme.status = ThemeStatus.ACTIVE
        theme.updated_by = _actor_id(actor)
        theme.version += 1
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="theme_version",
            entity_id=entity.id,
            action=AuditAction.PUBLISH,
            actor_user_id=_actor_id(actor),
            changes={"status": "published", "is_immutable": True},
        )
        self.events.publish(
            event_type=ExperienceEventType.THEME_VERSION_PUBLISHED,
            aggregate_type="theme_version",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={
                "theme_id": str(entity.theme_id),
                "version_number": entity.version_number,
            },
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def retire_theme_version(
        self, tenant_id: UUID, version_id: UUID, *, actor: CurrentUser | None = None
    ) -> ExperienceThemeVersion:
        entity = await self.get_theme_version(tenant_id, version_id)
        self.theme_version_policy.assert_retire(current=entity.status)
        usage = await self.site_bindings.count_active_by_version(tenant_id, version_id)
        if usage > 0:
            raise AppError(
                "نسخه دارای تخصیص فعال است",
                error_code="theme_version_in_use",
                status_code=409,
                details={"binding_count": usage},
            )
        theme = await self.get_theme(tenant_id, entity.theme_id)
        entity.status = ThemeVersionStatus.RETIRED
        entity.retired_at = datetime.now(timezone.utc)
        entity.updated_by = _actor_id(actor)
        entity.version += 1
        if theme.published_version_id == entity.id:
            theme.published_version_id = None
            theme.updated_by = _actor_id(actor)
            theme.version += 1
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="theme_version",
            entity_id=entity.id,
            action=AuditAction.STATUS_CHANGE,
            actor_user_id=_actor_id(actor),
            changes={"status": "retired"},
        )
        self.events.publish(
            event_type=ExperienceEventType.THEME_VERSION_RETIRED,
            aggregate_type="theme_version",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={
                "theme_id": str(entity.theme_id),
                "version_number": entity.version_number,
            },
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    # --- Layouts ---

    async def create_layout(
        self,
        tenant_id: UUID,
        body: ExperienceLayoutCreate,
        *,
        actor: CurrentUser | None = None,
    ) -> ExperienceLayout:
        ws = await self.workspaces.get(tenant_id, body.workspace_id)
        if ws is None:
            raise NotFoundError("فضای کاری یافت نشد")
        code = validate_code(body.code)
        name = validate_non_empty(body.name, "name")
        kind = validate_layout_kind(body.kind)
        if await self.layouts.get_by_code(tenant_id, body.workspace_id, code):
            raise AppError(
                "کد چیدمان تکراری است",
                error_code="duplicate_code",
                status_code=409,
            )
        entity = ExperienceLayout(
            tenant_id=tenant_id,
            workspace_id=body.workspace_id,
            code=code,
            name=name,
            description=body.description,
            kind=kind,
            status=body.status,
            direction_mode=body.direction_mode,
            category=body.category,
            tags=body.tags,
            metadata_json=body.metadata_json,
            created_by=_actor_id(actor),
            updated_by=_actor_id(actor),
        )
        try:
            await self.layouts.add(entity)
        except IntegrityError as exc:
            raise AppError(
                "کد چیدمان تکراری است",
                error_code="duplicate_code",
                status_code=409,
            ) from exc
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="layout",
            entity_id=entity.id,
            action=AuditAction.CREATE,
            actor_user_id=_actor_id(actor),
            changes=_jsonable_changes(body.model_dump()),
        )
        self.events.publish(
            event_type=ExperienceEventType.LAYOUT_CREATED,
            aggregate_type="layout",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={
                "code": entity.code,
                "workspace_id": str(entity.workspace_id),
                "kind": entity.kind.value,
            },
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def get_layout(self, tenant_id: UUID, layout_id: UUID) -> ExperienceLayout:
        entity = await self.layouts.get(tenant_id, layout_id)
        if entity is None:
            raise NotFoundError("چیدمان یافت نشد")
        return entity

    async def list_layouts(
        self,
        tenant_id: UUID,
        *,
        offset: int,
        limit: int,
        spec: LayoutListSpec | None = None,
    ) -> tuple[list[ExperienceLayout], int]:
        items, total = await self.layouts.list_filtered(
            tenant_id, offset=offset, limit=limit, spec=spec
        )
        return list(items), total

    async def update_layout(
        self,
        tenant_id: UUID,
        layout_id: UUID,
        body: ExperienceLayoutUpdate,
        *,
        actor: CurrentUser | None = None,
    ) -> ExperienceLayout:
        entity = await self.get_layout(tenant_id, layout_id)
        ensure_optimistic_version(entity, body.version)
        data = body.model_dump(exclude_unset=True, exclude={"version"})
        if "name" in data and data["name"] is not None:
            data["name"] = validate_non_empty(data["name"], "name")
        if "kind" in data and data["kind"] is not None:
            data["kind"] = validate_layout_kind(data["kind"])
        if "status" in data and data["status"] is not None:
            self.layout_lifecycle_policy.assert_status_change(
                current=entity.status, target=data["status"]
            )
        _apply_update(entity, data)
        entity.updated_by = _actor_id(actor)
        entity.version += 1
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="layout",
            entity_id=entity.id,
            action=AuditAction.UPDATE,
            actor_user_id=_actor_id(actor),
            changes=_jsonable_changes(data),
        )
        self.events.publish(
            event_type=ExperienceEventType.LAYOUT_UPDATED,
            aggregate_type="layout",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={"code": entity.code, "status": entity.status.value},
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def activate_layout(
        self, tenant_id: UUID, layout_id: UUID, *, actor: CurrentUser | None = None
    ) -> ExperienceLayout:
        return await self._set_layout_status(
            tenant_id,
            layout_id,
            LayoutStatus.ACTIVE,
            ExperienceEventType.LAYOUT_ACTIVATED,
            actor=actor,
        )

    async def deprecate_layout(
        self, tenant_id: UUID, layout_id: UUID, *, actor: CurrentUser | None = None
    ) -> ExperienceLayout:
        return await self._set_layout_status(
            tenant_id,
            layout_id,
            LayoutStatus.DEPRECATED,
            ExperienceEventType.LAYOUT_DEPRECATED,
            actor=actor,
        )

    async def archive_layout(
        self, tenant_id: UUID, layout_id: UUID, *, actor: CurrentUser | None = None
    ) -> ExperienceLayout:
        return await self._set_layout_status(
            tenant_id,
            layout_id,
            LayoutStatus.ARCHIVED,
            ExperienceEventType.LAYOUT_ARCHIVED,
            actor=actor,
        )

    async def _set_layout_status(
        self,
        tenant_id: UUID,
        layout_id: UUID,
        target: LayoutStatus,
        event_type: ExperienceEventType,
        *,
        actor: CurrentUser | None,
    ) -> ExperienceLayout:
        entity = await self.get_layout(tenant_id, layout_id)
        self.layout_lifecycle_policy.assert_status_change(
            current=entity.status, target=target
        )
        entity.status = target
        entity.updated_by = _actor_id(actor)
        entity.version += 1
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="layout",
            entity_id=entity.id,
            action=AuditAction.STATUS_CHANGE,
            actor_user_id=_actor_id(actor),
            changes={"status": target.value},
        )
        self.events.publish(
            event_type=event_type,
            aggregate_type="layout",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={"code": entity.code, "status": target.value},
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def soft_delete_layout(
        self, tenant_id: UUID, layout_id: UUID, *, actor: CurrentUser | None = None
    ) -> ExperienceLayout:
        entity = await self.get_layout(tenant_id, layout_id)
        usage = await self.page_bindings.count_active_by_layout(tenant_id, layout_id)
        if usage > 0:
            raise AppError(
                "چیدمان دارای تخصیص فعال به صفحه است",
                error_code="layout_in_use",
                status_code=409,
                details={"binding_count": usage},
            )
        await self.layouts.soft_delete(entity, deleted_by=_actor_id(actor))
        entity.updated_by = _actor_id(actor)
        entity.version += 1
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="layout",
            entity_id=entity.id,
            action=AuditAction.DELETE,
            actor_user_id=_actor_id(actor),
            changes={"is_deleted": True},
        )
        self.events.publish(
            event_type=ExperienceEventType.LAYOUT_UPDATED,
            aggregate_type="layout",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={"code": entity.code, "is_deleted": True},
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    # --- Layout Versions ---

    async def create_layout_version(
        self,
        tenant_id: UUID,
        body: ExperienceLayoutVersionCreate,
        *,
        actor: CurrentUser | None = None,
    ) -> ExperienceLayoutVersion:
        layout = await self.get_layout(tenant_id, body.layout_id)
        if layout.workspace_id != body.workspace_id:
            raise AppError(
                "workspace_id با چیدمان هم‌خوان نیست",
                error_code="workspace_mismatch",
                status_code=422,
            )
        next_number = (
            await self.layout_versions.max_version_number(tenant_id, body.layout_id) + 1
        )
        entity = ExperienceLayoutVersion(
            tenant_id=tenant_id,
            workspace_id=body.workspace_id,
            layout_id=body.layout_id,
            version_number=next_number,
            status=LayoutVersionStatus.DRAFT,
            regions=ensure_regions_payload(body.regions, "regions"),
            structure_shell=ensure_shell_payload(body.structure_shell, "structure_shell"),
            changelog=body.changelog,
            metadata_json=body.metadata_json,
            is_immutable=False,
            created_by=_actor_id(actor),
            updated_by=_actor_id(actor),
        )
        await self.layout_versions.add(entity)
        layout.latest_version_number = next_number
        layout.updated_by = _actor_id(actor)
        layout.version += 1
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="layout_version",
            entity_id=entity.id,
            action=AuditAction.CREATE,
            actor_user_id=_actor_id(actor),
            changes=_jsonable_changes(
                {"layout_id": body.layout_id, "version_number": next_number}
            ),
        )
        self.events.publish(
            event_type=ExperienceEventType.LAYOUT_VERSION_CREATED,
            aggregate_type="layout_version",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={
                "layout_id": str(body.layout_id),
                "version_number": next_number,
            },
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def get_layout_version(
        self, tenant_id: UUID, version_id: UUID
    ) -> ExperienceLayoutVersion:
        entity = await self.layout_versions.get(tenant_id, version_id)
        if entity is None:
            raise NotFoundError("نسخه چیدمان یافت نشد")
        return entity

    async def list_layout_versions(
        self,
        tenant_id: UUID,
        *,
        offset: int,
        limit: int,
        spec: LayoutVersionListSpec | None = None,
    ) -> tuple[list[ExperienceLayoutVersion], int]:
        items, total = await self.layout_versions.list_filtered(
            tenant_id, offset=offset, limit=limit, spec=spec
        )
        return list(items), total

    async def update_layout_version(
        self,
        tenant_id: UUID,
        version_id: UUID,
        body: ExperienceLayoutVersionUpdate,
        *,
        actor: CurrentUser | None = None,
    ) -> ExperienceLayoutVersion:
        entity = await self.get_layout_version(tenant_id, version_id)
        self.layout_version_policy.assert_can_update(entity)
        ensure_optimistic_version(entity, body.version)
        data = body.model_dump(exclude_unset=True, exclude={"version"})
        if "regions" in data:
            data["regions"] = ensure_regions_payload(data["regions"], "regions")
        if "structure_shell" in data:
            data["structure_shell"] = ensure_shell_payload(
                data["structure_shell"], "structure_shell"
            )
        _apply_update(entity, data)
        entity.updated_by = _actor_id(actor)
        entity.version += 1
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="layout_version",
            entity_id=entity.id,
            action=AuditAction.UPDATE,
            actor_user_id=_actor_id(actor),
            changes=_jsonable_changes(data),
        )
        self.events.publish(
            event_type=ExperienceEventType.LAYOUT_VERSION_UPDATED,
            aggregate_type="layout_version",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={
                "layout_id": str(entity.layout_id),
                "version_number": entity.version_number,
            },
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def publish_layout_version(
        self, tenant_id: UUID, version_id: UUID, *, actor: CurrentUser | None = None
    ) -> ExperienceLayoutVersion:
        entity = await self.get_layout_version(tenant_id, version_id)
        self.layout_version_policy.assert_publish(current=entity.status)
        layout = await self.get_layout(tenant_id, entity.layout_id)
        now = datetime.now(timezone.utc)
        entity.status = LayoutVersionStatus.PUBLISHED
        entity.is_immutable = True
        entity.published_at = now
        entity.updated_by = _actor_id(actor)
        entity.version += 1
        layout.published_version_id = entity.id
        if layout.status == LayoutStatus.DRAFT:
            layout.status = LayoutStatus.ACTIVE
        layout.updated_by = _actor_id(actor)
        layout.version += 1
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="layout_version",
            entity_id=entity.id,
            action=AuditAction.PUBLISH,
            actor_user_id=_actor_id(actor),
            changes={"status": "published", "is_immutable": True},
        )
        self.events.publish(
            event_type=ExperienceEventType.LAYOUT_VERSION_PUBLISHED,
            aggregate_type="layout_version",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={
                "layout_id": str(entity.layout_id),
                "version_number": entity.version_number,
            },
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def retire_layout_version(
        self, tenant_id: UUID, version_id: UUID, *, actor: CurrentUser | None = None
    ) -> ExperienceLayoutVersion:
        entity = await self.get_layout_version(tenant_id, version_id)
        self.layout_version_policy.assert_retire(current=entity.status)
        usage = await self.page_bindings.count_active_by_version(tenant_id, version_id)
        if usage > 0:
            raise AppError(
                "نسخه دارای تخصیص فعال است",
                error_code="layout_version_in_use",
                status_code=409,
                details={"binding_count": usage},
            )
        layout = await self.get_layout(tenant_id, entity.layout_id)
        entity.status = LayoutVersionStatus.RETIRED
        entity.retired_at = datetime.now(timezone.utc)
        entity.updated_by = _actor_id(actor)
        entity.version += 1
        if layout.published_version_id == entity.id:
            layout.published_version_id = None
            layout.updated_by = _actor_id(actor)
            layout.version += 1
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="layout_version",
            entity_id=entity.id,
            action=AuditAction.STATUS_CHANGE,
            actor_user_id=_actor_id(actor),
            changes={"status": "retired"},
        )
        self.events.publish(
            event_type=ExperienceEventType.LAYOUT_VERSION_RETIRED,
            aggregate_type="layout_version",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={
                "layout_id": str(entity.layout_id),
                "version_number": entity.version_number,
            },
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    # --- Site Theme Bindings ---

    async def assign_site_theme(
        self,
        tenant_id: UUID,
        body: SiteThemeBindingCreate,
        *,
        actor: CurrentUser | None = None,
    ) -> SiteThemeBinding:
        site = await self.sites.get(tenant_id, body.site_id)
        if site is None:
            raise NotFoundError("سایت یافت نشد")
        theme = await self.get_theme(tenant_id, body.theme_id)
        version = await self.get_theme_version(tenant_id, body.theme_version_id)
        if version.theme_id != body.theme_id:
            raise AppError(
                "theme_version_id با theme_id هم‌خوان نیست",
                error_code="version_theme_mismatch",
                status_code=422,
            )
        self.site_theme_policy.assert_theme_assignable(theme)
        self.site_theme_policy.assert_version_assignable(version)

        previous = await self.site_bindings.get_active_for_site(tenant_id, body.site_id)
        replaced_previous = False
        if previous is not None:
            await self.site_bindings.soft_delete(previous, deleted_by=_actor_id(actor))
            previous.is_active = False
            previous.updated_by = _actor_id(actor)
            previous.version += 1
            replaced_previous = True

        entity = SiteThemeBinding(
            tenant_id=tenant_id,
            workspace_id=body.workspace_id,
            site_id=body.site_id,
            theme_id=body.theme_id,
            theme_version_id=body.theme_version_id,
            is_active=True,
            metadata_json=body.metadata_json,
            created_by=_actor_id(actor),
            updated_by=_actor_id(actor),
        )
        await self.site_bindings.add(entity)
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="site_theme_binding",
            entity_id=entity.id,
            action=AuditAction.REPLACE if replaced_previous else AuditAction.ASSIGN,
            actor_user_id=_actor_id(actor),
            changes=_jsonable_changes(body.model_dump()),
        )
        self.events.publish(
            event_type=ExperienceEventType.SITE_THEME_ASSIGNED,
            aggregate_type="site_theme_binding",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={
                "site_id": str(entity.site_id),
                "theme_id": str(entity.theme_id),
                "theme_version_id": str(entity.theme_version_id),
            },
        )
        if replaced_previous:
            self.events.publish(
                event_type=ExperienceEventType.SITE_THEME_REPLACED,
                aggregate_type="site_theme_binding",
                aggregate_id=entity.id,
                tenant_id=tenant_id,
                payload={
                    "site_id": str(entity.site_id),
                    "previous_binding_id": str(previous.id),
                },
            )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def get_site_theme_binding(
        self, tenant_id: UUID, binding_id: UUID
    ) -> SiteThemeBinding:
        entity = await self.site_bindings.get(tenant_id, binding_id)
        if entity is None:
            raise NotFoundError("تخصیص تم سایت یافت نشد")
        return entity

    async def list_site_theme_bindings(
        self,
        tenant_id: UUID,
        *,
        offset: int,
        limit: int,
        spec: SiteThemeBindingListSpec | None = None,
    ) -> tuple[list[SiteThemeBinding], int]:
        items, total = await self.site_bindings.list_filtered(
            tenant_id, offset=offset, limit=limit, spec=spec
        )
        return list(items), total

    async def update_site_theme_binding(
        self,
        tenant_id: UUID,
        binding_id: UUID,
        body: SiteThemeBindingUpdate,
        *,
        actor: CurrentUser | None = None,
    ) -> SiteThemeBinding:
        entity = await self.get_site_theme_binding(tenant_id, binding_id)
        ensure_optimistic_version(entity, body.version)
        data = body.model_dump(exclude_unset=True, exclude={"version"})
        _apply_update(entity, data)
        entity.updated_by = _actor_id(actor)
        entity.version += 1
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="site_theme_binding",
            entity_id=entity.id,
            action=AuditAction.UPDATE,
            actor_user_id=_actor_id(actor),
            changes=_jsonable_changes(data),
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def soft_delete_site_theme_binding(
        self, tenant_id: UUID, binding_id: UUID, *, actor: CurrentUser | None = None
    ) -> SiteThemeBinding:
        entity = await self.get_site_theme_binding(tenant_id, binding_id)
        entity.is_active = False
        await self.site_bindings.soft_delete(entity, deleted_by=_actor_id(actor))
        entity.updated_by = _actor_id(actor)
        entity.version += 1
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="site_theme_binding",
            entity_id=entity.id,
            action=AuditAction.DELETE,
            actor_user_id=_actor_id(actor),
            changes={"is_deleted": True},
        )
        self.events.publish(
            event_type=ExperienceEventType.SITE_THEME_UNASSIGNED,
            aggregate_type="site_theme_binding",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={"site_id": str(entity.site_id)},
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    # --- Page Layout Bindings ---

    async def assign_page_layout(
        self,
        tenant_id: UUID,
        body: PageLayoutBindingCreate,
        *,
        actor: CurrentUser | None = None,
    ) -> PageLayoutBinding:
        site = await self.sites.get(tenant_id, body.site_id)
        if site is None:
            raise NotFoundError("سایت یافت نشد")
        page = await self.pages.get(tenant_id, body.page_id)
        if page is None:
            raise NotFoundError("صفحه یافت نشد")
        if page.site_id != body.site_id:
            raise AppError(
                "page_id با site_id هم‌خوان نیست",
                error_code="page_site_mismatch",
                status_code=422,
            )
        layout = await self.get_layout(tenant_id, body.layout_id)
        version = await self.get_layout_version(tenant_id, body.layout_version_id)
        if version.layout_id != body.layout_id:
            raise AppError(
                "layout_version_id با layout_id هم‌خوان نیست",
                error_code="version_layout_mismatch",
                status_code=422,
            )
        self.page_layout_policy.assert_layout_assignable(layout)
        self.page_layout_policy.assert_version_assignable(version)

        previous = await self.page_bindings.get_active_for_page(tenant_id, body.page_id)
        replaced_previous = False
        if previous is not None:
            await self.page_bindings.soft_delete(previous, deleted_by=_actor_id(actor))
            previous.is_active = False
            previous.updated_by = _actor_id(actor)
            previous.version += 1
            replaced_previous = True

        entity = PageLayoutBinding(
            tenant_id=tenant_id,
            workspace_id=body.workspace_id,
            site_id=body.site_id,
            page_id=body.page_id,
            layout_id=body.layout_id,
            layout_version_id=body.layout_version_id,
            is_active=True,
            metadata_json=body.metadata_json,
            created_by=_actor_id(actor),
            updated_by=_actor_id(actor),
        )
        await self.page_bindings.add(entity)
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="page_layout_binding",
            entity_id=entity.id,
            action=AuditAction.REPLACE if replaced_previous else AuditAction.ASSIGN,
            actor_user_id=_actor_id(actor),
            changes=_jsonable_changes(body.model_dump()),
        )
        self.events.publish(
            event_type=ExperienceEventType.PAGE_LAYOUT_ASSIGNED,
            aggregate_type="page_layout_binding",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={
                "page_id": str(entity.page_id),
                "layout_id": str(entity.layout_id),
                "layout_version_id": str(entity.layout_version_id),
            },
        )
        if replaced_previous:
            self.events.publish(
                event_type=ExperienceEventType.PAGE_LAYOUT_REPLACED,
                aggregate_type="page_layout_binding",
                aggregate_id=entity.id,
                tenant_id=tenant_id,
                payload={
                    "page_id": str(entity.page_id),
                    "previous_binding_id": str(previous.id),
                },
            )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def get_page_layout_binding(
        self, tenant_id: UUID, binding_id: UUID
    ) -> PageLayoutBinding:
        entity = await self.page_bindings.get(tenant_id, binding_id)
        if entity is None:
            raise NotFoundError("تخصیص چیدمان صفحه یافت نشد")
        return entity

    async def list_page_layout_bindings(
        self,
        tenant_id: UUID,
        *,
        offset: int,
        limit: int,
        spec: PageLayoutBindingListSpec | None = None,
    ) -> tuple[list[PageLayoutBinding], int]:
        items, total = await self.page_bindings.list_filtered(
            tenant_id, offset=offset, limit=limit, spec=spec
        )
        return list(items), total

    async def update_page_layout_binding(
        self,
        tenant_id: UUID,
        binding_id: UUID,
        body: PageLayoutBindingUpdate,
        *,
        actor: CurrentUser | None = None,
    ) -> PageLayoutBinding:
        entity = await self.get_page_layout_binding(tenant_id, binding_id)
        ensure_optimistic_version(entity, body.version)
        data = body.model_dump(exclude_unset=True, exclude={"version"})
        _apply_update(entity, data)
        entity.updated_by = _actor_id(actor)
        entity.version += 1
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="page_layout_binding",
            entity_id=entity.id,
            action=AuditAction.UPDATE,
            actor_user_id=_actor_id(actor),
            changes=_jsonable_changes(data),
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def soft_delete_page_layout_binding(
        self, tenant_id: UUID, binding_id: UUID, *, actor: CurrentUser | None = None
    ) -> PageLayoutBinding:
        entity = await self.get_page_layout_binding(tenant_id, binding_id)
        entity.is_active = False
        await self.page_bindings.soft_delete(entity, deleted_by=_actor_id(actor))
        entity.updated_by = _actor_id(actor)
        entity.version += 1
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="page_layout_binding",
            entity_id=entity.id,
            action=AuditAction.DELETE,
            actor_user_id=_actor_id(actor),
            changes={"is_deleted": True},
        )
        self.events.publish(
            event_type=ExperienceEventType.PAGE_LAYOUT_UNASSIGNED,
            aggregate_type="page_layout_binding",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={"page_id": str(entity.page_id)},
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity
