from collections.abc import Sequence
from datetime import UTC
from datetime import datetime
from uuid import UUID

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

from ..errors import AppError
from ..errors import ErrorCategory
from ..db.models import Job
from ..db.models import JobSource
from ..db.models import JobSourceStatus
from ..db.models import JobStatus
from ..db.models import Source
from .base import ServiceBase


class JobDeleteBlockedError(AppError):
    """Raised when a job delete operation is blocked by lifecycle policy."""


class JobCancelBlockedError(AppError):
    """Raised when a job cancel operation is blocked by lifecycle policy."""


class JobResubmitBlockedError(AppError):
    """Raised when a job resubmit operation is blocked by lifecycle policy."""


class JobService(ServiceBase):
    """Thin service class for managing jobs in the database."""

    #
    # CRUD Operations
    #

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

    async def read_job(self, job_id: UUID, session: AsyncSession | None = None) -> Job:
        """Read an existing job from the database.

        The related document is always eagerly loaded so callers can safely
        access ``job.document`` in async contexts without triggering lazy-load IO.
        """
        async with self._session_scope(session) as _session:
            query = (
                select(Job)
                .options(
                    selectinload(Job.document),  # pyright: ignore[reportArgumentType]
                    selectinload(Job.job_sources).selectinload(JobSource.source),  # pyright: ignore[reportArgumentType]
                )
                .where(Job.id == job_id)
                .execution_options(populate_existing=True)
            )
            job = (await _session.exec(query)).first()
        if job is None:
            raise ValueError(f"Job with id {job_id} not found")
        return job

    async def update_job(self, job: Job, session: AsyncSession | None = None) -> Job:
        """Update an existing job in the database."""
        async with self._session_scope(session) as _session:
            merged = await _session.merge(job)
            await self._finalize(session=_session, caller_session=session, refresh=(merged,))
            return merged

    async def delete_job(self, job: Job, session: AsyncSession | None = None) -> None:
        """Delete a job from the database."""
        async with self._session_scope(session) as _session:
            await _session.delete(job)
            await self._finalize(session=_session, caller_session=session)

    # Query Operations

    async def query_jobs(
        self,
        *,
        status: JobStatus | None = None,
        filename: str | None = None,
        session: AsyncSession | None = None,
    ) -> Sequence[Job]:
        """Query jobs from the database based on provided filters."""
        async with self._session_scope(session) as _session:
            query = select(Job).options(
                selectinload(Job.document),  # pyright: ignore[reportArgumentType]
                selectinload(Job.job_sources).selectinload(JobSource.source),  # pyright: ignore[reportArgumentType]
            )
            if status is not None:
                query = query.where(Job.status == status)
            if filename is not None:
                query = query.where(Job.job_sources.any(JobSource.source.has(Source.filename == filename)))
            result = await _session.exec(query)
            return result.all()

    async def list_jobs(
        self,
        *,
        load_docs: bool = False,
        session: AsyncSession | None = None,
    ) -> Sequence[Job]:
        """List all jobs in the database with eagerly loaded documents."""
        _ = load_docs
        async with self._session_scope(session) as _session:
            query = select(Job).options(
                selectinload(Job.document),  # pyright: ignore[reportArgumentType]
                selectinload(Job.job_sources).selectinload(JobSource.source),  # pyright: ignore[reportArgumentType]
            )
            result = await _session.exec(query)
            return result.all()

    # Other Operations

    async def mark_job_status(
        self,
        job_id: UUID,
        status: JobStatus,
        session: AsyncSession | None = None,
    ) -> Job:
        """Mark a job with a new status."""
        return await self.update_job_state(job_id=job_id, status=status, session=session)

    async def update_job_state(
        self,
        *,
        job_id: UUID,
        status: JobStatus,
        retry_count_increment: int = 0,
        session: AsyncSession | None = None,
    ) -> Job:
        """Update a job's lifecycle fields.

        When ``session`` is provided, this method flushes so callers can commit
        once at an orchestration boundary.
        """
        async with self._session_scope(session) as _session:
            query = (
                select(Job)
                .options(selectinload(Job.document))  # pyright: ignore[reportArgumentType]
                .where(Job.id == job_id)
                .execution_options(populate_existing=True)
            )
            job = (await _session.exec(query)).first()
            if job is None:
                raise ValueError(f"Job with id {job_id} not found")
            job.status = status
            if retry_count_increment:
                job.retry_count += retry_count_increment
            job.date_updated = datetime.now(UTC)
            await self._finalize(session=_session, caller_session=session, refresh=(job,))
            return job

    async def read_next_queued_job(
        self,
        *,
        session: AsyncSession | None = None,
    ) -> Job | None:
        """Read the next queued job ordered by creation time."""
        async with self._session_scope(session) as _session:
            query = (
                select(Job)
                .options(
                    selectinload(Job.document),  # pyright: ignore[reportArgumentType]
                    selectinload(Job.job_sources).selectinload(JobSource.source),  # pyright: ignore[reportArgumentType]
                )
                .where(Job.status == JobStatus.QUEUED)
                # Break ties by id so "next" is stable when two rows share close timestamps.
                .order_by(Job.date_created, Job.id)  # pyright: ignore[reportArgumentType]
            )
            return (await _session.exec(query)).first()

    async def requeue_stale_processing_jobs(
        self,
        *,
        stale_before: datetime,
        session: AsyncSession | None = None,
    ) -> int:
        """Move stale processing jobs back to queued state.

        Jobs with ``status=PROCESSING`` and ``date_updated`` older than
        ``stale_before`` are considered stale and re-queued.
        """
        async with self._session_scope(session) as _session:
            query = select(Job).where(Job.status == JobStatus.PROCESSING).where(Job.date_updated < stale_before)
            stale_jobs = (await _session.exec(query)).all()
            if not stale_jobs:
                return 0

            now = datetime.now(UTC)
            for job in stale_jobs:
                job.status = JobStatus.QUEUED
                job.date_updated = now

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

    async def delete_job_with_guardrails(self, *, job_id: UUID, session: AsyncSession | None = None) -> None:
        """Delete a job with lifecycle guardrails and dependent cleanup policy.

        Policy:
        - Block when the job is actively processing.
        - Otherwise remove related JobSource rows, then delete the job.
        """
        async with self._session_scope(session) as _session:
            query = (
                select(Job)
                .options(selectinload(Job.job_sources))  # pyright: ignore[reportArgumentType]
                .where(Job.id == job_id)
                .execution_options(populate_existing=True)
            )
            job = (await _session.exec(query)).first()
            if job is None:
                raise ValueError(f"Job with id {job_id} not found")

            if job.status == JobStatus.PROCESSING:
                raise JobDeleteBlockedError(
                    "Job delete blocked while status is processing",
                    category=ErrorCategory.VALIDATION,
                    suggestion="Wait for processing to complete, or move the job out of processing before deleting.",
                )

            for job_source in list(job.job_sources):
                await _session.delete(job_source)

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

    async def cancel_job(self, *, job_id: UUID, session: AsyncSession | None = None) -> Job:
        """Cancel a queued/processing job and stop remaining source work."""
        async with self._session_scope(session) as _session:
            query = (
                select(Job)
                .options(
                    selectinload(Job.job_sources).selectinload(JobSource.source),  # pyright: ignore[reportArgumentType]
                )
                .where(Job.id == job_id)
                .execution_options(populate_existing=True)
            )
            job = (await _session.exec(query)).first()
            if job is None:
                raise ValueError(f"Job with id {job_id} not found")

            if job.status in {JobStatus.TRANSCRIBED, JobStatus.COMPLETED}:
                raise JobCancelBlockedError(
                    "Job cancel is not allowed for transcribed/completed jobs",
                    category=ErrorCategory.VALIDATION,
                    suggestion="Use resubmit for reprocessing needs, or leave the terminal job unchanged.",
                )

            now = datetime.now(UTC)
            job.status = JobStatus.FAILED
            job.date_updated = now

            for job_source in job.job_sources:
                if job_source.status == JobSourceStatus.TRANSCRIBED:
                    continue
                job_source.status = JobSourceStatus.FAILED
                job_source.raw_transcription = None
                job_source.error_detail = "Cancelled by user"
                job_source.executed_at = now
                if job_source.source is not None:
                    job_source.source.raw_transcription = None

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

    async def resubmit_non_transcribed_sources(self, *, job_id: UUID, session: AsyncSession | None = None) -> int:
        """Reset non-transcribed source executions and queue the job for reprocessing."""
        async with self._session_scope(session) as _session:
            query = (
                select(Job)
                .options(
                    selectinload(Job.job_sources).selectinload(JobSource.source),  # pyright: ignore[reportArgumentType]
                )
                .where(Job.id == job_id)
                .execution_options(populate_existing=True)
            )
            job = (await _session.exec(query)).first()
            if job is None:
                raise ValueError(f"Job with id {job_id} not found")

            if job.status == JobStatus.PROCESSING:
                raise JobResubmitBlockedError(
                    "Job resubmit is blocked while processing is active",
                    category=ErrorCategory.VALIDATION,
                    suggestion="Cancel processing first, then resubmit remaining sources.",
                )

            candidates = [job_source for job_source in job.job_sources if job_source.status != JobSourceStatus.TRANSCRIBED]
            if not candidates:
                raise JobResubmitBlockedError(
                    "Job has no non-transcribed sources to resubmit",
                    category=ErrorCategory.VALIDATION,
                    suggestion="Only failed or pending sources can be resubmitted.",
                )

            now = datetime.now(UTC)
            for job_source in candidates:
                job_source.status = JobSourceStatus.PENDING
                job_source.raw_transcription = None
                job_source.error_detail = None
                job_source.executed_at = now
                if job_source.source is not None:
                    job_source.source.raw_transcription = None

            job.status = JobStatus.QUEUED
            job.date_updated = now

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