"""Forms, surveys & appointment application services — Phase 11.6."""
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.forms import (
    AppointmentBookingRef,
    ExperienceAppointmentPage,
    ExperienceForm,
    ExperienceSurvey,
    FormSubmission,
    SurveyResponse,
)
from app.models.types import (
    AppointmentPageStatus,
    AuditAction,
    FormStatus,
    FormSubmissionStatus,
    SurveyResponseStatus,
    SurveyStatus,
)
from app.policies.forms import (
    AppointmentBookingPolicy,
    AppointmentPagePolicy,
    FormLifecyclePolicy,
    FormSubmissionPolicy,
    SurveyLifecyclePolicy,
    SurveyResponsePolicy,
)
from app.repositories.forms import (
    AppointmentBookingRefRepository,
    ExperienceAppointmentPageRepository,
    ExperienceFormRepository,
    ExperienceSurveyRepository,
    FormSubmissionRepository,
    SurveyResponseRepository,
)
from app.repositories.foundation import ExperienceWorkspaceRepository
from app.repositories.sites import ExperiencePageRepository, ExperienceSiteRepository
from app.schemas.forms import (
    AppointmentBookingRefCreate,
    AppointmentBookingRefUpdate,
    ExperienceAppointmentPageCreate,
    ExperienceAppointmentPageUpdate,
    ExperienceFormCreate,
    ExperienceFormUpdate,
    ExperienceSurveyCreate,
    ExperienceSurveyUpdate,
    FormSubmissionCreate,
    FormSubmissionUpdate,
    SurveyResponseCreate,
    SurveyResponseUpdate,
)
from app.services.audit_service import AuditService
from app.specifications.forms import (
    AppointmentBookingListSpec,
    AppointmentPageListSpec,
    FormListSpec,
    FormSubmissionListSpec,
    SurveyListSpec,
    SurveyResponseListSpec,
)
from app.validators import ensure_optimistic_version, validate_code, validate_non_empty
from app.validators.forms import ensure_json_object, validate_external_ref
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 ExperienceFormsService:
    def __init__(self, session: AsyncSession) -> None:
        self.session = session
        self.forms = ExperienceFormRepository(session)
        self.submissions = FormSubmissionRepository(session)
        self.surveys = ExperienceSurveyRepository(session)
        self.responses = SurveyResponseRepository(session)
        self.appointment_pages = ExperienceAppointmentPageRepository(session)
        self.bookings = AppointmentBookingRefRepository(session)
        self.workspaces = ExperienceWorkspaceRepository(session)
        self.sites = ExperienceSiteRepository(session)
        self.pages = ExperiencePageRepository(session)
        self.audit = AuditService(session)
        self.events = get_event_publisher()
        self.form_policy = FormLifecyclePolicy()
        self.submission_policy = FormSubmissionPolicy()
        self.survey_policy = SurveyLifecyclePolicy()
        self.response_policy = SurveyResponsePolicy()
        self.appointment_policy = AppointmentPagePolicy()
        self.booking_policy = AppointmentBookingPolicy()

    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

    # ── Forms ─────────────────────────────────────────────────────────

    async def create_form(
        self,
        tenant_id: UUID,
        body: ExperienceFormCreate,
        *,
        actor: CurrentUser | None = None,
    ) -> ExperienceForm:
        await self._require_workspace(tenant_id, body.workspace_id)
        if body.site_id is not None:
            await self._require_site(
                tenant_id, body.site_id, workspace_id=body.workspace_id
            )
        code = validate_code(body.code)
        if await self.forms.get_by_code(tenant_id, body.workspace_id, code):
            raise AppError(
                "کد فرم تکراری است", error_code="duplicate_code", status_code=409
            )
        entity = ExperienceForm(
            tenant_id=tenant_id,
            workspace_id=body.workspace_id,
            site_id=body.site_id,
            page_id=body.page_id,
            code=code,
            name=validate_non_empty(body.name, "name"),
            description=body.description,
            status=body.status,
            form_schema_json=ensure_json_object(
                body.form_schema_json, "form_schema_json"
            ),
            submit_action_json=ensure_json_object(
                body.submit_action_json, "submit_action_json"
            ),
            metadata_json=ensure_json_object(body.metadata_json, "metadata_json"),
            created_by=_actor_id(actor),
            updated_by=_actor_id(actor),
        )
        try:
            await self.forms.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="form",
            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.FORM_CREATED,
            aggregate_type="form",
            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 get_form(self, tenant_id: UUID, form_id: UUID) -> ExperienceForm:
        entity = await self.forms.get(tenant_id, form_id)
        if entity is None:
            raise NotFoundError("فرم یافت نشد")
        return entity

    async def list_forms(
        self,
        tenant_id: UUID,
        *,
        offset: int,
        limit: int,
        spec: FormListSpec | None = None,
    ) -> tuple[list[ExperienceForm], int]:
        items, total = await self.forms.list_filtered(
            tenant_id, offset=offset, limit=limit, spec=spec
        )
        return list(items), total

    async def update_form(
        self,
        tenant_id: UUID,
        form_id: UUID,
        body: ExperienceFormUpdate,
        *,
        actor: CurrentUser | None = None,
    ) -> ExperienceForm:
        entity = await self.get_form(tenant_id, form_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")
        for field in ("form_schema_json", "submit_action_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.form_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="form",
            entity_id=entity.id,
            action=AuditAction.UPDATE,
            actor_user_id=_actor_id(actor),
            changes=_jsonable_changes(data),
        )
        self.events.publish(
            event_type=ExperienceEventType.FORM_UPDATED,
            aggregate_type="form",
            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 soft_delete_form(
        self, tenant_id: UUID, form_id: UUID, *, actor: CurrentUser | None = None
    ) -> ExperienceForm:
        entity = await self.get_form(tenant_id, form_id)
        await self.forms.soft_delete(entity, deleted_by=_actor_id(actor))
        entity.status = FormStatus.ARCHIVED
        entity.updated_by = _actor_id(actor)
        entity.version += 1
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="form",
            entity_id=entity.id,
            action=AuditAction.DELETE,
            actor_user_id=_actor_id(actor),
            changes={"is_deleted": True},
        )
        self.events.publish(
            event_type=ExperienceEventType.FORM_ARCHIVED,
            aggregate_type="form",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={"code": entity.code},
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def create_submission(
        self,
        tenant_id: UUID,
        body: FormSubmissionCreate,
        *,
        actor: CurrentUser | None = None,
    ) -> FormSubmission:
        await self._require_workspace(tenant_id, body.workspace_id)
        form = await self.get_form(tenant_id, body.form_id)
        if form.workspace_id != body.workspace_id:
            raise AppError(
                "فرم متعلق به این فضای کاری نیست",
                error_code="form_workspace_mismatch",
                status_code=422,
            )
        self.form_policy.assert_accepts_submissions(form)
        entity = FormSubmission(
            tenant_id=tenant_id,
            workspace_id=body.workspace_id,
            form_id=body.form_id,
            status=FormSubmissionStatus.RECEIVED,
            payload_json=ensure_json_object(body.payload_json, "payload_json"),
            crm_contact_ref=validate_external_ref(
                body.crm_contact_ref, "crm_contact_ref"
            ),
            communication_ref=validate_external_ref(
                body.communication_ref, "communication_ref"
            ),
            external_ref=validate_external_ref(body.external_ref, "external_ref"),
            submitted_at=datetime.now(timezone.utc),
            metadata_json=ensure_json_object(body.metadata_json, "metadata_json"),
            created_by=_actor_id(actor),
            updated_by=_actor_id(actor),
        )
        await self.submissions.add(entity)
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="form_submission",
            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.FORM_SUBMISSION_RECEIVED,
            aggregate_type="form_submission",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={
                "form_id": str(entity.form_id),
                "crm_contact_ref": entity.crm_contact_ref,
            },
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def get_submission(
        self, tenant_id: UUID, submission_id: UUID
    ) -> FormSubmission:
        entity = await self.submissions.get(tenant_id, submission_id)
        if entity is None:
            raise NotFoundError("ارسال فرم یافت نشد")
        return entity

    async def list_submissions(
        self,
        tenant_id: UUID,
        *,
        offset: int,
        limit: int,
        spec: FormSubmissionListSpec | None = None,
    ) -> tuple[list[FormSubmission], int]:
        items, total = await self.submissions.list_filtered(
            tenant_id, offset=offset, limit=limit, spec=spec
        )
        return list(items), total

    async def update_submission(
        self,
        tenant_id: UUID,
        submission_id: UUID,
        body: FormSubmissionUpdate,
        *,
        actor: CurrentUser | None = None,
    ) -> FormSubmission:
        entity = await self.get_submission(tenant_id, submission_id)
        ensure_optimistic_version(entity, body.version)
        data = body.model_dump(exclude_unset=True, exclude={"version"})
        for field in ("crm_contact_ref", "communication_ref", "external_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.submission_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="form_submission",
            entity_id=entity.id,
            action=AuditAction.UPDATE,
            actor_user_id=_actor_id(actor),
            changes=_jsonable_changes(data),
        )
        self.events.publish(
            event_type=ExperienceEventType.FORM_SUBMISSION_UPDATED,
            aggregate_type="form_submission",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={"status": entity.status.value},
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    # ── Surveys ───────────────────────────────────────────────────────

    async def create_survey(
        self,
        tenant_id: UUID,
        body: ExperienceSurveyCreate,
        *,
        actor: CurrentUser | None = None,
    ) -> ExperienceSurvey:
        await self._require_workspace(tenant_id, body.workspace_id)
        if body.site_id is not None:
            await self._require_site(
                tenant_id, body.site_id, workspace_id=body.workspace_id
            )
        code = validate_code(body.code)
        if await self.surveys.get_by_code(tenant_id, body.workspace_id, code):
            raise AppError(
                "کد نظرسنجی تکراری است", error_code="duplicate_code", status_code=409
            )
        entity = ExperienceSurvey(
            tenant_id=tenant_id,
            workspace_id=body.workspace_id,
            site_id=body.site_id,
            page_id=body.page_id,
            code=code,
            name=validate_non_empty(body.name, "name"),
            description=body.description,
            status=body.status,
            questions_json=ensure_json_object(body.questions_json, "questions_json"),
            metadata_json=ensure_json_object(body.metadata_json, "metadata_json"),
            created_by=_actor_id(actor),
            updated_by=_actor_id(actor),
        )
        try:
            await self.surveys.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="survey",
            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.SURVEY_CREATED,
            aggregate_type="survey",
            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 get_survey(self, tenant_id: UUID, survey_id: UUID) -> ExperienceSurvey:
        entity = await self.surveys.get(tenant_id, survey_id)
        if entity is None:
            raise NotFoundError("نظرسنجی یافت نشد")
        return entity

    async def list_surveys(
        self,
        tenant_id: UUID,
        *,
        offset: int,
        limit: int,
        spec: SurveyListSpec | None = None,
    ) -> tuple[list[ExperienceSurvey], int]:
        items, total = await self.surveys.list_filtered(
            tenant_id, offset=offset, limit=limit, spec=spec
        )
        return list(items), total

    async def update_survey(
        self,
        tenant_id: UUID,
        survey_id: UUID,
        body: ExperienceSurveyUpdate,
        *,
        actor: CurrentUser | None = None,
    ) -> ExperienceSurvey:
        entity = await self.get_survey(tenant_id, survey_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")
        for field in ("questions_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.survey_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="survey",
            entity_id=entity.id,
            action=AuditAction.UPDATE,
            actor_user_id=_actor_id(actor),
            changes=_jsonable_changes(data),
        )
        self.events.publish(
            event_type=ExperienceEventType.SURVEY_UPDATED,
            aggregate_type="survey",
            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 soft_delete_survey(
        self, tenant_id: UUID, survey_id: UUID, *, actor: CurrentUser | None = None
    ) -> ExperienceSurvey:
        entity = await self.get_survey(tenant_id, survey_id)
        await self.surveys.soft_delete(entity, deleted_by=_actor_id(actor))
        entity.status = SurveyStatus.ARCHIVED
        entity.updated_by = _actor_id(actor)
        entity.version += 1
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="survey",
            entity_id=entity.id,
            action=AuditAction.DELETE,
            actor_user_id=_actor_id(actor),
            changes={"is_deleted": True},
        )
        self.events.publish(
            event_type=ExperienceEventType.SURVEY_ARCHIVED,
            aggregate_type="survey",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={"code": entity.code},
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def create_survey_response(
        self,
        tenant_id: UUID,
        body: SurveyResponseCreate,
        *,
        actor: CurrentUser | None = None,
    ) -> SurveyResponse:
        await self._require_workspace(tenant_id, body.workspace_id)
        survey = await self.get_survey(tenant_id, body.survey_id)
        if survey.workspace_id != body.workspace_id:
            raise AppError(
                "نظرسنجی متعلق به این فضای کاری نیست",
                error_code="survey_workspace_mismatch",
                status_code=422,
            )
        self.survey_policy.assert_accepts_responses(survey)
        entity = SurveyResponse(
            tenant_id=tenant_id,
            workspace_id=body.workspace_id,
            survey_id=body.survey_id,
            status=SurveyResponseStatus.RECEIVED,
            answers_json=ensure_json_object(body.answers_json, "answers_json"),
            crm_contact_ref=validate_external_ref(
                body.crm_contact_ref, "crm_contact_ref"
            ),
            external_ref=validate_external_ref(body.external_ref, "external_ref"),
            submitted_at=datetime.now(timezone.utc),
            metadata_json=ensure_json_object(body.metadata_json, "metadata_json"),
            created_by=_actor_id(actor),
            updated_by=_actor_id(actor),
        )
        await self.responses.add(entity)
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="survey_response",
            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.SURVEY_RESPONSE_RECEIVED,
            aggregate_type="survey_response",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={"survey_id": str(entity.survey_id)},
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def get_survey_response(
        self, tenant_id: UUID, response_id: UUID
    ) -> SurveyResponse:
        entity = await self.responses.get(tenant_id, response_id)
        if entity is None:
            raise NotFoundError("پاسخ نظرسنجی یافت نشد")
        return entity

    async def list_survey_responses(
        self,
        tenant_id: UUID,
        *,
        offset: int,
        limit: int,
        spec: SurveyResponseListSpec | None = None,
    ) -> tuple[list[SurveyResponse], int]:
        items, total = await self.responses.list_filtered(
            tenant_id, offset=offset, limit=limit, spec=spec
        )
        return list(items), total

    async def update_survey_response(
        self,
        tenant_id: UUID,
        response_id: UUID,
        body: SurveyResponseUpdate,
        *,
        actor: CurrentUser | None = None,
    ) -> SurveyResponse:
        entity = await self.get_survey_response(tenant_id, response_id)
        ensure_optimistic_version(entity, body.version)
        data = body.model_dump(exclude_unset=True, exclude={"version"})
        for field in ("crm_contact_ref", "external_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.response_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="survey_response",
            entity_id=entity.id,
            action=AuditAction.UPDATE,
            actor_user_id=_actor_id(actor),
            changes=_jsonable_changes(data),
        )
        self.events.publish(
            event_type=ExperienceEventType.SURVEY_RESPONSE_UPDATED,
            aggregate_type="survey_response",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={"status": entity.status.value},
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    # ── Appointment pages & booking refs ──────────────────────────────

    async def create_appointment_page(
        self,
        tenant_id: UUID,
        body: ExperienceAppointmentPageCreate,
        *,
        actor: CurrentUser | None = None,
    ) -> ExperienceAppointmentPage:
        await self._require_workspace(tenant_id, body.workspace_id)
        await self._require_site(
            tenant_id, body.site_id, workspace_id=body.workspace_id
        )
        code = validate_code(body.code)
        if await self.appointment_pages.get_by_code(
            tenant_id, body.workspace_id, code
        ):
            raise AppError(
                "کد صفحه نوبت تکراری است",
                error_code="duplicate_code",
                status_code=409,
            )
        entity = ExperienceAppointmentPage(
            tenant_id=tenant_id,
            workspace_id=body.workspace_id,
            site_id=body.site_id,
            page_id=body.page_id,
            code=code,
            name=validate_non_empty(body.name, "name"),
            description=body.description,
            status=body.status,
            booking_provider_ref=validate_external_ref(
                body.booking_provider_ref, "booking_provider_ref"
            ),
            service_ref=validate_external_ref(body.service_ref, "service_ref"),
            timezone=body.timezone,
            availability_shell_json=ensure_json_object(
                body.availability_shell_json, "availability_shell_json"
            ),
            metadata_json=ensure_json_object(body.metadata_json, "metadata_json"),
            created_by=_actor_id(actor),
            updated_by=_actor_id(actor),
        )
        try:
            await self.appointment_pages.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="appointment_page",
            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.APPOINTMENT_PAGE_CREATED,
            aggregate_type="appointment_page",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={
                "code": entity.code,
                "booking_provider_ref": entity.booking_provider_ref,
            },
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def get_appointment_page(
        self, tenant_id: UUID, page_id: UUID
    ) -> ExperienceAppointmentPage:
        entity = await self.appointment_pages.get(tenant_id, page_id)
        if entity is None:
            raise NotFoundError("صفحه نوبت یافت نشد")
        return entity

    async def list_appointment_pages(
        self,
        tenant_id: UUID,
        *,
        offset: int,
        limit: int,
        spec: AppointmentPageListSpec | None = None,
    ) -> tuple[list[ExperienceAppointmentPage], int]:
        items, total = await self.appointment_pages.list_filtered(
            tenant_id, offset=offset, limit=limit, spec=spec
        )
        return list(items), total

    async def update_appointment_page(
        self,
        tenant_id: UUID,
        page_id: UUID,
        body: ExperienceAppointmentPageUpdate,
        *,
        actor: CurrentUser | None = None,
    ) -> ExperienceAppointmentPage:
        entity = await self.get_appointment_page(tenant_id, page_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")
        for field in ("booking_provider_ref", "service_ref"):
            if field in data:
                data[field] = validate_external_ref(data[field], field)
        for field in ("availability_shell_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.appointment_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="appointment_page",
            entity_id=entity.id,
            action=AuditAction.UPDATE,
            actor_user_id=_actor_id(actor),
            changes=_jsonable_changes(data),
        )
        self.events.publish(
            event_type=ExperienceEventType.APPOINTMENT_PAGE_UPDATED,
            aggregate_type="appointment_page",
            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 soft_delete_appointment_page(
        self, tenant_id: UUID, page_id: UUID, *, actor: CurrentUser | None = None
    ) -> ExperienceAppointmentPage:
        entity = await self.get_appointment_page(tenant_id, page_id)
        await self.appointment_pages.soft_delete(entity, deleted_by=_actor_id(actor))
        entity.status = AppointmentPageStatus.ARCHIVED
        entity.updated_by = _actor_id(actor)
        entity.version += 1
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="appointment_page",
            entity_id=entity.id,
            action=AuditAction.DELETE,
            actor_user_id=_actor_id(actor),
            changes={"is_deleted": True},
        )
        self.events.publish(
            event_type=ExperienceEventType.APPOINTMENT_PAGE_ARCHIVED,
            aggregate_type="appointment_page",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={"code": entity.code},
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def create_booking_ref(
        self,
        tenant_id: UUID,
        body: AppointmentBookingRefCreate,
        *,
        actor: CurrentUser | None = None,
    ) -> AppointmentBookingRef:
        await self._require_workspace(tenant_id, body.workspace_id)
        page = await self.get_appointment_page(tenant_id, body.appointment_page_id)
        if page.workspace_id != body.workspace_id:
            raise AppError(
                "صفحه نوبت متعلق به این فضای کاری نیست",
                error_code="appointment_page_workspace_mismatch",
                status_code=422,
            )
        self.appointment_policy.assert_accepts_bookings(page)
        external_ref = validate_external_ref(
            body.external_booking_ref, "external_booking_ref"
        )
        assert external_ref is not None
        if await self.bookings.get_by_external_ref(
            tenant_id, body.appointment_page_id, external_ref
        ):
            raise AppError(
                "ارجاع رزرو تکراری است",
                error_code="duplicate_booking_ref",
                status_code=409,
            )
        entity = AppointmentBookingRef(
            tenant_id=tenant_id,
            workspace_id=body.workspace_id,
            appointment_page_id=body.appointment_page_id,
            external_booking_ref=external_ref,
            status=body.status,
            crm_contact_ref=validate_external_ref(
                body.crm_contact_ref, "crm_contact_ref"
            ),
            slot_start=body.slot_start,
            slot_end=body.slot_end,
            metadata_json=ensure_json_object(body.metadata_json, "metadata_json"),
            created_by=_actor_id(actor),
            updated_by=_actor_id(actor),
        )
        try:
            await self.bookings.add(entity)
        except IntegrityError as exc:
            raise AppError(
                "ارجاع رزرو تکراری است",
                error_code="duplicate_booking_ref",
                status_code=409,
            ) from exc
        await self.audit.record(
            tenant_id=tenant_id,
            entity_type="appointment_booking_ref",
            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.APPOINTMENT_BOOKING_REF_CREATED,
            aggregate_type="appointment_booking_ref",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={
                "appointment_page_id": str(entity.appointment_page_id),
                "external_booking_ref": entity.external_booking_ref,
            },
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity

    async def get_booking_ref(
        self, tenant_id: UUID, booking_id: UUID
    ) -> AppointmentBookingRef:
        entity = await self.bookings.get(tenant_id, booking_id)
        if entity is None:
            raise NotFoundError("ارجاع رزرو یافت نشد")
        return entity

    async def list_booking_refs(
        self,
        tenant_id: UUID,
        *,
        offset: int,
        limit: int,
        spec: AppointmentBookingListSpec | None = None,
    ) -> tuple[list[AppointmentBookingRef], int]:
        items, total = await self.bookings.list_filtered(
            tenant_id, offset=offset, limit=limit, spec=spec
        )
        return list(items), total

    async def update_booking_ref(
        self,
        tenant_id: UUID,
        booking_id: UUID,
        body: AppointmentBookingRefUpdate,
        *,
        actor: CurrentUser | None = None,
    ) -> AppointmentBookingRef:
        entity = await self.get_booking_ref(tenant_id, booking_id)
        ensure_optimistic_version(entity, body.version)
        data = body.model_dump(exclude_unset=True, exclude={"version"})
        if "crm_contact_ref" in data:
            data["crm_contact_ref"] = validate_external_ref(
                data["crm_contact_ref"], "crm_contact_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.booking_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="appointment_booking_ref",
            entity_id=entity.id,
            action=AuditAction.UPDATE,
            actor_user_id=_actor_id(actor),
            changes=_jsonable_changes(data),
        )
        self.events.publish(
            event_type=ExperienceEventType.APPOINTMENT_BOOKING_REF_UPDATED,
            aggregate_type="appointment_booking_ref",
            aggregate_id=entity.id,
            tenant_id=tenant_id,
            payload={"status": entity.status.value},
        )
        await self.session.commit()
        await self.session.refresh(entity)
        return entity
