"""Async facade over CoordinationDB for job record operations.

Delegates all storage to :class:`~mcp_vm_blackbox.coordination_db.CoordinationDB`
via the :func:`~mcp_vm_blackbox.coordination_db.get_db` singleton.

JSON evidence files are written only on terminal state transitions
(paused, completed, failed) so that git-tracked evidence remains readable
without incurring a load-modify-save round-trip on every step append (DR-3).
"""

from __future__ import annotations

import uuid
from datetime import UTC, datetime
from typing import TYPE_CHECKING

from mcp_vm_blackbox._helpers import _atomic_write_json, default_blackbox_data_root
from mcp_vm_blackbox.coordination_db import get_db
from mcp_vm_blackbox.models import EvidenceAttachment, IssueClassification, JobRecord, JobStep, StepOutcome

if TYPE_CHECKING:
    from pathlib import Path

_GITIGNORE_CONTENT = "*\n!.gitignore\n"

_DEFAULT_ROOT = default_blackbox_data_root()

# Legal status transitions: current_status -> set of allowed next statuses
_LEGAL_TRANSITIONS: dict[str, set[str]] = {
    "active": {"paused", "completed", "failed"},
    "paused": {"active", "completed", "failed"},
    "completed": set(),
    "failed": {"active"},
}

# Statuses that trigger a JSON evidence file write (DR-3)
_TERMINAL_STATES: frozenset[str] = frozenset({"paused", "completed", "failed"})


class JobStore:
    """Async facade over CoordinationDB for job records.

    Storage layout::

        {root} / jobs / {vm_name} / {job_id}.json  (written on terminal states only)

    All reads and real-time state are served from SQLite via CoordinationDB.
    JSON files are written only when a job transitions to a terminal state
    (paused, completed, failed) to preserve git-tracked evidence.
    """

    def __init__(self, root: Path) -> None:
        """Initialise the job store.

        Args:
            root: Repo-local storage root (typically from :func:`default_blackbox_data_root`).
        """
        self._root = root
        self._jobs_dir = root / "jobs"

    def _ensure_vm_dir(self, vm_name: str) -> Path:
        """Ensure the per-VM evidence directory exists and return its path.

        Args:
            vm_name: Target VM name.

        Returns:
            The per-VM directory under ``{root}/jobs/{vm_name}/``.
        """
        vm_dir = self._jobs_dir / vm_name
        vm_dir.mkdir(parents=True, exist_ok=True)
        gitignore = self._jobs_dir / ".gitignore"
        if not gitignore.exists():
            gitignore.write_text(_GITIGNORE_CONTENT, encoding="utf-8")
        return vm_dir

    def _job_path(self, vm_name: str, job_id: str) -> Path:
        return self._jobs_dir / vm_name / f"{job_id}.json"

    async def start(self, vm_name: str, scenario_name: str, agent_id: str, step: str) -> str:
        """Create a new job and return its ID.

        Args:
            vm_name: Target VM name.
            scenario_name: Scenario name for this job.
            agent_id: Agent creating the job.
            step: Description of the initial step/action.

        Returns:
            The generated ``job_id`` (12-char hex string).
        """
        job_id = uuid.uuid4().hex[:12]
        now = datetime.now(UTC).isoformat()
        initial_step = JobStep(agent_id=agent_id, action=step, timestamp=now, outcome=None)
        record = JobRecord(
            job_id=job_id,
            vm_name=vm_name,
            scenario_name=scenario_name,
            created_by=agent_id,
            last_agent_id=agent_id,
            status="active",
            steps=[initial_step],
            created_at=now,
            updated_at=now,
        )
        db = await get_db(self._root)
        await db.insert_job(record)
        return job_id

    async def append_step(
        self,
        job_id: str,
        vm_name: str,
        agent_id: str,
        action: str,
        outcome: str | None = None,
        evidence: list[EvidenceAttachment] | None = None,
        intent: str | None = None,
        reason: str | None = None,
        outcome_status: StepOutcome | None = None,
        outcome_detail: str | None = None,
        issue_classification: IssueClassification | None = None,
        resolution: str | None = None,
        next: str | None = None,  # noqa: A002
    ) -> None:
        """Append a step to an existing job record.

        Writes the step directly to SQLite without loading the full record
        (DR-3 — no load-modify-save round-trip).

        Args:
            job_id: Job identifier.
            vm_name: VM name (part of the composite key).
            agent_id: Agent performing this step.
            action: Description of the action.
            outcome: Optional outcome of the action (free-text, legacy).
            evidence: Optional list of evidence attachments for this step.
            intent: What the agent intends to do (declare phase).
            reason: Why this action is being taken (declare phase).
            outcome_status: Structured outcome: success/failed/blocked/skipped.
            outcome_detail: Detailed explanation of what happened (reflect phase).
            issue_classification: Issue classification if a problem was detected.
            resolution: What was done about the issue, if any (reflect phase).
            next: What comes next after this step (reflect phase).

        Raises:
            FileNotFoundError: If no job with the given (job_id, vm_name) exists.
        """
        db = await get_db(self._root)
        record = await db.get_job(job_id, vm_name)
        if record is None:
            msg = f"Job not found: job_id={job_id!r}, vm_name={vm_name!r}"
            raise FileNotFoundError(msg)

        now = datetime.now(UTC).isoformat()
        new_step = JobStep(
            agent_id=agent_id,
            action=action,
            timestamp=now,
            outcome=outcome,
            evidence=evidence,
            intent=intent,
            reason=reason,
            outcome_status=outcome_status,
            outcome_detail=outcome_detail,
            issue_classification=issue_classification,
            resolution=resolution,
            next=next,
        )
        await db.insert_step(job_id, vm_name, new_step)
        await db.update_job_status(job_id, vm_name, record.status, now)

    async def set_status(self, job_id: str, vm_name: str, status: str) -> None:
        """Update the status of a job record.

        When transitioning to a terminal state (paused, completed, failed),
        the full job record is also written to ``{root}/jobs/{vm_name}/{job_id}.json``
        so that git-tracked evidence remains readable (DR-3).

        Args:
            job_id: Job identifier.
            vm_name: VM name (part of the composite key).
            status: New status value.

        Raises:
            FileNotFoundError: If no job with the given (job_id, vm_name) exists.
            ValueError: If the status transition is not legal.
        """
        db = await get_db(self._root)
        record = await db.get_job(job_id, vm_name)
        if record is None:
            msg = f"Job not found: job_id={job_id!r}, vm_name={vm_name!r}"
            raise FileNotFoundError(msg)

        allowed = _LEGAL_TRANSITIONS.get(record.status, set())
        if status not in allowed:
            msg = f"Invalid status transition: {record.status!r} -> {status!r} (allowed: {sorted(allowed)})"
            raise ValueError(msg)

        now = datetime.now(UTC).isoformat()
        await db.update_job_status(job_id, vm_name, status, now)

        if status in _TERMINAL_STATES:
            self._ensure_vm_dir(vm_name)
            updated_record = record.model_copy(update={"status": status, "updated_at": now})
            _atomic_write_json(self._job_path(vm_name, job_id), updated_record.model_dump())

    async def load(self, job_id: str, vm_name: str) -> JobRecord:
        """Load a job record from the database.

        Args:
            job_id: Job identifier.
            vm_name: VM name (part of the composite key).

        Returns:
            The :class:`~mcp_vm_blackbox.models.JobRecord` with embedded steps.

        Raises:
            FileNotFoundError: If no job with the given (job_id, vm_name) exists.
        """
        db = await get_db(self._root)
        record = await db.get_job(job_id, vm_name)
        if record is None:
            msg = f"Job not found: job_id={job_id!r}, vm_name={vm_name!r}"
            raise FileNotFoundError(msg)
        return record

    async def list_by_vm(self, vm_name: str) -> list[JobRecord]:
        """Return all job records for a given VM.

        Args:
            vm_name: VM name to filter by.

        Returns:
            List of job records (empty if the VM has no jobs).
        """
        db = await get_db(self._root)
        return await db.list_jobs_by_vm(vm_name)

    async def list_by_status(self, status: str) -> list[JobRecord]:
        """Return all job records matching the given status.

        Args:
            status: Status value to filter by.

        Returns:
            List of matching job records.
        """
        db = await get_db(self._root)
        return await db.list_jobs_by_status(status)

    async def list_all(self) -> list[JobRecord]:
        """Return all job records across all VMs.

        Returns:
            List of all job records.
        """
        db = await get_db(self._root)
        return await db.list_all_jobs()


# Shared singleton — import this instead of constructing JobStore directly.
default_job_store = JobStore(root=_DEFAULT_ROOT)
