from __future__ import annotations

from datetime import datetime, timezone
from typing import Optional
from uuid import UUID

from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession

from src.apps.notifications.constants import (
    WS_EVENT_NOTIFICATION,
    NotificationScope,
    NotificationType,
)
from src.apps.notifications.models.notification import Notification


class NotificationService:
    """Read/write access to the in-app notification bell.

    Scope discipline: every query below filters on `recipient_scope` AND, for
    tenant scope, on `tenant_id`. RLS is inactive in this database, so nothing
    else prevents a tenant from reading another tenant's — or the platform's —
    notifications.

    Commit discipline: writes only `flush()`. HTTP requests auto-commit in
    `get_db()`; ARQ jobs must commit themselves.
    """

    def __init__(self, db: AsyncSession):
        self.db = db

    # ── Write ──────────────────────────────────────────────────────────────

    async def create(
        self,
        *,
        scope: NotificationScope,
        type: NotificationType,
        title: str,
        tenant_id: Optional[UUID] = None,
        body: Optional[str] = None,
        entity_type: Optional[str] = None,
        entity_id: Optional[UUID] = None,
        link: Optional[str] = None,
        user_id: Optional[UUID] = None,
    ) -> Notification:
        """Insert one notification. Does not broadcast — callers schedule the
        WebSocket fan-out *after* the transaction commits (see
        `build_ws_payload`), so a rolled-back request never announces a row
        that does not exist."""
        if scope == NotificationScope.TENANT and tenant_id is None:
            raise ValueError("tenant-scoped notification requires a tenant_id")
        if scope == NotificationScope.PLATFORM and tenant_id is not None:
            raise ValueError("platform-scoped notification must not carry a tenant_id")

        notification = Notification(
            recipient_scope=scope.value,
            tenant_id=tenant_id,
            user_id=user_id,
            type=type.value,
            title=title,
            body=body,
            entity_type=entity_type,
            entity_id=entity_id,
            link=link,
        )
        self.db.add(notification)
        await self.db.flush()
        return notification

    async def mark_read(
        self,
        notification_id: UUID,
        *,
        scope: NotificationScope,
        tenant_id: Optional[UUID] = None,
    ) -> Optional[Notification]:
        """Mark one notification read. Returns None when the row does not exist
        *or* belongs to another scope/tenant — the caller turns that into a 404
        so the two cases are indistinguishable to a probing client."""
        notification = (
            await self.db.execute(
                select(Notification).where(
                    Notification.id == notification_id,
                    *self._scope_filters(scope, tenant_id),
                )
            )
        ).scalar_one_or_none()
        if notification is None:
            return None
        if notification.read_at is None:
            notification.read_at = datetime.now(timezone.utc)
            await self.db.flush()
        return notification

    async def mark_all_read(
        self,
        *,
        scope: NotificationScope,
        tenant_id: Optional[UUID] = None,
    ) -> int:
        """Mark every unread notification in the scope read. Returns the count
        affected."""
        result = await self.db.execute(
            update(Notification)
            .where(
                Notification.read_at.is_(None),
                *self._scope_filters(scope, tenant_id),
            )
            .values(read_at=datetime.now(timezone.utc))
        )
        await self.db.flush()
        return result.rowcount or 0

    # ── Read ───────────────────────────────────────────────────────────────

    async def list(
        self,
        *,
        scope: NotificationScope,
        tenant_id: Optional[UUID] = None,
        page: int = 1,
        page_size: int = 20,
        unread_only: bool = False,
    ) -> tuple[list[Notification], int]:
        filters = list(self._scope_filters(scope, tenant_id))
        if unread_only:
            filters.append(Notification.read_at.is_(None))

        total = (
            await self.db.execute(
                select(func.count()).select_from(Notification).where(*filters)
            )
        ).scalar_one()

        items = (
            (
                await self.db.execute(
                    select(Notification)
                    .where(*filters)
                    .order_by(Notification.created_at.desc())
                    .offset((page - 1) * page_size)
                    .limit(page_size)
                )
            )
            .scalars()
            .all()
        )
        return list(items), total

    async def count_unread(
        self,
        *,
        scope: NotificationScope,
        tenant_id: Optional[UUID] = None,
    ) -> int:
        return (
            await self.db.execute(
                select(func.count())
                .select_from(Notification)
                .where(
                    Notification.read_at.is_(None),
                    *self._scope_filters(scope, tenant_id),
                )
            )
        ).scalar_one()

    # ── Helpers ────────────────────────────────────────────────────────────

    @staticmethod
    def _scope_filters(scope: NotificationScope, tenant_id: Optional[UUID]):
        """The isolation predicate shared by every query in this service.

        Platform rows are matched on `tenant_id IS NULL` rather than just on
        scope, so a mis-written tenant row can never leak into the site-admin
        bell.
        """
        if scope == NotificationScope.TENANT:
            if tenant_id is None:
                raise ValueError("tenant scope requires a tenant_id")
            return (
                Notification.recipient_scope == NotificationScope.TENANT.value,
                Notification.tenant_id == tenant_id,
            )
        return (
            Notification.recipient_scope == NotificationScope.PLATFORM.value,
            Notification.tenant_id.is_(None),
        )

    @staticmethod
    def build_ws_payload(notification: Notification) -> dict:
        """camelCase frame matching the `notification` variant of the admin
        client's `AdminWSEvent` union."""
        return {
            "type": WS_EVENT_NOTIFICATION,
            "id": str(notification.id),
            "notificationType": notification.type,
            "title": notification.title,
            "body": notification.body or "",
            "link": notification.link or "",
            "createdAt": notification.created_at.isoformat()
            if notification.created_at
            else datetime.now(timezone.utc).isoformat(),
        }
