from __future__ import annotations

import hashlib
import ipaddress
import json
import math
import os
import re
import stat
import struct
import time
from collections.abc import Callable, Iterator
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import IO, Literal, TypedDict

import paho.mqtt.client as mqtt
from paho.mqtt.enums import CallbackAPIVersion
from paho.mqtt.properties import Properties
from paho.mqtt.reasoncodes import ReasonCode

from k1link.artifacts import utc_now_iso

REPORT_TOPICS: tuple[str, ...] = (
    "lixel/application/report/#",
    "RealtimePointcloud",
    "RealtimePath",
    "DeviceStatus",
)

DEFAULT_MAX_MESSAGE_BYTES = 64 * 1024 * 1024
MAX_CONFIGURABLE_MESSAGE_BYTES = 256 * 1024 * 1024
MAX_TOPIC_BYTES = 65_535
CONNECT_TIMEOUT_SECONDS = 10.0
KEEPALIVE_SECONDS = 30
LOOP_INTERVAL_SECONDS = 0.25
GROUP_COMMIT_INTERVAL_SECONDS = 0.5
GROUP_COMMIT_MAX_BYTES = 4 * 1024 * 1024
GROUP_COMMIT_MAX_MESSAGES = 32
CAPTURE_CLOCK_FILENAME = "mqtt.timeline.json"
CAPTURE_CLOCK_ORIGIN_FILENAME = "mqtt.timeline.origin.json"
CAPTURE_CLOCK_ORIGIN_SCHEMA_VERSION = 1
CAPTURE_CLOCK_SEALED_PATTERN = re.compile(r"^mqtt\.timeline\.session-[a-f0-9]{64}\.json$")
CAPTURE_CLOCK_SCHEMA_VERSION = 1
MAX_CAPTURE_CLOCK_BYTES = 16 * 1024
MAX_CAPTURE_CLOCK_SPAN_NS = (1 << 53) - 1

# Eight-byte file signature followed by repeated >IQ, topic UTF-8 bytes, payload bytes.
RAW_MAGIC = b"K1MQTT\x00\x01"
FRAME_HEADER = struct.Struct(">IQ")

_PRIVATE_NETWORKS = tuple(
    ipaddress.ip_network(cidr) for cidr in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")
)

StopReason = Literal[
    "duration_elapsed",
    "external_stop",
    "keyboard_interrupt",
    "message_too_large",
    "connection_failed",
    "connection_lost",
    "subscription_failed",
    "capture_error",
]


class ArtifactPaths(TypedDict):
    raw: str
    metadata_jsonl: str
    capture_clock_origin: str
    capture_clock: str
    summary: str


class ArtifactHashes(TypedDict):
    raw_sha256: str
    metadata_jsonl_sha256: str
    capture_clock_origin_sha256: str
    capture_clock_sha256: str


class RawFormat(TypedDict):
    magic_hex: str
    frame_header_struct: str
    frame_layout: str


class CaptureSummary(TypedDict):
    schema_version: int
    created_at_utc: str
    completed_at_utc: str
    capture_clock_scope: Literal["transport", "session"]
    sensitivity: str
    target_ipv4: str
    target_port: int
    mqtt_protocol: str
    subscription_qos: int
    clean_session: bool
    reconnect_enabled: bool
    publishing_enabled: bool
    subscriptions: list[str]
    requested_duration_seconds: float | None
    capture_elapsed_seconds: float
    session_elapsed_seconds: float
    operation_elapsed_seconds: float
    max_message_bytes: int
    connected: bool
    subscribed: bool
    stop_reason: StopReason
    error: str | None
    message_count: int
    rejected_message_count: int
    payload_bytes: int
    raw_bytes: int
    topic_counts: dict[str, int]
    raw_format: RawFormat
    artifacts: ArtifactPaths
    artifact_hashes: ArtifactHashes


@dataclass(frozen=True, slots=True)
class CaptureClockEnvelope:
    """Durable host-clock bounds shared by MQTT, camera, and derived replay."""

    started_at_epoch_ns: int
    started_monotonic_ns: int
    completed_at_epoch_ns: int
    completed_monotonic_ns: int
    artifact_byte_length: int
    artifact_sha256: str

    @property
    def duration_ns(self) -> int:
        return self.completed_monotonic_ns - self.started_monotonic_ns


@dataclass(frozen=True, slots=True)
class CaptureClockOrigin:
    """Fsynced session zero published before any camera producer can start."""

    started_at_epoch_ns: int
    started_monotonic_ns: int
    artifact_byte_length: int
    artifact_sha256: str


class CaptureError(RuntimeError):
    """A one-shot capture failed after preserving all artifacts written so far."""

    def __init__(self, message: str, summary: CaptureSummary | None = None) -> None:
        super().__init__(message)
        self.summary = summary


class MessageTooLargeError(CaptureError):
    """An MQTT message exceeded the configured evidence boundary."""


class CaptureFormatError(ValueError):
    """A raw capture is corrupt, truncated or outside configured reader bounds."""


@dataclass(frozen=True, slots=True)
class CaptureFrame:
    sequence: int
    topic: str
    payload: bytes
    raw_frame_offset: int
    raw_payload_offset: int
    raw_frame_bytes: int


@dataclass(frozen=True, slots=True)
class CapturedMqttMessage:
    """A message flushed to the raw writer before it is exposed to live preview."""

    sequence: int
    topic: str
    payload: bytes
    qos: int
    retain: bool
    dup: bool
    received_at_utc: str
    received_at_epoch_ns: int
    received_monotonic_ns: int


@dataclass
class _CaptureState:
    connected: bool = False
    subscribed: bool = False
    stopping: bool = False
    subscription_mid: int | None = None
    stop_reason: StopReason = "capture_error"
    error: str | None = None


class _CaptureWriter:
    def __init__(self, out_dir: Path, max_message_bytes: int) -> None:
        self.out_dir = out_dir.expanduser().resolve()
        self.raw_path = self.out_dir / "mqtt.raw.k1mqtt"
        self.metadata_path = self.out_dir / "mqtt.metadata.jsonl"
        self.capture_clock_origin_path = self.out_dir / CAPTURE_CLOCK_ORIGIN_FILENAME
        self.capture_clock_path = self.out_dir / CAPTURE_CLOCK_FILENAME
        self.summary_path = self.out_dir / "mqtt.summary.json"
        self.max_message_bytes = max_message_bytes
        self.message_count = 0
        self.rejected_message_count = 0
        self.payload_bytes = 0
        self.topic_counts: dict[str, int] = {}
        self._raw: IO[bytes] | None = None
        self._metadata: IO[str] | None = None
        self._pending_metadata: list[str] = []
        self._pending_raw_bytes = 0
        self._last_commit_monotonic = time.monotonic()
        self._started_at_epoch_ns: int | None = None
        self._started_monotonic_ns: int | None = None

    def open(self) -> None:
        self.out_dir.mkdir(parents=True, exist_ok=True)
        artifact_paths = (
            self.raw_path,
            self.metadata_path,
            self.capture_clock_origin_path,
            self.capture_clock_path,
            self.summary_path,
        )
        existing = [path.name for path in artifact_paths if path.exists()]
        if existing:
            names = ", ".join(existing)
            raise FileExistsError(f"refusing to overwrite existing capture artifact(s): {names}")

        try:
            self._raw = _open_binary_exclusive(self.raw_path)
            self._raw.write(RAW_MAGIC)
            self._metadata = _open_text_exclusive(self.metadata_path)
            _fsync_directory(self.out_dir)
            # This is the earliest durable point from which the capture can
            # accept evidence. The camera is armed only after this writer is
            # running, so both modalities share this exact monotonic zero.
            self._started_at_epoch_ns = time.time_ns()
            self._started_monotonic_ns = time.monotonic_ns()
            _write_json_atomic_new(
                self.capture_clock_origin_path,
                {
                    "schema_version": CAPTURE_CLOCK_ORIGIN_SCHEMA_VERSION,
                    "started_at_epoch_ns": self._started_at_epoch_ns,
                    "started_monotonic_ns": self._started_monotonic_ns,
                },
            )
        except BaseException:
            with suppress(OSError):
                self.close()
            raise

    def record(self, message: mqtt.MQTTMessage) -> CapturedMqttMessage:
        raw = self._require_raw()
        metadata = self._require_metadata()
        topic = message.topic
        topic_bytes = topic.encode("utf-8")
        payload = message.payload
        received_at_utc = utc_now_iso()
        received_at_epoch_ns = time.time_ns()
        received_monotonic_ns = time.monotonic_ns()

        if not 1 <= len(topic_bytes) <= MAX_TOPIC_BYTES:
            raise ValueError(
                f"incoming MQTT topic is {len(topic_bytes)} bytes; expected 1..{MAX_TOPIC_BYTES}"
            )

        if len(payload) > self.max_message_bytes:
            self.rejected_message_count += 1
            record = {
                "schema_version": 1,
                "record_type": "rejected_message",
                "sequence": self.message_count + self.rejected_message_count,
                "received_at_utc": received_at_utc,
                "received_monotonic_ns": received_monotonic_ns,
                "topic": topic,
                "payload_bytes": len(payload),
                "max_message_bytes": self.max_message_bytes,
                "reason": "message_too_large",
            }
            self._commit_pending()
            self._write_metadata(metadata, record)
            os.fsync(metadata.fileno())
            raise MessageTooLargeError(
                f"message on {topic!r} is {len(payload)} bytes; "
                f"limit is {self.max_message_bytes} bytes"
            )

        offset = raw.tell()
        header = FRAME_HEADER.pack(len(topic_bytes), len(payload))
        raw.write(header)
        raw.write(topic_bytes)
        raw.write(payload)
        raw.flush()

        self.message_count += 1
        self.payload_bytes += len(payload)
        self.topic_counts[topic] = self.topic_counts.get(topic, 0) + 1
        frame_bytes = len(header) + len(topic_bytes) + len(payload)
        record = {
            "schema_version": 1,
            "record_type": "message",
            "sequence": self.message_count,
            "received_at_utc": received_at_utc,
            "received_at_epoch_ns": received_at_epoch_ns,
            "received_monotonic_ns": received_monotonic_ns,
            "topic": topic,
            "qos": message.qos,
            "retain": message.retain,
            "dup": message.dup,
            "payload_bytes": len(payload),
            "payload_sha256": hashlib.sha256(payload).hexdigest(),
            "raw_frame_offset": offset,
            "raw_payload_offset": offset + len(header) + len(topic_bytes),
            "raw_frame_bytes": frame_bytes,
        }
        self._pending_metadata.append(
            json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
        )
        self._pending_raw_bytes += frame_bytes
        if (
            len(self._pending_metadata) >= GROUP_COMMIT_MAX_MESSAGES
            or self._pending_raw_bytes >= GROUP_COMMIT_MAX_BYTES
            or time.monotonic() - self._last_commit_monotonic >= GROUP_COMMIT_INTERVAL_SECONDS
        ):
            self._commit_pending()
        return CapturedMqttMessage(
            sequence=self.message_count,
            topic=topic,
            payload=payload,
            qos=message.qos,
            retain=message.retain,
            dup=message.dup,
            received_at_utc=received_at_utc,
            received_at_epoch_ns=received_at_epoch_ns,
            received_monotonic_ns=received_monotonic_ns,
        )

    def close(self) -> None:
        first_error: OSError | None = None
        try:
            self._commit_pending()
        except OSError as exc:
            first_error = exc
        # Make the magic (including the zero-message case) and any last stream
        # buffers durable before the handles are released.
        for stream in (self._raw, self._metadata):
            if stream is None or stream.closed:
                continue
            try:
                stream.flush()
                os.fsync(stream.fileno())
            except OSError as exc:
                if first_error is None:
                    first_error = exc
            finally:
                try:
                    stream.close()
                except OSError as exc:
                    if first_error is None:
                        first_error = exc
        if first_error is not None:
            raise first_error

    def finalize_capture_clock(self) -> CaptureClockEnvelope:
        """Publish the exact capture envelope after every producer is sealed."""

        started_at_epoch_ns = self._started_at_epoch_ns
        started_monotonic_ns = self._started_monotonic_ns
        if started_at_epoch_ns is None or started_monotonic_ns is None:
            raise RuntimeError("capture writer has no start clock")
        origin = read_capture_clock_origin(self.capture_clock_origin_path)
        if (
            origin.started_at_epoch_ns != started_at_epoch_ns
            or origin.started_monotonic_ns != started_monotonic_ns
        ):
            raise CaptureFormatError("capture clock origin changed before finalization")
        completed_at_epoch_ns = time.time_ns()
        completed_monotonic_ns = time.monotonic_ns()
        document = {
            "schema_version": CAPTURE_CLOCK_SCHEMA_VERSION,
            "started_at_epoch_ns": started_at_epoch_ns,
            "started_monotonic_ns": started_monotonic_ns,
            "completed_at_epoch_ns": completed_at_epoch_ns,
            "completed_monotonic_ns": completed_monotonic_ns,
        }
        _write_json_atomic_new(self.capture_clock_path, document)
        return read_capture_clock_envelope(self.capture_clock_path)

    def maybe_commit(self, now_monotonic: float | None = None) -> None:
        if not self._pending_metadata:
            return
        now = time.monotonic() if now_monotonic is None else now_monotonic
        if now - self._last_commit_monotonic >= GROUP_COMMIT_INTERVAL_SECONDS:
            self._commit_pending(now_monotonic=now)

    @property
    def raw_bytes(self) -> int:
        if self.raw_path.exists():
            return self.raw_path.stat().st_size
        return 0

    def _require_raw(self) -> IO[bytes]:
        if self._raw is None or self._raw.closed:
            raise RuntimeError("capture writer is not open")
        return self._raw

    def _require_metadata(self) -> IO[str]:
        if self._metadata is None or self._metadata.closed:
            raise RuntimeError("capture writer is not open")
        return self._metadata

    @staticmethod
    def _write_metadata(stream: IO[str], record: dict[str, object]) -> None:
        stream.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n")
        stream.flush()

    def _commit_pending(self, *, now_monotonic: float | None = None) -> None:
        if not self._pending_metadata:
            return
        raw = self._require_raw()
        metadata = self._require_metadata()
        # Group commit invariant: durable raw bytes always precede durable
        # metadata references. A crash can therefore lose only the bounded
        # in-memory group, never expose metadata pointing past durable raw.
        raw.flush()
        os.fsync(raw.fileno())
        payload = "".join(self._pending_metadata)
        self._pending_metadata.clear()
        self._pending_raw_bytes = 0
        metadata.write(payload)
        metadata.flush()
        os.fsync(metadata.fileno())
        self._last_commit_monotonic = time.monotonic() if now_monotonic is None else now_monotonic


def validate_private_ipv4(value: str) -> str:
    """Require a literal RFC1918 address so capture cannot target arbitrary hosts."""
    try:
        address = ipaddress.ip_address(value)
    except ValueError as exc:
        raise ValueError("host must be a literal private IPv4 address") from exc
    if not isinstance(address, ipaddress.IPv4Address) or not any(
        address in network for network in _PRIVATE_NETWORKS
    ):
        raise ValueError("host must be an RFC1918 private IPv4 address")
    return str(address)


def iter_capture_frames(
    path: Path,
    *,
    max_payload_bytes: int = MAX_CONFIGURABLE_MESSAGE_BYTES,
    max_topic_bytes: int = MAX_TOPIC_BYTES,
) -> Iterator[CaptureFrame]:
    """Yield validated frames from a K1 MQTT raw capture without decoding payloads."""
    if not 1 <= max_payload_bytes <= MAX_CONFIGURABLE_MESSAGE_BYTES:
        raise ValueError(
            f"max_payload_bytes must be between 1 and {MAX_CONFIGURABLE_MESSAGE_BYTES}"
        )
    if not 1 <= max_topic_bytes <= MAX_TOPIC_BYTES:
        raise ValueError(f"max_topic_bytes must be between 1 and {MAX_TOPIC_BYTES}")

    with path.expanduser().open("rb") as stream:
        magic = stream.read(len(RAW_MAGIC))
        if magic != RAW_MAGIC:
            if len(magic) < len(RAW_MAGIC):
                raise CaptureFormatError("raw capture is truncated before the complete magic")
            raise CaptureFormatError("raw capture magic/version is not supported")

        sequence = 0
        while True:
            frame_offset = stream.tell()
            header = stream.read(FRAME_HEADER.size)
            if not header:
                return
            if len(header) != FRAME_HEADER.size:
                raise CaptureFormatError(
                    f"frame at offset {frame_offset} has a truncated length header"
                )
            topic_length, payload_length = FRAME_HEADER.unpack(header)
            if not 1 <= topic_length <= max_topic_bytes:
                raise CaptureFormatError(
                    f"frame at offset {frame_offset} topic length {topic_length} "
                    f"is outside 1..{max_topic_bytes}"
                )
            if payload_length > max_payload_bytes:
                raise CaptureFormatError(
                    f"frame at offset {frame_offset} payload length {payload_length} "
                    f"exceeds {max_payload_bytes}"
                )

            topic_raw = _read_exact(
                stream,
                topic_length,
                description=f"topic at frame offset {frame_offset}",
            )
            try:
                topic = topic_raw.decode("utf-8")
            except UnicodeDecodeError as exc:
                raise CaptureFormatError(
                    f"frame at offset {frame_offset} topic is not valid UTF-8"
                ) from exc
            payload_offset = stream.tell()
            payload = _read_exact(
                stream,
                payload_length,
                description=f"payload at frame offset {frame_offset}",
            )
            sequence += 1
            yield CaptureFrame(
                sequence=sequence,
                topic=topic,
                payload=payload,
                raw_frame_offset=frame_offset,
                raw_payload_offset=payload_offset,
                raw_frame_bytes=FRAME_HEADER.size + topic_length + payload_length,
            )


def read_capture_clock_envelope(
    path: Path,
    *,
    expected_sha256: str | None = None,
) -> CaptureClockEnvelope:
    """Read one bounded, no-follow capture clock artifact and prove stability."""

    candidate = path.expanduser().absolute()
    flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
    try:
        descriptor = os.open(candidate, flags)
    except OSError as exc:
        raise CaptureFormatError("capture clock artifact is unavailable") from exc
    try:
        before = os.fstat(descriptor)
        if not stat.S_ISREG(before.st_mode) or not 1 <= before.st_size <= MAX_CAPTURE_CLOCK_BYTES:
            raise CaptureFormatError("capture clock artifact is not a bounded regular file")
        payload = bytearray()
        while len(payload) <= MAX_CAPTURE_CLOCK_BYTES:
            chunk = os.read(descriptor, min(4096, MAX_CAPTURE_CLOCK_BYTES + 1 - len(payload)))
            if not chunk:
                break
            payload.extend(chunk)
        after = os.fstat(descriptor)
        try:
            current = os.lstat(candidate)
        except OSError as exc:
            raise CaptureFormatError("capture clock artifact changed during validation") from exc
        if (
            len(payload) != before.st_size
            or _file_identity(before) != _file_identity(after)
            or stat.S_ISLNK(current.st_mode)
            or (current.st_dev, current.st_ino) != (before.st_dev, before.st_ino)
        ):
            raise CaptureFormatError("capture clock artifact changed during validation")
    finally:
        os.close(descriptor)

    digest = hashlib.sha256(payload).hexdigest()
    if expected_sha256 is not None and digest != expected_sha256:
        raise CaptureFormatError("capture clock digest does not match its summary")
    try:
        value = json.loads(payload.decode("utf-8"))
    except (UnicodeDecodeError, json.JSONDecodeError) as exc:
        raise CaptureFormatError("capture clock artifact is not valid JSON") from exc
    expected_keys = {
        "schema_version",
        "started_at_epoch_ns",
        "started_monotonic_ns",
        "completed_at_epoch_ns",
        "completed_monotonic_ns",
    }
    if not isinstance(value, dict) or set(value) != expected_keys:
        raise CaptureFormatError("capture clock artifact has an unsupported shape")
    if value["schema_version"] != CAPTURE_CLOCK_SCHEMA_VERSION:
        raise CaptureFormatError("capture clock schema is unsupported")
    for key in expected_keys - {"schema_version"}:
        item = value[key]
        if not isinstance(item, int) or isinstance(item, bool) or item < 0:
            raise CaptureFormatError("capture clock contains an invalid timestamp")
    started_at_epoch_ns = value["started_at_epoch_ns"]
    started_monotonic_ns = value["started_monotonic_ns"]
    completed_at_epoch_ns = value["completed_at_epoch_ns"]
    completed_monotonic_ns = value["completed_monotonic_ns"]
    duration_ns = completed_monotonic_ns - started_monotonic_ns
    if duration_ns < 0 or duration_ns > MAX_CAPTURE_CLOCK_SPAN_NS:
        raise CaptureFormatError("capture clock monotonic span is outside bounds")
    return CaptureClockEnvelope(
        started_at_epoch_ns=started_at_epoch_ns,
        started_monotonic_ns=started_monotonic_ns,
        completed_at_epoch_ns=completed_at_epoch_ns,
        completed_monotonic_ns=completed_monotonic_ns,
        artifact_byte_length=len(payload),
        artifact_sha256=digest,
    )


def read_capture_clock_origin(
    path: Path,
    *,
    expected_sha256: str | None = None,
) -> CaptureClockOrigin:
    candidate = path.expanduser().absolute()
    try:
        payload = _read_stable_payload(candidate, MAX_CAPTURE_CLOCK_BYTES)
    except OSError as exc:
        raise CaptureFormatError("capture clock origin is unavailable") from exc
    digest = hashlib.sha256(payload).hexdigest()
    if expected_sha256 is not None and digest != expected_sha256:
        raise CaptureFormatError("capture clock origin digest does not match its summary")
    try:
        value = json.loads(payload.decode("utf-8"))
    except (UnicodeDecodeError, json.JSONDecodeError) as exc:
        raise CaptureFormatError("capture clock origin is not valid JSON") from exc
    expected_keys = {
        "schema_version",
        "started_at_epoch_ns",
        "started_monotonic_ns",
    }
    if not isinstance(value, dict) or set(value) != expected_keys:
        raise CaptureFormatError("capture clock origin has an unsupported shape")
    if value["schema_version"] != CAPTURE_CLOCK_ORIGIN_SCHEMA_VERSION:
        raise CaptureFormatError("capture clock origin schema is unsupported")
    epoch_ns = value["started_at_epoch_ns"]
    monotonic_ns = value["started_monotonic_ns"]
    if (
        not isinstance(epoch_ns, int)
        or isinstance(epoch_ns, bool)
        or epoch_ns < 0
        or not isinstance(monotonic_ns, int)
        or isinstance(monotonic_ns, bool)
        or monotonic_ns < 0
    ):
        raise CaptureFormatError("capture clock origin contains an invalid timestamp")
    return CaptureClockOrigin(
        started_at_epoch_ns=epoch_ns,
        started_monotonic_ns=monotonic_ns,
        artifact_byte_length=len(payload),
        artifact_sha256=digest,
    )


def is_capture_clock_filename(value: object) -> bool:
    return isinstance(value, str) and (
        value == CAPTURE_CLOCK_FILENAME or CAPTURE_CLOCK_SEALED_PATTERN.fullmatch(value) is not None
    )


def seal_capture_clock(capture_root: Path) -> CaptureClockEnvelope:
    """Extend the provisional MQTT clock after every session producer is sealed.

    The acquisition owner must call this after camera shutdown and runtime
    shutdown, but before releasing its active-session lease. A failure raises
    ``CaptureError``. The content-addressed clock is published first and the
    summary pointer switches second, so a crash leaves the old pointer valid
    and a later owner retry can finish sealing.
    """

    try:
        root = capture_root.expanduser().resolve(strict=True)
    except OSError as exc:
        raise CaptureError("capture clock root is unavailable") from exc
    if not root.is_dir():
        raise CaptureError("capture clock root is not a directory")
    summary_path = root / "mqtt.summary.json"
    try:
        summary = _read_bounded_json_object(summary_path, MAX_CAPTURE_CLOCK_BYTES * 8)
        artifacts = summary.get("artifacts")
        hashes = summary.get("artifact_hashes")
        declared_name = artifacts.get("capture_clock") if isinstance(artifacts, dict) else None
        declared_origin_name = (
            artifacts.get("capture_clock_origin") if isinstance(artifacts, dict) else None
        )
        if (
            summary.get("schema_version") != 2
            or not isinstance(artifacts, dict)
            or not is_capture_clock_filename(declared_name)
            or declared_origin_name != CAPTURE_CLOCK_ORIGIN_FILENAME
            or not isinstance(hashes, dict)
            or not isinstance(hashes.get("capture_clock_origin_sha256"), str)
            or not isinstance(hashes.get("capture_clock_sha256"), str)
        ):
            raise CaptureFormatError("capture summary has no supported clock contract")
        assert isinstance(declared_name, str)
        origin = read_capture_clock_origin(
            root / CAPTURE_CLOCK_ORIGIN_FILENAME,
            expected_sha256=hashes["capture_clock_origin_sha256"],
        )
        clock_path = root / declared_name
        current = read_capture_clock_envelope(
            clock_path,
            expected_sha256=hashes["capture_clock_sha256"],
        )
        if (
            current.started_at_epoch_ns != origin.started_at_epoch_ns
            or current.started_monotonic_ns != origin.started_monotonic_ns
        ):
            raise CaptureFormatError("capture clock does not match its durable origin")
        scope = summary.get("capture_clock_scope")
        if scope == "session":
            if CAPTURE_CLOCK_SEALED_PATTERN.fullmatch(declared_name) is None:
                raise CaptureFormatError("sealed capture summary points to a provisional clock")
            if declared_name != f"mqtt.timeline.session-{current.artifact_sha256}.json":
                raise CaptureFormatError(
                    "sealed capture clock filename does not match its content digest"
                )
            return current
        if scope != "transport" or declared_name != CAPTURE_CLOCK_FILENAME:
            raise CaptureFormatError("provisional capture summary has an invalid clock pointer")
        completed_at_epoch_ns = time.time_ns()
        completed_monotonic_ns = time.monotonic_ns()
        if completed_monotonic_ns < current.completed_monotonic_ns:
            raise CaptureFormatError("session completion precedes MQTT completion")
        document = {
            "schema_version": CAPTURE_CLOCK_SCHEMA_VERSION,
            "started_at_epoch_ns": current.started_at_epoch_ns,
            "started_monotonic_ns": current.started_monotonic_ns,
            "completed_at_epoch_ns": completed_at_epoch_ns,
            "completed_monotonic_ns": completed_monotonic_ns,
        }
        sealed_digest = hashlib.sha256(_serialize_json(document)).hexdigest()
        sealed_name = f"mqtt.timeline.session-{sealed_digest}.json"
        sealed_path = root / sealed_name
        # A retry can converge on an already durable content-addressed
        # artifact. It is still fully revalidated below.
        with suppress(FileExistsError):
            _write_json_atomic_new(sealed_path, document)
        sealed = read_capture_clock_envelope(
            sealed_path,
            expected_sha256=sealed_digest,
        )
        artifacts["capture_clock"] = sealed_name
        hashes["capture_clock_sha256"] = sealed.artifact_sha256
        summary["capture_clock_scope"] = "session"
        summary["session_elapsed_seconds"] = round(
            sealed.duration_ns / 1_000_000_000,
            9,
        )
        summary["completed_at_utc"] = utc_now_iso()
        _write_json_atomic_replace(summary_path, summary)
        return sealed
    except (OSError, CaptureFormatError, UnicodeDecodeError, json.JSONDecodeError) as exc:
        raise CaptureError(f"could not seal session capture clock: {exc}") from exc


def capture_mqtt(
    host: str,
    out_dir: Path,
    *,
    port: int = 1883,
    duration_seconds: float | None = 60.0,
    max_message_bytes: int = DEFAULT_MAX_MESSAGE_BYTES,
    on_clock_established: Callable[[], None] | None = None,
    on_ready: Callable[[], None] | None = None,
    on_message_recorded: Callable[[CapturedMqttMessage], None] | None = None,
    should_stop: Callable[[], bool] | None = None,
    _client_factory: Callable[[], mqtt.Client] | None = None,
) -> CaptureSummary:
    """Capture the fixed K1 report subscriptions once, without publishing or reconnecting."""
    target_ipv4 = validate_private_ipv4(host)
    if not 1 <= port <= 65535:
        raise ValueError("port must be between 1 and 65535")
    if duration_seconds is not None and (
        not math.isfinite(duration_seconds) or duration_seconds <= 0
    ):
        raise ValueError("duration_seconds must be finite and greater than zero")
    if not 1 <= max_message_bytes <= MAX_CONFIGURABLE_MESSAGE_BYTES:
        raise ValueError(
            f"max_message_bytes must be between 1 and {MAX_CONFIGURABLE_MESSAGE_BYTES}"
        )

    client = (
        _client_factory()
        if _client_factory is not None
        else mqtt.Client(
            callback_api_version=CallbackAPIVersion.VERSION2,
            clean_session=True,
            protocol=mqtt.MQTTv311,
            reconnect_on_failure=False,
        )
    )
    writer = _CaptureWriter(out_dir, max_message_bytes)
    try:
        writer.open()
        if on_clock_established is not None:
            on_clock_established()
    except BaseException:
        with suppress(OSError):
            writer.close()
        raise
    state = _CaptureState()
    created_at_utc = utc_now_iso()
    operation_started = time.monotonic()
    capture_started: float | None = None
    failure: CaptureError | None = None

    def fail(reason: StopReason, message: str) -> None:
        if state.error is None:
            state.stop_reason = reason
            state.error = message

    def on_connect(
        callback_client: mqtt.Client,
        _userdata: object,
        _flags: mqtt.ConnectFlags,
        reason_code: ReasonCode,
        _properties: Properties | None,
    ) -> None:
        if reason_code.is_failure:
            fail("connection_failed", f"broker rejected connection: {reason_code}")
            return
        state.connected = True
        try:
            result, mid = callback_client.subscribe([(topic, 0) for topic in REPORT_TOPICS])
        except (OSError, RuntimeError, ValueError) as exc:
            fail(
                "subscription_failed",
                f"subscribe failed: {type(exc).__name__}: {exc}",
            )
            return
        if result != mqtt.MQTT_ERR_SUCCESS or mid is None:
            fail("subscription_failed", f"subscribe failed: {mqtt.error_string(result)}")
            return
        state.subscription_mid = mid

    def on_subscribe(
        _callback_client: mqtt.Client,
        _userdata: object,
        mid: int,
        reason_codes: list[ReasonCode],
        _properties: Properties | None,
    ) -> None:
        if mid != state.subscription_mid:
            fail("subscription_failed", f"unexpected SUBACK message id: {mid}")
            return
        if len(reason_codes) != len(REPORT_TOPICS) or any(
            reason_code.is_failure for reason_code in reason_codes
        ):
            fail("subscription_failed", "broker rejected one or more fixed subscriptions")
            return
        state.subscribed = True

    def on_message(
        _callback_client: mqtt.Client,
        _userdata: object,
        message: mqtt.MQTTMessage,
    ) -> None:
        if state.error is not None:
            return
        try:
            recorded = writer.record(message)
        except MessageTooLargeError as exc:
            fail("message_too_large", str(exc))
        except (OSError, RuntimeError, ValueError) as exc:
            fail("capture_error", f"artifact write failed: {type(exc).__name__}: {exc}")
            return
        if on_message_recorded is not None:
            try:
                on_message_recorded(recorded)
            except (OSError, RuntimeError, ValueError) as exc:
                fail("capture_error", f"preview callback failed: {type(exc).__name__}: {exc}")

    def on_disconnect(
        _callback_client: mqtt.Client,
        _userdata: object,
        _flags: mqtt.DisconnectFlags,
        reason_code: ReasonCode,
        _properties: Properties | None,
    ) -> None:
        if not state.stopping:
            fail("connection_lost", f"broker connection ended: {reason_code}")

    client.on_connect = on_connect
    client.on_subscribe = on_subscribe
    client.on_message = on_message
    client.on_disconnect = on_disconnect

    connect_attempted = False
    try:
        connect_attempted = True
        connect_result = client.connect(
            target_ipv4,
            port=port,
            keepalive=KEEPALIVE_SECONDS,
        )
        if connect_result != mqtt.MQTT_ERR_SUCCESS:
            fail("connection_failed", f"connect failed: {mqtt.error_string(connect_result)}")

        while state.error is None:
            now = time.monotonic()
            if should_stop is not None and should_stop():
                state.stop_reason = "external_stop"
                break
            if state.subscribed and capture_started is None:
                capture_started = now
                if on_ready is not None:
                    on_ready()
            if (
                capture_started is not None
                and duration_seconds is not None
                and now - capture_started >= duration_seconds
            ):
                state.stop_reason = "duration_elapsed"
                break
            if capture_started is None and now - operation_started >= CONNECT_TIMEOUT_SECONDS:
                fail("connection_failed", "timed out waiting for CONNACK/SUBACK")
                break

            loop_result = client.loop(timeout=LOOP_INTERVAL_SECONDS)
            try:
                writer.maybe_commit()
            except OSError as exc:
                fail("capture_error", f"group commit failed: {type(exc).__name__}: {exc}")
                break
            if loop_result != mqtt.MQTT_ERR_SUCCESS and state.error is None:
                fail(
                    "connection_lost",
                    f"MQTT network loop failed: {mqtt.error_string(loop_result)}",
                )
    except KeyboardInterrupt:
        state.stop_reason = "keyboard_interrupt"
    except (OSError, RuntimeError, ValueError) as exc:
        fail("connection_failed", f"MQTT capture failed: {type(exc).__name__}: {exc}")
    finally:
        state.stopping = True
        if connect_attempted:
            try:
                client.disconnect()
            except (OSError, RuntimeError, ValueError) as exc:
                if state.error is None:
                    fail("capture_error", f"disconnect failed: {type(exc).__name__}: {exc}")
        try:
            writer.close()
        except OSError as exc:
            if state.error is None:
                fail("capture_error", f"artifact close failed: {type(exc).__name__}: {exc}")

    try:
        capture_clock = writer.finalize_capture_clock()
    except (OSError, RuntimeError, CaptureFormatError) as exc:
        raise CaptureError(f"could not publish capture clock: {type(exc).__name__}: {exc}") from exc
    operation_completed = capture_clock.completed_monotonic_ns / 1_000_000_000
    capture_elapsed = 0.0 if capture_started is None else operation_completed - capture_started

    summary = _build_summary(
        writer=writer,
        target_ipv4=target_ipv4,
        port=port,
        duration_seconds=duration_seconds,
        capture_elapsed=capture_elapsed,
        operation_elapsed=operation_completed - operation_started,
        max_message_bytes=max_message_bytes,
        created_at_utc=created_at_utc,
        capture_clock=capture_clock,
        state=state,
    )
    try:
        _write_summary_exclusive(writer.summary_path, summary)
    except OSError as exc:
        raise CaptureError(f"could not write capture summary: {type(exc).__name__}: {exc}") from exc

    if state.error is not None:
        failure = CaptureError(state.error, summary)
    if failure is not None:
        raise failure
    return summary


def _build_summary(
    *,
    writer: _CaptureWriter,
    target_ipv4: str,
    port: int,
    duration_seconds: float | None,
    capture_elapsed: float,
    operation_elapsed: float,
    max_message_bytes: int,
    created_at_utc: str,
    capture_clock: CaptureClockEnvelope,
    state: _CaptureState,
) -> CaptureSummary:
    capture_clock_origin = read_capture_clock_origin(writer.capture_clock_origin_path)
    return {
        "schema_version": 2,
        "created_at_utc": created_at_utc,
        "completed_at_utc": utc_now_iso(),
        "capture_clock_scope": "transport",
        "sensitivity": "contains raw K1 MQTT payloads and local addressing; do not commit",
        "target_ipv4": target_ipv4,
        "target_port": port,
        "mqtt_protocol": "3.1.1",
        "subscription_qos": 0,
        "clean_session": True,
        "reconnect_enabled": False,
        "publishing_enabled": False,
        "subscriptions": list(REPORT_TOPICS),
        "requested_duration_seconds": duration_seconds,
        "capture_elapsed_seconds": round(capture_elapsed, 6),
        "session_elapsed_seconds": round(capture_clock.duration_ns / 1_000_000_000, 9),
        "operation_elapsed_seconds": round(operation_elapsed, 6),
        "max_message_bytes": max_message_bytes,
        "connected": state.connected,
        "subscribed": state.subscribed,
        "stop_reason": state.stop_reason,
        "error": state.error,
        "message_count": writer.message_count,
        "rejected_message_count": writer.rejected_message_count,
        "payload_bytes": writer.payload_bytes,
        "raw_bytes": writer.raw_bytes,
        "topic_counts": dict(sorted(writer.topic_counts.items())),
        "raw_format": {
            "magic_hex": RAW_MAGIC.hex(),
            "frame_header_struct": FRAME_HEADER.format,
            "frame_layout": "topic_length:uint32, payload_length:uint64, topic_utf8, payload",
        },
        "artifacts": {
            "raw": writer.raw_path.name,
            "metadata_jsonl": writer.metadata_path.name,
            "capture_clock_origin": writer.capture_clock_origin_path.name,
            "capture_clock": writer.capture_clock_path.name,
            "summary": writer.summary_path.name,
        },
        "artifact_hashes": {
            "raw_sha256": _sha256_file(writer.raw_path),
            "metadata_jsonl_sha256": _sha256_file(writer.metadata_path),
            "capture_clock_origin_sha256": capture_clock_origin.artifact_sha256,
            "capture_clock_sha256": capture_clock.artifact_sha256,
        },
    }


def _sha256_file(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as stream:
        for chunk in iter(lambda: stream.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def _read_exact(stream: IO[bytes], length: int, *, description: str) -> bytes:
    value = stream.read(length)
    if len(value) != length:
        raise CaptureFormatError(
            f"{description} is truncated: expected {length} bytes, got {len(value)}"
        )
    return value


def _open_binary_exclusive(path: Path) -> IO[bytes]:
    descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
    try:
        return os.fdopen(descriptor, "wb")
    except BaseException:
        os.close(descriptor)
        raise


def _open_text_exclusive(path: Path) -> IO[str]:
    descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
    try:
        return os.fdopen(descriptor, "w", encoding="utf-8", newline="\n")
    except BaseException:
        os.close(descriptor)
        raise


def _write_summary_exclusive(path: Path, summary: CaptureSummary) -> None:
    serialized = json.dumps(summary, ensure_ascii=False, indent=2) + "\n"
    with _open_text_exclusive(path) as stream:
        stream.write(serialized)
        stream.flush()
        os.fsync(stream.fileno())
    _fsync_directory(path.parent)


def _write_json_atomic_new(path: Path, value: object) -> None:
    """Publish a new JSON artifact atomically without replacing evidence."""

    serialized = _serialize_json(value)
    temporary = path.with_name(f".{path.name}.{os.getpid()}.{time.monotonic_ns()}.tmp")
    descriptor = os.open(
        temporary,
        os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0),
        0o600,
    )
    try:
        view = memoryview(serialized)
        while view:
            written = os.write(descriptor, view)
            view = view[written:]
        os.fsync(descriptor)
    finally:
        os.close(descriptor)
    try:
        # Hard-link publication is atomic and fails if another artifact has
        # appeared at the destination; unlike replace it never overwrites.
        os.link(temporary, path, follow_symlinks=False)
        temporary.unlink()
        _fsync_directory(path.parent)
    except BaseException:
        temporary.unlink(missing_ok=True)
        raise


def _write_json_atomic_replace(path: Path, value: object) -> None:
    serialized = _serialize_json(value)
    temporary = path.with_name(f".{path.name}.{os.getpid()}.{time.monotonic_ns()}.tmp")
    descriptor = os.open(
        temporary,
        os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0),
        0o600,
    )
    try:
        view = memoryview(serialized)
        while view:
            written = os.write(descriptor, view)
            view = view[written:]
        os.fsync(descriptor)
    finally:
        os.close(descriptor)
    try:
        os.replace(temporary, path)
        _fsync_directory(path.parent)
    except BaseException:
        temporary.unlink(missing_ok=True)
        raise


def _read_bounded_json_object(path: Path, max_bytes: int) -> dict[str, object]:
    payload = _read_stable_payload(path, max_bytes)
    value = json.loads(payload.decode("utf-8"))
    if not isinstance(value, dict):
        raise CaptureFormatError("JSON artifact is not an object")
    return value


def _read_stable_payload(path: Path, max_bytes: int) -> bytes:
    flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
    descriptor = os.open(path, flags)
    try:
        before = os.fstat(descriptor)
        if not stat.S_ISREG(before.st_mode) or not 1 <= before.st_size <= max_bytes:
            raise CaptureFormatError("artifact is not a bounded regular file")
        chunks: list[bytes] = []
        remaining = before.st_size
        while remaining:
            chunk = os.read(descriptor, min(4096, remaining))
            if not chunk:
                break
            chunks.append(chunk)
            remaining -= len(chunk)
        payload = b"".join(chunks)
        after = os.fstat(descriptor)
        current = os.lstat(path)
        if (
            len(payload) != before.st_size
            or _file_identity(before) != _file_identity(after)
            or stat.S_ISLNK(current.st_mode)
            or (current.st_dev, current.st_ino) != (before.st_dev, before.st_ino)
        ):
            raise CaptureFormatError("artifact changed during validation")
        return payload
    finally:
        os.close(descriptor)


def _file_identity(value: os.stat_result) -> tuple[int, int, int, int, int]:
    return (value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns, value.st_ctime_ns)


def _serialize_json(value: object) -> bytes:
    return (json.dumps(value, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")


def _fsync_directory(path: Path) -> None:
    descriptor = os.open(path, os.O_RDONLY)
    try:
        os.fsync(descriptor)
    finally:
        os.close(descriptor)
