# src/chitai/services/calibre_import.py

"""
Run a Calibre import in the background and report on it.

The import outlives its request — a real library takes minutes to hours — so the handler
starts a task and hands back a handle to poll. The work itself is
`BookService.create_many_from_calibre`; everything here is lifecycle: state, progress,
cancellation, and a session of its own.

**This registry lives in memory, and therefore assumes one worker process.** That holds
today: the production `CMD` is `litestar run`, which is single-process, and the consume
watcher is already an in-process singleton with the same constraint. `TODO.md` records
that the production image should move to uvicorn with a worker count — the day that
happens, a poll can land on a worker that has never heard of the job, and this needs an
`import_jobs` table instead. It is written down here because nothing else will say so.
"""

from __future__ import annotations

import asyncio
import shutil
import uuid
from dataclasses import dataclass, field
from enum import StrEnum
from pathlib import Path

from chitai.config import settings
from chitai.database.models import Library
from chitai.services.book import (
    BookService,
    CalibreImportProgress,
    CalibreImportResult,
    UnimportedBook,
)
from chitai.services.calibre import CalibreLibrary


class ImportState(StrEnum):
    """Where a job has got to."""

    RUNNING = "running"
    FINISHED = "finished"

    # Stopped on request. What it imported is complete.
    CANCELLED = "cancelled"

    # The run itself broke — an unreadable catalogue, a missing library. Distinct from
    # individual books failing, which `failures` carries and which never stops the run.
    FAILED = "failed"


@dataclass
class ImportJob:
    """One import, running or finished."""

    id: str
    library_id: int
    source: str

    state: ImportState = ImportState.RUNNING
    total: int = 0
    processed: int = 0
    created: int = 0
    skipped: int = 0
    failed: int = 0

    current_title: str | None = None
    failures: list[UnimportedBook] = field(default_factory=list)

    # Books imported that look like something the library already had. A count, not the
    # records: the duplicates screen is what shows them, and a big import would make
    # this the largest thing in the response for no benefit.
    possible_duplicates: int = 0

    # Why the whole run stopped, when `state` is FAILED.
    error: str | None = None

    # The directory the job owns and must delete when it ends: what the uploaded archive
    # was unpacked into.
    workspace: Path | None = None

    _stop: bool = False

    @property
    def finished(self) -> bool:
        return self.state is not ImportState.RUNNING

    def absorb(self, result: CalibreImportResult) -> None:
        """Take the final counts from a finished run."""
        self.total = result.total
        self.created = len(result.created)
        self.skipped = len(result.skipped)
        self.failed = len(result.failed)
        self.failures = list(result.failed)
        self.possible_duplicates = len(result.possible_duplicates)
        self.current_title = None

        self.state = ImportState.CANCELLED if result.stopped else ImportState.FINISHED


class CalibreImportRegistry:
    """
    The imports this process knows about.

    One instance, held at module scope below. Jobs are kept after they finish so the
    screen that started one can still read its result; nothing evicts them, which is
    fine for a handful of one-time migrations and is the other reason a table would be
    the answer if this ever needed to be durable.
    """

    def __init__(self) -> None:
        self._jobs: dict[str, ImportJob] = {}

        # Held only to keep the tasks referenced. Without this the event loop is free to
        # garbage-collect a running task mid-import.
        self._tasks: set[asyncio.Task] = set()

    def get(self, job_id: str) -> ImportJob | None:
        return self._jobs.get(job_id)

    def running_for(self, library_id: int) -> ImportJob | None:
        """The unfinished import for a library, if it has one."""
        return next(
            (
                job
                for job in self._jobs.values()
                if job.library_id == library_id and not job.finished
            ),
            None,
        )

    def cancel(self, job_id: str) -> ImportJob | None:
        """
        Ask a job to stop after the book it is on.

        Not `task.cancel()`: that would abandon a book mid-copy, leaving files on disk
        with no row describing them. The flag is read between books.
        """
        job = self._jobs.get(job_id)

        if job is not None and not job.finished:
            job._stop = True

        return job

    def start(
        self,
        library: Library,
        source: Path,
        workspace: Path,
        label: str,
        allow_duplicates: bool = False,
    ) -> ImportJob:
        """
        Begin importing, and return the handle to poll.

        Args:
            library: The library to import into.
            source: The unpacked Calibre library's directory.
            workspace: A directory the job owns and deletes when it ends — what the
                uploaded archive was unpacked into. The books have been copied into the
                library by then, so nothing is lost with it.
            label: What to report as the source. `source` is a temp directory that would
                mean nothing to the reader, so this is the archive's own name.
            allow_duplicates: Import books whose files are already stored.

        Returns:
            The job, already running.
        """
        job = ImportJob(
            id=str(uuid.uuid4()),
            library_id=library.id,
            source=label,
            workspace=workspace,
        )
        self._jobs[job.id] = job

        task = asyncio.create_task(self._run(job, library.id, source, allow_duplicates))
        self._tasks.add(task)
        task.add_done_callback(self._tasks.discard)

        return job

    async def _run(
        self, job: ImportJob, library_id: int, source: Path, allow_duplicates: bool
    ) -> None:
        """
        Do the import, recording everything on the job.

        Opens a **session of its own**: the request that started this is long gone, and
        its session was closed with it.
        """
        from chitai.services.library import LibraryService

        def on_progress(progress: CalibreImportProgress) -> None:
            job.total = progress.total
            job.processed = progress.processed
            job.current_title = progress.title

            if progress.outcome == "created":
                job.created += 1
            elif progress.outcome == "skipped":
                job.skipped += 1
            else:
                job.failed += 1

        catalogue = CalibreLibrary(source)

        try:
            await catalogue.open()

            async with settings.alchemy_config.get_session() as session:
                library = await LibraryService(session=session).get(library_id)

                result = await BookService(session=session).create_many_from_calibre(
                    catalogue,
                    library,
                    allow_duplicates=allow_duplicates,
                    on_progress=on_progress,
                    should_stop=lambda: job._stop,
                )

            job.absorb(result)
        except Exception as exc:
            job.state = ImportState.FAILED
            job.error = f"{type(exc).__name__}: {exc}"
        finally:
            await catalogue.close()

            # An unpacked archive is a second copy of the whole library, and the books
            # worth keeping have been copied into the library proper by now. Removed
            # even when the run failed — especially then, since nothing will come back
            # for it.
            if job.workspace is not None:
                await asyncio.to_thread(shutil.rmtree, job.workspace, True)


registry = CalibreImportRegistry()
