import logging
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import UTC
from datetime import datetime
from pathlib import Path
import shutil
from uuid import UUID

from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import selectinload
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession

from ..db.models import Document
from ..db.models import DocumentPerson
from ..db.models import Person
from ..errors import AppError
from ..errors import ErrorCategory
from .base import ServiceBase

logger = logging.getLogger(__name__)


class DocumentError(AppError):
    """Raised when document operations fail."""


class MissingSourceError(DocumentError):
    """Raised when a document has no associated sources."""


class UploadError(DocumentError):
    """Raised when uploaded content cannot be persisted safely."""


class DocumentAlreadyExistsError(DocumentError):
    """Raised when a document with the same name already exists in the database."""


class DocumentDeleteBlockedError(DocumentError):
    """Raised when a document delete is blocked by dependent records."""


class PersonDeleteBlockedError(DocumentError):
    """Raised when a person delete is blocked by linked documents."""


@dataclass(frozen=True)
class UploadJobResult:
    """Summary of created upload records."""

    document_id: UUID
    job_id: UUID
    stored_path: Path
    original_filename: str


class DocumentService(ServiceBase):
    """Thin service class for managing documents in the database."""

    #
    # CRUD Operations
    #

    async def create_document(
        self,
        document: Document,
        *,
        session: AsyncSession | None = None,
    ) -> Document:
        """Create a new document in the database."""
        async with self._session_scope(session) as _session:
            _session.add(document)
            try:
                await self._finalize(session=_session, caller_session=session, refresh=(document,))
            except IntegrityError as exc:
                raise DocumentAlreadyExistsError(
                    f"Document with id {document.id} already exists",
                    category=ErrorCategory.VALIDATION,
                    suggestion="Rename the file and try again.",
                ) from exc
            return document

    async def read_document(self, document_id: UUID, *, session: AsyncSession | None = None) -> Document:
        """Read an existing document from the database.

        The selectinload option is used to eagerly load related jobs and sources.
        """
        async with self._session_scope(session) as _session:
            document = await _session.get(
                Document,
                document_id,
                options=(
                    selectinload(Document.jobs),  # pyright: ignore[reportArgumentType]
                    selectinload(Document.sources),  # pyright: ignore[reportArgumentType]
                ),
            )
        if document is None:
            raise DocumentError(
                f"Document with id {document_id} not found",
                category=ErrorCategory.NOT_FOUND,
                suggestion="Re-upload the source document and retry.",
            )
        elif not document.sources:
            raise MissingSourceError(
                f"Document with id {document_id} has no associated source records",
                category=ErrorCategory.NOT_FOUND,
                suggestion="Upload at least one source for this document and retry.",
            )
        return document

    async def update_document(self, document: Document, *, session: AsyncSession | None = None) -> Document:
        """Update an existing document in the database."""
        async with self._session_scope(session) as _session:
            document.updated_at = datetime.now(UTC)
            merged = await _session.merge(document)
            await self._finalize(session=_session, caller_session=session, refresh=(merged,))
            return merged

    async def delete_document(self, document: Document, *, session: AsyncSession | None = None) -> None:
        """Delete a document from the database."""
        document_id = document.id
        async with self._session_scope(session) as _session:
            existing = await _session.get(
                Document,
                document.id,
                options=(
                    selectinload(Document.jobs),  # pyright: ignore[reportArgumentType]
                    selectinload(Document.sources),  # pyright: ignore[reportArgumentType]
                ),
            )
            if existing is None:
                raise DocumentError(
                    f"Document with id {document.id} not found",
                    category=ErrorCategory.NOT_FOUND,
                    suggestion="Verify the document id and retry.",
                )

            has_jobs = bool(existing.jobs)
            has_sources = bool(existing.sources)
            if has_jobs or has_sources:
                blocked_by: list[str] = []
                if has_sources:
                    blocked_by.append("Sources")
                if has_jobs:
                    blocked_by.append("Jobs")
                raise DocumentDeleteBlockedError(
                    f"Document delete blocked by related records: {', '.join(blocked_by)}",
                    category=ErrorCategory.VALIDATION,
                    suggestion="Remove related Sources and Jobs first, then retry deletion.",
                )

            await _session.delete(existing)
            await self._finalize(session=_session, caller_session=session)

        self._delete_document_storage_folder(document_id=document_id)

    def _delete_document_storage_folder(self, *, document_id: UUID) -> None:
        """Best-effort cleanup for document-scoped source storage."""
        document_dir = self.settings.upload_dir / "documents" / str(document_id)
        if not document_dir.exists():
            return

        try:
            shutil.rmtree(document_dir)
            logger.info("Deleted document storage folder: %s", document_dir)
        except OSError:
            logger.warning("Failed to delete document storage folder: %s", document_dir)

    async def create_person(self, person: Person, *, session: AsyncSession | None = None) -> Person:
        """Create a new person in the database."""
        async with self._session_scope(session) as _session:
            _session.add(person)
            await self._finalize(session=_session, caller_session=session, refresh=(person,))
            return person

    async def read_person(self, person_id: UUID, *, session: AsyncSession | None = None) -> Person:
        """Read an existing person from the database."""
        async with self._session_scope(session) as _session:
            person = await _session.get(Person, person_id)
        if person is None:
            raise DocumentError(
                f"Person with id {person_id} not found",
                category=ErrorCategory.NOT_FOUND,
                suggestion="Verify the person id and retry.",
            )
        return person

    async def read_person_detail(self, person_id: UUID, *, session: AsyncSession | None = None) -> Person:
        """Read a person with eagerly loaded document links for UI detail rendering."""
        async with self._session_scope(session) as _session:
            query = (
                select(Person)
                .options(
                    selectinload(Person.document_people).selectinload(DocumentPerson.document),  # pyright: ignore[reportArgumentType]
                )
                .where(Person.id == person_id)
                .execution_options(populate_existing=True)
            )
            person = (await _session.exec(query)).first()

        if person is None:
            raise DocumentError(
                f"Person with id {person_id} not found",
                category=ErrorCategory.NOT_FOUND,
                suggestion="Verify the person id and retry.",
            )
        return person

    async def update_person(self, person: Person, *, session: AsyncSession | None = None) -> Person:
        """Update an existing person in the database."""
        async with self._session_scope(session) as _session:
            person.updated_at = datetime.now(UTC)
            merged = await _session.merge(person)
            await self._finalize(session=_session, caller_session=session, refresh=(merged,))
            return merged

    async def delete_person(self, person: Person, *, session: AsyncSession | None = None) -> None:
        """Delete a person from the database."""
        async with self._session_scope(session) as _session:
            existing = await _session.get(
                Person,
                person.id,
                options=(
                    selectinload(Person.document_people),  # pyright: ignore[reportArgumentType]
                ),
            )
            if existing is None:
                raise DocumentError(
                    f"Person with id {person.id} not found",
                    category=ErrorCategory.NOT_FOUND,
                    suggestion="Verify the person id and retry.",
                )

            for link in list(existing.document_people):
                await _session.delete(link)

            await _session.delete(existing)
            await self._finalize(session=_session, caller_session=session)

    async def create_document_person(
        self,
        document_person: DocumentPerson,
        *,
        session: AsyncSession | None = None,
    ) -> DocumentPerson:
        """Create a document-person association in the database."""
        async with self._session_scope(session) as _session:
            _session.add(document_person)
            await self._finalize(session=_session, caller_session=session, refresh=(document_person,))
            return document_person

    async def read_document_person(
        self,
        document_person_id: UUID,
        *,
        session: AsyncSession | None = None,
    ) -> DocumentPerson:
        """Read an existing document-person association from the database."""
        async with self._session_scope(session) as _session:
            document_person = await _session.get(DocumentPerson, document_person_id)
        if document_person is None:
            raise DocumentError(
                f"DocumentPerson with id {document_person_id} not found",
                category=ErrorCategory.NOT_FOUND,
                suggestion="Verify the document-person id and retry.",
            )
        return document_person

    async def update_document_person(
        self,
        document_person: DocumentPerson,
        *,
        session: AsyncSession | None = None,
    ) -> DocumentPerson:
        """Update an existing document-person association in the database."""
        async with self._session_scope(session) as _session:
            merged = await _session.merge(document_person)
            await self._finalize(session=_session, caller_session=session, refresh=(merged,))
            return merged

    async def delete_document_person(
        self,
        document_person: DocumentPerson,
        *,
        session: AsyncSession | None = None,
    ) -> None:
        """Delete a document-person association from the database."""
        async with self._session_scope(session) as _session:
            await _session.delete(document_person)
            await self._finalize(session=_session, caller_session=session)

    # Query Operations

    async def query_documents(
        self, *, name: str | None = None, session: AsyncSession | None = None
    ) -> Sequence[Document]:
        """Query documents from the database based on provided filters."""
        async with self._session_scope(session) as _session:
            query = select(Document)
            if name is not None:
                query = query.where(Document.name == name)
            result = await _session.exec(query)
            return result.all()

    async def list_documents(self, *, session: AsyncSession | None = None) -> Sequence[Document]:
        """List all documents in the database."""
        async with self._session_scope(session) as _session:
            result = await _session.exec(select(Document))
            return result.all()

    async def read_document_detail(self, document_id: UUID, *, session: AsyncSession | None = None) -> Document:
        """Read a document with eagerly loaded relations for UI detail rendering."""
        async with self._session_scope(session) as _session:
            query = (
                select(Document)
                .options(
                    selectinload(Document.jobs),  # pyright: ignore[reportArgumentType]
                    selectinload(Document.sources),  # pyright: ignore[reportArgumentType]
                    selectinload(Document.document_people).selectinload(DocumentPerson.person),  # pyright: ignore[reportArgumentType]
                )
                .where(Document.id == document_id)
                .execution_options(populate_existing=True)
            )
            document = (await _session.exec(query)).first()
        if document is None:
            raise DocumentError(
                f"Document with id {document_id} not found",
                category=ErrorCategory.NOT_FOUND,
                suggestion="Verify the document id and retry.",
            )
        return document

    async def list_people(self, *, session: AsyncSession | None = None) -> Sequence[Person]:
        """List all people in the database."""
        async with self._session_scope(session) as _session:
            result = await _session.exec(select(Person))
            return result.all()

    async def list_document_people(
        self,
        *,
        document_id: UUID | None = None,
        person_id: UUID | None = None,
        session: AsyncSession | None = None,
    ) -> Sequence[DocumentPerson]:
        """List document-person associations, optionally filtered by document or person."""
        async with self._session_scope(session) as _session:
            query = select(DocumentPerson).options(
                selectinload(DocumentPerson.document),  # pyright: ignore[reportArgumentType]
                selectinload(DocumentPerson.person),  # pyright: ignore[reportArgumentType]
            )
            if document_id is not None:
                query = query.where(DocumentPerson.document_id == document_id)
            if person_id is not None:
                query = query.where(DocumentPerson.person_id == person_id)
            result = await _session.exec(query)
            return result.all()
