#!/usr/bin/env python3
"""Atomically manage plan- and Milestone-required agent-ui work mappings."""

from __future__ import annotations

import argparse
import fcntl
import hashlib
import json
import os
import re
import stat
import tempfile
from contextlib import contextmanager
from datetime import datetime
from functools import wraps
from pathlib import Path, PurePosixPath
from typing import Any


SCHEMA_VERSION = 4
SHA_PATTERN = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$")
STATUS_PATH_PATTERN = re.compile(
    r"^agent-ui/definition/(?:views|components)/[a-z0-9][a-z0-9/-]*/index\.md$"
)
FRAME_PATH_PATTERN = re.compile(
    r"^agent-ui/frame/views/[a-z0-9][a-z0-9/-]*/index\.md$"
)
SCOPE_PATTERN = re.compile(r"^(?:all|view:[a-z0-9][a-z0-9-]*|component:[a-z0-9][a-z0-9/-]*)$")
MILESTONE_PATH_PATTERN = re.compile(
    r"^agent-roadmap/phase/[a-z0-9][a-z0-9-]*/milestones/"
    r"[a-z0-9][a-z0-9-]*\.md$"
)


class StateError(ValueError):
    pass


@contextmanager
def _state_lock(path: Path):
    directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
    try:
        fcntl.flock(directory_fd, fcntl.LOCK_EX)
        yield
    finally:
        fcntl.flock(directory_fd, fcntl.LOCK_UN)
        os.close(directory_fd)


def _locked_state_command(handler):
    @wraps(handler)
    def wrapped(args: argparse.Namespace) -> dict[str, Any]:
        with _state_lock(args.state):
            return handler(args)

    return wrapped


def _stable_unique(values: list[str]) -> list[str]:
    return list(dict.fromkeys(values))


def _path_trees_overlap(left: str, right: str) -> bool:
    left_path = PurePosixPath(left)
    right_path = PurePosixPath(right)
    return (
        left_path == right_path
        or left_path in right_path.parents
        or right_path in left_path.parents
    )


def _require_relative_path(value: str, *, prefix: str | None = None) -> str:
    path = PurePosixPath(value)
    if not value or path.is_absolute() or ".." in path.parts or "." in path.parts:
        raise StateError(f"invalid repository-relative path: {value!r}")
    normalized = path.as_posix()
    if prefix and not normalized.startswith(prefix):
        raise StateError(f"path must start with {prefix!r}: {value!r}")
    return normalized


def _require_task_path(value: str) -> str:
    normalized = _require_relative_path(value, prefix="agent-task/")
    if normalized == "agent-task" or normalized.startswith("agent-task/archive/"):
        raise StateError(f"task path must be the original active path: {value!r}")
    return normalized.rstrip("/")


def _require_milestone_path(value: str) -> str:
    normalized = _require_relative_path(value, prefix="agent-roadmap/phase/")
    if not MILESTONE_PATH_PATTERN.fullmatch(normalized):
        raise StateError(f"milestone path must be an active Milestone document: {value!r}")
    return normalized


def _require_timestamp(value: str) -> str:
    try:
        parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
    except ValueError as exc:
        raise StateError(f"invalid ISO-8601 timestamp: {value!r}") from exc
    if parsed.tzinfo is None:
        raise StateError(f"timestamp must include a timezone: {value!r}")
    return value


def _require_string_list(value: Any, *, field: str) -> list[str]:
    if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value):
        raise StateError(f"{field} must be a list of non-empty strings")
    return _stable_unique(value)


def _require_object_list(value: Any, *, field: str) -> list[dict[str, Any]]:
    if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
        raise StateError(f"{field} must be a list of objects")
    return value


def _normalize_pending_entry(raw: Any) -> dict[str, Any]:
    if not isinstance(raw, dict):
        raise StateError("pending_code_work entries must be objects")

    legacy_paths = _require_string_list(raw.get("agent_ui_paths", []), field="agent_ui_paths")
    status_paths = _require_string_list(raw.get("status_paths", []), field="status_paths")
    frame_paths = _require_string_list(raw.get("frame_paths", []), field="frame_paths")
    if legacy_paths:
        status_paths.extend(path for path in legacy_paths if not path.startswith("agent-ui/frame/"))
        frame_paths.extend(path for path in legacy_paths if path.startswith("agent-ui/frame/"))

    status_paths = [
        _require_relative_path(path, prefix="agent-ui/definition/")
        for path in _stable_unique(status_paths)
    ]
    frame_paths = [
        _require_relative_path(path, prefix="agent-ui/frame/")
        for path in _stable_unique(frame_paths)
    ]
    if not status_paths:
        raise StateError("pending_code_work requires at least one status_path")
    if not all(STATUS_PATH_PATTERN.fullmatch(path) for path in status_paths):
        raise StateError("status_paths must be active view/component index.md paths")
    if not all(FRAME_PATH_PATTERN.fullmatch(path) for path in frame_paths):
        raise StateError("frame_paths must be active frame-view index.md paths")
    if set(status_paths) & set(frame_paths):
        raise StateError("status_paths and frame_paths must be disjoint")

    code_paths = [
        _require_relative_path(path)
        for path in _require_string_list(raw.get("code_paths", []), field="code_paths")
    ]
    if not code_paths:
        raise StateError("pending_code_work requires at least one code_path")
    if any(path.startswith(("agent-ui/", "agent-task/")) for path in code_paths):
        raise StateError("code_paths must not point to agent-ui or agent-task artifacts")

    scope = str(raw.get("scope", "")).strip() or "all"
    if not SCOPE_PATTERN.fullmatch(scope):
        raise StateError(f"invalid sync scope: {scope!r}")

    return {
        "task_path": _require_task_path(str(raw.get("task_path", ""))),
        "scope": scope,
        "status_paths": status_paths,
        "frame_paths": frame_paths,
        "code_paths": code_paths,
        "verification_requirements": _require_string_list(
            raw.get("verification_requirements", []),
            field="verification_requirements",
        ),
        "prepared_at": _require_timestamp(str(raw.get("prepared_at", ""))),
        "state": "pending",
    }


def _normalize_pending_milestone_entry(raw: Any) -> dict[str, Any]:
    if not isinstance(raw, dict):
        raise StateError("pending_milestone_work entries must be objects")
    entry = _normalize_pending_entry(
        {
            **raw,
            "task_path": "agent-task/milestone-placeholder",
        }
    )
    return {
        "milestone_path": _require_milestone_path(str(raw.get("milestone_path", ""))),
        "scope": entry["scope"],
        "status_paths": entry["status_paths"],
        "frame_paths": entry["frame_paths"],
        "code_paths": entry["code_paths"],
        "verification_requirements": entry["verification_requirements"],
        "prepared_at": entry["prepared_at"],
        "state": "pending",
    }


def _normalize_reconciled_entry(raw: Any) -> dict[str, Any]:
    if not isinstance(raw, dict):
        raise StateError("reconciled_code_work entries must be objects")
    scope = str(raw.get("scope", "")).strip() or "all"
    if not SCOPE_PATTERN.fullmatch(scope):
        raise StateError(f"invalid reconciled sync scope: {scope!r}")
    entry = {
        "task_path": _require_task_path(str(raw.get("task_path", ""))),
        "completion_log": _require_relative_path(
            str(raw.get("completion_log", "")),
            prefix="agent-task/archive/",
        ),
        "scope": scope,
        "status_paths": [
            _require_relative_path(path, prefix="agent-ui/definition/")
            for path in _require_string_list(raw.get("status_paths", []), field="status_paths")
        ],
        "frame_paths": [
            _require_relative_path(path, prefix="agent-ui/frame/")
            for path in _require_string_list(raw.get("frame_paths", []), field="frame_paths")
        ],
        "code_paths": [
            _require_relative_path(path)
            for path in _require_string_list(raw.get("code_paths", []), field="code_paths")
        ],
        "validation_evidence": str(raw.get("validation_evidence", "")).strip(),
        "reconciled_at": _require_timestamp(str(raw.get("reconciled_at", ""))),
        "sync_result_head": raw.get("sync_result_head"),
    }
    if not entry["status_paths"] or not entry["code_paths"] or not entry["validation_evidence"]:
        raise StateError("reconciled_code_work entry is incomplete")
    if not all(STATUS_PATH_PATTERN.fullmatch(path) for path in entry["status_paths"]):
        raise StateError("reconciled status_paths must be active view/component index.md paths")
    if not all(FRAME_PATH_PATTERN.fullmatch(path) for path in entry["frame_paths"]):
        raise StateError("reconciled frame_paths must be active frame-view index.md paths")
    if any(path.startswith(("agent-ui/", "agent-task/")) for path in entry["code_paths"]):
        raise StateError("reconciled code_paths must not point to agent-ui or agent-task artifacts")
    if entry["sync_result_head"] is not None and not SHA_PATTERN.fullmatch(
        str(entry["sync_result_head"])
    ):
        raise StateError("sync_result_head must be a 40- or 64-character lowercase hex hash")
    return entry


def _normalize_reconciled_milestone_entry(raw: Any) -> dict[str, Any]:
    if not isinstance(raw, dict):
        raise StateError("reconciled_milestone_work entries must be objects")
    synthetic = _normalize_reconciled_entry(
        {
            **raw,
            "task_path": "agent-task/milestone-placeholder",
            "completion_log": "agent-task/archive/2000/01/milestone-placeholder/complete.log",
        }
    )
    closure_evidence = str(raw.get("closure_evidence", "")).strip()
    if not closure_evidence:
        raise StateError("reconciled_milestone_work requires closure_evidence")
    return {
        "milestone_path": _require_milestone_path(str(raw.get("milestone_path", ""))),
        "closure_evidence": closure_evidence,
        "scope": synthetic["scope"],
        "status_paths": synthetic["status_paths"],
        "frame_paths": synthetic["frame_paths"],
        "code_paths": synthetic["code_paths"],
        "validation_evidence": synthetic["validation_evidence"],
        "reconciled_at": synthetic["reconciled_at"],
        "sync_result_head": synthetic["sync_result_head"],
    }


def _normalize_state(raw: Any) -> dict[str, Any]:
    if not isinstance(raw, dict):
        raise StateError("sync state must be a JSON object")
    version = raw.get("schema_version")
    if version not in {1, 2, 3, 4}:
        raise StateError(f"unsupported schema_version: {version!r}")

    state = dict(raw)
    state["schema_version"] = SCHEMA_VERSION
    state["pending_code_work"] = [
        _normalize_pending_entry(entry)
        for entry in _require_object_list(
            raw.get("pending_code_work", []),
            field="pending_code_work",
        )
    ]
    state["reconciled_code_work"] = [
        _normalize_reconciled_entry(entry)
        for entry in _require_object_list(
            raw.get("reconciled_code_work", []),
            field="reconciled_code_work",
        )
    ]
    state["pending_milestone_work"] = [
        _normalize_pending_milestone_entry(entry)
        for entry in _require_object_list(
            raw.get("pending_milestone_work", []),
            field="pending_milestone_work",
        )
    ]
    state["reconciled_milestone_work"] = [
        _normalize_reconciled_milestone_entry(entry)
        for entry in _require_object_list(
            raw.get("reconciled_milestone_work", []),
            field="reconciled_milestone_work",
        )
    ]
    pending_keys = [entry["task_path"] for entry in state["pending_code_work"]]
    if len(pending_keys) != len(set(pending_keys)):
        raise StateError("pending_code_work contains duplicate task_path values")
    milestone_keys = [
        entry["milestone_path"] for entry in state["pending_milestone_work"]
    ]
    if len(milestone_keys) != len(set(milestone_keys)):
        raise StateError("pending_milestone_work contains duplicate milestone_path values")
    pending_entries = state["pending_code_work"] + state["pending_milestone_work"]
    pending_status_paths = [
        path for entry in pending_entries for path in entry["status_paths"]
    ]
    if len(pending_status_paths) != len(set(pending_status_paths)):
        raise StateError("pending work assigns one status_path to multiple owners")
    pending_frame_paths = [
        path for entry in pending_entries for path in entry["frame_paths"]
    ]
    if len(pending_frame_paths) != len(set(pending_frame_paths)):
        raise StateError("pending work assigns one frame_path to multiple owners")
    reconciled_keys = [
        (entry["task_path"], entry["completion_log"])
        for entry in state["reconciled_code_work"]
    ]
    if len(reconciled_keys) != len(set(reconciled_keys)):
        raise StateError("reconciled_code_work contains duplicate task/completion pairs")
    reconciled_milestone_keys = [
        (entry["milestone_path"], entry["closure_evidence"])
        for entry in state["reconciled_milestone_work"]
    ]
    if len(reconciled_milestone_keys) != len(set(reconciled_milestone_keys)):
        raise StateError(
            "reconciled_milestone_work contains duplicate milestone/evidence pairs"
        )
    notes = raw.get("notes", [])
    state["notes"] = _require_string_list(notes, field="notes")
    return state


def _load_state(path: Path, expected_sha256: str) -> tuple[dict[str, Any], str]:
    if not path.is_file():
        raise StateError(f"sync state does not exist: {path}")
    if not re.fullmatch(r"[0-9a-f]{64}", expected_sha256):
        raise StateError("expected_sha256 must be a 64-character lowercase hex digest")
    payload = path.read_bytes()
    actual_sha256 = hashlib.sha256(payload).hexdigest()
    if actual_sha256 != expected_sha256:
        raise StateError(
            "sync state changed after inspection: "
            f"expected={expected_sha256} actual={actual_sha256}"
        )
    try:
        raw = json.loads(payload)
    except json.JSONDecodeError as exc:
        raise StateError(f"invalid sync state JSON: {exc}") from exc
    return _normalize_state(raw), actual_sha256


def _write_state(path: Path, state: dict[str, Any]) -> str:
    payload = (json.dumps(state, ensure_ascii=False, indent=2) + "\n").encode()
    mode = stat.S_IMODE(path.stat().st_mode)
    temporary: Path | None = None
    try:
        with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as handle:
            temporary = Path(handle.name)
            handle.write(payload)
            handle.flush()
            os.fsync(handle.fileno())
        os.chmod(temporary, mode)
        os.replace(temporary, path)
        temporary = None
        directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
        try:
            os.fsync(directory_fd)
        finally:
            os.close(directory_fd)
    finally:
        if temporary is not None:
            temporary.unlink(missing_ok=True)
    return hashlib.sha256(payload).hexdigest()


def _pending_entry_from_args(args: argparse.Namespace) -> dict[str, Any]:
    return _normalize_pending_entry(
        {
            "task_path": args.task_path,
            "scope": args.scope,
            "status_paths": args.status_path,
            "frame_paths": args.frame_path,
            "code_paths": args.code_path,
            "verification_requirements": args.verification_requirement,
            "prepared_at": args.prepared_at,
            "state": "pending",
        }
    )


def _pending_milestone_entry_from_args(args: argparse.Namespace) -> dict[str, Any]:
    return _normalize_pending_milestone_entry(
        {
            "milestone_path": args.milestone_path,
            "scope": args.scope,
            "status_paths": args.status_path,
            "frame_paths": args.frame_path,
            "code_paths": args.code_path,
            "verification_requirements": args.verification_requirement,
            "prepared_at": args.prepared_at,
            "state": "pending",
        }
    )


@_locked_state_command
def _prepare(args: argparse.Namespace) -> dict[str, Any]:
    state, before_sha256 = _load_state(args.state, args.expected_sha256)
    requested = _pending_entry_from_args(args)
    previous_task_path = (
        _require_task_path(args.previous_task_path) if args.previous_task_path else None
    )
    if previous_task_path == requested["task_path"]:
        raise StateError("previous_task_path must differ from task_path")
    matches = [
        index
        for index, entry in enumerate(state["pending_code_work"])
        if entry["task_path"] == requested["task_path"]
    ]
    if len(matches) > 1:
        raise StateError(f"multiple pending entries for {requested['task_path']}")

    if previous_task_path is not None:
        previous_matches = [
            index
            for index, entry in enumerate(state["pending_code_work"])
            if entry["task_path"] == previous_task_path
        ]
        if len(previous_matches) != 1:
            raise StateError(
                f"expected exactly one previous pending entry for {previous_task_path}, "
                f"found {len(previous_matches)}"
            )
        if matches:
            raise StateError(f"new task_path already has a pending entry: {requested['task_path']}")
        existing = state["pending_code_work"][previous_matches[0]]
        semantic_fields = (
            "scope",
            "status_paths",
            "frame_paths",
            "code_paths",
            "verification_requirements",
            "state",
        )
        changed = any(existing[field] != requested[field] for field in semantic_fields)
        if changed and not args.replace:
            raise StateError(
                "rebound mapping changes scope or evidence; verify the closure child "
                "and rerun with --replace"
            )
        del state["pending_code_work"][previous_matches[0]]
        state["pending_code_work"].append(requested)
        status = "rebound"
    elif matches:
        index = matches[0]
        existing = state["pending_code_work"][index]
        semantic_fields = (
            "task_path",
            "scope",
            "status_paths",
            "frame_paths",
            "code_paths",
            "verification_requirements",
            "state",
        )
        if all(existing[field] == requested[field] for field in semantic_fields):
            status = "already_prepared"
        elif args.replace:
            state["pending_code_work"][index] = requested
            status = "replaced"
        else:
            raise StateError(
                f"pending entry differs for {requested['task_path']}; "
                "verify the new plan scope and rerun with --replace"
            )
    else:
        state["pending_code_work"].append(requested)
        status = "prepared"

    state = _normalize_state(state)
    after_sha256 = _write_state(args.state, state)
    return {
        "status": status,
        "task_path": requested["task_path"],
        "previous_task_path": previous_task_path,
        "before_sha256": before_sha256,
        "after_sha256": after_sha256,
    }


@_locked_state_command
def _prepare_milestone(args: argparse.Namespace) -> dict[str, Any]:
    state, before_sha256 = _load_state(args.state, args.expected_sha256)
    requested = _pending_milestone_entry_from_args(args)
    if not Path(requested["milestone_path"]).is_file():
        raise StateError(
            f"active Milestone does not exist: {requested['milestone_path']}"
        )
    matches = [
        index
        for index, entry in enumerate(state["pending_milestone_work"])
        if entry["milestone_path"] == requested["milestone_path"]
    ]
    if len(matches) > 1:
        raise StateError(
            f"multiple pending entries for {requested['milestone_path']}"
        )

    if matches:
        index = matches[0]
        existing = state["pending_milestone_work"][index]
        semantic_fields = (
            "milestone_path",
            "scope",
            "status_paths",
            "frame_paths",
            "code_paths",
            "verification_requirements",
            "state",
        )
        if all(existing[field] == requested[field] for field in semantic_fields):
            status = "already_prepared"
        elif args.replace:
            state["pending_milestone_work"][index] = requested
            status = "replaced"
        else:
            raise StateError(
                f"pending entry differs for {requested['milestone_path']}; "
                "verify the Milestone scope and rerun with --replace"
            )
    else:
        state["pending_milestone_work"].append(requested)
        status = "prepared"

    state = _normalize_state(state)
    after_sha256 = _write_state(args.state, state)
    return {
        "status": status,
        "milestone_path": requested["milestone_path"],
        "before_sha256": before_sha256,
        "after_sha256": after_sha256,
    }


def _completion_log_location(path: Path) -> tuple[Path, str]:
    workspace = Path.cwd().resolve()
    resolved = path.resolve()
    try:
        relative = resolved.relative_to(workspace)
    except ValueError as exc:
        raise StateError(f"completion log is outside the current workspace: {path}") from exc
    normalized = _require_relative_path(relative.as_posix(), prefix="agent-task/archive/")
    return resolved, normalized


def _validate_completion_log(path: Path, task_path: str) -> str:
    resolved, normalized = _completion_log_location(path)
    if not resolved.is_file():
        raise StateError(f"completion log does not exist: {path}")

    archive_parts = PurePosixPath(normalized).parts
    if len(archive_parts) < 6 or archive_parts[-1] != "complete.log":
        raise StateError("completion log must be under agent-task/archive/YYYY/MM/<task>/")
    if not re.fullmatch(r"[0-9]{4}", archive_parts[2]) or not re.fullmatch(
        r"(?:0[1-9]|1[0-2])",
        archive_parts[3],
    ):
        raise StateError("completion log archive path must contain YYYY/MM")
    archived_task_parts = archive_parts[4:-1]
    original_task_parts = PurePosixPath(task_path.removeprefix("agent-task/")).parts
    same_parent = archived_task_parts[:-1] == original_task_parts[:-1]
    final_name = archived_task_parts[-1] if archived_task_parts else ""
    original_final_name = original_task_parts[-1]
    suffix_match = re.fullmatch(re.escape(original_final_name) + r"(?:_[0-9]+)?", final_name)
    if not same_parent or suffix_match is None:
        raise StateError("completion log archive path does not match task_path")

    text = resolved.read_text()
    task_name = task_path.removeprefix("agent-task/")
    if f"# Complete - {task_name}" not in text.splitlines()[:3]:
        raise StateError("completion log task header does not match task_path")
    if "## 루프 이력" not in text:
        raise StateError("completion log has no loop history")
    terminal = re.compile(r"^\|[^|]*\|[^|]*\|\s*(?:PASS|RESOLVED|PASS/RESOLVED)\s*\|", re.MULTILINE)
    if not terminal.search(text):
        raise StateError("completion log has no terminal PASS/RESOLVED row")
    return normalized


@_locked_state_command
def _reconcile(args: argparse.Namespace) -> dict[str, Any]:
    state, before_sha256 = _load_state(args.state, args.expected_sha256)
    task_path = _require_task_path(args.task_path)
    completion_log = _validate_completion_log(args.completion_log, task_path)

    completed = [
        entry
        for entry in state["reconciled_code_work"]
        if entry["task_path"] == task_path and entry["completion_log"] == completion_log
    ]
    if completed:
        state = _normalize_state(state)
        after_sha256 = _write_state(args.state, state)
        return {
            "status": "already_reconciled",
            "task_path": task_path,
            "before_sha256": before_sha256,
            "after_sha256": after_sha256,
        }

    matches = [
        index
        for index, entry in enumerate(state["pending_code_work"])
        if entry["task_path"] == task_path
    ]
    if len(matches) != 1:
        raise StateError(f"expected exactly one pending entry for {task_path}, found {len(matches)}")
    pending = state["pending_code_work"][matches[0]]

    sync_result_head = args.sync_result_head
    if sync_result_head is not None and not SHA_PATTERN.fullmatch(sync_result_head):
        raise StateError("sync_result_head must be a 40- or 64-character lowercase hex hash")
    reconciled = _normalize_reconciled_entry(
        {
            "task_path": task_path,
            "completion_log": completion_log,
            "scope": pending["scope"],
            "status_paths": pending["status_paths"],
            "frame_paths": pending["frame_paths"],
            "code_paths": pending["code_paths"],
            "validation_evidence": args.validation_evidence,
            "reconciled_at": args.reconciled_at,
            "sync_result_head": sync_result_head,
        }
    )

    del state["pending_code_work"][matches[0]]
    state["reconciled_code_work"].append(reconciled)
    state["last_sync_mode"] = "plan-reconcile"
    state["last_synced_at"] = args.reconciled_at
    state["agent_ui_paths"] = pending["status_paths"] + pending["frame_paths"]
    state["code_paths"] = pending["code_paths"]
    if sync_result_head is not None:
        state["last_synced_head"] = sync_result_head
        baseline_evidence = f"baseline=updated:{sync_result_head}"
    else:
        baseline_evidence = "baseline=preserved:no validated sync_result_head"
    state["notes"].append(
        f"plan reconcile: task={task_path}; completion={completion_log}; "
        f"validation={args.validation_evidence}; {baseline_evidence}"
    )

    state = _normalize_state(state)
    after_sha256 = _write_state(args.state, state)
    return {
        "status": "reconciled",
        "task_path": task_path,
        "before_sha256": before_sha256,
        "after_sha256": after_sha256,
    }


@_locked_state_command
def _reconcile_milestone(args: argparse.Namespace) -> dict[str, Any]:
    state, before_sha256 = _load_state(args.state, args.expected_sha256)
    milestone_path = _require_milestone_path(args.milestone_path)
    closure_evidence = args.closure_evidence.strip()
    if not closure_evidence:
        raise StateError("closure_evidence must be non-empty")

    matches = [
        index
        for index, entry in enumerate(state["pending_milestone_work"])
        if entry["milestone_path"] == milestone_path
    ]
    if not matches:
        completed = [
            entry
            for entry in state["reconciled_milestone_work"]
            if entry["milestone_path"] == milestone_path
            and entry["closure_evidence"] == closure_evidence
        ]
        if completed:
            state = _normalize_state(state)
            after_sha256 = _write_state(args.state, state)
            return {
                "status": "already_reconciled",
                "milestone_path": milestone_path,
                "before_sha256": before_sha256,
                "after_sha256": after_sha256,
            }
    if len(matches) != 1:
        raise StateError(
            f"expected exactly one pending entry for {milestone_path}, "
            f"found {len(matches)}"
        )
    if not Path(milestone_path).is_file():
        raise StateError(f"active Milestone does not exist: {milestone_path}")
    pending = state["pending_milestone_work"][matches[0]]

    sync_result_head = args.sync_result_head
    if sync_result_head is not None and not SHA_PATTERN.fullmatch(sync_result_head):
        raise StateError("sync_result_head must be a 40- or 64-character lowercase hex hash")
    reconciled = _normalize_reconciled_milestone_entry(
        {
            "milestone_path": milestone_path,
            "closure_evidence": closure_evidence,
            "scope": pending["scope"],
            "status_paths": pending["status_paths"],
            "frame_paths": pending["frame_paths"],
            "code_paths": pending["code_paths"],
            "validation_evidence": args.validation_evidence,
            "reconciled_at": args.reconciled_at,
            "sync_result_head": sync_result_head,
        }
    )

    del state["pending_milestone_work"][matches[0]]
    state["reconciled_milestone_work"].append(reconciled)
    state["last_sync_mode"] = "milestone-reconcile"
    state["last_synced_at"] = args.reconciled_at
    state["agent_ui_paths"] = pending["status_paths"] + pending["frame_paths"]
    state["code_paths"] = pending["code_paths"]
    if sync_result_head is not None:
        state["last_synced_head"] = sync_result_head
        baseline_evidence = f"baseline=updated:{sync_result_head}"
    else:
        baseline_evidence = "baseline=preserved:no validated sync_result_head"
    state["notes"].append(
        f"milestone reconcile: milestone={milestone_path}; "
        f"closure={closure_evidence}; validation={args.validation_evidence}; "
        f"{baseline_evidence}"
    )

    state = _normalize_state(state)
    after_sha256 = _write_state(args.state, state)
    return {
        "status": "reconciled",
        "milestone_path": milestone_path,
        "before_sha256": before_sha256,
        "after_sha256": after_sha256,
    }


@_locked_state_command
def _record_sync(args: argparse.Namespace) -> dict[str, Any]:
    state, before_sha256 = _load_state(args.state, args.expected_sha256)
    if not SHA_PATTERN.fullmatch(args.sync_result_head):
        raise StateError("sync_result_head must be a 40- or 64-character lowercase hex hash")
    synced_at = _require_timestamp(args.synced_at)
    agent_ui_paths = [
        _require_relative_path(path, prefix="agent-ui/")
        for path in _stable_unique(args.agent_ui_path)
    ]
    code_paths = [
        _require_relative_path(path) for path in _stable_unique(args.code_path)
    ]
    if not agent_ui_paths:
        raise StateError("record-sync requires agent_ui_paths")
    if args.sync_mode in {"incremental", "full"} and not code_paths:
        raise StateError("incremental/full record-sync requires code_paths")
    if any(path.startswith(("agent-ui/", "agent-task/")) for path in code_paths):
        raise StateError("record-sync code_paths must not point to agent-ui or agent-task artifacts")
    if args.sync_mode in {"incremental", "full"}:
        pending_paths = {
            path
            for entry in state["pending_code_work"] + state["pending_milestone_work"]
            for path in entry["status_paths"] + entry["frame_paths"]
        }
        overlapping_paths = sorted(
            pending_path
            for pending_path in pending_paths
            if any(
                _path_trees_overlap(pending_path, sync_path)
                for sync_path in agent_ui_paths
            )
        )
        if overlapping_paths:
            raise StateError(
                "incremental/full record-sync overlaps pending work paths: "
                + ", ".join(overlapping_paths)
            )

    state["last_sync_mode"] = args.sync_mode
    state["last_synced_head"] = args.sync_result_head
    state["last_synced_at"] = synced_at
    state["agent_ui_paths"] = agent_ui_paths
    state["code_paths"] = code_paths
    state["notes"] = _stable_unique(state["notes"] + args.note)
    state = _normalize_state(state)
    after_sha256 = _write_state(args.state, state)
    return {
        "status": "sync_recorded",
        "before_sha256": before_sha256,
        "after_sha256": after_sha256,
        "sync_result_head": args.sync_result_head,
    }


def _parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser()
    subparsers = parser.add_subparsers(dest="command", required=True)

    prepare = subparsers.add_parser("prepare")
    prepare.add_argument("--state", type=Path, required=True)
    prepare.add_argument("--expected-sha256", required=True)
    prepare.add_argument("--task-path", required=True)
    prepare.add_argument("--previous-task-path")
    prepare.add_argument("--scope", default="all")
    prepare.add_argument("--status-path", action="append", default=[], required=True)
    prepare.add_argument("--frame-path", action="append", default=[])
    prepare.add_argument("--code-path", action="append", default=[], required=True)
    prepare.add_argument("--verification-requirement", action="append", default=[])
    prepare.add_argument("--prepared-at", required=True)
    prepare.add_argument("--replace", action="store_true")
    prepare.set_defaults(handler=_prepare)

    prepare_milestone = subparsers.add_parser("prepare-milestone")
    prepare_milestone.add_argument("--state", type=Path, required=True)
    prepare_milestone.add_argument("--expected-sha256", required=True)
    prepare_milestone.add_argument("--milestone-path", required=True)
    prepare_milestone.add_argument("--scope", default="all")
    prepare_milestone.add_argument(
        "--status-path",
        action="append",
        default=[],
        required=True,
    )
    prepare_milestone.add_argument("--frame-path", action="append", default=[])
    prepare_milestone.add_argument(
        "--code-path",
        action="append",
        default=[],
        required=True,
    )
    prepare_milestone.add_argument(
        "--verification-requirement",
        action="append",
        default=[],
    )
    prepare_milestone.add_argument("--prepared-at", required=True)
    prepare_milestone.add_argument("--replace", action="store_true")
    prepare_milestone.set_defaults(handler=_prepare_milestone)

    reconcile = subparsers.add_parser("reconcile")
    reconcile.add_argument("--state", type=Path, required=True)
    reconcile.add_argument("--expected-sha256", required=True)
    reconcile.add_argument("--task-path", required=True)
    reconcile.add_argument("--completion-log", type=Path, required=True)
    reconcile.add_argument("--validation-evidence", required=True)
    reconcile.add_argument("--reconciled-at", required=True)
    reconcile.add_argument("--sync-result-head")
    reconcile.set_defaults(handler=_reconcile)

    reconcile_milestone = subparsers.add_parser("reconcile-milestone")
    reconcile_milestone.add_argument("--state", type=Path, required=True)
    reconcile_milestone.add_argument("--expected-sha256", required=True)
    reconcile_milestone.add_argument("--milestone-path", required=True)
    reconcile_milestone.add_argument("--closure-evidence", required=True)
    reconcile_milestone.add_argument("--validation-evidence", required=True)
    reconcile_milestone.add_argument("--reconciled-at", required=True)
    reconcile_milestone.add_argument("--sync-result-head")
    reconcile_milestone.set_defaults(handler=_reconcile_milestone)

    record_sync = subparsers.add_parser("record-sync")
    record_sync.add_argument("--state", type=Path, required=True)
    record_sync.add_argument("--expected-sha256", required=True)
    record_sync.add_argument(
        "--sync-mode",
        choices=("incremental", "full", "baseline", "migration"),
        required=True,
    )
    record_sync.add_argument("--sync-result-head", required=True)
    record_sync.add_argument("--synced-at", required=True)
    record_sync.add_argument("--agent-ui-path", action="append", default=[], required=True)
    record_sync.add_argument("--code-path", action="append", default=[])
    record_sync.add_argument("--note", action="append", default=[])
    record_sync.set_defaults(handler=_record_sync)
    return parser


def main() -> int:
    args = _parser().parse_args()
    try:
        result = args.handler(args)
    except (OSError, StateError) as exc:
        print(json.dumps({"status": "error", "error": str(exc)}, ensure_ascii=False))
        return 2
    print(json.dumps(result, ensure_ascii=False, sort_keys=True))
    return 0


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