"""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 pathlib import Path
from typing import Any, Self, TypedDict

import aiosqlite

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

_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()``.
    """
    return {str(k): row[k] for k in row.keys()}


_DEFAULT_ROOT = Path(".mcp-vm-blackbox")

# ---------------------------------------------------------------------------
# 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,
    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 = 1
"""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] = {}
"""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"],
        )

    # ------------------------------------------------------------------
    # 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
                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"],
                }
                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).
        """
        for index, item_id in enumerate(ordered_ids):
            await self._c.execute(
                """
                UPDATE todos
                SET priority = ?
                WHERE item_id = ? AND job_id = ? AND vm_name = ?
                """,
                (index * 10, item_id, job_id, vm_name),
            )
        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()
        result: dict[str, FleetVmJobsView] = {}
        for vm in vms:
            active_jobs = await self.list_jobs_by_vm(vm.vm_name)
            result[vm.vm_name] = {
                "vm": vm.model_dump(),
                "jobs": [j.model_dump() for j in active_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
            ``.mcp-vm-blackbox`` relative to the working directory.

    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()
