"""Seal one completed iPhone capture without rewriting its original manifest.

The capture manifest is written when the bounded packet capture ends. Operator
events may be appended after that point, so they belong in a separate derived
integrity inventory rather than in a retroactively edited capture manifest.
"""

from __future__ import annotations

import argparse
import hashlib
import json
import os
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

INTEGRITY_SCHEMA = "missioncore.xgrids-k1/iphone-session-integrity/v1alpha1"
CAPTURE_MANIFEST = "manifest.redacted.json"
INTEGRITY_INVENTORY = "integrity.private.json"
OPTIONAL_SESSION_ARTIFACTS = {
    "capture.stderr.log": "capture-stderr",
    "operator-events.private.jsonl": "operator-events",
}


class SessionIntegrityError(ValueError):
    """The session cannot be safely verified or sealed."""


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 _load_object(path: Path) -> dict[str, Any]:
    try:
        value = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        raise SessionIntegrityError(f"cannot read JSON object: {path.name}") from exc
    if not isinstance(value, dict):
        raise SessionIntegrityError(f"JSON root must be an object: {path.name}")
    return value


def _safe_artifact_path(session_dir: Path, relative_path: object) -> Path:
    if not isinstance(relative_path, str) or not relative_path:
        raise SessionIntegrityError("artifact relative_path must be a non-empty string")
    relative = Path(relative_path)
    if relative.is_absolute() or ".." in relative.parts:
        raise SessionIntegrityError("artifact path must stay inside the session")
    path = session_dir / relative
    if path.is_symlink() or not path.is_file():
        raise SessionIntegrityError(f"artifact is missing or is a symlink: {relative_path}")
    return path


def _record(path: Path, *, kind: str, session_dir: Path) -> dict[str, Any]:
    before = path.stat()
    digest = sha256_file(path)
    after = path.stat()
    if (before.st_size, before.st_mtime_ns) != (after.st_size, after.st_mtime_ns):
        raise SessionIntegrityError(f"artifact changed while hashing: {path.name}")
    return {
        "kind": kind,
        "relative_path": str(path.relative_to(session_dir)),
        "size_bytes": after.st_size,
        "sha256": digest,
        "mode_octal": f"{after.st_mode & 0o777:04o}",
    }


def build_inventory(session_dir: Path) -> dict[str, Any]:
    """Verify a completed capture and return a derived integrity inventory."""
    session_dir = session_dir.expanduser().resolve()
    manifest_path = session_dir / CAPTURE_MANIFEST
    if manifest_path.is_symlink() or not manifest_path.is_file():
        raise SessionIntegrityError(f"missing {CAPTURE_MANIFEST}")

    manifest = _load_object(manifest_path)
    if manifest.get("status") != "completed":
        raise SessionIntegrityError("only a completed capture can be sealed")
    session_id = manifest.get("session_id")
    if not isinstance(session_id, str) or session_id != session_dir.name:
        raise SessionIntegrityError("manifest session_id does not match the directory")
    artifacts = manifest.get("artifacts")
    if not isinstance(artifacts, list) or not artifacts:
        raise SessionIntegrityError("capture manifest has no artifacts")

    verified: list[dict[str, Any]] = []
    for item in artifacts:
        if not isinstance(item, dict):
            raise SessionIntegrityError("manifest artifact must be an object")
        path = _safe_artifact_path(session_dir, item.get("relative_path"))
        actual = _record(
            path,
            kind=str(item.get("kind") or "capture-manifest-artifact"),
            session_dir=session_dir,
        )
        if item.get("size_bytes") != actual["size_bytes"]:
            raise SessionIntegrityError(f"artifact size mismatch: {actual['relative_path']}")
        if item.get("sha256") != actual["sha256"]:
            raise SessionIntegrityError(f"artifact hash mismatch: {actual['relative_path']}")
        verified.append(actual)

    derived = [_record(manifest_path, kind="capture-manifest", session_dir=session_dir)]
    for relative_name, kind in OPTIONAL_SESSION_ARTIFACTS.items():
        path = session_dir / relative_name
        if path.exists():
            if path.is_symlink() or not path.is_file():
                raise SessionIntegrityError(
                    f"optional artifact is not a regular file: {relative_name}"
                )
            derived.append(_record(path, kind=kind, session_dir=session_dir))

    return {
        "schema": INTEGRITY_SCHEMA,
        "session_id": session_id,
        "sealed_at_utc": datetime.now(UTC).isoformat(),
        "capture_manifest_preserved": True,
        "verified_manifest_artifacts": verified,
        "derived_session_artifacts": derived,
    }


def seal_session(session_dir: Path, *, overwrite: bool = False) -> Path:
    """Write an ignored mode-0600 inventory next to the immutable capture."""
    os.umask(0o077)
    resolved = session_dir.expanduser().resolve()
    destination = resolved / INTEGRITY_INVENTORY
    if destination.exists() and not overwrite:
        raise SessionIntegrityError(f"{INTEGRITY_INVENTORY} already exists")
    inventory = build_inventory(resolved)
    temporary = resolved / f".{INTEGRITY_INVENTORY}.tmp"
    temporary.write_text(json.dumps(inventory, indent=2) + "\n", encoding="utf-8")
    temporary.chmod(0o600)
    temporary.replace(destination)
    destination.chmod(0o600)
    return destination


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Verify and seal one completed Mission Core iPhone capture session"
    )
    parser.add_argument("session", type=Path, help="completed ignored session directory")
    parser.add_argument(
        "--overwrite",
        action="store_true",
        help="replace only the derived inventory; never changes the capture manifest",
    )
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    try:
        destination = seal_session(args.session, overwrite=args.overwrite)
    except SessionIntegrityError as exc:
        raise SystemExit(str(exc)) from exc
    print(f"sealed: {destination}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
