import logging
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from uuid import UUID
from uuid import uuid4

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

from ..config import Settings
from ..config import get_settings
from ..errors import AppError
from ..errors import ErrorCategory
from ..models import Document
from ..models import Job
from ..models import JobStatus
from .base import ServiceBase

logger = logging.getLogger(__name__)

SUPPORTED_UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}


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


class MissingImageError(DocumentError):
    """Raised when a required image is missing."""


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


@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."""

    async def create_document(self, document: Document) -> Document:
        """Create a new document in the database."""
        async with self.session_factory() as session:
            session.add(document)
            await session.commit()
            await session.refresh(document)
            return document

    async def read_document(self, document_id: UUID) -> Document:
        """Read an existing document from the database.

        The selectinload option is used to eagerly load related jobs for the document.
        """
        async with self.session_factory() as session:
            document = await session.get(
                Document,
                document_id,
                options=(selectinload(Document.jobs),),  # 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 Path(document.file_path).exists():
            raise MissingImageError(
                f"Document with id {document_id} is missing its image file in {self.settings.upload_dir:!s}",
                category=ErrorCategory.NOT_FOUND,
                suggestion="Re-upload the source document and retry.",
            )
        return document

    async def update_document(self, document: Document) -> Document:
        """Update an existing document in the database."""
        async with self.session_factory() as session:
            await session.merge(document)
            await session.commit()
            await session.refresh(document)
            return document

    async def delete_document(self, document: Document) -> None:
        """Delete a document from the database."""
        async with self.session_factory() as session:
            await session.delete(document)
            await session.commit()

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

    async def list_documents(self) -> Sequence[Document]:
        """List all documents in the database."""
        async with self.session_factory() as session:
            return (await session.exec(select(Document))).all()

    async def upload_file(
        self,
        *,
        filename: str,
        file_bytes: bytes,
    ) -> UploadJobResult:
        """Persist an uploaded file and create document/job records."""
        stored_path = store_file(filename=filename, file_bytes=file_bytes, settings=self.settings)
        try:
            document, job = await _create_upload_records(
                session=self.session_factory(),
                original_filename=filename,
                stored_path=stored_path,
            )
        except Exception as exc:
            _best_effort_delete(stored_path)
            raise UploadError(
                "Failed to create upload database records",
                category=ErrorCategory.INFRA_TRANSIENT,
                suggestion="Retry upload. If this keeps happening, verify database availability.",
                retriable=True,
            ) from exc

        logger.info("Created upload job document_id=%s job_id=%s", document.id, job.id)
        return UploadJobResult(
            document_id=document.id,
            job_id=job.id,
            stored_path=stored_path,
            original_filename=Path(filename).name,
        )


def store_file(*, filename: str, file_bytes: bytes, settings: Settings | None = None) -> Path:
    """Persist an uploaded file to the configured upload directory."""
    runtime_settings = settings or get_settings()
    _validate_upload(filename=filename, file_bytes=file_bytes)

    upload_dir = runtime_settings.upload_dir
    upload_dir.mkdir(parents=True, exist_ok=True)

    stored_name = _build_stored_filename(filename)
    stored_path = upload_dir / stored_name

    try:
        stored_path.write_bytes(file_bytes)
    except OSError as exc:
        raise UploadError(
            "Failed to persist upload file",
            category=ErrorCategory.INFRA_PERSISTENT,
            suggestion="Check upload directory permissions and available disk space, then retry.",
        ) from exc

    logger.info("Stored uploaded file: %s", stored_path)
    return stored_path


async def create_upload_job(
    *,
    filename: str,
    file_bytes: bytes,
    session: AsyncSession,
    settings: Settings,
) -> UploadJobResult:
    """Create upload-backed document and queued job records."""
    stored_path = store_file(filename=filename, file_bytes=file_bytes, settings=settings)
    try:
        document, job = await _create_upload_records(
            session=session,
            original_filename=filename,
            stored_path=stored_path,
        )
    except Exception as exc:
        _best_effort_delete(stored_path)
        raise UploadError(
            "Failed to create upload database records",
            category=ErrorCategory.INFRA_TRANSIENT,
            suggestion="Retry upload. If this keeps happening, verify database availability.",
            retriable=True,
        ) from exc

    logger.info("Created upload job document_id=%s job_id=%s", document.id, job.id)
    return UploadJobResult(
        document_id=document.id,
        job_id=job.id,
        stored_path=stored_path,
        original_filename=Path(filename).name,
    )


async def _create_upload_records(
    *,
    session: AsyncSession,
    original_filename: str,
    stored_path: Path,
) -> tuple[Document, Job]:
    document = Document(
        filename=Path(original_filename).name,
        file_path=stored_path,
    )
    session.add(document)
    await session.flush()

    job = Job(
        document_id=document.id,
        status=JobStatus.QUEUED,
    )
    session.add(job)
    await session.commit()
    await session.refresh(document)
    await session.refresh(job)
    return document, job


def _validate_upload(*, filename: str, file_bytes: bytes) -> None:
    if not file_bytes:
        raise UploadError(
            "Upload payload is empty",
            category=ErrorCategory.VALIDATION,
            suggestion="Select a non-empty file and try again.",
        )

    safe_name = Path(filename).name
    if not safe_name:
        raise UploadError(
            "Upload filename is required",
            category=ErrorCategory.VALIDATION,
            suggestion="Choose a file with a valid filename and retry.",
        )

    suffix = Path(safe_name).suffix.lower()
    if suffix not in SUPPORTED_UPLOAD_EXTENSIONS:
        raise UploadError(
            f"Unsupported upload extension: {suffix}",
            category=ErrorCategory.USER_INPUT,
            suggestion="Upload JPG, JPEG, PNG, TIFF, or PDF files only.",
        )


def _build_stored_filename(filename: str) -> str:
    safe_name = Path(filename).name
    return f"{uuid4()}_{safe_name}"


def _best_effort_delete(path: Path) -> None:
    try:
        if path.exists():
            path.unlink()
    except OSError:
        logger.warning("Failed to clean up upload file after DB error: %s", path)
