"""Publishing, SEO & PWA application services — Phase 11.7."""
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.publishing import (
    DomainEdgeBinding,
    PublishRelease,
    PwaManifest,
    RobotsShell,
    SeoMetadata,
    SitemapShell,
)
from app.models.types import (
    AuditAction,
    PublishReleaseStatus,
    PublishTarget,
)
from app.policies.publishing import (
    DomainEdgeBindingPolicy,
    PublishReleasePolicy,
    PwaPolicy,
    RobotsPolicy,
    SeoPolicy,
    SitemapPolicy,
)
from app.repositories.foundation import ExperienceWorkspaceRepository
from app.repositories.publishing import (
    DomainEdgeBindingRepository,
    PublishReleaseRepository,
    PwaManifestRepository,
    RobotsShellRepository,
    SeoMetadataRepository,
    SitemapShellRepository,
)
from app.repositories.sites import (
    ExperiencePageRepository,
    ExperienceSiteDomainRepository,
    ExperienceSiteRepository,
)
from app.schemas.publishing import (
    DomainEdgeBindingCreate,
    DomainEdgeBindingUpdate,
    PublishReleaseCreate,
    PublishReleaseUpdate,
    PwaManifestUpdate,
    PwaManifestUpsert,
    RobotsShellUpdate,
    RobotsShellUpsert,
    SeoMetadataUpdate,
    SeoMetadataUpsert,
    SitemapShellUpdate,
    SitemapShellUpsert,
)
from app.services.audit_service import AuditService
from app.specifications.publishing import (
    DomainEdgeBindingListSpec,
    PublishReleaseListSpec,
    PwaManifestListSpec,
    RobotsShellListSpec,
    SeoMetadataListSpec,
    SitemapShellListSpec,
)
from app.validators import ensure_optimistic_version
from app.validators.publishing import (
    ensure_json_object,
    validate_external_ref,
    validate_publish_target,
    validate_seo_target,
)
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(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 ExperiencePublishingService:
    def __init__(self, session: AsyncSession) -> None:
        self.session = session
        self.releases = PublishReleaseRepository(session)
        self.edges = DomainEdgeBindingRepository(session)
        self.seo = SeoMetadataRepository(session)
        self.sitemaps = SitemapShellRepository(session)
        self.robots = RobotsShellRepository(session)
        self.pwa = PwaManifestRepository(session)
        self.workspaces = ExperienceWorkspaceRepository(session)
        self.sites = ExperienceSiteRepository(session)
        self.pages = ExperiencePageRepository(session)
        self.domains = ExperienceSiteDomainRepository(session)
        self.audit = AuditService(session)
        self.events = get_event_publisher()
        self.publish_policy = PublishReleasePolicy()
        self.edge_policy = DomainEdgeBindingPolicy()
        self.seo_policy = SeoPolicy()
        self.sitemap_policy = SitemapPolicy()
        self.robots_policy = RobotsPolicy()
        self.pwa_policy = PwaPolicy()

    async def _require_workspace(self, tenant_id: UUID, workspace_id: UUID):
        ws = await self.workspaces.get(tenant_id, workspace_id)
        if ws is None:
            raise NotFoundError("فضای کاری یافت نشد")
        return ws

    async def _require_site(self, tenant_id: UUID, site_id: UUID, workspace_id: UUID | None = None):
        site = await self.sites.get(tenant_id, site_id)
        if site is None:
            raise NotFoundError("سایت یافت نشد")
        if workspace_id is not None and site.workspace_id != workspace_id:
            raise AppError(
                "سایت متعلق به این فضای کاری نیست",
                error_code="site_workspace_mismatch",
                status_code=422,
            )
        return site

    # ── Publish releases ──────────────────────────────────────────────

    async def create_publish_release(
        self,
        tenant_id: UUID,
        body: PublishReleaseCreate,
        *,
        actor: CurrentUser | None = None,
    ) -> PublishRelease:
        await self._require_workspace(tenant_id, body.workspace_id)
        target_type = validate_publish_target(body.target_type)
        if target_type == PublishTarget.SITE:
            target = await self._require_site(
                tenant_id, body.target_id, workspace_id=body.workspace_id
            )
            if target.publish_status.value == "archived":
                raise AppError(
                    "سایت آرشیو شده قابل انتشار نیست",
                    error_code="invalid_status",
                    status_code=409,
                )
            from app.models.types import PublishStatus

            target.publish_status = PublishStatus.PUBLISHED
            target.published_at = datetime.now(timezone.utc)
            target.archived_at = None
            target.version += 1
            target.updated_by = _actor_id(actor)
            self.events.publish(
                event_type=ExperienceEventType.SITE_PUBLISHED,
                aggregate_type="site",
                aggregate_id=target.id,
                tenant_id=tenant_id,
                payload={"code": target.code},
            )
        else:
            from app.models.types import PublishStatus

            page = await self.pages.get(tenant_id, body.target_id)
            if page is None or page.workspace_id != body.workspace_id:
                raise NotFoundError("صفحه یافت نشد")
            if page.publish_status.value == "archived":
                raise AppError(
                    "صفحه آرشیو شده قابل انتشار نیست",
                    error_code="invalid_status",
                    status_code=409,
                )
            page.publish_status = PublishStatus.PUBLISHED
            page.published_at = datetime.now(timezone.utc)
            page.archived_at = None
            page.version += 1
            page.updated_by = _actor_id(actor)
            self.events.publish(
                event_type=ExperienceEventType.PAGE_PUBLISHED,
                aggregate_type="page",
                aggregate_id=page.id,
                tenant_id=tenant_id,
                payload={"slug": page.slug},
            )

        status = PublishReleaseStatus.PUBLISHED
        completed_at = datetime.now(timezone.utc)
        event = ExperienceEventType.PUBLISH_RELEASE_COMPLETED

        entity = PublishRelease(
            tenant_id=tenant_id,
            workspace_id=body.workspace_id,
            target_type=target_type,
            target_id=body.target_id,
            status=status,
            notes=body.notes,
            completed_at=completed_at,
            metadata_json=ensure_json_object(body.metadata_json, "metadata_json"),
            created_by=_actor_id(actor),
            updated_by=_actor_id(actor),
        )
        await self.releases.add(entity)
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="publish_release",
            entity_id=entity.id,
            action=AuditAction.PUBLISH,
            actor_user_id=_actor_id(actor),
            changes=_jsonable(body.model_dump()),
        )
        self.events.publish(
            event_type=event,
            aggregate_type="publish_release",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={
                "target_type": entity.target_type.value,
                "target_id": str(entity.target_id),
                "status": entity.status.value,
            },
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def get_publish_release(self, tenant_id: UUID, release_id: UUID) -> PublishRelease:
        entity = await self.releases.get(tenant_id, release_id)
        if entity is None:
            raise NotFoundError("انتشار یافت نشد")
        return entity

    async def list_publish_releases(
        self, tenant_id, *, offset, limit, spec: PublishReleaseListSpec | None = None
    ):
        items, total = await self.releases.list_filtered(
            tenant_id, offset=offset, limit=limit, spec=spec
        )
        return list(items), total

    async def update_publish_release(
        self,
        tenant_id: UUID,
        release_id: UUID,
        body: PublishReleaseUpdate,
        *,
        actor: CurrentUser | None = None,
    ) -> PublishRelease:
        entity = await self.get_publish_release(tenant_id, release_id)
        ensure_optimistic_version(entity, body.version)
        data = body.model_dump(exclude_unset=True, exclude={"version"})
        if "metadata_json" in data:
            data["metadata_json"] = ensure_json_object(
                data["metadata_json"], "metadata_json"
            )
        if "status" in data and data["status"] is not None:
            self.publish_policy.assert_status_change(
                current=entity.status, target=data["status"]
            )
            if data["status"] == PublishReleaseStatus.PUBLISHED:
                data["completed_at"] = datetime.now(timezone.utc)
            if data["status"] == PublishReleaseStatus.ROLLED_BACK:
                from app.models.types import PublishStatus

                if entity.target_type == PublishTarget.SITE:
                    site = await self.sites.get(tenant_id, entity.target_id)
                    if site is not None:
                        site.publish_status = PublishStatus.DRAFT
                        site.version += 1
                        site.updated_by = _actor_id(actor)
                        self.events.publish(
                            event_type=ExperienceEventType.SITE_UNPUBLISHED,
                            aggregate_type="site",
                            aggregate_id=site.id,
                            tenant_id=tenant_id,
                            payload={"code": site.code},
                        )
                else:
                    page = await self.pages.get(tenant_id, entity.target_id)
                    if page is not None:
                        page.publish_status = PublishStatus.DRAFT
                        page.version += 1
                        page.updated_by = _actor_id(actor)
                        self.events.publish(
                            event_type=ExperienceEventType.PAGE_UNPUBLISHED,
                            aggregate_type="page",
                            aggregate_id=page.id,
                            tenant_id=tenant_id,
                            payload={"slug": page.slug},
                        )
        _apply_update(entity, data)
        entity.updated_by = _actor_id(actor)
        entity.version += 1
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="publish_release",
            entity_id=entity.id,
            action=AuditAction.UPDATE,
            actor_user_id=_actor_id(actor),
            changes=_jsonable(data),
        )
        self.events.publish(
            event_type=ExperienceEventType.PUBLISH_RELEASE_UPDATED,
            aggregate_type="publish_release",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={"status": entity.status.value},
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    # ── Domain edge bindings ──────────────────────────────────────────

    async def create_domain_edge_binding(
        self,
        tenant_id: UUID,
        body: DomainEdgeBindingCreate,
        *,
        actor: CurrentUser | None = None,
    ) -> DomainEdgeBinding:
        await self._require_workspace(tenant_id, body.workspace_id)
        await self._require_site(tenant_id, body.site_id, workspace_id=body.workspace_id)
        domain = await self.domains.get(tenant_id, body.site_domain_id)
        if domain is None or domain.site_id != body.site_id:
            raise NotFoundError("دامنه سایت یافت نشد")
        if await self.edges.get_by_site_domain(tenant_id, body.site_domain_id):
            raise AppError(
                "اتصال edge برای این دامنه قبلاً ثبت شده",
                error_code="duplicate_domain_edge_binding",
                status_code=409,
            )
        entity = DomainEdgeBinding(
            tenant_id=tenant_id,
            workspace_id=body.workspace_id,
            site_id=body.site_id,
            site_domain_id=body.site_domain_id,
            host=domain.host,
            status=body.status,
            dns_status_ref=validate_external_ref(body.dns_status_ref, "dns_status_ref"),
            ssl_cert_ref=validate_external_ref(body.ssl_cert_ref, "ssl_cert_ref"),
            edge_map_ref=validate_external_ref(body.edge_map_ref, "edge_map_ref"),
            metadata_json=ensure_json_object(body.metadata_json, "metadata_json"),
            created_by=_actor_id(actor),
            updated_by=_actor_id(actor),
        )
        try:
            await self.edges.add(entity)
        except IntegrityError as exc:
            raise AppError(
                "اتصال edge تکراری است",
                error_code="duplicate_domain_edge_binding",
                status_code=409,
            ) from exc
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="domain_edge_binding",
            entity_id=entity.id,
            action=AuditAction.CREATE,
            actor_user_id=_actor_id(actor),
            changes=_jsonable(body.model_dump()),
        )
        self.events.publish(
            event_type=ExperienceEventType.DOMAIN_EDGE_BOUND,
            aggregate_type="domain_edge_binding",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={"host": entity.host, "status": entity.status.value},
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def get_domain_edge_binding(self, tenant_id: UUID, binding_id: UUID):
        entity = await self.edges.get(tenant_id, binding_id)
        if entity is None:
            raise NotFoundError("اتصال edge دامنه یافت نشد")
        return entity

    async def list_domain_edge_bindings(
        self, tenant_id, *, offset, limit, spec: DomainEdgeBindingListSpec | None = None
    ):
        items, total = await self.edges.list_filtered(
            tenant_id, offset=offset, limit=limit, spec=spec
        )
        return list(items), total

    async def update_domain_edge_binding(
        self,
        tenant_id: UUID,
        binding_id: UUID,
        body: DomainEdgeBindingUpdate,
        *,
        actor: CurrentUser | None = None,
    ):
        entity = await self.get_domain_edge_binding(tenant_id, binding_id)
        ensure_optimistic_version(entity, body.version)
        data = body.model_dump(exclude_unset=True, exclude={"version"})
        for field in ("dns_status_ref", "ssl_cert_ref", "edge_map_ref"):
            if field in data:
                data[field] = validate_external_ref(data[field], field)
        if "metadata_json" in data:
            data["metadata_json"] = ensure_json_object(
                data["metadata_json"], "metadata_json"
            )
        if "status" in data and data["status"] is not None:
            self.edge_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="domain_edge_binding",
            entity_id=entity.id,
            action=AuditAction.UPDATE,
            actor_user_id=_actor_id(actor),
            changes=_jsonable(data),
        )
        self.events.publish(
            event_type=ExperienceEventType.DOMAIN_EDGE_UPDATED,
            aggregate_type="domain_edge_binding",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={"host": entity.host, "status": entity.status.value},
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    # ── SEO / sitemap / robots / PWA (upsert helpers) ─────────────────

    async def upsert_seo(
        self, tenant_id: UUID, body: SeoMetadataUpsert, *, actor: CurrentUser | None = None
    ):
        await self._require_workspace(tenant_id, body.workspace_id)
        target_type = validate_seo_target(body.target_type)
        existing = await self.seo.get_by_target(tenant_id, target_type, body.target_id)
        payload = {
            "status": body.status,
            "title": body.title,
            "description": body.description,
            "canonical_url": body.canonical_url,
            "robots_directive": body.robots_directive,
            "og_json": ensure_json_object(body.og_json, "og_json"),
            "metadata_json": ensure_json_object(body.metadata_json, "metadata_json"),
        }
        if existing is not None:
            if body.version is not None:
                ensure_optimistic_version(existing, body.version)
            if body.status != existing.status:
                self.seo_policy.assert_status_change(
                    current=existing.status, target=body.status
                )
            _apply_update(existing, payload)
            existing.updated_by = _actor_id(actor)
            existing.version += 1
            entity = existing
            action = AuditAction.UPDATE
        else:
            entity = SeoMetadata(
                tenant_id=tenant_id,
                workspace_id=body.workspace_id,
                target_type=target_type,
                target_id=body.target_id,
                created_by=_actor_id(actor),
                updated_by=_actor_id(actor),
                **payload,
            )
            await self.seo.add(entity)
            action = AuditAction.CREATE
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="seo_metadata",
            entity_id=entity.id,
            action=action,
            actor_user_id=_actor_id(actor),
            changes=_jsonable(body.model_dump(exclude={"version"})),
        )
        self.events.publish(
            event_type=ExperienceEventType.SEO_METADATA_UPSERTED,
            aggregate_type="seo_metadata",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={
                "target_type": entity.target_type.value,
                "target_id": str(entity.target_id),
            },
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def get_seo(self, tenant_id: UUID, seo_id: UUID):
        entity = await self.seo.get(tenant_id, seo_id)
        if entity is None:
            raise NotFoundError("SEO metadata یافت نشد")
        return entity

    async def list_seo(self, tenant_id, *, offset, limit, spec: SeoMetadataListSpec | None = None):
        items, total = await self.seo.list_filtered(
            tenant_id, offset=offset, limit=limit, spec=spec
        )
        return list(items), total

    async def update_seo(
        self, tenant_id, seo_id, body: SeoMetadataUpdate, *, actor: CurrentUser | None = None
    ):
        entity = await self.get_seo(tenant_id, seo_id)
        ensure_optimistic_version(entity, body.version)
        data = body.model_dump(exclude_unset=True, exclude={"version"})
        for field in ("og_json", "metadata_json"):
            if field in data:
                data[field] = ensure_json_object(data[field], field)
        if "status" in data and data["status"] is not None:
            self.seo_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="seo_metadata",
            entity_id=entity.id,
            action=AuditAction.UPDATE,
            actor_user_id=_actor_id(actor),
            changes=_jsonable(data),
        )
        self.events.publish(
            event_type=ExperienceEventType.SEO_METADATA_UPDATED,
            aggregate_type="seo_metadata",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={"status": entity.status.value},
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def upsert_sitemap(
        self, tenant_id, body: SitemapShellUpsert, *, actor: CurrentUser | None = None
    ):
        await self._require_workspace(tenant_id, body.workspace_id)
        await self._require_site(tenant_id, body.site_id, workspace_id=body.workspace_id)
        existing = await self.sitemaps.get_by_site(tenant_id, body.site_id)
        payload = {
            "status": body.status,
            "entries_json": ensure_json_object(body.entries_json, "entries_json"),
            "storage_file_ref": validate_external_ref(
                body.storage_file_ref, "storage_file_ref"
            ),
            "metadata_json": ensure_json_object(body.metadata_json, "metadata_json"),
        }
        if existing is not None:
            if body.version is not None:
                ensure_optimistic_version(existing, body.version)
            if body.status != existing.status:
                self.sitemap_policy.assert_status_change(
                    current=existing.status, target=body.status
                )
            _apply_update(existing, payload)
            existing.updated_by = _actor_id(actor)
            existing.version += 1
            entity = existing
            action = AuditAction.UPDATE
        else:
            entity = SitemapShell(
                tenant_id=tenant_id,
                workspace_id=body.workspace_id,
                site_id=body.site_id,
                created_by=_actor_id(actor),
                updated_by=_actor_id(actor),
                **payload,
            )
            await self.sitemaps.add(entity)
            action = AuditAction.CREATE
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="sitemap_shell",
            entity_id=entity.id,
            action=action,
            actor_user_id=_actor_id(actor),
            changes=_jsonable(body.model_dump(exclude={"version"})),
        )
        self.events.publish(
            event_type=ExperienceEventType.SITEMAP_UPSERTED,
            aggregate_type="sitemap_shell",
            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

    async def get_sitemap(self, tenant_id, shell_id):
        entity = await self.sitemaps.get(tenant_id, shell_id)
        if entity is None:
            raise NotFoundError("sitemap یافت نشد")
        return entity

    async def list_sitemaps(self, tenant_id, *, offset, limit, spec=None):
        items, total = await self.sitemaps.list_filtered(
            tenant_id, offset=offset, limit=limit, spec=spec
        )
        return list(items), total

    async def update_sitemap(
        self, tenant_id, shell_id, body: SitemapShellUpdate, *, actor=None
    ):
        entity = await self.get_sitemap(tenant_id, shell_id)
        ensure_optimistic_version(entity, body.version)
        data = body.model_dump(exclude_unset=True, exclude={"version"})
        if "entries_json" in data:
            data["entries_json"] = ensure_json_object(data["entries_json"], "entries_json")
        if "storage_file_ref" in data:
            data["storage_file_ref"] = validate_external_ref(
                data["storage_file_ref"], "storage_file_ref"
            )
        if "metadata_json" in data:
            data["metadata_json"] = ensure_json_object(
                data["metadata_json"], "metadata_json"
            )
        if "status" in data and data["status"] is not None:
            self.sitemap_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="sitemap_shell",
            entity_id=entity.id,
            action=AuditAction.UPDATE,
            actor_user_id=_actor_id(actor),
            changes=_jsonable(data),
        )
        self.events.publish(
            event_type=ExperienceEventType.SITEMAP_UPDATED,
            aggregate_type="sitemap_shell",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={"status": entity.status.value},
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def upsert_robots(
        self, tenant_id, body: RobotsShellUpsert, *, actor: CurrentUser | None = None
    ):
        await self._require_workspace(tenant_id, body.workspace_id)
        await self._require_site(tenant_id, body.site_id, workspace_id=body.workspace_id)
        existing = await self.robots.get_by_site(tenant_id, body.site_id)
        payload = {
            "status": body.status,
            "content_text": body.content_text,
            "rules_json": ensure_json_object(body.rules_json, "rules_json"),
            "metadata_json": ensure_json_object(body.metadata_json, "metadata_json"),
        }
        if existing is not None:
            if body.version is not None:
                ensure_optimistic_version(existing, body.version)
            if body.status != existing.status:
                self.robots_policy.assert_status_change(
                    current=existing.status, target=body.status
                )
            _apply_update(existing, payload)
            existing.updated_by = _actor_id(actor)
            existing.version += 1
            entity = existing
            action = AuditAction.UPDATE
        else:
            entity = RobotsShell(
                tenant_id=tenant_id,
                workspace_id=body.workspace_id,
                site_id=body.site_id,
                created_by=_actor_id(actor),
                updated_by=_actor_id(actor),
                **payload,
            )
            await self.robots.add(entity)
            action = AuditAction.CREATE
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="robots_shell",
            entity_id=entity.id,
            action=action,
            actor_user_id=_actor_id(actor),
            changes=_jsonable(body.model_dump(exclude={"version"})),
        )
        self.events.publish(
            event_type=ExperienceEventType.ROBOTS_UPSERTED,
            aggregate_type="robots_shell",
            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

    async def get_robots(self, tenant_id, shell_id):
        entity = await self.robots.get(tenant_id, shell_id)
        if entity is None:
            raise NotFoundError("robots shell یافت نشد")
        return entity

    async def list_robots(self, tenant_id, *, offset, limit, spec=None):
        items, total = await self.robots.list_filtered(
            tenant_id, offset=offset, limit=limit, spec=spec
        )
        return list(items), total

    async def update_robots(
        self, tenant_id, shell_id, body: RobotsShellUpdate, *, actor=None
    ):
        entity = await self.get_robots(tenant_id, shell_id)
        ensure_optimistic_version(entity, body.version)
        data = body.model_dump(exclude_unset=True, exclude={"version"})
        for field in ("rules_json", "metadata_json"):
            if field in data:
                data[field] = ensure_json_object(data[field], field)
        if "status" in data and data["status"] is not None:
            self.robots_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="robots_shell",
            entity_id=entity.id,
            action=AuditAction.UPDATE,
            actor_user_id=_actor_id(actor),
            changes=_jsonable(data),
        )
        self.events.publish(
            event_type=ExperienceEventType.ROBOTS_UPDATED,
            aggregate_type="robots_shell",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={"status": entity.status.value},
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def upsert_pwa(
        self, tenant_id, body: PwaManifestUpsert, *, actor: CurrentUser | None = None
    ):
        await self._require_workspace(tenant_id, body.workspace_id)
        await self._require_site(tenant_id, body.site_id, workspace_id=body.workspace_id)
        existing = await self.pwa.get_by_site(tenant_id, body.site_id)
        payload = {
            "status": body.status,
            "name": body.name,
            "short_name": body.short_name,
            "start_url": body.start_url,
            "manifest_json": ensure_json_object(body.manifest_json, "manifest_json"),
            "service_worker_ref": validate_external_ref(
                body.service_worker_ref, "service_worker_ref"
            ),
            "metadata_json": ensure_json_object(body.metadata_json, "metadata_json"),
        }
        if existing is not None:
            if body.version is not None:
                ensure_optimistic_version(existing, body.version)
            if body.status != existing.status:
                self.pwa_policy.assert_status_change(
                    current=existing.status, target=body.status
                )
            _apply_update(existing, payload)
            existing.updated_by = _actor_id(actor)
            existing.version += 1
            entity = existing
            action = AuditAction.UPDATE
        else:
            entity = PwaManifest(
                tenant_id=tenant_id,
                workspace_id=body.workspace_id,
                site_id=body.site_id,
                created_by=_actor_id(actor),
                updated_by=_actor_id(actor),
                **payload,
            )
            await self.pwa.add(entity)
            action = AuditAction.CREATE
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="pwa_manifest",
            entity_id=entity.id,
            action=action,
            actor_user_id=_actor_id(actor),
            changes=_jsonable(body.model_dump(exclude={"version"})),
        )
        self.events.publish(
            event_type=ExperienceEventType.PWA_MANIFEST_UPSERTED,
            aggregate_type="pwa_manifest",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={"site_id": str(entity.site_id), "status": entity.status.value},
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def get_pwa(self, tenant_id, manifest_id):
        entity = await self.pwa.get(tenant_id, manifest_id)
        if entity is None:
            raise NotFoundError("PWA manifest یافت نشد")
        return entity

    async def list_pwa(self, tenant_id, *, offset, limit, spec=None):
        items, total = await self.pwa.list_filtered(
            tenant_id, offset=offset, limit=limit, spec=spec
        )
        return list(items), total

    async def update_pwa(
        self, tenant_id, manifest_id, body: PwaManifestUpdate, *, actor=None
    ):
        entity = await self.get_pwa(tenant_id, manifest_id)
        ensure_optimistic_version(entity, body.version)
        data = body.model_dump(exclude_unset=True, exclude={"version"})
        for field in ("manifest_json", "metadata_json"):
            if field in data:
                data[field] = ensure_json_object(data[field], field)
        if "service_worker_ref" in data:
            data["service_worker_ref"] = validate_external_ref(
                data["service_worker_ref"], "service_worker_ref"
            )
        if "status" in data and data["status"] is not None:
            self.pwa_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="pwa_manifest",
            entity_id=entity.id,
            action=AuditAction.UPDATE,
            actor_user_id=_actor_id(actor),
            changes=_jsonable(data),
        )
        self.events.publish(
            event_type=ExperienceEventType.PWA_MANIFEST_UPDATED,
            aggregate_type="pwa_manifest",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={"status": entity.status.value},
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity
