"""Batch processing — job queue, execution, and status tracking.

Provides a thread-safe in-memory job queue for running ECL computations,
DAG discovery, fairness audits, and other Orxaq operations as background
jobs with status tracking and result retrieval.

Stdlib-only (Tier 0 compliant).
"""

from __future__ import annotations

import enum
import json
import threading
import time
import uuid
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any


class JobStatus(enum.Enum):
    """Lifecycle states for a batch job."""

    PENDING = "pending"
    RUNNING = "running"
    COMPLETED = "completed"
    FAILED = "failed"
    CANCELLED = "cancelled"


@dataclass
class JobResult:
    """Outcome of a completed or failed job."""

    data: Any = None
    error: str | None = None
    duration_ms: float = 0.0


@dataclass
class Job:
    """A single unit of work in the batch queue."""

    job_id: str
    job_type: str
    params: dict = field(default_factory=dict)
    status: JobStatus = JobStatus.PENDING
    result: JobResult | None = None
    created_at: float = field(default_factory=time.monotonic)
    started_at: float | None = None
    finished_at: float | None = None
    error: str | None = None

    def to_dict(self) -> dict:
        """Serialize job to a dict."""
        d: dict[str, Any] = {
            "job_id": self.job_id,
            "job_type": self.job_type,
            "params": self.params,
            "status": self.status.value,
            "created_at": self.created_at,
        }
        if self.started_at is not None:
            d["started_at"] = self.started_at
        if self.finished_at is not None:
            d["finished_at"] = self.finished_at
        if self.error:
            d["error"] = self.error
        if self.result and self.result.data is not None:
            d["result"] = self.result.data
        if self.result:
            d["duration_ms"] = self.result.duration_ms
        return d


class BatchQueue:
    """Thread-safe in-memory job queue with worker execution."""

    def __init__(self, max_history: int = 1000) -> None:
        self._lock = threading.Lock()
        self._jobs: dict[str, Job] = {}
        self._pending: list[str] = []
        self._handlers: dict[str, Callable[..., Any]] = {}
        self._max_history = max_history

    def register(self, job_type: str, handler: Callable[..., Any]) -> None:
        """Register a handler function for a job type."""
        self._handlers[job_type] = handler

    def submit(self, job_type: str, params: dict | None = None) -> str:
        """Submit a new job. Returns job_id."""
        if job_type not in self._handlers:
            msg = f"Unknown job type: {job_type}"
            raise ValueError(msg)
        job_id = uuid.uuid4().hex[:12]
        job = Job(
            job_id=job_id,
            job_type=job_type,
            params=params or {},
        )
        with self._lock:
            self._jobs[job_id] = job
            self._pending.append(job_id)
        return job_id

    def get(self, job_id: str) -> Job | None:
        """Get job by ID."""
        with self._lock:
            return self._jobs.get(job_id)

    def cancel(self, job_id: str) -> bool:
        """Cancel a pending job. Returns True if cancelled."""
        with self._lock:
            job = self._jobs.get(job_id)
            if job is None or job.status != JobStatus.PENDING:
                return False
            job.status = JobStatus.CANCELLED
            if job_id in self._pending:
                self._pending.remove(job_id)
            return True

    def process_one(self) -> str | None:
        """Process the next pending job. Returns job_id or None."""
        with self._lock:
            if not self._pending:
                return None
            job_id = self._pending.pop(0)
            job = self._jobs[job_id]
            job.status = JobStatus.RUNNING
            job.started_at = time.monotonic()
            handler = self._handlers[job.job_type]

        t0 = time.monotonic()
        try:
            result_data = handler(**job.params)
            duration = (time.monotonic() - t0) * 1000
            with self._lock:
                job.result = JobResult(data=result_data, duration_ms=duration)
                job.status = JobStatus.COMPLETED
                job.finished_at = time.monotonic()
        except Exception as exc:  # noqa: BLE001
            duration = (time.monotonic() - t0) * 1000
            with self._lock:
                job.result = JobResult(error=str(exc), duration_ms=duration)
                job.status = JobStatus.FAILED
                job.error = str(exc)
                job.finished_at = time.monotonic()

        self._evict_old()
        return job_id

    def process_all(self) -> list[str]:
        """Process all pending jobs sequentially."""
        processed = []
        while True:
            jid = self.process_one()
            if jid is None:
                break
            processed.append(jid)
        return processed

    def list_jobs(self, status: JobStatus | None = None) -> list[Job]:
        """List jobs, optionally filtered by status."""
        with self._lock:
            jobs = list(self._jobs.values())
        if status is not None:
            jobs = [j for j in jobs if j.status == status]
        return sorted(jobs, key=lambda j: j.created_at, reverse=True)

    def pending_count(self) -> int:
        """Number of pending jobs."""
        with self._lock:
            return len(self._pending)

    def stats(self) -> dict:
        """Queue statistics."""
        with self._lock:
            by_status: dict[str, int] = {}
            total_duration = 0.0
            completed = 0
            for job in self._jobs.values():
                key = job.status.value
                by_status[key] = by_status.get(key, 0) + 1
                if job.result and job.result.duration_ms > 0:
                    total_duration += job.result.duration_ms
                    completed += 1
            return {
                "total_jobs": len(self._jobs),
                "by_status": by_status,
                "avg_duration_ms": (total_duration / completed if completed else 0.0),
                "handler_types": list(self._handlers.keys()),
            }

    def _evict_old(self) -> None:
        """Remove oldest completed/failed jobs over max."""
        with self._lock:
            terminal = [
                j
                for j in self._jobs.values()
                if j.status
                in (
                    JobStatus.COMPLETED,
                    JobStatus.FAILED,
                    JobStatus.CANCELLED,
                )
            ]
            if len(terminal) <= self._max_history:
                return
            terminal.sort(key=lambda j: j.created_at)
            to_remove = len(terminal) - self._max_history
            for j in terminal[:to_remove]:
                del self._jobs[j.job_id]

    def clear(self) -> int:
        """Clear all completed/failed/cancelled jobs."""
        with self._lock:
            to_remove = [
                jid
                for jid, j in self._jobs.items()
                if j.status
                in (
                    JobStatus.COMPLETED,
                    JobStatus.FAILED,
                    JobStatus.CANCELLED,
                )
            ]
            for jid in to_remove:
                del self._jobs[jid]
            return len(to_remove)


def save_results(jobs: list[Job], path: str | Path) -> Path:
    """Save completed job results to a JSON file."""
    p = Path(path)
    p.parent.mkdir(parents=True, exist_ok=True)
    records = [j.to_dict() for j in jobs if j.status == JobStatus.COMPLETED]
    p.write_text(json.dumps(records, indent=2, default=str))
    return p
