"""SQLite coordination database for VM job and registry state.

Replaces JSON file-globbing with a single WAL-mode SQLite database.
All methods are async via aiosqlite.  A module-level singleton is
accessed through :func:`get_db`.
"""

from __future__ import annotations

import asyncio
import atexit
import json
import logging
import sqlite3
import uuid
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, Self, TypedDict

import aiosqlite

from mcp_vm_blackbox._helpers import default_blackbox_data_root
from mcp_vm_blackbox.models import IssueClassification, JobRecord, JobStep, StepOutcome, VMRegistryEntry

if TYPE_CHECKING:
    from pathlib import Path

_logger = logging.getLogger(__name__)


class FleetVmJobsView(TypedDict):
    """Per-VM entry returned by :meth:`CoordinationDB.get_fleet_status`."""

    vm: dict[str, Any]
    jobs: list[dict[str, Any]]


def _sqlite_row_as_dict(row: sqlite3.Row) -> dict[str, Any]:
    """Build a plain dict from a SQLite row (``dict(row)`` overloads are unreliable for static typing).

    Note: ``for x in row`` iterates *values*; column names require ``row.keys()``.
    """
    keys = row.keys()
    return {str(k): row[k] for k in keys}


_DEFAULT_ROOT = default_blackbox_data_root()

# ---------------------------------------------------------------------------
# Schema DDL — kept close to the code that executes it so the two stay in sync
# ---------------------------------------------------------------------------

_DDL = """\
PRAGMA foreign_keys = ON;

CREATE TABLE IF NOT EXISTS jobs (
    job_id        TEXT NOT NULL,
    vm_name       TEXT NOT NULL,
    scenario_name TEXT NOT NULL,
    created_by    TEXT NOT NULL,
    last_agent_id TEXT NOT NULL,
    status        TEXT NOT NULL CHECK(status IN ('active', 'paused', 'completed', 'failed')),
    created_at    TEXT NOT NULL,
    updated_at    TEXT NOT NULL,
    pilot_context_usage REAL,
    PRIMARY KEY (job_id, vm_name)
);

CREATE TABLE IF NOT EXISTS job_steps (
    step_id              INTEGER PRIMARY KEY AUTOINCREMENT,
    job_id               TEXT NOT NULL,
    vm_name              TEXT NOT NULL,
    agent_id             TEXT NOT NULL,
    action               TEXT NOT NULL,
    timestamp            TEXT NOT NULL,
    outcome              TEXT,
    intent               TEXT,
    reason               TEXT,
    outcome_status       TEXT CHECK(
        outcome_status IN ('success', 'failed', 'blocked', 'skipped')
        OR outcome_status IS NULL
    ),
    outcome_detail       TEXT,
    issue_classification TEXT CHECK(
        issue_classification IN ('user-facing', 'harness', 'environment')
        OR issue_classification IS NULL
    ),
    resolution           TEXT,
    next                 TEXT,
    evidence             TEXT,
    FOREIGN KEY (job_id, vm_name) REFERENCES jobs(job_id, vm_name)
);

CREATE TABLE IF NOT EXISTS vm_registry (
    vm_name        TEXT PRIMARY KEY,
    uuid           TEXT NOT NULL,
    running        INTEGER NOT NULL CHECK(running IN (0, 1)),
    last_refreshed TEXT NOT NULL,
    recording      INTEGER NOT NULL DEFAULT 0 CHECK(recording IN (0, 1)),
    recording_path TEXT,
    recording_started_at TEXT
);

CREATE INDEX IF NOT EXISTS idx_jobs_status    ON jobs(status);
CREATE INDEX IF NOT EXISTS idx_jobs_vm_name   ON jobs(vm_name);
CREATE INDEX IF NOT EXISTS idx_jobs_vm_status ON jobs(vm_name, status);
CREATE INDEX IF NOT EXISTS idx_steps_job      ON job_steps(job_id, vm_name);
CREATE INDEX IF NOT EXISTS idx_steps_ts       ON job_steps(timestamp);

CREATE TABLE IF NOT EXISTS handoffs (
    handoff_id             TEXT PRIMARY KEY,
    job_id                 TEXT NOT NULL,
    vm_name                TEXT NOT NULL,
    todo_position          TEXT NOT NULL,
    last_completed_step_id INTEGER,
    next_intended_action   TEXT NOT NULL,
    key_observations       TEXT NOT NULL,
    full_log_path          TEXT NOT NULL,
    created_at             TEXT NOT NULL,
    FOREIGN KEY (job_id, vm_name) REFERENCES jobs(job_id, vm_name)
);

CREATE INDEX IF NOT EXISTS idx_handoffs_job ON handoffs(job_id, vm_name);
CREATE INDEX IF NOT EXISTS idx_handoffs_ts  ON handoffs(created_at);

CREATE TABLE IF NOT EXISTS issues (
    issue_id         TEXT PRIMARY KEY,
    job_id           TEXT NOT NULL,
    vm_name          TEXT NOT NULL,
    classification   TEXT NOT NULL CHECK(
        classification IN ('user-facing', 'harness', 'environment')
    ),
    severity         TEXT NOT NULL CHECK(
        severity IN ('blocker', 'major', 'minor', 'observation')
    ),
    summary          TEXT NOT NULL,
    detail           TEXT,
    resolution       TEXT,
    resolution_type  TEXT,
    evidence_paths   TEXT,
    step_id          INTEGER,
    created_at       TEXT NOT NULL,
    resolved_at      TEXT,
    FOREIGN KEY (job_id, vm_name) REFERENCES jobs(job_id, vm_name)
);

CREATE INDEX IF NOT EXISTS idx_issues_job ON issues(job_id, vm_name);
CREATE INDEX IF NOT EXISTS idx_issues_ts  ON issues(created_at);

CREATE TABLE IF NOT EXISTS steering_messages (
    message_id      TEXT PRIMARY KEY,
    target_pilot    TEXT,
    job_id          TEXT NOT NULL,
    vm_name         TEXT NOT NULL,
    sender          TEXT NOT NULL CHECK(
        sender IN ('orchestrator', 'user', 'scheduled-task')
    ),
    content         TEXT NOT NULL,
    priority        TEXT NOT NULL CHECK(
        priority IN ('normal', 'urgent')
    ),
    created_at      TEXT NOT NULL,
    acknowledged_at TEXT,
    acted_on        INTEGER NOT NULL DEFAULT 0 CHECK(acted_on IN (0, 1)),
    delivery        TEXT NOT NULL CHECK(
        delivery IN ('channel', 'hook')
    ),
    channel_meta    TEXT,
    FOREIGN KEY (job_id, vm_name) REFERENCES jobs(job_id, vm_name)
);

CREATE INDEX IF NOT EXISTS idx_steering_job ON steering_messages(job_id, vm_name, acknowledged_at);
CREATE INDEX IF NOT EXISTS idx_steering_ts  ON steering_messages(created_at);

CREATE TABLE IF NOT EXISTS todos (
    item_id      TEXT PRIMARY KEY,
    job_id       TEXT NOT NULL,
    vm_name      TEXT NOT NULL,
    title        TEXT NOT NULL,
    description  TEXT,
    status       TEXT NOT NULL DEFAULT 'queued' CHECK(
        status IN ('queued', 'in_progress', 'done', 'blocked', 'skipped')
    ),
    priority     INTEGER NOT NULL DEFAULT 100,
    added_by     TEXT NOT NULL CHECK(
        added_by IN ('orchestrator', 'user', 'scheduled_task')
    ),
    assigned_pilot TEXT,
    created_at   TEXT NOT NULL,
    started_at   TEXT,
    completed_at TEXT,
    blocked_by   TEXT,
    FOREIGN KEY (job_id, vm_name) REFERENCES jobs(job_id, vm_name)
);
CREATE INDEX IF NOT EXISTS idx_todos_job      ON todos(job_id, vm_name);
CREATE INDEX IF NOT EXISTS idx_todos_priority ON todos(job_id, priority);
CREATE INDEX IF NOT EXISTS idx_todos_status   ON todos(job_id, status);
"""

# ---------------------------------------------------------------------------
# Schema versioning
# ---------------------------------------------------------------------------

SCHEMA_VERSION: int = 2
"""Current schema version.  Stamped into ``PRAGMA user_version`` on every
fresh database.  Increment this constant when adding a new migration entry
to :data:`_MIGRATIONS`."""

_MIGRATIONS: dict[int, str] = {2: "ALTER TABLE jobs ADD COLUMN pilot_context_usage REAL;"}
"""Map of schema version → DDL to execute when upgrading from the previous
version.  Keys start at 2 (version 1 is the baseline represented by :data:`_DDL`).
Invariant: ``max(_MIGRATIONS.keys(), default=1) <= SCHEMA_VERSION``."""


async def _apply_migrations(conn: aiosqlite.Connection) -> None:
    """Stamp fresh databases and apply any pending schema migrations.

    Reads ``PRAGMA user_version`` from *conn* and compares it to
    :data:`SCHEMA_VERSION`.  Four cases are handled:

    - **Fresh database** (``user_version == 0``): stamp to ``SCHEMA_VERSION``
      and return — the full schema was already created by ``_apply_schema()``.
    - **Up-to-date** (``user_version == SCHEMA_VERSION``): nothing to do.
    - **Newer than code** (``user_version > SCHEMA_VERSION``): log a warning
      and return — do not attempt a downgrade.
    - **Needs migration** (``0 < user_version < SCHEMA_VERSION``): execute
      each entry in :data:`_MIGRATIONS` whose key is greater than the current
      version, in ascending order, stamping ``user_version`` after each step.

    Args:
        conn: An open :class:`aiosqlite.Connection` with WAL mode already set.
    """
    async with conn.execute("PRAGMA user_version") as cur:
        row = await cur.fetchone()
    current: int = row[0] if row else 0

    if current == 0:
        # Fresh database — baseline schema already applied; just stamp it.
        await conn.executescript(f"PRAGMA user_version = {SCHEMA_VERSION};")
        return

    if current == SCHEMA_VERSION:
        return

    if current > SCHEMA_VERSION:
        _logger.warning(
            "coordination.db user_version=%d is newer than SCHEMA_VERSION=%d — skipping migrations to avoid downgrade",
            current,
            SCHEMA_VERSION,
        )
        return

    # Apply pending migrations in ascending version order.
    for version in sorted(k for k in _MIGRATIONS if k > current):
        await conn.executescript(_MIGRATIONS[version])
        await conn.executescript(f"PRAGMA user_version = {version};")

    await conn.commit()


# ---------------------------------------------------------------------------
# CoordinationDB
# ---------------------------------------------------------------------------


class CoordinationDB:
    """SQLite-backed coordination database for jobs and the VM registry.

    Use :func:`get_db` to obtain the shared singleton.  For tests, construct
    directly and call :meth:`initialize` / :meth:`close` explicitly, or use
    it as an async context manager::

        async with CoordinationDB(root=tmp_path) as db:
            ...
    """

    def __init__(self, root: Path) -> None:
        """Store the database root.  Connection is not opened until :meth:`initialize`.

        Args:
            root: Directory that will contain ``coordination.db`` and supporting
                files (e.g. ``.gitignore``, ``migration_v1.done``).
        """
        self._root = root
        self._db_path = root / "coordination.db"
        self._conn: aiosqlite.Connection | None = None

    # ------------------------------------------------------------------
    # Lifecycle
    # ------------------------------------------------------------------

    async def initialize(self) -> None:
        """Open the SQLite connection, apply PRAGMAs, create schema, and migrate.

        Creates the root directory if absent.  Writes ``.gitignore`` to
        prevent the SQLite WAL artefacts from being committed accidentally.

        Raises:
            OSError: If the database file cannot be created (permissions, disk full).
            aiosqlite.Error: On any SQLite-level failure during PRAGMA or DDL.
        """
        self._root.mkdir(parents=True, exist_ok=True)
        self._conn = await aiosqlite.connect(self._db_path)
        self._conn.row_factory = aiosqlite.Row
        await self._conn.execute("PRAGMA journal_mode = WAL")
        await self._conn.execute("PRAGMA foreign_keys = ON")
        await self._conn.execute("PRAGMA synchronous = NORMAL")
        await self._conn.execute("PRAGMA busy_timeout = 5000")
        await self._apply_schema()
        await _apply_migrations(self._c)
        await self._maybe_migrate()
        self._write_gitignore()

    async def close(self) -> None:
        """Close the SQLite connection if open.  Safe to call multiple times."""
        if self._conn is not None:
            await self._conn.close()
            self._conn = None

    async def __aenter__(self) -> Self:
        """Enter the async context manager, initialising the database.

        Returns:
            This :class:`CoordinationDB` instance.
        """
        await self.initialize()
        return self

    async def __aexit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
        """Exit the async context manager, closing the database connection."""
        await self.close()

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    @property
    def _c(self) -> aiosqlite.Connection:
        """Return the open connection; raise if :meth:`initialize` was not called."""
        if self._conn is None:
            msg = "CoordinationDB is not initialized — call await db.initialize() first"
            raise RuntimeError(msg)
        return self._conn

    async def _apply_schema(self) -> None:
        """Execute the DDL script to create tables and indexes."""
        await self._c.executescript(_DDL)
        await self._c.commit()

    def _write_gitignore(self) -> None:
        """Write a ``.gitignore`` in the root dir to exclude SQLite artefacts."""
        gi = self._root / ".gitignore"
        if not gi.exists():
            gi.write_text("*.db\n*.db-shm\n*.db-wal\n!.gitignore\n", encoding="utf-8")

    async def _maybe_migrate(self) -> None:
        """Import existing JSON job files into SQLite on first startup.

        Checks for a sentinel file ``migration_v1.done`` in the root directory.
        If absent, scans ``{root}/jobs/{vm_name}/*.json``, validates each file
        with :class:`~mcp_vm_blackbox.models.JobRecord`, and inserts it via
        ``INSERT OR IGNORE`` so the operation is idempotent.

        Corrupt or unreadable files are logged and skipped; migration is not
        aborted on partial failure.
        """
        sentinel = self._root / "migration_v1.done"
        if sentinel.exists():
            return

        jobs_dir = self._root / "jobs"
        if not jobs_dir.exists():
            sentinel.touch()
            return

        json_files = list(jobs_dir.glob("*/*.json"))
        if not json_files:
            sentinel.touch()
            return

        migrated = 0
        skipped = 0
        for path in json_files:
            try:
                data = json.loads(path.read_text(encoding="utf-8"))
                record = JobRecord.model_validate(data)
                await self._insert_job_record(record)
                migrated += 1
            except (json.JSONDecodeError, ValueError, OSError):
                _logger.warning("Migration: skipped corrupt file %s", path, exc_info=True)
                skipped += 1

        _logger.info("Migration complete: %d jobs migrated, %d skipped", migrated, skipped)
        sentinel.touch()

    async def _insert_job_record(self, record: JobRecord) -> None:
        """Insert a :class:`JobRecord` and its steps using INSERT OR IGNORE.

        Used by migration; safe to call on duplicate job_id/vm_name pairs.

        Args:
            record: The job record to insert.
        """
        await self._c.execute(
            """
            INSERT OR IGNORE INTO jobs
                (job_id, vm_name, scenario_name, created_by, last_agent_id,
                 status, created_at, updated_at)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?)
            """,
            (
                record.job_id,
                record.vm_name,
                record.scenario_name,
                record.created_by,
                record.last_agent_id,
                record.status,
                record.created_at,
                record.updated_at,
            ),
        )
        for step in record.steps:
            await self._c.execute(
                """
                INSERT OR IGNORE INTO job_steps
                    (job_id, vm_name, agent_id, action, timestamp, outcome,
                     intent, reason, outcome_status, outcome_detail,
                     issue_classification, resolution, next, evidence)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                """,
                (
                    record.job_id,
                    record.vm_name,
                    step.agent_id,
                    step.action,
                    step.timestamp,
                    step.outcome,
                    step.intent,
                    step.reason,
                    step.outcome_status.value if step.outcome_status else None,
                    step.outcome_detail,
                    step.issue_classification.value if step.issue_classification else None,
                    step.resolution,
                    step.next,
                    json.dumps([e.model_dump() for e in step.evidence]) if step.evidence else None,
                ),
            )
        await self._c.commit()

    async def _fetch_steps(self, job_id: str, vm_name: str) -> list[JobStep]:
        """Fetch all steps for a job, ordered by ``step_id`` (insertion order).

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

        Returns:
            Ordered list of :class:`~mcp_vm_blackbox.models.JobStep` instances.
        """
        async with self._c.execute(
            """
            SELECT agent_id, action, timestamp, outcome, intent, reason,
                   outcome_status, outcome_detail, issue_classification,
                   resolution, next, evidence
            FROM job_steps
            WHERE job_id = ? AND vm_name = ?
            ORDER BY step_id ASC
            """,
            (job_id, vm_name),
        ) as cur:
            rows = await cur.fetchall()

        steps: list[JobStep] = []
        for row in rows:
            os_val = row["outcome_status"]
            ic_val = row["issue_classification"]
            ev_raw = row["evidence"]
            evidence = json.loads(ev_raw) if ev_raw else None
            steps.append(
                JobStep(
                    agent_id=row["agent_id"],
                    action=row["action"],
                    timestamp=row["timestamp"],
                    outcome=row["outcome"],
                    intent=row["intent"],
                    reason=row["reason"],
                    outcome_status=StepOutcome(os_val) if os_val else None,
                    outcome_detail=row["outcome_detail"],
                    issue_classification=IssueClassification(ic_val) if ic_val else None,
                    resolution=row["resolution"],
                    next=row["next"],
                    evidence=evidence,
                )
            )
        return steps

    async def _row_to_record(self, row: aiosqlite.Row) -> JobRecord:
        """Reconstruct a full :class:`JobRecord` from a jobs-table row.

        Fetches associated steps from the ``job_steps`` table.

        Args:
            row: A row from the ``jobs`` table with all columns selected.

        Returns:
            A :class:`~mcp_vm_blackbox.models.JobRecord` with embedded steps.
        """
        steps = await self._fetch_steps(row["job_id"], row["vm_name"])
        return JobRecord(
            job_id=row["job_id"],
            vm_name=row["vm_name"],
            scenario_name=row["scenario_name"],
            created_by=row["created_by"],
            last_agent_id=row["last_agent_id"],
            status=row["status"],
            steps=steps,
            created_at=row["created_at"],
            updated_at=row["updated_at"],
            pilot_context_usage=row["pilot_context_usage"],
        )

    # ------------------------------------------------------------------
    # Job CRUD
    # ------------------------------------------------------------------

    async def insert_job(self, record: JobRecord) -> None:
        """Insert a new job record into the database.

        Args:
            record: The job record to persist.

        Raises:
            aiosqlite.IntegrityError: If a job with the same (job_id, vm_name)
                already exists.
        """
        await self._c.execute(
            """
            INSERT INTO jobs
                (job_id, vm_name, scenario_name, created_by, last_agent_id,
                 status, created_at, updated_at)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?)
            """,
            (
                record.job_id,
                record.vm_name,
                record.scenario_name,
                record.created_by,
                record.last_agent_id,
                record.status,
                record.created_at,
                record.updated_at,
            ),
        )
        for step in record.steps:
            await self.insert_step(record.job_id, record.vm_name, step)
        await self._c.commit()

    async def insert_step(self, job_id: str, vm_name: str, step: JobStep) -> None:
        """Append a step to the ``job_steps`` table.

        Args:
            job_id: Job identifier.
            vm_name: VM name (part of the composite FK).
            step: The step to append.
        """
        await self._c.execute(
            """
            INSERT INTO job_steps
                (job_id, vm_name, agent_id, action, timestamp, outcome,
                 intent, reason, outcome_status, outcome_detail,
                 issue_classification, resolution, next, evidence)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """,
            (
                job_id,
                vm_name,
                step.agent_id,
                step.action,
                step.timestamp,
                step.outcome,
                step.intent,
                step.reason,
                step.outcome_status.value if step.outcome_status else None,
                step.outcome_detail,
                step.issue_classification.value if step.issue_classification else None,
                step.resolution,
                step.next,
                json.dumps([e.model_dump() for e in step.evidence]) if step.evidence else None,
            ),
        )
        await self._c.commit()

    async def update_job_status(self, job_id: str, vm_name: str, status: str, updated_at: str) -> None:
        """Update the status and ``updated_at`` timestamp for a job.

        Args:
            job_id: Job identifier.
            vm_name: VM name (part of the composite PK).
            status: New status value (must satisfy the table CHECK constraint).
            updated_at: ISO-8601 UTC timestamp to record as the update time.
        """
        await self._c.execute(
            "UPDATE jobs SET status = ?, updated_at = ? WHERE job_id = ? AND vm_name = ?",
            (status, updated_at, job_id, vm_name),
        )
        await self._c.commit()

    async def get_job(self, job_id: str, vm_name: str) -> JobRecord | None:
        """Return the job record for the given (job_id, vm_name) pair, or None.

        Args:
            job_id: Job identifier.
            vm_name: VM name.

        Returns:
            The :class:`~mcp_vm_blackbox.models.JobRecord` with embedded steps,
            or ``None`` if not found.
        """
        async with self._c.execute("SELECT * FROM jobs WHERE job_id = ? AND vm_name = ?", (job_id, vm_name)) as cur:
            row = await cur.fetchone()
        if row is None:
            return None
        return await self._row_to_record(row)

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

        Args:
            vm_name: VM name to filter by.

        Returns:
            List of :class:`~mcp_vm_blackbox.models.JobRecord` instances (may be empty).
        """
        async with self._c.execute("SELECT * FROM jobs WHERE vm_name = ?", (vm_name,)) as cur:
            rows = await cur.fetchall()
        results: list[JobRecord] = [await self._row_to_record(row) for row in rows]
        return results

    async def list_jobs_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 :class:`~mcp_vm_blackbox.models.JobRecord` instances.
        """
        async with self._c.execute("SELECT * FROM jobs WHERE status = ?", (status,)) as cur:
            rows = await cur.fetchall()
        results: list[JobRecord] = [await self._row_to_record(row) for row in rows]
        return results

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

        Returns:
            List of all :class:`~mcp_vm_blackbox.models.JobRecord` instances.
        """
        async with self._c.execute("SELECT * FROM jobs") as cur:
            rows = await cur.fetchall()
        results: list[JobRecord] = [await self._row_to_record(row) for row in rows]
        return results

    # ------------------------------------------------------------------
    # VM Registry CRUD
    # ------------------------------------------------------------------

    async def upsert_vm(self, entry: VMRegistryEntry) -> None:
        """Insert or replace a VM registry entry.

        Args:
            entry: The :class:`~mcp_vm_blackbox.models.VMRegistryEntry` to persist.
        """
        await self._c.execute(
            """
            INSERT INTO vm_registry
                (vm_name, uuid, running, last_refreshed,
                 recording, recording_path, recording_started_at)
            VALUES (?, ?, ?, ?, ?, ?, ?)
            ON CONFLICT(vm_name) DO UPDATE SET
                uuid                 = excluded.uuid,
                running              = excluded.running,
                last_refreshed       = excluded.last_refreshed,
                recording            = excluded.recording,
                recording_path       = excluded.recording_path,
                recording_started_at = excluded.recording_started_at
            """,
            (
                entry.vm_name,
                entry.uuid,
                1 if entry.running else 0,
                entry.last_refreshed,
                1 if entry.recording else 0,
                entry.recording_path,
                entry.recording_started_at,
            ),
        )
        await self._c.commit()

    async def upsert_vms(self, entries: list[VMRegistryEntry]) -> None:
        """Upsert multiple VM registry entries in a single transaction.

        Args:
            entries: List of :class:`~mcp_vm_blackbox.models.VMRegistryEntry` to persist.
        """
        for entry in entries:
            await self._c.execute(
                """
                INSERT INTO vm_registry
                    (vm_name, uuid, running, last_refreshed,
                     recording, recording_path, recording_started_at)
                VALUES (?, ?, ?, ?, ?, ?, ?)
                ON CONFLICT(vm_name) DO UPDATE SET
                    uuid                 = excluded.uuid,
                    running              = excluded.running,
                    last_refreshed       = excluded.last_refreshed,
                    recording            = excluded.recording,
                    recording_path       = excluded.recording_path,
                    recording_started_at = excluded.recording_started_at
                """,
                (
                    entry.vm_name,
                    entry.uuid,
                    1 if entry.running else 0,
                    entry.last_refreshed,
                    1 if entry.recording else 0,
                    entry.recording_path,
                    entry.recording_started_at,
                ),
            )
        await self._c.commit()

    async def get_vm(self, vm_name: str) -> VMRegistryEntry | None:
        """Return the registry entry for the given VM name, or None.

        Args:
            vm_name: VirtualBox VM name.

        Returns:
            The :class:`~mcp_vm_blackbox.models.VMRegistryEntry`, or ``None``
            if the VM is not in the registry.
        """
        async with self._c.execute("SELECT * FROM vm_registry WHERE vm_name = ?", (vm_name,)) as cur:
            row = await cur.fetchone()
        if row is None:
            return None
        return self._vm_row_to_entry(row)

    async def list_all_vms(self) -> list[VMRegistryEntry]:
        """Return all VM registry entries.

        Returns:
            List of :class:`~mcp_vm_blackbox.models.VMRegistryEntry` instances
            (empty if the registry is unpopulated).
        """
        async with self._c.execute("SELECT * FROM vm_registry") as cur:
            rows = await cur.fetchall()
        return [self._vm_row_to_entry(row) for row in rows]

    @staticmethod
    def _vm_row_to_entry(row: aiosqlite.Row) -> VMRegistryEntry:
        """Convert a ``vm_registry`` table row to a :class:`VMRegistryEntry`.

        Args:
            row: A row from the ``vm_registry`` table.

        Returns:
            The corresponding :class:`~mcp_vm_blackbox.models.VMRegistryEntry`.
        """
        return VMRegistryEntry(
            vm_name=row["vm_name"],
            uuid=row["uuid"],
            running=bool(row["running"]),
            last_refreshed=row["last_refreshed"],
            recording=bool(row["recording"]),
            recording_path=row["recording_path"],
            recording_started_at=row["recording_started_at"],
        )

    # ------------------------------------------------------------------
    # Handoff CRUD
    # ------------------------------------------------------------------

    async def create_handoff(
        self,
        job_id: str,
        vm_name: str,
        todo_position: str,
        next_intended_action: str,
        key_observations: str,
        full_log_path: str,
        last_completed_step_id: int | None = None,
    ) -> tuple[str, str]:
        """Insert a HandoffReference row and return (handoff_id, created_at).

        Resolves last_completed_step_id from MAX(step_id) when not provided.
        Validates that the (job_id, vm_name) pair exists in the jobs table.

        Args:
            job_id: Job identifier (FK to jobs).
            vm_name: VM name (FK to jobs).
            todo_position: Current position in the TODO list at handoff time.
            next_intended_action: What the pilot intended to do next.
            key_observations: Critical facts the incoming pilot must know.
            full_log_path: Path to the evidence/log directory for this job.
            last_completed_step_id: Override for the last step ID. When None,
                resolved by querying MAX(step_id) from job_steps.

        Returns:
            Tuple of (handoff_id, created_at) — both strings.

        Raises:
            ValueError: If no job record exists for (job_id, vm_name).
            aiosqlite.Error: On SQLite-level failure.
        """
        async with self._c.execute("SELECT 1 FROM jobs WHERE job_id = ? AND vm_name = ?", (job_id, vm_name)) as cur:
            row = await cur.fetchone()
        if row is None:
            msg = f"No job found for job_id={job_id!r} vm_name={vm_name!r}"
            raise ValueError(msg)

        if last_completed_step_id is None:
            async with self._c.execute(
                "SELECT MAX(step_id) FROM job_steps WHERE job_id = ? AND vm_name = ?", (job_id, vm_name)
            ) as cur:
                max_row = await cur.fetchone()
            last_completed_step_id = max_row[0] if max_row else None

        handoff_id = str(uuid.uuid4())
        created_at = _utc_now()

        await self._c.execute(
            """
            INSERT INTO handoffs
                (handoff_id, job_id, vm_name, todo_position, last_completed_step_id,
                 next_intended_action, key_observations, full_log_path, created_at)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
            """,
            (
                handoff_id,
                job_id,
                vm_name,
                todo_position,
                last_completed_step_id,
                next_intended_action,
                key_observations,
                full_log_path,
                created_at,
            ),
        )
        await self._c.commit()
        return (handoff_id, created_at)

    async def get_latest_handoff(self, job_id: str, vm_name: str) -> dict[str, object] | None:
        """Return the most recent handoff record for the job, or None.

        Args:
            job_id: Job identifier.
            vm_name: VM name.

        Returns:
            Dict with all handoff columns, or None if no handoff exists.
        """
        async with self._c.execute(
            """
            SELECT * FROM handoffs
            WHERE job_id = ? AND vm_name = ?
            ORDER BY created_at DESC
            LIMIT 1
            """,
            (job_id, vm_name),
        ) as cur:
            row = await cur.fetchone()
        if row is None:
            return None
        return _sqlite_row_as_dict(row)

    async def list_handoffs_by_job(self, job_id: str, vm_name: str) -> list[dict[str, object]]:
        """Return all handoff records for the job, ordered by created_at ascending.

        Args:
            job_id: Job identifier.
            vm_name: VM name.

        Returns:
            List of dicts (may be empty), oldest first.
        """
        async with self._c.execute(
            """
            SELECT * FROM handoffs
            WHERE job_id = ? AND vm_name = ?
            ORDER BY created_at ASC
            """,
            (job_id, vm_name),
        ) as cur:
            rows = await cur.fetchall()
        return [_sqlite_row_as_dict(row) for row in rows]

    # ------------------------------------------------------------------
    # Issue CRUD
    # ------------------------------------------------------------------

    async def insert_issue(
        self,
        job_id: str,
        vm_name: str,
        classification: str,
        severity: str,
        summary: str,
        detail: str | None = None,
        resolution: str | None = None,
        resolution_type: str | None = None,
        evidence_paths: list[str] | None = None,
        step_id: int | None = None,
    ) -> tuple[str, str]:
        """Insert an issue record and return (issue_id, created_at).

        Validates that the (job_id, vm_name) pair exists in the jobs table.

        Args:
            job_id: Job identifier (FK to jobs).
            vm_name: VM name (FK to jobs).
            classification: Issue classification value (must match IssueClassification).
            severity: Issue severity value (must match IssueSeverity).
            summary: One-line summary of the issue.
            detail: Detailed description (optional).
            resolution: Resolution description (optional, typically null at creation).
            resolution_type: Resolution category (optional, typically null at creation).
            evidence_paths: List of evidence file paths (optional).
            step_id: Originating step_id from job_steps (optional).

        Returns:
            Tuple of (issue_id, created_at) — both strings.

        Raises:
            ValueError: If no job record exists for (job_id, vm_name).
            aiosqlite.Error: On SQLite-level failure.
        """
        async with self._c.execute("SELECT 1 FROM jobs WHERE job_id = ? AND vm_name = ?", (job_id, vm_name)) as cur:
            row = await cur.fetchone()
        if row is None:
            msg = f"No job found for job_id={job_id!r} vm_name={vm_name!r}"
            raise ValueError(msg)

        issue_id = str(uuid.uuid4())
        created_at = _utc_now()
        ep_json = json.dumps(evidence_paths) if evidence_paths else None

        await self._c.execute(
            """
            INSERT INTO issues
                (issue_id, job_id, vm_name, classification, severity, summary,
                 detail, resolution, resolution_type, evidence_paths,
                 step_id, created_at, resolved_at)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
            """,
            (
                issue_id,
                job_id,
                vm_name,
                classification,
                severity,
                summary,
                detail,
                resolution,
                resolution_type,
                ep_json,
                step_id,
                created_at,
            ),
        )
        await self._c.commit()
        return (issue_id, created_at)

    async def list_issues(
        self, job_id: str, vm_name: str, classification: str | None = None, severity: str | None = None
    ) -> list[dict[str, object]]:
        """Return issue records for the job, with optional filters.

        Args:
            job_id: Job identifier.
            vm_name: VM name.
            classification: Filter by classification value (optional).
            severity: Filter by severity value (optional).

        Returns:
            List of dicts with all issue columns (may be empty).
            evidence_paths is deserialized from JSON to list[str].
            Ordered by created_at ASC.
        """
        query = "SELECT * FROM issues WHERE job_id = ? AND vm_name = ?"
        params: list[str] = [job_id, vm_name]

        if classification is not None:
            query += " AND classification = ?"
            params.append(classification)
        if severity is not None:
            query += " AND severity = ?"
            params.append(severity)

        query += " ORDER BY created_at ASC"

        async with self._c.execute(query, params) as cur:
            rows = await cur.fetchall()

        result: list[dict[str, object]] = []
        for row in rows:
            d = _sqlite_row_as_dict(row)
            ep_raw = row["evidence_paths"]
            d["evidence_paths"] = json.loads(ep_raw) if ep_raw else []
            result.append(d)
        return result

    # ------------------------------------------------------------------
    # Steering CRUD
    # ------------------------------------------------------------------

    async def get_pending_steering_message(self, job_id: str, vm_name: str) -> dict[str, object] | None:
        """Return the oldest unacknowledged steering message for the job, or None.

        Args:
            job_id: Job identifier.
            vm_name: VM name.

        Returns:
            Dict with all steering_messages columns (acted_on converted to bool),
            or None if no pending message exists.
        """
        async with self._c.execute(
            """
            SELECT * FROM steering_messages
            WHERE job_id = ? AND vm_name = ? AND acknowledged_at IS NULL
            ORDER BY created_at ASC
            LIMIT 1
            """,
            (job_id, vm_name),
        ) as cur:
            row = await cur.fetchone()
        if row is None:
            return None
        d = _sqlite_row_as_dict(row)
        d["acted_on"] = bool(row["acted_on"])
        return d

    async def insert_steering_message(
        self,
        job_id: str,
        vm_name: str,
        sender: str,
        content: str,
        priority: str,
        delivery: str,
        target_pilot: str | None = None,
        channel_meta: str | None = None,
    ) -> tuple[str, str]:
        """Insert a steering message and return (message_id, created_at).

        Validates that the (job_id, vm_name) pair exists in the jobs table.

        Args:
            job_id: Job identifier (FK to jobs).
            vm_name: VM name (FK to jobs).
            sender: Sender type (must match SteeringSender enum).
            content: Steering instruction text.
            priority: Priority level (must match SteeringPriority enum).
            delivery: Delivery mechanism (must match SteeringDelivery enum).
            target_pilot: Target pilot agent ID (optional — null = broadcast).
            channel_meta: Delivery-specific metadata (optional).

        Returns:
            Tuple of (message_id, created_at) — both strings.

        Raises:
            ValueError: If no job record exists for (job_id, vm_name).
            aiosqlite.Error: On SQLite-level failure.
        """
        async with self._c.execute("SELECT 1 FROM jobs WHERE job_id = ? AND vm_name = ?", (job_id, vm_name)) as cur:
            row = await cur.fetchone()
        if row is None:
            msg = f"No job found for job_id={job_id!r} vm_name={vm_name!r}"
            raise ValueError(msg)

        message_id = str(uuid.uuid4())
        created_at = _utc_now()

        await self._c.execute(
            """
            INSERT INTO steering_messages
                (message_id, target_pilot, job_id, vm_name, sender, content,
                 priority, created_at, acknowledged_at, acted_on, delivery, channel_meta)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, ?, ?)
            """,
            (message_id, target_pilot, job_id, vm_name, sender, content, priority, created_at, delivery, channel_meta),
        )
        await self._c.commit()
        return (message_id, created_at)

    async def acknowledge_steering_message(self, message_id: str) -> str:
        """Set acknowledged_at on a steering message and return the timestamp.

        Args:
            message_id: The UUID of the message to acknowledge.

        Returns:
            The acknowledged_at ISO-8601 timestamp string.

        Raises:
            ValueError: If no steering message exists with the given message_id.
            aiosqlite.Error: On SQLite-level failure.
        """
        acknowledged_at = _utc_now()
        async with self._c.execute(
            "UPDATE steering_messages SET acknowledged_at = ? WHERE message_id = ?", (acknowledged_at, message_id)
        ) as cur:
            rowcount = cur.rowcount
        if rowcount == 0:
            msg = f"No steering message found for message_id={message_id!r}"
            raise ValueError(msg)
        await self._c.commit()
        return acknowledged_at

    # ------------------------------------------------------------------
    # Dashboard fleet aggregation
    # ------------------------------------------------------------------

    async def fleet_status_for_dashboard(self) -> list[dict[str, object]]:
        """Return a dashboard-shaped fleet view: all VMs with their jobs.

        Returns one entry per VM in ``vm_registry``, including VMs with no
        active jobs (``active_job_count=0``, ``jobs=[]``).  Each job entry
        contains only summary fields — no step log is embedded.

        Returns:
            List of dicts, one per VM, with keys:
            ``vm_name``, ``uuid``, ``running``, ``last_refreshed``,
            ``recording``, ``recording_path``, ``recording_started_at``,
            ``jobs`` (list of job summary dicts), ``active_job_count``,
            ``total_job_count``.
        """
        async with self._c.execute("SELECT * FROM vm_registry ORDER BY vm_name") as cur:
            vm_rows = await cur.fetchall()

        result: list[dict[str, object]] = []
        for vm_row in vm_rows:
            vm_name = vm_row["vm_name"]
            async with self._c.execute(
                """
                SELECT job_id, scenario_name, status, last_agent_id, updated_at, pilot_context_usage
                FROM jobs
                WHERE vm_name = ?
                ORDER BY updated_at DESC
                """,
                (vm_name,),
            ) as cur:
                job_rows = await cur.fetchall()

            jobs: list[dict[str, object]] = [
                {
                    "job_id": r["job_id"],
                    "scenario_name": r["scenario_name"],
                    "status": r["status"],
                    "last_agent_id": r["last_agent_id"],
                    "updated_at": r["updated_at"],
                    "pilot_context_usage": r["pilot_context_usage"],
                }
                for r in job_rows
            ]
            active_count = sum(1 for j in jobs if j["status"] == "active")
            result.append({
                "vm_name": vm_name,
                "uuid": vm_row["uuid"],
                "running": bool(vm_row["running"]),
                "last_refreshed": vm_row["last_refreshed"],
                "recording": bool(vm_row["recording"]),
                "recording_path": vm_row["recording_path"],
                "recording_started_at": vm_row["recording_started_at"],
                "jobs": jobs,
                "active_job_count": active_count,
                "total_job_count": len(jobs),
            })
        return result

    async def get_job_by_id_only(self, job_id: str) -> JobRecord | None:
        """Return the first job record matching ``job_id`` regardless of VM.

        Used by the dashboard when only ``job_id`` is known (e.g. TODO routes).
        Resolves to the first match via ``LIMIT 1``; callers relying on
        uniqueness should ensure job IDs are globally unique (UUID4 satisfies
        this in practice).

        Args:
            job_id: Job identifier to look up.

        Returns:
            The :class:`~mcp_vm_blackbox.models.JobRecord` with embedded steps,
            or ``None`` if not found.
        """
        async with self._c.execute("SELECT * FROM jobs WHERE job_id = ? LIMIT 1", (job_id,)) as cur:
            row = await cur.fetchone()
        if row is None:
            return None
        return await self._row_to_record(row)

    async def list_steps_paginated(
        self, job_id: str, vm_name: str, limit: int, offset: int
    ) -> tuple[list[dict[str, object]], int]:
        """Return a paginated slice of steps for a job, plus the total count.

        Both the COUNT and the paginated SELECT run inside the same connection
        context — no second connection is opened.

        Args:
            job_id: Job identifier.
            vm_name: VM name (part of the composite FK).
            limit: Maximum number of steps to return.
            offset: Number of steps to skip before returning results.

        Returns:
            Tuple of (items, total) where ``items`` is a list of step dicts
            and ``total`` is the unfiltered count for the job.
        """
        async with self._c.execute(
            "SELECT COUNT(*) FROM job_steps WHERE job_id = ? AND vm_name = ?", (job_id, vm_name)
        ) as cur:
            count_row = await cur.fetchone()
        total: int = count_row[0] if count_row else 0

        async with self._c.execute(
            """
            SELECT step_id, agent_id, action, timestamp, outcome,
                   intent, reason, outcome_status, outcome_detail,
                   issue_classification, resolution, next, evidence
            FROM job_steps
            WHERE job_id = ? AND vm_name = ?
            ORDER BY step_id ASC
            LIMIT ? OFFSET ?
            """,
            (job_id, vm_name, limit, offset),
        ) as cur:
            rows = await cur.fetchall()

        items: list[dict[str, object]] = []
        for row in rows:
            d = _sqlite_row_as_dict(row)
            ev_raw = row["evidence"]
            d["evidence"] = json.loads(ev_raw) if ev_raw else None
            items.append(d)

        return (items, total)

    # ------------------------------------------------------------------
    # TODO CRUD
    # ------------------------------------------------------------------

    async def list_todos(self, job_id: str, vm_name: str) -> list[dict[str, object]]:
        """Return all TODO items for a job, ordered by priority then creation time.

        Args:
            job_id: Job identifier.
            vm_name: VM name.

        Returns:
            List of dicts with all ``todos`` columns (may be empty), ordered
            by ``priority ASC, created_at ASC``.
        """
        async with self._c.execute(
            """
            SELECT * FROM todos
            WHERE job_id = ? AND vm_name = ?
            ORDER BY priority ASC, created_at ASC
            """,
            (job_id, vm_name),
        ) as cur:
            rows = await cur.fetchall()
        return [_sqlite_row_as_dict(row) for row in rows]

    async def insert_todo(self, record: dict[str, object]) -> dict[str, object]:
        """Insert a TODO item and return the inserted row as a dict.

        The ``record`` dict must contain all required fields: ``item_id``,
        ``job_id``, ``vm_name``, ``title``, ``status``, ``priority``,
        ``added_by``, ``created_at``.  Optional fields default to ``None``
        when absent.

        Args:
            record: Dict of column values for the new TODO row.

        Returns:
            The inserted row as a dict (all columns, including nulls).

        Raises:
            aiosqlite.IntegrityError: If ``item_id`` is a duplicate or FK fails.
        """
        await self._c.execute(
            """
            INSERT INTO todos
                (item_id, job_id, vm_name, title, description, status,
                 priority, added_by, assigned_pilot, created_at,
                 started_at, completed_at, blocked_by)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """,
            (
                record["item_id"],
                record["job_id"],
                record["vm_name"],
                record["title"],
                record.get("description"),
                record.get("status", "queued"),
                record.get("priority", 100),
                record["added_by"],
                record.get("assigned_pilot"),
                record["created_at"],
                record.get("started_at"),
                record.get("completed_at"),
                record.get("blocked_by"),
            ),
        )
        await self._c.commit()

        async with self._c.execute("SELECT * FROM todos WHERE item_id = ?", (record["item_id"],)) as cur:
            row = await cur.fetchone()
        if row is None:
            msg = f"INSERT succeeded but SELECT returned no row for item_id={record['item_id']!r}"
            raise RuntimeError(msg)
        return _sqlite_row_as_dict(row)

    async def update_todo(self, item_id: str, job_id: str, patch: dict[str, object]) -> dict[str, object] | None:
        """Apply a partial update to a TODO item and return the updated row.

        Only the following fields may be patched: ``title``, ``description``,
        ``status``, ``priority``, ``assigned_pilot``, ``blocked_by``,
        ``started_at``, ``completed_at``.  Unrecognised keys in ``patch`` are
        silently ignored.  Returns ``None`` if no row matches
        ``(item_id, job_id)``.

        Args:
            item_id: Primary key of the TODO item.
            job_id: Job identifier (used as a secondary guard against
                cross-job updates).
            patch: Dict of fields to update with their new values.

        Returns:
            The updated row as a dict, or ``None`` if not found.
        """
        allowed = {
            "title",
            "description",
            "status",
            "priority",
            "assigned_pilot",
            "blocked_by",
            "started_at",
            "completed_at",
        }
        updates = {k: v for k, v in patch.items() if k in allowed}
        if not updates:
            # Nothing to patch — return existing row or None
            async with self._c.execute(
                "SELECT * FROM todos WHERE item_id = ? AND job_id = ?", (item_id, job_id)
            ) as cur:
                row = await cur.fetchone()
            return _sqlite_row_as_dict(row) if row is not None else None

        set_clause = ", ".join(f"{col} = ?" for col in updates)
        values = list(updates.values())
        values.extend([item_id, job_id])

        async with self._c.execute(
            f"UPDATE todos SET {set_clause} WHERE item_id = ? AND job_id = ?",  # noqa: S608
            values,
        ) as cur:
            rowcount = cur.rowcount

        if rowcount == 0:
            return None

        await self._c.commit()
        async with self._c.execute("SELECT * FROM todos WHERE item_id = ? AND job_id = ?", (item_id, job_id)) as cur:
            row = await cur.fetchone()
        return _sqlite_row_as_dict(row) if row is not None else None

    async def reorder_todos(self, job_id: str, vm_name: str, ordered_ids: list[str]) -> None:
        """Reassign priority values based on the supplied ordering.

        Items present in ``ordered_ids`` receive ``priority = index * 10``
        (0-based index).  Items not in ``ordered_ids`` retain their current
        priority value.

        Args:
            job_id: Job identifier.
            vm_name: VM name (scopes the update to a single job).
            ordered_ids: Ordered list of ``item_id`` values — first element
                gets the lowest priority number (highest precedence).
        """
        params = [(index * 10, item_id, job_id, vm_name) for index, item_id in enumerate(ordered_ids)]
        await self._c.executemany(
            """
            UPDATE todos
            SET priority = ?
            WHERE item_id = ? AND job_id = ? AND vm_name = ?
            """,
            params,
        )
        await self._c.commit()

    # ------------------------------------------------------------------
    # Fleet aggregation (legacy — kept for MCP vm_fleet_status tool)
    # ------------------------------------------------------------------

    async def get_fleet_status(self) -> dict[str, FleetVmJobsView]:
        """Return an aggregated view of all VMs and their active jobs.

        Returns:
            A dict mapping ``vm_name`` to a sub-dict with keys ``vm`` (the
            :class:`~mcp_vm_blackbox.models.VMRegistryEntry` dict) and ``jobs``
            (a list of active :class:`~mcp_vm_blackbox.models.JobRecord` dicts).
        """
        vms = await self.list_all_vms()
        all_jobs = await self.list_all_jobs()

        # Group all jobs by vm_name in Python — single DB query instead of N per-VM queries
        jobs_by_vm: dict[str, list[JobRecord]] = {}
        for job in all_jobs:
            jobs_by_vm.setdefault(job.vm_name, []).append(job)

        result: dict[str, FleetVmJobsView] = {}
        for vm in vms:
            vm_jobs = jobs_by_vm.get(vm.vm_name, [])
            result[vm.vm_name] = {
                "vm": vm.model_dump(),
                "jobs": [j.model_dump() for j in vm_jobs if j.status == "active"],
            }
        return result


# ---------------------------------------------------------------------------
# Module-level singleton
# ---------------------------------------------------------------------------

_db_instance: CoordinationDB | None = None


async def get_db(root: Path | None = None) -> CoordinationDB:
    """Return the shared :class:`CoordinationDB` singleton.

    Creates and initialises the instance on the first call; returns the cached
    instance on subsequent calls.  ``root`` is only honoured on the first call —
    pass a custom root in tests via :class:`CoordinationDB` directly.

    Args:
        root: Override for the storage root directory.  Defaults to
            :func:`~mcp_vm_blackbox._helpers.default_blackbox_data_root` (repo
            discovery from cwd, or :envvar:`MCP_VM_BLACKBOX_DATA_ROOT`, or
            ``~/.mcp-vm-blackbox``).

    Returns:
        The initialised :class:`CoordinationDB` singleton.
    """
    global _db_instance  # noqa: PLW0603
    if _db_instance is None:
        effective_root = root if root is not None else _DEFAULT_ROOT
        _db_instance = CoordinationDB(root=effective_root)
        await _db_instance.initialize()
    return _db_instance


def _atexit_close() -> None:
    """Best-effort synchronous close of the singleton on process exit."""
    global _db_instance  # noqa: PLW0603
    if _db_instance is None:
        return
    try:
        loop = asyncio.get_event_loop()
        if loop.is_running():
            # Cannot block in a running loop; schedule and move on — task reference kept to satisfy RUF006
            close_task = loop.create_task(_db_instance.close())
            del close_task
        else:
            loop.run_until_complete(_db_instance.close())
    except (OSError, RuntimeError, sqlite3.Error):
        _logger.debug("atexit: error closing CoordinationDB", exc_info=True)
    finally:
        _db_instance = None


atexit.register(_atexit_close)


def _reset_db_instance_for_testing() -> None:
    """Reset the module-level singleton.  Call from test teardown only."""
    global _db_instance  # noqa: PLW0603
    _db_instance = None


# ---------------------------------------------------------------------------
# Convenience timestamp helper
# ---------------------------------------------------------------------------


def _utc_now() -> str:
    """Return the current UTC time as an ISO-8601 string.

    Returns:
        ISO-8601 UTC timestamp string.
    """
    return datetime.now(UTC).isoformat()
