"""In-memory job registry for OneNote imports.

Each call to ``POST /api/plugins/onenote-import/run`` (and each agent-tool
invocation) registers a ``JobState`` in this module's process-level registry.
Callers poll ``GET /api/plugins/onenote-import/progress/{job_id}`` for live
counters + ETA while the import runs, or read ``GET /jobs`` for history.

State lives in-process only — restarts lose job history, but actual import
progress is persisted via ``.onenote-state.json`` in the target folder so
the next run resumes idempotently. Jobs are a UX concern, not a correctness
concern; keeping them in memory avoids a second source-of-truth.

Concurrency: the registry is protected by a single ``asyncio.Lock`` so
inserts and reads from the async router path are coherent. The importer
mutates ``JobState`` fields directly from a worker thread (via
``asyncio.to_thread``), which is safe because (a) each field is an int or
small list and CPython int increments are atomic, and (b) readers tolerate
brief staleness.
"""

from __future__ import annotations

import asyncio
import secrets
from dataclasses import dataclass, field
from datetime import UTC, datetime


def new_job_id() -> str:
    """8-char URL-safe random id. Short enough to be pasteable; long enough
    to avoid collision within a single process's ~thousand imports."""
    return secrets.token_urlsafe(6)


def _iso_now() -> str:
    return datetime.now(UTC).isoformat()


@dataclass
class JobState:
    """Live state of one import run. Mutated by the importer as it works.

    Pure data — no behavior methods that touch the filesystem or the
    registry. Cloning it via ``to_public_dict`` is how the HTTP layer
    returns a stable snapshot to the client.
    """

    job_id: str
    state: str = "pending"  # pending | running | completed | failed | cancelled
    started_at: str = ""
    finished_at: str = ""

    # Request parameters (echoed back so callers know what they kicked off).
    target_folder: str = ""
    notebooks_filter: list[str] | None = None
    incremental: bool = True

    # Chat conversation that initiated this job, captured from the agent
    # contextvar at tool-call time. When set, the plugin posts a follow-up
    # message here on terminal state (completed / failed / cancelled).
    # Empty string when the import was kicked off from a non-chat path
    # (REST, CLI, cron) — no chat thread to follow up with.
    origin_conversation_id: str = ""

    # Counters — updated live by the importer.
    notebooks_total: int = 0
    pages_total: int = 0
    pages_processed: int = 0  # imported + skipped + errored
    pages_imported: int = 0
    pages_skipped: int = 0

    # Current activity — for "Processing: NB / Section / Page" UX.
    current_notebook: str = ""
    current_section_path: list[str] = field(default_factory=list)
    current_page: str = ""

    # Error / completion shape.
    errors: list[dict] = field(default_factory=list)
    error: str = ""  # fatal error that aborted the whole run
    summary: dict = field(default_factory=dict)

    # Cooperative cancellation flag checked by the importer between pages.
    cancel_requested: bool = False

    # ── mutations (called from importer) ─────────────────────────────

    def mark_running(self) -> None:
        self.state = "running"
        if not self.started_at:
            self.started_at = _iso_now()

    def mark_completed(self, summary: dict) -> None:
        self.state = "completed"
        self.finished_at = _iso_now()
        self.summary = summary

    def mark_failed(self, message: str, summary: dict | None = None) -> None:
        self.state = "failed"
        self.finished_at = _iso_now()
        self.error = message
        if summary is not None:
            self.summary = summary

    def mark_cancelled(self, summary: dict | None = None) -> None:
        self.state = "cancelled"
        self.finished_at = _iso_now()
        if summary is not None:
            self.summary = summary

    def set_current(self, notebook: str, section_path: list[str], page: str) -> None:
        self.current_notebook = notebook
        self.current_section_path = list(section_path)
        self.current_page = page

    def inc_imported(self) -> None:
        self.pages_imported += 1
        self.pages_processed += 1

    def inc_skipped(self) -> None:
        self.pages_skipped += 1
        self.pages_processed += 1

    def record_error(self, entry: dict) -> None:
        self.errors.append(entry)
        self.pages_processed += 1

    # ── read shape (for HTTP responses) ──────────────────────────────

    def to_public_dict(self) -> dict:
        elapsed = _elapsed_seconds(self.started_at, self.finished_at)
        eta = _estimate_eta(
            pages_total=self.pages_total,
            pages_processed=self.pages_processed,
            elapsed_s=elapsed,
            running=(self.state == "running"),
        )
        # Progress ratio for progress bars. Only meaningful once
        # pages_total has been populated by the importer (happens right
        # after list_pages returns, which is near the start of _run_sync).
        ratio = (
            self.pages_processed / self.pages_total
            if self.pages_total > 0
            else 0.0
        )
        return {
            "job_id": self.job_id,
            "state": self.state,
            "started_at": self.started_at,
            "finished_at": self.finished_at,
            "elapsed_s": round(elapsed, 2),
            "eta_s": eta,
            "progress_ratio": round(ratio, 4),
            "target_folder": self.target_folder,
            "notebooks_filter": self.notebooks_filter,
            "incremental": self.incremental,
            "notebooks_total": self.notebooks_total,
            "pages_total": self.pages_total,
            "pages_processed": self.pages_processed,
            "pages_imported": self.pages_imported,
            "pages_skipped": self.pages_skipped,
            "errors_count": len(self.errors),
            # Only expose the first few errors to keep the response small;
            # the full list lives in the completed summary.
            "errors": self.errors[:5],
            "current_notebook": self.current_notebook,
            "current_section": "/".join(self.current_section_path),
            "current_page": self.current_page,
            "error": self.error,
            "summary": self.summary if self.state in {"completed", "failed", "cancelled"} else {},
            "cancel_requested": self.cancel_requested,
        }


def _elapsed_seconds(started_at: str, finished_at: str) -> float:
    """Seconds between start and now (or finish, if the job ended)."""
    if not started_at:
        return 0.0
    try:
        start = datetime.fromisoformat(started_at)
    except ValueError:
        return 0.0
    end: datetime
    if finished_at:
        try:
            end = datetime.fromisoformat(finished_at)
        except ValueError:
            end = datetime.now(UTC)
    else:
        end = datetime.now(UTC)
    return max(0.0, (end - start).total_seconds())


def _estimate_eta(
    *,
    pages_total: int,
    pages_processed: int,
    elapsed_s: float,
    running: bool,
) -> float | None:
    """Best-effort ETA in seconds. None when we can't estimate honestly.

    Honest ETA rules:
      - Not running → None (no forward motion)
      - pages_total unknown or 0 → None (we don't know the denominator)
      - pages_processed == 0 → None (no data point to extrapolate from)
      - pages_processed >= pages_total → 0 (we're done or done-ish)
    """
    if not running or pages_total <= 0 or pages_processed <= 0:
        return None
    if pages_processed >= pages_total:
        return 0.0
    rate = pages_processed / elapsed_s if elapsed_s > 0 else 0.0
    if rate <= 0:
        return None
    remaining = pages_total - pages_processed
    return round(remaining / rate, 2)


class JobRegistry:
    """Per-process store of running + finished jobs.

    Bounded history: retains at most ``history_limit`` finished jobs,
    oldest evicted first. Running jobs are never evicted.
    """

    def __init__(self, *, history_limit: int = 20) -> None:
        self._jobs: dict[str, JobState] = {}
        self._order: list[str] = []  # insertion order for eviction
        self._lock = asyncio.Lock()
        self._history_limit = history_limit

    async def create(
        self,
        *,
        target_folder: str,
        notebooks_filter: list[str] | None,
        incremental: bool,
    ) -> JobState:
        job = JobState(
            job_id=new_job_id(),
            target_folder=target_folder,
            notebooks_filter=notebooks_filter,
            incremental=incremental,
        )
        async with self._lock:
            self._jobs[job.job_id] = job
            self._order.append(job.job_id)
            self._evict_old()
        return job

    async def get(self, job_id: str) -> JobState | None:
        async with self._lock:
            return self._jobs.get(job_id)

    async def list_recent(self, limit: int = 20) -> list[JobState]:
        async with self._lock:
            # Reverse so newest-first.
            ids = list(reversed(self._order))[:limit]
            return [self._jobs[jid] for jid in ids if jid in self._jobs]

    async def request_cancel(self, job_id: str) -> bool:
        async with self._lock:
            job = self._jobs.get(job_id)
            if job is None:
                return False
            if job.state in {"completed", "failed", "cancelled"}:
                return False
            job.cancel_requested = True
            return True

    def _evict_old(self) -> None:
        """Drop the oldest finished jobs once history exceeds the limit.

        Running jobs never get evicted — they're by definition in-progress.
        """
        if len(self._order) <= self._history_limit:
            return
        overflow = len(self._order) - self._history_limit
        evicted = 0
        new_order: list[str] = []
        for jid in self._order:
            job = self._jobs.get(jid)
            if (
                evicted < overflow
                and job is not None
                and job.state in {"completed", "failed", "cancelled"}
            ):
                self._jobs.pop(jid, None)
                evicted += 1
                continue
            new_order.append(jid)
        self._order = new_order
