#!/usr/bin/env python3
"""Mind Mem Apply Engine v1.0 — Atomic proposal application with rollback.

Reads proposals from intelligence/proposed/, validates, executes Ops,
runs post-checks, and rolls back on failure.

Usage:
    python3 maintenance/apply_engine.py <ProposalId> [workspace_path]
    python3 maintenance/apply_engine.py P-20260213-002
    python3 maintenance/apply_engine.py P-20260213-002 --dry-run
    python3 maintenance/apply_engine.py --rollback <ReceiptTS>

Exit codes: 0 = applied, 1 = failed (rolled back), 2 = validation error
"""

import difflib
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
from datetime import datetime, timedelta

# Import block parser from same directory
from .backup_restore import WAL
from .block_parser import get_by_id, parse_file
from .corpus_registry import SNAPSHOT_DIRS
from .mind_filelock import FileLock
from .namespaces import NamespaceManager

# ═══════════════════════════════════════════════
# Configuration
# ═══════════════════════════════════════════════

VALID_OPS = {
    "append_block",
    "insert_after_block",
    "update_field",
    "append_list_item",
    "replace_range",
    "set_status",
    "supersede_decision",
}

VALID_RISKS = {"low", "medium", "high"}
VALID_STATUSES = {"staged", "applied", "rejected", "deferred", "expired", "rolled_back"}
VALID_TYPES = {"decision", "task", "edit"}

PROPOSED_FILES = [
    "intelligence/proposed/DECISIONS_PROPOSED.md",
    "intelligence/proposed/TASKS_PROPOSED.md",
    "intelligence/proposed/EDITS_PROPOSED.md",
]

# Files to snapshot for rollback (intelligence/applied excluded to prevent recursive nesting)
# SNAPSHOT_DIRS imported from corpus_registry
SNAPSHOT_FILES = ["AGENTS.md", "MEMORY.md", "IDENTITY.md", "mind-mem.json"]


def _list_workspace_files(ws):
    """List all files in workspace (relative paths) for orphan detection."""
    result = set()
    for d in SNAPSHOT_DIRS:
        dirpath = os.path.join(ws, d)
        if os.path.isdir(dirpath):
            for root, dirs, files in os.walk(dirpath):
                for f in files:
                    result.add(os.path.relpath(os.path.join(root, f), ws))
    for f in SNAPSHOT_FILES:
        if os.path.isfile(os.path.join(ws, f)):
            result.add(f)
    # Intelligence files (excluding applied/)
    intel_dir = os.path.join(ws, "intelligence")
    if os.path.isdir(intel_dir):
        for root, dirs, files in os.walk(intel_dir):
            if "applied" in root.split(os.sep):
                continue
            for f in files:
                result.add(os.path.relpath(os.path.join(root, f), ws))
    return result


def _cleanup_orphan_files(ws, pre_apply_files):
    """Delete files created during a failed transaction (orphan cleanup)."""
    current_files = _list_workspace_files(ws)
    orphans = current_files - pre_apply_files
    for orphan in orphans:
        path = os.path.join(ws, orphan)
        if os.path.isfile(path):
            os.remove(path)
            print(f"  Cleaned orphan: {orphan}")


# ═══════════════════════════════════════════════
# Path Safety
# ═══════════════════════════════════════════════


def _safe_resolve(ws, rel_path):
    """Resolve rel_path within ws, rejecting traversal and symlink escapes.

    Returns the resolved absolute path.
    Raises ValueError if the path escapes the workspace.
    """
    ws_real = os.path.realpath(ws)
    joined = os.path.join(ws, rel_path)
    resolved = os.path.realpath(joined)
    if not resolved.startswith(ws_real + os.sep) and resolved != ws_real:
        raise ValueError(f"Path escapes workspace: {rel_path}")
    return resolved


# ═══════════════════════════════════════════════
# Proposal Discovery & Validation
# ═══════════════════════════════════════════════


def find_proposal(ws, proposal_id):
    """Find a proposal block by ProposalId across all proposed files."""
    for pfile in PROPOSED_FILES:
        path = os.path.join(ws, pfile)
        if not os.path.isfile(path):
            continue
        try:
            blocks = parse_file(path)
        except (OSError, UnicodeDecodeError, ValueError):
            continue
        for b in blocks:
            if b.get("ProposalId") == proposal_id or b.get("_id") == proposal_id:
                return b, path
    return None, None


def validate_proposal(proposal):
    """Validate a proposal block before execution. Returns list of errors."""
    errors = []

    # Required fields
    for field in ("ProposalId", "Type", "TargetBlock", "Risk", "Status", "Evidence", "Rollback", "Fingerprint"):
        if not proposal.get(field):
            errors.append(f"Missing required field: {field}")

    # Enum checks
    if proposal.get("Risk") not in VALID_RISKS:
        errors.append(f"Invalid Risk: {proposal.get('Risk')} (must be {VALID_RISKS})")
    if proposal.get("Type") not in VALID_TYPES:
        errors.append(f"Invalid Type: {proposal.get('Type')} (must be {VALID_TYPES})")
    if proposal.get("Status") != "staged":
        errors.append(f"Status must be 'staged' to apply (got '{proposal.get('Status')}')")

    # Evidence must be non-empty
    evidence = proposal.get("Evidence", [])
    if isinstance(evidence, str):
        evidence = [evidence]
    evidence = [e for e in evidence if isinstance(e, str) and e.strip()]
    if not evidence:
        errors.append("Evidence is empty")

    # Ops validation
    ops = proposal.get("Ops", [])
    if not ops:
        errors.append("No Ops defined")
    for i, op in enumerate(ops):
        op_type = op.get("op")
        if op_type not in VALID_OPS:
            errors.append(f"Ops[{i}]: invalid op type '{op_type}'")
        if not op.get("file"):
            errors.append(f"Ops[{i}]: missing 'file'")
        if op_type in (
            "update_field",
            "append_list_item",
            "set_status",
            "replace_range",
            "insert_after_block",
            "supersede_decision",
        ) and not op.get("target"):
            errors.append(f"Ops[{i}]: op '{op_type}' requires 'target'")

    # Reject paths with traversal components
    for i, op in enumerate(ops):
        f = op.get("file", "")
        if ".." in f.split(os.sep) or ".." in f.split("/") or os.path.isabs(f) or f.startswith("/"):
            errors.append(f"Ops[{i}]: path '{f}' contains traversal or is absolute")

    # FilesTouched must match Ops files
    files_touched = set(proposal.get("FilesTouched", []))
    ops_files = set(op.get("file", "") for op in ops)
    if files_touched and ops_files and not ops_files.issubset(files_touched):
        errors.append(f"Ops reference files not in FilesTouched: {ops_files - files_touched}")

    # Fingerprint integrity: recompute and verify against stored value
    stored_fp = proposal.get("Fingerprint", "")
    if stored_fp and ops:
        computed_fp = compute_fingerprint(proposal)
        if computed_fp != stored_fp:
            errors.append(
                f"Fingerprint mismatch: stored={stored_fp}, computed={computed_fp} (proposal may have been tampered)"
            )

    return errors


# ═══════════════════════════════════════════════
# Precondition Checks
# ═══════════════════════════════════════════════


def check_preconditions(ws):
    """Run validate.sh and intel_scan.py. Returns (ok, report)."""
    report = []
    # Use scripts from our own installation directory, not from the workspace
    _script_dir = os.path.dirname(os.path.abspath(__file__))

    # P2: validate.sh (from installation, not workspace)
    validate_sh = os.path.join(_script_dir, "validate.sh")
    if not os.path.isfile(validate_sh):
        report.append("validate: SKIP (script not found)")
        return True, report
    try:
        result = subprocess.run(["bash", validate_sh, ws], capture_output=True, text=True, timeout=60)
        # Find the TOTAL line (contains "issues")
        total_line = ""
        for line in result.stdout.strip().split("\n"):
            if "issues" in line and "TOTAL" in line:
                total_line = line.strip()
                break
        if re.search(r"\b0 issues\b", total_line):
            report.append(f"validate: PASS ({total_line})")
        else:
            report.append(f"validate: FAIL ({total_line or 'no TOTAL line found'})")
            return False, report
    except Exception as e:
        report.append(f"validate: ERROR ({e})")
        return False, report

    # P3: intel_scan.py (from installation, not workspace)
    intel_scan = os.path.join(_script_dir, "intel_scan.py")
    if not os.path.isfile(intel_scan):
        report.append("intel_scan: SKIP (script not found)")
        return True, report
    try:
        result = subprocess.run(["python3", intel_scan, ws], capture_output=True, text=True, timeout=60)
        # Find the TOTAL line
        total_line = ""
        for line in result.stdout.strip().split("\n"):
            if "critical" in line and "TOTAL" in line:
                total_line = line.strip()
                break
        if "0 critical" in total_line:
            report.append(f"intel_scan: PASS ({total_line})")
        else:
            report.append(f"intel_scan: FAIL ({total_line or 'no TOTAL line found'})")
            return False, report
    except Exception as e:
        report.append(f"intel_scan: ERROR ({e})")
        return False, report

    return True, report


# ═══════════════════════════════════════════════
# Snapshot & Rollback
# ═══════════════════════════════════════════════


def _safe_copy(src, dst):
    """Copy a file for snapshot purposes. Always uses copy2 (not hardlinks).

    Hardlinks are unsuitable for mutable-file snapshots because Python's
    open("w") truncates the inode in-place, corrupting both the workspace
    file and its hardlinked snapshot copy. A true copy ensures the snapshot
    preserves the original content after the workspace file is modified.
    """
    os.makedirs(os.path.dirname(dst), exist_ok=True)
    shutil.copy2(src, dst)


def _build_manifest(snap_dir, files):
    """Write snapshot manifest for efficient delta-based restore."""
    # Normalize to POSIX separators for cross-platform portability
    normalized = [f.replace(os.sep, "/") for f in files]
    manifest_path = os.path.join(snap_dir, "MANIFEST.json")
    with open(manifest_path, "w", encoding="utf-8") as f:
        json.dump({"files": normalized, "version": 1}, f)


def _read_manifest(snap_dir):
    """Read snapshot manifest, or None for legacy snapshots."""
    manifest_path = os.path.join(snap_dir, "MANIFEST.json")
    if not os.path.exists(manifest_path):
        return None
    with open(manifest_path, encoding="utf-8") as f:
        data = json.load(f)
    return data.get("files", [])


def create_snapshot(ws, ts, files_touched=None):
    """Create a pre-apply snapshot for rollback.

    When files_touched is provided (list of relative paths from the proposal's
    FilesTouched field), only those files are snapshotted — O(touched) instead
    of O(workspace). Falls back to full snapshot when files_touched is empty
    or None.

    The intelligence/applied/ directory is always excluded to prevent recursive
    nesting (snapshots containing snapshots).
    """
    snap_dir = os.path.join(ws, "intelligence/applied", ts)
    os.makedirs(snap_dir, exist_ok=True)

    manifest_files = []

    if files_touched:
        # Minimal snapshot: only snapshot files the proposal will modify
        ws_real = os.path.realpath(ws)
        for rel_path in files_touched:
            resolved = os.path.realpath(os.path.join(ws_real, rel_path))
            if not resolved.startswith(ws_real + os.sep) and resolved != ws_real:
                continue  # skip paths that escape workspace
            src = resolved
            if os.path.isfile(src):
                _safe_copy(src, os.path.join(snap_dir, rel_path))
                manifest_files.append(rel_path)
        # Always snapshot config files (needed for rollback integrity)
        for f in SNAPSHOT_FILES:
            src = os.path.join(ws, f)
            if os.path.isfile(src):
                _safe_copy(src, os.path.join(snap_dir, f))
                manifest_files.append(f)
    else:
        # Full snapshot: walk directories file-by-file (delta-based)
        for d in SNAPSHOT_DIRS:
            src_dir = os.path.join(ws, d)
            if os.path.isdir(src_dir):
                for root, _dirs, files in os.walk(src_dir):
                    for fname in files:
                        src_file = os.path.join(root, fname)
                        rel = os.path.relpath(src_file, ws)
                        dst_file = os.path.join(snap_dir, rel)
                        os.makedirs(os.path.dirname(dst_file), exist_ok=True)
                        _safe_copy(src_file, dst_file)
                        manifest_files.append(rel)

        # Copy intelligence files individually (NOT recursively) to avoid
        # snapshotting intelligence/applied/ into itself
        intel_src = os.path.join(ws, "intelligence")
        if os.path.isdir(intel_src):
            for root, dirs, files in os.walk(intel_src):
                # Skip applied/ subtree to prevent recursive nesting
                rel_root = os.path.relpath(root, ws)
                if "applied" in rel_root.split(os.sep):
                    dirs.clear()  # prune walk into applied/
                    continue
                for fname in files:
                    src_file = os.path.join(root, fname)
                    rel = os.path.relpath(src_file, ws)
                    dst_file = os.path.join(snap_dir, rel)
                    os.makedirs(os.path.dirname(dst_file), exist_ok=True)
                    _safe_copy(src_file, dst_file)
                    manifest_files.append(rel)

        for f in SNAPSHOT_FILES:
            src = os.path.join(ws, f)
            if os.path.isfile(src):
                _safe_copy(src, os.path.join(snap_dir, f))
                manifest_files.append(f)

    _build_manifest(snap_dir, manifest_files)
    return snap_dir


def restore_snapshot(ws, snap_dir):
    """Restore workspace from snapshot.

    If a MANIFEST.json exists (v1.7.1+ snapshots), restores only the listed
    files — O(manifest) instead of O(workspace). Falls back to the legacy
    copytree approach for older snapshots without a manifest.

    The legacy path uses an atomic temp+rename pattern per directory.
    Intelligence/applied/ is always skipped to prevent deleting active snapshots.
    """
    manifest = _read_manifest(snap_dir)
    if manifest is not None:
        # Manifest-based fast-path: restore only listed files
        for rel_posix in manifest:
            rel_path = rel_posix.replace("/", os.sep)
            src = os.path.join(snap_dir, rel_path)
            dst = os.path.join(ws, rel_path)
            if os.path.exists(src):
                os.makedirs(os.path.dirname(dst), exist_ok=True)
                shutil.copy2(src, dst)

        # Clean up orphan files: remove workspace files in snapshotted dirs
        # that aren't in the manifest (files created after snapshot)
        _cleanup_orphans_from_manifest(ws, snap_dir, manifest)
        return

    # Legacy fallback: copytree-based restore for pre-manifest snapshots
    for d in SNAPSHOT_DIRS:
        src = os.path.join(snap_dir, d)
        dst = os.path.join(ws, d)
        if os.path.isdir(src):
            # Atomic pattern: copy to temp first, then swap
            tmp_dst = dst + ".rollback_tmp"
            if os.path.islink(tmp_dst):
                os.unlink(tmp_dst)
            elif os.path.isdir(tmp_dst):
                shutil.rmtree(tmp_dst)
            shutil.copytree(src, tmp_dst)
            if os.path.islink(dst):
                os.unlink(dst)
            elif os.path.isdir(dst):
                shutil.rmtree(dst)
            os.rename(tmp_dst, dst)

    # Restore intelligence files (skip applied/ to avoid deleting active snapshots)
    intel_snap = os.path.join(snap_dir, "intelligence")
    intel_ws = os.path.join(ws, "intelligence")
    if os.path.isdir(intel_snap):
        for item in os.listdir(intel_snap):
            if item == "applied":
                continue
            src = os.path.join(intel_snap, item)
            dst = os.path.join(intel_ws, item)
            if os.path.isfile(src):
                shutil.copy2(src, dst)
            elif os.path.isdir(src):
                tmp_dst = dst + ".rollback_tmp"
                if os.path.islink(tmp_dst):
                    os.unlink(tmp_dst)
                elif os.path.isdir(tmp_dst):
                    shutil.rmtree(tmp_dst)
                shutil.copytree(src, tmp_dst)
                if os.path.islink(dst):
                    os.unlink(dst)
                elif os.path.isdir(dst):
                    shutil.rmtree(dst)
                os.rename(tmp_dst, dst)

    for f in SNAPSHOT_FILES:
        src = os.path.join(snap_dir, f)
        if os.path.isfile(src):
            shutil.copy2(src, os.path.join(ws, f))


def _cleanup_orphans_from_manifest(ws, snap_dir, manifest):
    """Remove files in snapshotted directories that aren't in the manifest.

    This handles the case where ops created new files after the snapshot —
    those files must be removed on rollback for true atomic restore.
    """
    # Normalize manifest to POSIX for consistent set lookups
    manifest_set = set(m.replace(os.sep, "/") for m in manifest)

    # Collect directories that were snapshotted
    snapshotted_dirs = set()
    for rel in manifest:
        top_dir = rel.split("/")[0]
        snapshotted_dirs.add(top_dir)

    # Walk each snapshotted top-level directory to find orphans
    for d in snapshotted_dirs:
        if d in ("intelligence",):
            # For intelligence, skip applied/ subtree
            intel_dir = os.path.join(ws, "intelligence")
            if os.path.isdir(intel_dir):
                for root, dirs, files in os.walk(intel_dir):
                    rel_root = os.path.relpath(root, ws)
                    if "applied" in rel_root.split(os.sep):
                        dirs.clear()
                        continue
                    for fname in files:
                        rel = os.path.relpath(os.path.join(root, fname), ws)
                        if rel.replace(os.sep, "/") not in manifest_set:
                            os.remove(os.path.join(root, fname))
        else:
            dirpath = os.path.join(ws, d)
            if os.path.isdir(dirpath):
                for root, _dirs, files in os.walk(dirpath):
                    for fname in files:
                        rel = os.path.relpath(os.path.join(root, fname), ws)
                        if rel.replace(os.sep, "/") not in manifest_set:
                            os.remove(os.path.join(root, fname))


def snapshot_diff(ws, snap_dir):
    """Return list of files that differ between current workspace and snapshot.

    Compares using the manifest when available (fast path), or walks the
    snapshot directory for legacy snapshots. Returns relative paths of files
    that are added, removed, or modified.
    """
    diffs = []
    manifest = _read_manifest(snap_dir)

    if manifest is not None:
        files_to_check = manifest
    else:
        # Legacy: walk the snapshot to discover files
        files_to_check = []
        for root, _dirs, files in os.walk(snap_dir):
            for fname in files:
                snap_file = os.path.join(root, fname)
                rel = os.path.relpath(snap_file, snap_dir).replace(os.sep, "/")
                if rel == "MANIFEST.json" or rel == "APPLY_RECEIPT.md":
                    continue
                files_to_check.append(rel)

    for rel_posix in files_to_check:
        rel_native = rel_posix.replace("/", os.sep)
        ws_file = os.path.join(ws, rel_native)
        snap_file = os.path.join(snap_dir, rel_native)

        ws_exists = os.path.isfile(ws_file)
        snap_exists = os.path.isfile(snap_file)

        if snap_exists and not ws_exists:
            diffs.append(rel_posix)
        elif not snap_exists and ws_exists:
            diffs.append(rel_posix)
        elif snap_exists and ws_exists:
            with open(snap_file, "rb") as f:
                snap_hash = hashlib.sha256(f.read()).hexdigest()
            with open(ws_file, "rb") as f:
                ws_hash = hashlib.sha256(f.read()).hexdigest()
            if snap_hash != ws_hash:
                diffs.append(rel_posix)

    return sorted(diffs)


# ═══════════════════════════════════════════════
# Apply Receipt
# ═══════════════════════════════════════════════


def write_receipt(snap_dir, proposal, ts, pre_checks, status="in_progress"):
    """Write APPLY_RECEIPT.md."""
    receipt_path = os.path.join(snap_dir, "APPLY_RECEIPT.md")
    ops_desc = ", ".join(op.get("op", "?") for op in proposal.get("Ops", []))
    lines = [
        f"[AR-{ts}]",
        f"Date: {datetime.now().strftime('%Y-%m-%d')}",
        f"Proposal: {proposal.get('ProposalId', '?')}",
        f"Action: {ops_desc}",
        f"Result: {status}",
        f"Snapshot: {ts}",
        f"Risk: {proposal.get('Risk', '?')}",
        f"TargetBlock: {proposal.get('TargetBlock', '?')}",
        "FilesTouched:",
    ]
    files_touched = proposal.get("FilesTouched", [])
    if not files_touched:
        files_touched = list({op.get("file", "") for op in proposal.get("Ops", []) if op.get("file")})
    for f in files_touched:
        lines.append(f"- {f}")
    lines.append("PreChecks:")
    for c in pre_checks:
        lines.append(f"- {c}")
    rb = proposal.get("Rollback", "?")
    rb_val = rb[0] if isinstance(rb, list) else rb
    lines.append(f"RollbackPlan: {rb_val}")
    lines.append(f"Status: {status}")
    lines.append("")

    with open(receipt_path, "w") as fh:
        fh.write("\n".join(lines))
    return receipt_path


def update_receipt(receipt_path, post_checks, delta, status, diff_text=None):
    """Update receipt with post-check results and diff."""
    with open(receipt_path, "a") as fh:
        fh.write("PostChecks:\n")
        for c in post_checks:
            fh.write(f"- {c}\n")
        fh.write("Delta:\n")
        for k, v in delta.items():
            fh.write(f"- {k}: {v}\n")

        if diff_text:
            fh.write("DIFF:\n")
            # Indent diff lines for block inclusion
            for line in diff_text.split("\n"):
                fh.write(f"  {line}\n")

        fh.write(f"Result: {status}\n")


def _get_mode(ws="."):
    """Read current governance_mode from intel-state.json.

    Supports legacy 'self_correcting_mode' for backward compatibility.
    """
    try:
        with open(os.path.join(ws, "memory/intel-state.json")) as f:
            state = json.load(f)
        return state.get("governance_mode", state.get("self_correcting_mode", "detect_only"))
    except Exception:
        return "detect_only"


# ═══════════════════════════════════════════════
# Op Executors
# ═══════════════════════════════════════════════


def execute_op(ws, op):
    """Execute a single op. Returns (success, message)."""
    op_type = op.get("op")
    raw_file = op.get("file", "")

    # Security: reject path traversal and symlink escape
    try:
        filepath = _safe_resolve(ws, raw_file)
    except ValueError as e:
        return False, f"SECURITY: {e}"

    if not os.path.isfile(filepath):
        return False, f"File not found: {filepath}"

    try:
        if op_type == "append_block":
            return _op_append_block(filepath, op)
        elif op_type == "insert_after_block":
            return _op_insert_after_block(filepath, op)
        elif op_type == "update_field":
            return _op_update_field(filepath, op)
        elif op_type == "append_list_item":
            return _op_append_list_item(filepath, op)
        elif op_type == "set_status":
            return _op_set_status(filepath, op)
        elif op_type == "replace_range":
            return _op_replace_range(filepath, op)
        elif op_type == "supersede_decision":
            return _op_supersede_decision(filepath, op)
        else:
            return False, f"Unknown op: {op_type}"
    except (OSError, IOError, ValueError, KeyError, IndexError) as e:
        return False, f"Op {op_type} failed: {e}"


def _op_append_block(filepath, op):
    """Append a new block at end of file."""
    patch = op.get("patch", "")
    if not patch:
        return False, "append_block: empty patch"

    with open(filepath, "a") as f:
        f.write(f"\n{patch}\n")
    return True, "append_block: OK"


def _op_insert_after_block(filepath, op):
    """Insert block after target block ID."""
    target = op.get("target")
    patch = op.get("patch", "")
    if not target or not patch:
        return False, "insert_after_block: missing target or patch"

    with open(filepath, "r") as f:
        lines = f.readlines()

    # Find end of target block (next block header or EOF)
    target_pattern = re.compile(rf"^\[{re.escape(target)}\]")
    found = False
    insert_at = None

    for i, line in enumerate(lines):
        if target_pattern.match(line):
            found = True
            continue
        if found and re.match(r"^\[[A-Z]+-[^\]]+\]\s*$", line):
            insert_at = i
            break

    if not found:
        return False, f"insert_after_block: target {target} not found"

    if insert_at is None:
        insert_at = len(lines)

    # Insert patch
    patch_lines = [line + "\n" for line in patch.split("\n")]
    lines[insert_at:insert_at] = ["\n"] + patch_lines

    with open(filepath, "w") as f:
        f.writelines(lines)
    return True, f"insert_after_block: inserted after {target}"


def _op_update_field(filepath, op):
    """Update a field value within a specific block."""
    target = op.get("target")
    field = op.get("field")
    value = op.get("value")
    if not target or not field:
        return False, "update_field: missing target or field"

    with open(filepath, "r") as f:
        lines = f.readlines()

    target_pattern = re.compile(rf"^\[{re.escape(target)}\]")
    in_target = False
    updated = False

    for i, line in enumerate(lines):
        if target_pattern.match(line):
            in_target = True
            continue
        if in_target and re.match(r"^\[[A-Z]+-[^\]]+\]\s*$", line):
            break
        if in_target:
            field_match = re.match(rf"^{re.escape(field)}:\s+.*$", line)
            if field_match:
                lines[i] = f"{field}: {value}\n"
                updated = True
                break

    if not updated:
        return False, f"update_field: field '{field}' not found in block {target}"

    with open(filepath, "w") as f:
        f.writelines(lines)
    return True, f"update_field: {target}.{field} = {value}"


def _op_append_list_item(filepath, op):
    """Append an item to a list field within a block."""
    target = op.get("target")
    list_field = op.get("list")
    item = op.get("item", "")
    if not target or not list_field:
        return False, "append_list_item: missing target or list"

    with open(filepath, "r") as f:
        lines = f.readlines()

    target_pattern = re.compile(rf"^\[{re.escape(target)}\]")
    in_target = False
    in_list = False
    insert_at = None

    for i, line in enumerate(lines):
        if target_pattern.match(line):
            in_target = True
            continue
        if in_target and re.match(r"^\[[A-Z]+-[^\]]+\]\s*$", line):
            break
        if in_target:
            # Find the list field
            if re.match(rf"^{re.escape(list_field)}:", line):
                in_list = True
                insert_at = i + 1
                continue
            if in_list:
                # Keep tracking list items
                if line.startswith("- ") or line.startswith("  -"):
                    insert_at = i + 1
                elif line.strip() == "":
                    insert_at = i
                    break
                else:
                    # New field = end of list
                    insert_at = i
                    break

    if insert_at is None:
        return False, f"append_list_item: list '{list_field}' not found in {target}"

    # Clean item — remove surrounding quotes if present
    item_clean = item.strip().strip('"').strip("'")
    lines.insert(insert_at, f"- {item_clean}\n")

    with open(filepath, "w") as f:
        f.writelines(lines)
    return True, f"append_list_item: added to {target}.{list_field}"


def _op_set_status(filepath, op):
    """Update Status field + auto-append History entry."""
    target = op.get("target")
    status = op.get("status")
    history = op.get("history", "")
    if not target or not status:
        return False, "set_status: missing target or status"

    # First update the Status field
    ok, msg = _op_update_field(filepath, {"target": target, "field": "Status", "value": status})
    if not ok:
        return False, f"set_status: field update failed: {msg}"

    # Then append History entry if provided
    if history:
        ok2, msg2 = _op_append_list_item(filepath, {"target": target, "list": "History", "item": history})
        if not ok2:
            return False, f"set_status: history append failed: {msg2}"

    return True, f"set_status: {target} -> {status}"


def _op_replace_range(filepath, op):
    """Replace content between start/end markers within a block."""
    target = op.get("target")
    range_spec = op.get("range", {})
    patch = op.get("patch", "")
    start_marker = range_spec.get("start", "")
    end_marker = range_spec.get("end", "")

    if not target or not start_marker or not end_marker:
        return False, "replace_range: missing target, range.start, or range.end"

    with open(filepath, "r") as f:
        lines = f.readlines()

    target_pattern = re.compile(rf"^\[{re.escape(target)}\]")
    in_target = False
    start_line = None
    end_line = None

    for i, line in enumerate(lines):
        if target_pattern.match(line):
            in_target = True
            continue
        if in_target and re.match(r"^\[[A-Z]+-[^\]]+\]\s*$", line):
            break
        if in_target:
            if start_marker in line and start_line is None:
                start_line = i
            if end_marker in line and start_line is not None:
                end_line = i
                break

    if start_line is None or end_line is None:
        return False, f"replace_range: markers not found in {target}"

    # Replace lines between markers (exclusive of end marker line)
    patch_lines = [line + "\n" for line in patch.split("\n")]
    lines[start_line:end_line] = patch_lines

    with open(filepath, "w") as f:
        f.writelines(lines)
    return True, f"replace_range: replaced {end_line - start_line} lines in {target}"


def _op_supersede_decision(filepath, op):
    """Atomic supersede: append new block + mark old as superseded."""
    target = op.get("target")
    new_block = op.get("new_block") or op.get("patch", "")
    if not target or not new_block:
        return False, "supersede_decision: missing target or new_block/patch"

    # Check that target exists and is not invariant enforcement
    blocks = parse_file(filepath)
    old = get_by_id(blocks, target)
    if not old:
        return False, f"supersede_decision: target {target} not found"

    # Invariant check
    sigs = old.get("ConstraintSignatures", [])
    has_invariant = any(s.get("enforcement") == "invariant" for s in sigs)
    if has_invariant:
        return False, (
            f"supersede_decision: {target} has invariant enforcement"
            " (manual edit required — invariants cannot be modified by automation)"
        )

    # Step 1: Mark old decision as superseded
    ok, msg = _op_update_field(filepath, {"target": target, "field": "Status", "value": "superseded"})
    if not ok:
        return False, f"supersede_decision step 1 (mark superseded): {msg}"

    # Step 2: Append new block
    ok2, msg2 = _op_append_block(filepath, {"patch": new_block})
    if not ok2:
        return False, f"supersede_decision step 2 (append new): {msg2}"

    return True, f"supersede_decision: {target} -> superseded, new block appended"


# ═══════════════════════════════════════════════
# v2.1.2 Operational Hardening
# ═══════════════════════════════════════════════


def compute_fingerprint(proposal):
    """Deterministic fingerprint from proposal content. Prevents duplicates.

    Includes op payload fields (value, patch, status) to distinguish proposals
    targeting the same block with different mutations.
    """
    canon = json.dumps(
        {
            "type": proposal.get("Type", ""),
            "target": proposal.get("TargetBlock", ""),
            "ops": [
                {
                    "op": op.get("op"),
                    "file": op.get("file"),
                    "target": op.get("target"),
                    "value": op.get("value", ""),
                    "patch": op.get("patch", ""),
                    "status": op.get("status", ""),
                }
                for op in proposal.get("Ops", [])
            ],
        },
        sort_keys=True,
    )
    return hashlib.sha256(canon.encode()).hexdigest()[:16]


def check_fingerprint_dedup(ws, proposal):
    """Check if a different proposal with same fingerprint already exists (staged or deferred)."""
    fp = compute_fingerprint(proposal)
    my_id = proposal.get("ProposalId", proposal.get("_id", ""))
    for pfile in PROPOSED_FILES:
        path = os.path.join(ws, pfile)
        if not os.path.isfile(path):
            continue
        blocks = parse_file(path)
        for b in blocks:
            if b.get("Status") in ("staged", "deferred"):
                bid = b.get("ProposalId", b.get("_id", ""))
                if bid == my_id:
                    continue  # Skip self
                existing_fp = b.get("Fingerprint", "")
                if existing_fp == fp:
                    return True, bid
    return False, None


def check_backlog_limit(ws):
    """Count staged proposals. Returns (count, limit_exceeded)."""
    # Read limit from mind-mem.json (source of truth for config), not intel-state.json
    config_path = os.path.join(ws, "mind-mem.json")
    try:
        with open(config_path) as f:
            config = json.load(f)
    except (OSError, json.JSONDecodeError, ValueError):
        config = {}
    limit = config.get("proposal_budget", {}).get("backlog_limit", 30)
    count = 0
    for pfile in PROPOSED_FILES:
        path = os.path.join(ws, pfile)
        if not os.path.isfile(path):
            continue
        blocks = parse_file(path)
        count += sum(1 for b in blocks if b.get("Status") == "staged")
    return count, count >= limit


def check_no_touch_window(ws):
    """Check if enough time has passed since last apply. Returns (ok, reason)."""
    state = _load_intel_state(ws)
    last_ts = state.get("last_apply_ts")
    if not last_ts:
        return True, "No previous apply"
    try:
        last = datetime.fromisoformat(last_ts.replace("Z", "+00:00"))
        # Normalize both to naive for safe comparison
        last_naive = last.replace(tzinfo=None)
        now_naive = datetime.now()
        delta = now_naive - last_naive
        if delta < timedelta(minutes=10):
            remaining = timedelta(minutes=10) - delta
            return False, f"No-touch window: {remaining.seconds // 60}m {remaining.seconds % 60}s remaining"
    except (ValueError, TypeError):
        pass
    return True, "Cooldown clear"


def check_deferred_cooldown(ws, proposal):
    """Check if a rejected/deferred proposal for same target is still in cooldown."""
    state = _load_intel_state(ws)
    cooldown_days = state.get("defer_cooldown_days", 7)
    target = proposal.get("TargetBlock", "")
    if not target:
        return True, "No target"

    cutoff = datetime.now() - timedelta(days=cooldown_days)

    for pfile in PROPOSED_FILES:
        path = os.path.join(ws, pfile)
        if not os.path.isfile(path):
            continue
        blocks = parse_file(path)
        for b in blocks:
            if b.get("Status") in ("rejected", "deferred") and b.get("TargetBlock") == target:
                created = b.get("Created", "")
                try:
                    created_dt = datetime.fromisoformat(created)
                    if created_dt > cutoff:
                        pid = b.get("ProposalId")
                        return False, (
                            f"Target {target} has {b.get('Status')} proposal {pid} within {cooldown_days}d cooldown"
                        )
                except (ValueError, TypeError):
                    pass
    return True, "No cooldown conflict"


def update_last_apply_ts(ws):
    """Record the timestamp of the last apply."""
    state = _load_intel_state(ws)
    state["last_apply_ts"] = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
    _save_intel_state(ws, state)


def generate_diff_text(ws, snap_dir, files_touched):
    """Generate unified diff showing what changed during apply."""
    diff_lines = []
    for rel_path in files_touched:
        old_path = os.path.join(snap_dir, rel_path)
        new_path = os.path.join(ws, rel_path)

        old_lines = []
        new_lines = []
        if os.path.isfile(old_path):
            with open(old_path, "r", errors="replace") as f:
                old_lines = f.readlines()
        if os.path.isfile(new_path):
            with open(new_path, "r", errors="replace") as f:
                new_lines = f.readlines()

        diff = difflib.unified_diff(old_lines, new_lines, fromfile=f"a/{rel_path}", tofile=f"b/{rel_path}", lineterm="")
        diff_text = "\n".join(diff)
        if diff_text:
            diff_lines.append(diff_text)

    if not diff_lines:
        return "(no differences detected)"
    return "\n\n".join(diff_lines)


def _load_intel_state(ws):
    """Load intel-state.json."""
    path = os.path.join(ws, "memory/intel-state.json")
    try:
        with open(path) as f:
            return json.load(f)
    except (OSError, json.JSONDecodeError):
        return {}


def _save_intel_state(ws, state):
    """Save intel-state.json atomically (write to temp, then rename).

    Uses FileLock to prevent race conditions with intel_scan.py's
    save_intel_state which locks the same file.
    """
    path = os.path.join(ws, "memory/intel-state.json")
    tmp_path = path + ".tmp"
    with FileLock(path):
        with open(tmp_path, "w", encoding="utf-8") as f:
            json.dump(state, f, indent=2)
            f.write("\n")
        os.replace(tmp_path, path)


# ═══════════════════════════════════════════════
# Main Apply Pipeline
# ═══════════════════════════════════════════════


def _get_workspace_lock_path(ws):
    """Return the path for the workspace-wide apply lock."""
    return os.path.join(ws, ".mind-mem-apply")


def apply_proposal(ws, proposal_id, dry_run=False, agent_id=None):
    """Main apply pipeline. Returns (success, message).

    Args:
        ws: Workspace path.
        proposal_id: Proposal ID to apply.
        dry_run: Validate without executing.
        agent_id: Optional agent ID for namespace ACL enforcement.
    """
    # Resolve symlinks early so all downstream code uses canonical paths.
    # Fixes macOS /var → /private/var mismatch in relpath computations.
    ws = os.path.realpath(ws)
    print("═══ Mind Mem Apply Engine v1.0 ═══")
    print(f"Proposal: {proposal_id}")
    print(f"Workspace: {ws}")
    print(f"Dry run: {dry_run}")
    print()

    # 0. Mode gate: detect_only blocks all apply/dry-run
    mode = _get_mode(ws)
    print(f"Mode: {mode}")
    if mode == "detect_only":
        print("ERROR: Cannot apply proposals in detect_only mode.")
        print("Switch to 'propose' or 'enforce' mode first.")
        return False, "Blocked: detect_only mode does not allow apply"

    # 1. Find proposal
    proposal, source_file = find_proposal(ws, proposal_id)
    if not proposal:
        print(f"ERROR: Proposal {proposal_id} not found in proposed/ files.")
        return False, "Proposal not found"

    print(f"Found in: {source_file}")
    print(f"Type: {proposal.get('Type')}  Risk: {proposal.get('Risk')}  Status: {proposal.get('Status')}")

    # 2. Validate proposal
    errors = validate_proposal(proposal)
    if errors:
        print(f"\nVALIDATION ERRORS ({len(errors)}):")
        for e in errors:
            print(f"  - {e}")
        return False, f"Validation failed: {len(errors)} error(s)"

    print("Proposal validation: PASS")

    # 2b. Fingerprint
    fp = compute_fingerprint(proposal)
    print(f"Fingerprint: {fp}")

    # 2c. v2.1.2 hardening checks (hard fail)
    print("\n--- Hardening Checks ---")

    # Dedup check
    is_dup, dup_id = check_fingerprint_dedup(ws, proposal)
    if is_dup:
        print(f"  FAIL: Duplicate fingerprint — matches {dup_id}")
        return False, f"Duplicate proposal (matches {dup_id})"
    print("  Dedup: PASS")

    # Backlog limit
    backlog_count, over_limit = check_backlog_limit(ws)
    if over_limit:
        print(f"  FAIL: Backlog limit exceeded ({backlog_count} staged)")
        return False, f"Backlog limit exceeded ({backlog_count} staged)"
    print(f"  Backlog: PASS ({backlog_count} staged)")

    # Deferred cooldown
    ok_cool, cool_reason = check_deferred_cooldown(ws, proposal)
    if not ok_cool:
        print(f"  FAIL: {cool_reason}")
        return False, cool_reason
    print("  Cooldown: PASS")

    # No-touch window (warning on dry-run, hard fail on real apply)
    ok_touch, touch_reason = check_no_touch_window(ws)
    if not ok_touch and not dry_run:
        print(f"  FAIL: {touch_reason}")
        return False, touch_reason
    elif not ok_touch:
        print(f"  WARN: {touch_reason} (dry-run, continuing)")
    else:
        print("  No-touch window: PASS")

    # --- Contradiction Detection (#432) ---
    print("\n--- Contradiction Check ---")
    try:
        from .contradiction_detector import check_proposal_contradictions

        contra_report = check_proposal_contradictions(ws, proposal)
        print(f"  {contra_report['summary']}")
        if contra_report["has_contradictions"]:
            for c in contra_report["conflicts"]:
                if c["conflict_type"] == "contradiction":
                    print(f"  ⚠️  {c['block_id']} (sim={c['similarity']:.2f}): {c['existing_excerpt'][:100]}...")

            # Check if blocking is enabled (default: true)
            block_on_detect = True
            try:
                config_path = os.path.join(ws, "mind-mem.json")
                with open(config_path) as f:
                    cfg = json.load(f)
                block_on_detect = cfg.get("contradiction", {}).get("block_on_detect", True)
            except Exception:
                pass  # default to blocking

            if block_on_detect:
                print("  Contradiction blocking is ENABLED (contradiction.block_on_detect=true)")
                return False, "Blocked: contradictions detected"
            else:
                print("  Contradiction blocking is DISABLED (contradiction.block_on_detect=false), continuing")
    except Exception as e:
        contra_report = None
        print(f"  WARNING: Contradiction check failed: {e}")

    if dry_run:
        # Dry run: show ops without mutation (skip preconditions to avoid side effects)
        print("\n--- DRY RUN: would execute these ops ---")
        for i, op in enumerate(proposal.get("Ops", [])):
            print(f"  [{i}] {op.get('op')} -> {op.get('file')}:{op.get('target', 'eof')}")
        print("\nDry run complete. No changes made.")
        return True, "Dry run OK"

    # Namespace ACL check: verify agent has write permission for all target files
    if agent_id:
        ns = NamespaceManager(ws, agent_id=agent_id)
        for op in proposal.get("Ops", []):
            op_file = op.get("file", "")
            if op_file and not ns.can_write(op_file):
                print(f"\nACL DENIED: agent '{agent_id}' cannot write to '{op_file}'")
                return False, f"ACL denied: agent '{agent_id}' cannot write to '{op_file}'"
        print(f"  ACL: PASS (agent '{agent_id}' has write access)")

    # Acquire workspace-wide lock to prevent concurrent applies
    lock_path = _get_workspace_lock_path(ws)
    lock = FileLock(lock_path, timeout=30.0)
    try:
        lock.acquire()
    except Exception as e:
        print(f"\nERROR: Could not acquire workspace lock: {e}")
        return False, f"Workspace lock timeout: {e}"

    try:
        return _apply_proposal_locked(ws, proposal, proposal_id, source_file, lock)
    finally:
        lock.release()


def _apply_proposal_locked(ws, proposal, proposal_id, source_file, lock):
    """Execute the apply pipeline while holding the workspace lock."""

    # 3. Replay WAL from any prior crash BEFORE snapshotting (ensures clean state)
    wal = WAL(ws)
    replayed = wal.replay()
    if replayed:
        print(f"\n  WAL: Replayed {replayed} pending entry(ies) from prior crash")

    # 4. Create snapshot AFTER WAL recovery (O1: snapshot before any mutation)
    ts = datetime.now().strftime("%Y%m%d-%H%M%S")
    print(f"\n--- Creating Snapshot: {ts} ---")
    # Record pre-apply file listing for orphan detection
    pre_apply_files = _list_workspace_files(ws)
    # Minimal snapshot: only files the proposal will touch (O(touched) vs O(workspace))
    files_touched = proposal.get("FilesTouched", [])
    if not files_touched:
        files_touched = list({op.get("file", "") for op in proposal.get("Ops", []) if op.get("file")})
    snap_dir = create_snapshot(ws, ts, files_touched=files_touched or None)
    snap_mode = "minimal" if files_touched else "full"
    print(f"  Snapshot: {snap_dir} ({snap_mode}, {len(files_touched)} files)")

    # 5. Check preconditions (may run validate.sh + intel_scan.py which write reports)
    print("\n--- Precondition Checks ---")
    ok, pre_report = check_preconditions(ws)
    for r in pre_report:
        print(f"  {r}")
    if not ok:
        print("PRECONDITIONS FAILED — rolling back.")
        restore_snapshot(ws, snap_dir)
        _cleanup_orphan_files(ws, pre_apply_files)
        return False, "Precondition check failed"

    receipt_path = write_receipt(snap_dir, proposal, ts, pre_report)
    print(f"  Receipt: {receipt_path}")

    # 6. Execute ops with WAL protection

    print(f"\n--- Executing {len(proposal.get('Ops', []))} Ops (WAL-protected) ---")
    delta: dict[str, list[str]] = {"created": [], "modified": []}
    wal_entries = []  # Track WAL entries for this apply
    actually_modified_files: set[str] = set()  # Track all files modified during execution
    for i, op in enumerate(proposal.get("Ops", [])):
        raw_file = op.get("file", "")
        try:
            filepath = _safe_resolve(ws, raw_file)
        except ValueError:
            filepath = raw_file

        # WAL: log intention before mutation
        wal_id = wal.begin(
            operation=op.get("op", "unknown"),
            target_path=filepath,
            content=json.dumps(op, default=str),
        )
        wal_entries.append(wal_id)

        ok, msg = execute_op(ws, op)
        print(f"  [{i}] {op.get('op')}: {msg}")
        if not ok:
            # WAL: rollback this failed op's WAL entry
            wal.rollback(wal_id)
            print(f"\nOP FAILED at step {i} — rolling back.")
            # Also rollback any previously committed WAL entries via snapshot
            restore_snapshot(ws, snap_dir)
            _cleanup_orphan_files(ws, pre_apply_files)
            update_receipt(receipt_path, ["ABORTED: op failure"], delta, "rolled_back")
            return False, f"Op {i} failed: {msg}"

        # WAL: commit successful op
        wal.commit(wal_id)

        # Track actually modified files for accurate rollback scope
        if filepath:
            actually_modified_files.add(filepath)

        # Track delta
        target = op.get("target", "")
        if op.get("op") in ("append_block", "insert_after_block", "supersede_decision"):
            delta["created"].append(target or "new")
        else:
            delta["modified"].append(target)

    # 6. Post-checks
    print("\n--- Post-checks ---")
    ok, post_report = check_preconditions(ws)
    for r in post_report:
        print(f"  {r}")

    if not ok:
        print("\nPOST-CHECKS FAILED — rolling back.")
        print(
            "  WARNING: WAL entries were already committed. Recovery relies on "
            "snapshot restore. If files_touched is incomplete, workspace may be "
            "inconsistent. Actually modified files: %s" % sorted(actually_modified_files)
        )
        restore_snapshot(ws, snap_dir)
        _cleanup_orphan_files(ws, pre_apply_files)
        update_receipt(receipt_path, post_report, delta, "rolled_back")
        # Also mark proposal as rolled back
        _mark_proposal_status(source_file, proposal_id, "rolled_back")
        return False, "Post-checks failed, rolled back"

    # 7. Generate DIFF text
    # Use actually_modified_files to ensure all touched files are included in diff
    files_touched = proposal.get("FilesTouched", [])
    if not files_touched:
        # Derive from Ops when FilesTouched is absent
        files_touched = list({op.get("file", "") for op in proposal.get("Ops", []) if op.get("file")})
    # Merge in any files that were actually modified but not listed
    all_touched = set(files_touched) | actually_modified_files
    diff_text = generate_diff_text(ws, snap_dir, list(all_touched))

    # 8. Commit receipt + mark proposal as applied + update last_apply_ts
    update_receipt(receipt_path, post_report, delta, "applied", diff_text)
    _mark_proposal_status(source_file, proposal_id, "applied")
    update_last_apply_ts(ws)
    print(f"\n═══ APPLIED: {proposal_id} ═══")
    print(f"Receipt: {receipt_path}")
    return True, f"Applied successfully. Receipt: {receipt_path}"


def _mark_proposal_status(source_file, proposal_id, new_status):
    """Update the Status field in the proposal's source file."""
    try:
        with open(source_file, "r") as f:
            content = f.read()
        # Find the proposal block and update its Status
        # Simple approach: find line with "ProposalId: <id>" then find "Status:" nearby
        lines = content.split("\n")
        in_proposal = False
        for i, line in enumerate(lines):
            if f"ProposalId: {proposal_id}" in line:
                in_proposal = True
                continue
            if in_proposal and line.startswith("Status:"):
                lines[i] = f"Status: {new_status}"
                break
            if in_proposal and re.match(r"^\[[A-Z]+-[^\]]+\]", line):
                break
        tmp_path = source_file + ".tmp"
        with open(tmp_path, "w", encoding="utf-8") as f:
            f.write("\n".join(lines))
        os.replace(tmp_path, source_file)
    except (OSError, IOError, json.JSONDecodeError) as e:
        print(f"WARNING: Could not update proposal status in {source_file}: {e}", file=sys.stderr)


def rollback(ws, receipt_ts):
    """Rollback from a receipt timestamp."""
    ws = os.path.realpath(ws)
    # Sanitize receipt_ts: must match YYYYMMDD-HHMMSS format (no traversal)
    if not re.match(r"^\d{8}-\d{6}$", receipt_ts):
        print(f"ERROR: Invalid receipt timestamp format: {receipt_ts} (expected YYYYMMDD-HHMMSS)")
        return False

    try:
        snap_dir = _safe_resolve(ws, os.path.join("intelligence/applied", receipt_ts))
    except ValueError as e:
        print(f"ERROR: {e}")
        return False

    if not os.path.isdir(snap_dir):
        print(f"ERROR: Snapshot directory not found: {snap_dir}")
        return False

    print(f"Restoring from snapshot: {snap_dir}")
    restore_snapshot(ws, snap_dir)

    # Re-run checks
    print("\n--- Post-rollback checks ---")
    ok, report = check_preconditions(ws)
    for r in report:
        print(f"  {r}")

    # Update receipt
    receipt_path = os.path.join(snap_dir, "APPLY_RECEIPT.md")
    if os.path.isfile(receipt_path):
        with open(receipt_path, "a") as f:
            f.write(f"\nRolledBack: {datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ')}\n")
            f.write("Result: rolled_back\n")

    print(f"\n═══ ROLLED BACK from {receipt_ts} ═══")
    return True


# ═══════════════════════════════════════════════
# CLI
# ═══════════════════════════════════════════════

if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description="Mind Mem Apply Engine v1.0")
    parser.add_argument("proposal_id", nargs="?", help="ProposalId to apply (e.g. P-20260213-002)")
    parser.add_argument("workspace", nargs="?", default=".", help="Workspace path")
    parser.add_argument("--dry-run", action="store_true", help="Validate and show ops without executing")
    parser.add_argument("--rollback", metavar="TS", help="Rollback to receipt timestamp")
    args = parser.parse_args()

    ws = os.path.abspath(args.workspace)
    os.chdir(ws)

    if args.rollback:
        ok = rollback(ws, args.rollback)
        sys.exit(0 if ok else 1)

    if not args.proposal_id:
        parser.error("proposal_id is required (or use --rollback)")

    ok, msg = apply_proposal(ws, args.proposal_id, dry_run=args.dry_run)
    print(f"\n{msg}")
    sys.exit(0 if ok else 1)
