from __future__ import annotations

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

from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession

from transcription.config import Settings
from transcription.config import get_settings
from transcription.errors import AppError
from transcription.errors import ErrorCategory

from ..db.models import Document
from ..db.models import Job
from ..db.models import JobSource
from ..db.models import JobSourceStatus
from ..db.models import Source
from .documents import UploadJobResult

logger = logging.getLogger(__name__)

SUPPORTED_UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}
SUPPORTED_PORTRAIT_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff"}


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


@dataclass(frozen=True)
class JobCreateResult:
    """Summary of explicit Job create records."""

    document_id: UUID
    job_id: UUID
    source_ids: tuple[UUID, ...]


@dataclass(frozen=True)
class PendingStoredUpload:
    """Pre-staged upload artifact tied to a source id."""

    source_id: UUID
    original_filename: str
    stored_path: Path


async def create_upload_job(
    *,
    filename: str,
    file_bytes: bytes,
    session: AsyncSession,
    settings: Settings | None = None,
) -> UploadJobResult:
    """Create upload-backed document and queued job records."""
    runtime_settings = settings or get_settings()
    document_id = uuid4()
    source_id = uuid4()
    stored_path = store_file(
        filename=filename,
        file_bytes=file_bytes,
        settings=runtime_settings,
        relative_directory=Path("documents") / str(document_id),
        filename_stem=str(source_id),
    )
    try:
        document, job = await _create_upload_records(
            session=session,
            document_id=document_id,
            source_id=source_id,
            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_job_for_document(
    *,
    document_id: UUID,
    uploads: Sequence[tuple[str, bytes]],
    session: AsyncSession,
    provider: str | None = None,
    model: str | None = None,
    prompt_name: str | None = None,
    settings: Settings | None = None,
) -> JobCreateResult:
    """Create a queued job for an existing document with one or more uploaded sources."""
    if not uploads:
        raise UploadError(
            "At least one upload is required to create a job",
            category=ErrorCategory.VALIDATION,
            suggestion="Upload one or more files and try again.",
        )

    runtime_settings = settings or get_settings()
    sorted_uploads = sorted(uploads, key=lambda item: Path(item[0]).name.casefold())
    stored_uploads: list[PendingStoredUpload] = []
    for filename, file_bytes in sorted_uploads:
        source_id = uuid4()
        stored_uploads.append(
            PendingStoredUpload(
                source_id=source_id,
                original_filename=filename,
                stored_path=store_file(
                    filename=filename,
                    file_bytes=file_bytes,
                    settings=runtime_settings,
                    relative_directory=Path("documents") / str(document_id),
                    filename_stem=str(source_id),
                ),
            )
        )

    try:
        job, source_ids = await _create_job_for_document_records(
            session=session,
            document_id=document_id,
            stored_uploads=stored_uploads,
            provider=provider,
            model=model,
            prompt_name=prompt_name,
        )
    except Exception as exc:
        for upload in stored_uploads:
            _best_effort_delete(upload.stored_path)
        raise UploadError(
            "Failed to create job records from uploads",
            category=ErrorCategory.INFRA_TRANSIENT,
            suggestion="Retry creation. If this keeps happening, verify database availability.",
            retriable=True,
        ) from exc

    logger.info("Created explicit job document_id=%s job_id=%s sources=%s", document_id, job.id, len(source_ids))
    return JobCreateResult(
        document_id=document_id,
        job_id=job.id,
        source_ids=tuple(source_ids),
    )


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

    job = Job(document_id=document.id)
    session.add(job)
    await session.flush()

    source = Source(
        id=source_id,
        document_id=document.id,
        page_number=1,
        upload_name=Path(original_filename).name,
        filename=stored_path.name,
        file_path=str(stored_path),
    )
    session.add(source)
    await session.flush()

    session.add(
        JobSource(
            job_id=job.id,
            source_id=source.id,
            status=JobSourceStatus.PENDING,
        )
    )

    await session.commit()
    await session.refresh(document)
    await session.refresh(job)
    return document, job


async def _create_job_for_document_records(
    *,
    session: AsyncSession,
    document_id: UUID,
    stored_uploads: Sequence[PendingStoredUpload],
    provider: str | None,
    model: str | None,
    prompt_name: str | None,
) -> tuple[Job, list[UUID]]:
    document = await session.get(Document, document_id)
    if document is None:
        raise UploadError(
            f"Document with id {document_id} not found",
            category=ErrorCategory.NOT_FOUND,
            suggestion="Select an existing document and retry.",
        )

    existing_sources = (
        await session.exec(select(Source).where(Source.document_id == document_id))
    ).all()
    next_page_number = (max((source.page_number for source in existing_sources), default=0) + 1)

    job = Job(
        document_id=document_id,
        provider=(provider or None),
        model=(model or None),
        prompt_name=(prompt_name or None),
    )
    session.add(job)
    await session.flush()

    source_ids: list[UUID] = []
    for page_offset, upload in enumerate(stored_uploads):
        source = Source(
            id=upload.source_id,
            document_id=document_id,
            page_number=next_page_number + page_offset,
            upload_name=Path(upload.original_filename).name,
            filename=upload.stored_path.name,
            file_path=str(upload.stored_path),
        )
        session.add(source)
        await session.flush()
        source_ids.append(source.id)

        session.add(
            JobSource(
                job_id=job.id,
                source_id=source.id,
                status=JobSourceStatus.PENDING,
            )
        )

    await session.commit()
    await session.refresh(job)
    return job, source_ids


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)


def store_file(
    *,
    filename: str,
    file_bytes: bytes,
    settings: Settings | None = None,
    relative_directory: Path | None = None,
    filename_stem: str | 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, supported_extensions=SUPPORTED_UPLOAD_EXTENSIONS)
    return _store_file_bytes(
        filename=filename,
        file_bytes=file_bytes,
        settings=runtime_settings,
        relative_directory=relative_directory,
        filename_stem=filename_stem,
    )


def store_person_portrait(
    *,
    person_id: UUID,
    filename: str,
    file_bytes: bytes,
    settings: Settings | None = None,
) -> Path:
    """Persist a portrait upload under persons/<person_id>."""
    runtime_settings = settings or get_settings()
    _validate_upload(filename=filename, file_bytes=file_bytes, supported_extensions=SUPPORTED_PORTRAIT_EXTENSIONS)
    return _store_file_bytes(
        filename=filename,
        file_bytes=file_bytes,
        settings=runtime_settings,
        relative_directory=Path("persons") / str(person_id),
    )


def _store_file_bytes(
    *,
    filename: str,
    file_bytes: bytes,
    settings: Settings,
    relative_directory: Path | None = None,
    filename_stem: str | None = None,
) -> Path:
    upload_dir = settings.upload_dir
    target_dir = upload_dir if relative_directory is None else upload_dir / relative_directory
    target_dir.mkdir(parents=True, exist_ok=True)

    stored_name = _build_stored_filename(filename=filename, filename_stem=filename_stem)
    stored_path = target_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


def _validate_upload(*, filename: str, file_bytes: bytes, supported_extensions: set[str]) -> 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_extensions:
        raise UploadError(
            f"Unsupported upload extension: {suffix}",
            category=ErrorCategory.USER_INPUT,
            suggestion="Upload a supported image or document file and retry.",
        )


def _build_stored_filename(*, filename: str, filename_stem: str | None = None) -> str:
    safe_name = Path(filename).name
    suffix = Path(safe_name).suffix.lower()
    stem = filename_stem or str(uuid4())
    return f"{stem}{suffix}"
