"""Prompt loading and provider-backed transcription service."""

from __future__ import annotations

import logging
import mimetypes
from collections.abc import Sequence
from contextlib import contextmanager
from datetime import UTC
from datetime import datetime
from pathlib import Path
from uuid import UUID

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

from transcription.config import Settings
from transcription.config import get_settings
from transcription.db.models import Job
from transcription.db.models import JobSource
from transcription.db.models import JobSourceStatus
from transcription.db.models import Source
from transcription.errors import AppError
from transcription.errors import ErrorCategory
from transcription.providers import ProviderAuthError
from transcription.providers import ProviderError
from transcription.providers import ProviderResponseError
from transcription.providers import TranscriptionProvider
from transcription.providers import TranscriptionResult
from transcription.providers import get_transcription_provider

from .base import ServiceBase

logger = logging.getLogger(__name__)

DEFAULT_PROMPT_FILE = "transcribe_document.md"
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}


class PromptLoadError(AppError):
    """Raised when prompt artifacts cannot be loaded safely."""


class TranscriptionError(AppError):
    """Raised when transcription execution fails."""


class TranscriptionNotFoundError(TranscriptionError):
    """Raised when a transcription-related resource is not found."""


class SourceDeleteBlockedError(TranscriptionError):
    """Raised when source deletion is blocked by dependency policy."""


class TranscriptionService(ServiceBase):
    """Service class for job transcription output and page-level source revisions."""

    provider: TranscriptionProvider

    def __init__(self, session_factory: async_sessionmaker[AsyncSession] | None = None):
        super().__init__(session_factory=session_factory)
        self.provider = get_transcription_provider(settings=self.settings)

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

    async def read_source(self, source_id: UUID, *, session: AsyncSession | None = None) -> Source:
        """Read an existing source page record."""
        async with self._session_scope(session) as _session:
            source = await _session.get(Source, source_id)
        if source is None:
            raise TranscriptionNotFoundError(
                f"Source with id {source_id} not found",
                category=ErrorCategory.NOT_FOUND,
                suggestion="Verify the source id and retry.",
            )
        return source

    async def read_source_detail(self, source_id: UUID, *, session: AsyncSession | None = None) -> Source:
        """Read a source page record with job-source context for UI detail rendering."""
        async with self._session_scope(session) as _session:
            query = (
                select(Source)
                .options(
                    selectinload(Source.job_sources).selectinload(JobSource.job),  # pyright: ignore[reportArgumentType]
                )
                .where(Source.id == source_id)
                .execution_options(populate_existing=True)
            )
            source = (await _session.exec(query)).first()

        if source is None:
            raise TranscriptionNotFoundError(
                f"Source with id {source_id} not found",
                category=ErrorCategory.NOT_FOUND,
                suggestion="Verify the source id and retry.",
            )
        return source

    async def update_source(self, source: Source, *, session: AsyncSession | None = None) -> Source:
        """Update an existing source page record."""
        async with self._session_scope(session) as _session:
            merged = await _session.merge(source)
            await self._finalize(session=_session, caller_session=session, refresh=(merged,))
            return merged

    async def delete_source(self, source: Source, *, session: AsyncSession | None = None) -> None:
        """Delete a source page record."""
        source_file_path = source.file_path
        async with self._session_scope(session) as _session:
            await _session.delete(source)
            await self._finalize(session=_session, caller_session=session)

        self._delete_source_file(source_file_path=source_file_path)

    async def delete_unlinked_source(self, *, source_id: UUID, session: AsyncSession | None = None) -> None:
        """Delete a source only when no JobSource links exist."""
        async with self._session_scope(session) as _session:
            source = await _session.get(
                Source,
                source_id,
                options=(
                    selectinload(Source.job_sources),  # pyright: ignore[reportArgumentType]
                ),
            )
            if source is None:
                raise TranscriptionNotFoundError(
                    f"Source with id {source_id} not found",
                    category=ErrorCategory.NOT_FOUND,
                    suggestion="Verify the source id and retry.",
                )

            if source.job_sources:
                raise SourceDeleteBlockedError(
                    "Source delete blocked because it is linked to one or more jobs",
                    category=ErrorCategory.VALIDATION,
                    suggestion="Remove JobSource links first, then retry deletion.",
                )

            source_file_path = source.file_path
            await _session.delete(source)
            await self._finalize(session=_session, caller_session=session)

        self._delete_source_file(source_file_path=source_file_path)

    async def list_sources(
        self,
        *,
        document_id: UUID | None = None,
        session: AsyncSession | None = None,
    ) -> Sequence[Source]:
        """List source pages, optionally filtered by document."""
        async with self._session_scope(session) as _session:
            query = select(Source)
            if document_id is not None:
                query = query.where(Source.document_id == document_id)
            result = await _session.exec(query)
            return result.all()

    async def query_sources(
        self,
        *,
        document_id: UUID | None = None,
        page_number: int | None = None,
        session: AsyncSession | None = None,
    ) -> Sequence[Source]:
        """Query source pages using the provided filters."""
        async with self._session_scope(session) as _session:
            query = select(Source)
            if document_id is not None:
                query = query.where(Source.document_id == document_id)
            if page_number is not None:
                query = query.where(Source.page_number == page_number)
            result = await _session.exec(query)
            return result.all()

    async def list_sources_detail(
        self,
        *,
        document_id: UUID | None = None,
        job_id: UUID | None = None,
        session: AsyncSession | None = None,
    ) -> Sequence[Source]:
        """List source pages with document/job link context for UI rendering."""
        async with self._session_scope(session) as _session:
            query = select(Source).options(
                selectinload(Source.document),
                selectinload(Source.job_sources),
            )
            if document_id is not None:
                query = query.where(Source.document_id == document_id)

            result = await _session.exec(query)
            sources = list(result.all())

            if job_id is not None:
                sources = [
                    source for source in sources if any(job_source.job_id == job_id for job_source in source.job_sources)
                ]

            return sources

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

    async def read_job_source(self, job_source_id: UUID, *, session: AsyncSession | None = None) -> JobSource:
        """Read an existing job_source record."""
        async with self._session_scope(session) as _session:
            job_source = await _session.get(
                JobSource,
                job_source_id,
                options=(selectinload(JobSource.source),),  # pyright: ignore[reportArgumentType]
            )
        if job_source is None:
            raise TranscriptionNotFoundError(
                f"JobSource with id {job_source_id} not found",
                category=ErrorCategory.NOT_FOUND,
                suggestion="Verify the job source id and retry.",
            )
        return job_source

    async def update_job_source(self, job_source: JobSource, *, session: AsyncSession | None = None) -> JobSource:
        """Update an existing job_source record."""
        async with self._session_scope(session) as _session:
            merged = await _session.merge(job_source)
            await self._finalize(session=_session, caller_session=session, refresh=(merged,))
            return merged

    async def delete_job_source(self, job_source: JobSource, *, session: AsyncSession | None = None) -> None:
        """Delete a job_source record."""
        async with self._session_scope(session) as _session:
            await _session.delete(job_source)
            await self._finalize(session=_session, caller_session=session)

    async def delete_source_from_job_context(
        self,
        *,
        job_id: UUID,
        source_id: UUID,
        session: AsyncSession | None = None,
    ) -> None:
        """Delete a source from an active job context with dependency guardrails.

        Policy:
        - Allowed when exactly one JobSource link exists and it points at ``job_id``.
        - Blocked when additional JobSource links exist (history/shared dependencies).
        """
        async with self._session_scope(session) as _session:
            source = await _session.get(
                Source,
                source_id,
                options=(
                    selectinload(Source.job_sources),  # pyright: ignore[reportArgumentType]
                ),
            )
            if source is None:
                raise TranscriptionNotFoundError(
                    f"Source with id {source_id} not found",
                    category=ErrorCategory.NOT_FOUND,
                    suggestion="Verify the source id and retry.",
                )

            linked_job_sources = list(source.job_sources)
            matching_links = [job_source for job_source in linked_job_sources if job_source.job_id == job_id]
            if not matching_links:
                raise TranscriptionNotFoundError(
                    f"Source {source_id} is not linked to job {job_id}",
                    category=ErrorCategory.NOT_FOUND,
                    suggestion="Open the source from its linked job context and retry.",
                )

            if len(linked_job_sources) > len(matching_links):
                raise SourceDeleteBlockedError(
                    "Source delete blocked by related job history",
                    category=ErrorCategory.VALIDATION,
                    suggestion="Remove additional JobSource links first, then retry deletion.",
                )

            for job_source in matching_links:
                await _session.delete(job_source)

            source_file_path = source.file_path
            await _session.delete(source)
            await self._finalize(session=_session, caller_session=session)

        self._delete_source_file(source_file_path=source_file_path)

    def _delete_source_file(self, *, source_file_path: str) -> None:
        """Best-effort cleanup for source media files."""
        candidate_path = Path(source_file_path)
        resolved_path = candidate_path if candidate_path.is_absolute() else self.settings.upload_dir / candidate_path

        if not resolved_path.exists():
            return

        try:
            resolved_path.unlink()
            logger.info("Deleted source file: %s", resolved_path)
        except OSError:
            logger.warning("Failed to delete source file: %s", resolved_path)

    async def list_job_sources(
        self,
        *,
        job_id: UUID | None = None,
        session: AsyncSession | None = None,
    ) -> Sequence[JobSource]:
        """List job-source records, optionally filtered by job."""
        async with self._session_scope(session) as _session:
            query = select(JobSource).options(
                selectinload(JobSource.job),  # pyright: ignore[reportArgumentType]
                selectinload(JobSource.source),  # pyright: ignore[reportArgumentType]
            )
            if job_id is not None:
                query = query.where(JobSource.job_id == job_id)
            result = await _session.exec(query)
            return result.all()

    async def transcribe_document(
        self,
        image_path: str | Path,
        job_id: UUID,
        *,
        prompt_name: str = DEFAULT_PROMPT_FILE,
        session: AsyncSession | None = None,
    ) -> None:
        """Transcribe a local image using the configured prompt and provider."""
        result = await transcribe_document_image(
            image_path=image_path,
            prompt_name=prompt_name,
            settings=self.settings,
            provider=self.provider,
        )
        await self.update_job_transcription(
            job_id=job_id,
            text=result.text,
            error_detail=None,
            provider=result.provider,
            model=result.model,
            prompt_name=result.prompt_name,
            session=session,
        )

    async def update_job_transcription(
        self,
        *,
        job_id: UUID,
        text: str | None,
        error_detail: str | None = None,
        provider: str | None = None,
        model: str | None = None,
        prompt_name: str = DEFAULT_PROMPT_FILE,
        session: AsyncSession | None = None,
    ) -> Job:
        """Persist transcription output for the first ordered source in a job's document.

        This compatibility helper keeps legacy single-source workflows working.
        New multi-source flows should use ``update_job_source_transcription``.
        """
        async with self._session_scope(session) as _session:
            job = await _session.get(Job, job_id)
            if job is None:
                raise TranscriptionNotFoundError(
                    f"Job with id {job_id} not found",
                    category=ErrorCategory.NOT_FOUND,
                    suggestion="Verify the job id and retry.",
                )

            job.provider = provider or job.provider or self.settings.provider.value
            job.model = model or job.model or _resolve_transcript_model(provider=self.provider, settings=self.settings)
            job.prompt_name = prompt_name or job.prompt_name or DEFAULT_PROMPT_FILE
            job.date_updated = datetime.now(UTC)

            source = await _session.exec(
                select(Source)
                .where(Source.document_id == job.document_id)
                .order_by(Source.page_number)  # pyright: ignore[reportArgumentType]
            )
            source_row = source.first()
            if source_row is not None:
                await self.update_job_source_transcription(
                    job_id=job.id,
                    source_id=source_row.id,
                    text=text,
                    error_detail=error_detail,
                    provider=provider,
                    model=model,
                    prompt_name=prompt_name,
                    session=_session,
                )

            await self._finalize(session=_session, caller_session=session, refresh=(job,))
            return job

    async def update_job_source_transcription(
        self,
        *,
        job_id: UUID,
        source_id: UUID,
        text: str | None,
        error_detail: str | None = None,
        provider: str | None = None,
        model: str | None = None,
        prompt_name: str = DEFAULT_PROMPT_FILE,
        session: AsyncSession | None = None,
    ) -> JobSource:
        """Persist transcription fields for one source within a specific job."""
        async with self._session_scope(session) as _session:
            job = await _session.get(Job, job_id)
            if job is None:
                raise TranscriptionNotFoundError(
                    f"Job with id {job_id} not found",
                    category=ErrorCategory.NOT_FOUND,
                    suggestion="Verify the job id and retry.",
                )

            source = await _session.get(Source, source_id)
            if source is None:
                raise TranscriptionNotFoundError(
                    f"Source with id {source_id} not found",
                    category=ErrorCategory.NOT_FOUND,
                    suggestion="Verify the source id and retry.",
                )

            if source.document_id != job.document_id:
                raise TranscriptionError(
                    f"Source {source_id} does not belong to job {job_id}",
                    category=ErrorCategory.VALIDATION,
                    suggestion="Link the source to the same document as the job and retry.",
                )

            job.provider = provider or job.provider or self.settings.provider.value
            job.model = model or job.model or _resolve_transcript_model(provider=self.provider, settings=self.settings)
            job.prompt_name = prompt_name or job.prompt_name or DEFAULT_PROMPT_FILE
            job.date_updated = datetime.now(UTC)

            source.raw_transcription = text

            existing_job_source = await _session.exec(
                select(JobSource).where(JobSource.job_id == job_id).where(JobSource.source_id == source_id)
            )
            job_source = existing_job_source.first()
            if job_source is None:
                job_source = JobSource(
                    job_id=job_id,
                    source_id=source_id,
                    status=JobSourceStatus.TRANSCRIBED if text is not None else JobSourceStatus.FAILED,
                    raw_transcription=text,
                    error_detail=error_detail,
                )
                _session.add(job_source)
            else:
                job_source.raw_transcription = text
                job_source.error_detail = error_detail
                job_source.status = JobSourceStatus.TRANSCRIBED if text is not None else JobSourceStatus.FAILED
                job_source.executed_at = datetime.now(UTC)

            await self._finalize(session=_session, caller_session=session, refresh=(job, source, job_source))
            return job_source

    async def upsert_revision_for_source(
        self,
        *,
        source_id: UUID,
        text: str,
        session: AsyncSession | None = None,
    ) -> Source:
        """Persist a human revision on a source page."""
        async with self._session_scope(session) as _session:
            source = await _session.get(Source, source_id)
            if source is None:
                raise TranscriptionNotFoundError(
                    f"Source with id {source_id} not found",
                    category=ErrorCategory.NOT_FOUND,
                    suggestion="Verify the source id and retry.",
                )

            source.revised_text = text
            source.date_revised = datetime.now(UTC)
            await self._finalize(session=_session, caller_session=session, refresh=(source,))
            return source

    async def read_revision_by_source(
        self,
        source_id: UUID,
        *,
        session: AsyncSession | None = None,
    ) -> Source | None:
        """Read the source record for a given page, including any revision text."""
        async with self._session_scope(session) as _session:
            return await _session.get(Source, source_id)

    async def list_revisions_by_job(
        self,
        job_id: UUID,
        *,
        session: AsyncSession | None = None,
    ) -> Sequence[Source]:
        """List source pages for a job that carry revision text."""
        async with self._session_scope(session) as _session:
            query = (
                select(Source)
                .join(JobSource, JobSource.source_id == Source.id)
                .where(JobSource.job_id == job_id)
                .where(Source.revised_text.is_not(None))
                .order_by(Source.date_revised)  # pyright: ignore[reportArgumentType]
            )
            result = await _session.exec(query)
            return result.all()


def _resolve_transcript_model(*, provider: TranscriptionProvider, settings: Settings) -> str:
    provider_model = getattr(provider, "model", None)
    if isinstance(provider_model, str) and provider_model.strip():
        return provider_model

    if settings.provider_model and settings.provider_model.strip():
        return settings.provider_model

    return "unknown"


async def transcribe_document_image(
    image_path: str | Path,
    *,
    prompt_name: str = DEFAULT_PROMPT_FILE,
    settings: Settings | None = None,
    provider: TranscriptionProvider | None = None,
) -> TranscriptionResult:
    """Transcribe a local image using the configured prompt and provider."""
    runtime_settings = settings or get_settings()
    prompt_text = load_prompt_text(prompt_name=prompt_name, settings=runtime_settings)
    image_bytes, mime_type = load_image_payload(image_path)

    adapter = provider or get_transcription_provider(settings=runtime_settings)
    logger.info("Starting transcription for image=%s mime_type=%s", image_path, mime_type)

    with handle_transcription_errors():
        result = await adapter.transcribe(
            prompt_text=prompt_text,
            image_bytes=image_bytes,
            mime_type=mime_type,
        )
    logger.info("Transcription completed for image=%s provider=%s", image_path, result.provider)
    return result


def load_prompt_text(*, prompt_name: str = DEFAULT_PROMPT_FILE, settings: Settings | None = None) -> str:
    """Load and validate prompt text from PROMPT_DIR."""
    runtime_settings = settings or get_settings()
    prompt_path = runtime_settings.prompt_dir / prompt_name

    if not prompt_path.exists() or not prompt_path.is_file():
        raise PromptLoadError(
            f"Prompt file not found: {prompt_path}",
            category=ErrorCategory.INFRA_PERSISTENT,
            suggestion="Verify PROMPT_DIR and prompt file configuration, then retry.",
        )

    prompt_text = prompt_path.read_text(encoding="utf-8").strip()
    if not prompt_text:
        raise PromptLoadError(
            f"Prompt file is empty: {prompt_path}",
            category=ErrorCategory.VALIDATION,
            suggestion="Populate the prompt file with valid instructions and retry.",
        )

    logger.info("Loaded prompt artifact: %s", prompt_path)
    return prompt_text


def load_image_payload(image_path: str | Path) -> tuple[bytes, str]:
    """Read image bytes and detect mime type for supported uploads."""
    path = Path(image_path)

    if not path.exists() or not path.is_file():
        raise TranscriptionError(
            f"Image file not found: {path}",
            category=ErrorCategory.NOT_FOUND,
            suggestion="Verify the uploaded file exists and retry from the jobs page.",
        )

    suffix = path.suffix.lower()
    if suffix not in SUPPORTED_EXTENSIONS:
        raise TranscriptionError(
            f"Unsupported file type: {suffix}",
            category=ErrorCategory.USER_INPUT,
            suggestion="Use JPG, JPEG, PNG, TIFF, or PDF files.",
        )

    mime_type, _ = mimetypes.guess_type(path.name)
    if suffix in {".tif", ".tiff"}:
        mime_type = "image/tiff"
    if not mime_type:
        raise TranscriptionError(
            f"Unable to determine MIME type for: {path}",
            category=ErrorCategory.VALIDATION,
            suggestion="Re-save the file in a supported format and retry.",
        )

    return path.read_bytes(), mime_type


@contextmanager
def handle_transcription_errors():
    """Context manager to handle transcription errors."""
    try:
        yield
    except ProviderAuthError as exc:
        raise TranscriptionError(
            "Provider authentication failed",
            category=ErrorCategory.INFRA_PERSISTENT,
            suggestion="Verify provider API credentials and retry.",
        ) from exc
    except ProviderResponseError as exc:
        raise TranscriptionError(
            "Provider returned an invalid response",
            category=ErrorCategory.EXTERNAL_PROVIDER,
            suggestion="Retry once. If it persists, try a different provider model or inspect provider status.",
            retriable=True,
        ) from exc
    except ProviderError as exc:
        raise TranscriptionError(
            "Provider transcription failed",
            category=ErrorCategory.EXTERNAL_PROVIDER,
            suggestion="Retry the transcription from jobs. If repeated, check provider availability.",
            retriable=True,
        ) from exc
