"""Session-owned worktree cleanup audit, TTL enforcement, and integrity (#401, #404).

LLM workflows create many session-owned worktrees under ``branches/``
(review, baseline, merge-simulation, issue, and conflict-fix worktrees).
When a run stops early, races a sibling session, hits a validation failure,
or loses shell/cwd state, those worktrees are left behind and later workflow
decisions get harder and riskier.

This module provides:

* ``build_worktree_metadata`` — ownership/purpose metadata for a worktree.
* ``classify_worktree`` — safety-first classification into the audit
  vocabulary (active open PR, active issue work, dirty, clean stale
  removable, detached review leftover, unsafe/unknown).
* ``assess_worktree_removal`` — a per-worktree removal decision with an
  explicit proof and block reasons.
* git-shelling helpers (``list_worktrees``, ``read_worktree_dirty``,
  ``git_worktree_list``, ``remove_worktree``) and ``audit_branches_directory``
  that classify every entry under ``branches/``.
* ``capture_cleanup_snapshot`` / ``reconcile_cleanup_audit`` — before/after
  reconciliation for bulk cleanup audits (#404).

Pure assessment functions take explicit state so they are unit-testable
without a filesystem or network. Only the thin git helpers shell out, and
removal is only ever executed after ``assess_worktree_removal`` proves the
worktree is safe to delete.
"""

from __future__ import annotations

import os
import re
import subprocess
from datetime import datetime, timezone
from typing import Any

from merged_cleanup_reconcile import branch_worktree_folder, read_local_worktree_state
from reviewer_worktree import parse_dirty_tracked_files, REVIEW_WORKTREE_RE

PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
DEFAULT_TTL_HOURS = float(os.environ.get("GITEA_WORKTREE_TTL_HOURS", "24") or 24)

# Workflow types that can create session-owned worktrees.
WORKFLOW_REVIEW = "review"
WORKFLOW_BASELINE = "baseline"
WORKFLOW_MERGE_SIMULATION = "merge_simulation"
WORKFLOW_ISSUE_WORK = "issue_work"
WORKFLOW_CONFLICT_FIX = "conflict_fix"
WORKFLOW_UNKNOWN = "unknown"

# Workflow types whose worktrees are transient and removed automatically at
# successful workflow completion (acceptance criterion 2).
AUTO_REMOVE_ON_SUCCESS = frozenset(
    {WORKFLOW_REVIEW, WORKFLOW_BASELINE, WORKFLOW_MERGE_SIMULATION}
)

# Cleanup-audit classification vocabulary (acceptance criterion 4).
CLASS_ACTIVE_OPEN_PR = "active_open_pr"
CLASS_ACTIVE_ISSUE_WORK = "active_issue_work"
CLASS_DIRTY_LOCAL = "dirty_local_worktree"
CLASS_CLEAN_STALE_REMOVABLE = "clean_stale_removable"
CLASS_DETACHED_REVIEW_LEFTOVER = "detached_review_leftover"
CLASS_UNSAFE_UNKNOWN = "unsafe_unknown"

# Only these two classifications may ever be removed automatically.
REMOVABLE_CLASSES = frozenset(
    {CLASS_CLEAN_STALE_REMOVABLE, CLASS_DETACHED_REVIEW_LEFTOVER}
)

_ISSUE_REF_RE = re.compile(r"issue-(\d+)", re.IGNORECASE)
_ISSUE_BRANCH_PREFIXES = ("feat/", "fix/", "docs/", "chore/")


def infer_workflow_type(path: str | None, branch: str | None = None) -> str:
    """Infer the creating workflow type from a worktree path or branch name."""
    text = f"{path or ''} {branch or ''}".lower()
    if "baseline" in text:
        return WORKFLOW_BASELINE
    if "merge-sim" in text or "merge_sim" in text or "mergesim" in text:
        return WORKFLOW_MERGE_SIMULATION
    if "review" in text or "review-pr" in text:
        return WORKFLOW_REVIEW
    if "conflict" in text:
        return WORKFLOW_CONFLICT_FIX
    if "issue-" in text or (branch or "").startswith(_ISSUE_BRANCH_PREFIXES):
        return WORKFLOW_ISSUE_WORK
    return WORKFLOW_UNKNOWN


def _extract_issue_number(path: str | None, branch: str | None) -> int | None:
    match = _ISSUE_REF_RE.search(f"{path or ''} {branch or ''}")
    return int(match.group(1)) if match else None


def build_worktree_metadata(
    *,
    path: str,
    branch: str | None = None,
    head_sha: str | None = None,
    workflow_type: str | None = None,
    issue_number: int | None = None,
    pr_number: int | None = None,
    creator: str | None = None,
    profile: str | None = None,
    created_at: str | None = None,
    last_used_at: str | None = None,
    cleanup_eligibility: str | None = None,
) -> dict[str, Any]:
    """Return ownership/purpose metadata for a session-owned worktree.

    Covers acceptance criterion 1: path, workflow type, issue/PR number,
    branch/head SHA, creator identity/profile, created and last-used
    timestamps, and cleanup eligibility.
    """
    wt = workflow_type or infer_workflow_type(path, branch)
    issue = issue_number
    if issue is None:
        issue = _extract_issue_number(path, branch)
    return {
        "path": path,
        "workflow_type": wt,
        "issue_number": issue,
        "pr_number": pr_number,
        "branch": branch,
        "head_sha": head_sha,
        "creator": creator,
        "profile": profile,
        "created_at": created_at,
        "last_used_at": last_used_at,
        "auto_remove_on_success": wt in AUTO_REMOVE_ON_SUCCESS,
        "cleanup_eligibility": cleanup_eligibility,
    }


def _parse_timestamp(value: str | None) -> datetime | None:
    if not value:
        return None
    text = str(value).strip()
    if not text:
        return None
    if text.endswith("Z"):
        text = text[:-1] + "+00:00"
    try:
        parsed = datetime.fromisoformat(text)
    except ValueError:
        return None
    if parsed.tzinfo is None:
        return parsed.replace(tzinfo=timezone.utc)
    return parsed


def is_ttl_expired(
    *,
    last_used_at: str | None,
    now: datetime | str | None,
    ttl_hours: float = DEFAULT_TTL_HOURS,
) -> bool:
    """Return True only when the age is known and exceeds ``ttl_hours``.

    Unknown or unparseable timestamps fail safe (not expired) so a worktree
    is never treated as removable merely because its age is unknown.
    """
    last = _parse_timestamp(last_used_at)
    now_dt = now if isinstance(now, datetime) else _parse_timestamp(now)
    if last is None or now_dt is None:
        return False
    if now_dt.tzinfo is None:
        now_dt = now_dt.replace(tzinfo=timezone.utc)
    return (now_dt - last).total_seconds() > ttl_hours * 3600.0


def classify_worktree(
    *,
    workflow_type: str,
    is_dirty: bool,
    has_open_pr: bool = False,
    has_active_lease: bool = False,
    has_active_issue_lock: bool = False,
    is_detached: bool = False,
    branch_gone: bool = False,
    ttl_expired: bool = False,
    is_protected: bool = False,
    metadata_known: bool = True,
) -> str:
    """Classify a worktree, safety-first: any preservation signal wins.

    Dirty, open-PR, leased, active-lock, protected, and unknown states are
    all non-removable and are checked before any removable classification,
    so nothing removable can shadow a preservation signal (criteria 6-8).
    """
    if is_protected:
        # The main checkout / a protected base branch is never removable.
        return CLASS_UNSAFE_UNKNOWN
    if is_dirty:
        return CLASS_DIRTY_LOCAL  # never auto-deleted (criterion 6)
    if has_open_pr:
        return CLASS_ACTIVE_OPEN_PR  # never auto-deleted (criterion 7)
    if has_active_lease:
        return CLASS_ACTIVE_ISSUE_WORK  # never auto-deleted (criterion 8)
    if has_active_issue_lock:
        return CLASS_ACTIVE_ISSUE_WORK
    if not metadata_known or workflow_type == WORKFLOW_UNKNOWN:
        return CLASS_UNSAFE_UNKNOWN  # never auto-deleted without proof

    # Clean, no PR, no lease, no lock, known workflow type.
    if workflow_type in AUTO_REMOVE_ON_SUCCESS:
        if is_detached or branch_gone:
            return CLASS_DETACHED_REVIEW_LEFTOVER
        return CLASS_CLEAN_STALE_REMOVABLE
    # issue_work / conflict_fix: only removable once the TTL has expired.
    if ttl_expired:
        return CLASS_CLEAN_STALE_REMOVABLE
    return CLASS_ACTIVE_ISSUE_WORK


def is_removable(classification: str) -> bool:
    """Return True only for the two auto-removable classifications."""
    return classification in REMOVABLE_CLASSES


def assess_worktree_removal(
    *,
    path: str,
    branch: str | None,
    head_sha: str | None,
    is_dirty: bool,
    has_open_pr: bool,
    has_active_lease: bool,
    classification: str,
) -> dict[str, Any]:
    """Return a per-worktree removal decision with proof (criterion 9).

    A worktree is only safe to remove when it is clean, has no active PR,
    has no active lease, and its classification is auto-removable.
    """
    block_reasons: list[str] = []
    if is_dirty:
        block_reasons.append("worktree has uncommitted changes")
    if has_open_pr:
        block_reasons.append("worktree branch has an open PR")
    if has_active_lease:
        block_reasons.append("worktree has an active lease")
    if not is_removable(classification):
        block_reasons.append(
            f"classification '{classification}' is not auto-removable"
        )
    return {
        "path": path,
        "branch": branch,
        "head_sha": head_sha,
        "classification": classification,
        "clean": not is_dirty,
        "no_active_pr": not has_open_pr,
        "no_active_lease": not has_active_lease,
        "safe_to_remove": not block_reasons,
        "block_reasons": block_reasons,
    }


def plan_success_cleanup(
    *,
    metadata: dict[str, Any],
    is_dirty: bool,
    has_open_pr: bool,
    has_active_lease: bool,
) -> dict[str, Any]:
    """Decide whether a just-completed worktree is removed at success.

    Review/baseline/merge-simulation worktrees are removed automatically at
    successful completion (criterion 2); everything else is preserved and
    reported. Dirty/PR/leased worktrees are always preserved (criteria 6-8).
    """
    workflow_type = metadata.get("workflow_type", WORKFLOW_UNKNOWN)
    if not metadata.get("auto_remove_on_success"):
        return {
            "remove": False,
            "reason": f"workflow type '{workflow_type}' is preserved by policy",
        }
    classification = classify_worktree(
        workflow_type=workflow_type,
        is_dirty=is_dirty,
        has_open_pr=has_open_pr,
        has_active_lease=has_active_lease,
    )
    decision = assess_worktree_removal(
        path=metadata.get("path", ""),
        branch=metadata.get("branch"),
        head_sha=metadata.get("head_sha"),
        is_dirty=is_dirty,
        has_open_pr=has_open_pr,
        has_active_lease=has_active_lease,
        classification=classification,
    )
    return {
        "remove": decision["safe_to_remove"],
        "reason": "clean transient worktree removable at success completion"
        if decision["safe_to_remove"]
        else "; ".join(decision["block_reasons"]),
        "classification": classification,
        "decision": decision,
    }


def cleanup_failure_report(path: str, reason: str) -> dict[str, Any]:
    """Structured leftover-worktree record for the final report (criterion 3)."""
    return {"path": path, "removed": False, "reason": reason}


# --------------------------------------------------------------------------
# git-shelling helpers (only these touch the filesystem)
# --------------------------------------------------------------------------


def parse_worktree_porcelain(text: str) -> list[dict[str, Any]]:
    """Parse ``git worktree list --porcelain`` output into entries."""
    entries: list[dict[str, Any]] = []
    current: dict[str, Any] = {}
    for raw in (text or "").splitlines():
        line = raw.rstrip("\n")
        if not line:
            if current:
                entries.append(current)
                current = {}
            continue
        if line.startswith("worktree "):
            if current:
                entries.append(current)
            current = {
                "path": line[len("worktree ") :].strip(),
                "head": None,
                "branch": None,
                "detached": False,
                "bare": False,
            }
        elif line.startswith("HEAD "):
            current["head"] = line[len("HEAD ") :].strip()
        elif line.startswith("branch "):
            ref = line[len("branch ") :].strip()
            current["branch"] = ref.replace("refs/heads/", "", 1)
        elif line == "detached":
            current["detached"] = True
        elif line == "bare":
            current["bare"] = True
    if current:
        entries.append(current)
    return entries


def list_worktrees(project_root: str) -> list[dict[str, Any]]:
    """Return parsed ``git worktree list`` entries for ``project_root``."""
    result = subprocess.run(
        ["git", "-C", project_root, "worktree", "list", "--porcelain"],
        capture_output=True,
        text=True,
        check=False,
    )
    if result.returncode != 0:
        return []
    return parse_worktree_porcelain(result.stdout)


def git_worktree_list(project_root: str) -> str:
    """Return plain ``git worktree list`` output for final verification (criterion 10)."""
    result = subprocess.run(
        ["git", "-C", project_root, "worktree", "list"],
        capture_output=True,
        text=True,
        check=False,
    )
    return (result.stdout or "").strip()


def read_worktree_dirty(path: str) -> dict[str, Any]:
    """Return dirty state for a worktree path via ``git status --porcelain``."""
    if not path or not os.path.isdir(path):
        return {"exists": False, "dirty": None, "dirty_files": []}
    result = subprocess.run(
        ["git", "-C", path, "status", "--porcelain"],
        capture_output=True,
        text=True,
        check=False,
    )
    files = [ln for ln in (result.stdout or "").splitlines() if ln.strip()]
    return {"exists": True, "dirty": bool(files), "dirty_files": files}


def remove_worktree(project_root: str, path: str) -> dict[str, Any]:
    """Remove a single worktree via ``git worktree remove`` (no ``--force``)."""
    result = subprocess.run(
        ["git", "-C", project_root, "worktree", "remove", path],
        capture_output=True,
        text=True,
        check=False,
    )
    ok = result.returncode == 0
    return {
        "path": path,
        "removed": ok,
        "reason": None if ok else (result.stderr or "git worktree remove failed").strip(),
    }


def _is_under_branches(project_root: str, path: str) -> bool:
    branches_root = os.path.join(os.path.abspath(project_root), "branches")
    return os.path.abspath(path or "").startswith(branches_root + os.sep)


def audit_branches_directory(
    project_root: str,
    *,
    open_pr_branches: set[str] | None = None,
    leased_branches: set[str] | None = None,
    active_issue_branches: set[str] | None = None,
    now: datetime | str | None = None,
    ttl_hours: float = DEFAULT_TTL_HOURS,
) -> dict[str, Any]:
    """Classify every session-owned worktree under ``branches/``.

    Read-only: shells out to git for discovery and dirty state, then applies
    the pure classifier. Returns per-worktree classifications, counts, the
    list of removable candidates, and the ``git worktree list`` proof.
    """
    open_pr_branches = open_pr_branches or set()
    leased_branches = leased_branches or set()
    active_issue_branches = active_issue_branches or set()

    worktrees: list[dict[str, Any]] = []
    for entry in list_worktrees(project_root):
        path = entry.get("path") or ""
        branch = entry.get("branch")
        is_protected = (branch in PROTECTED_BRANCHES) or not _is_under_branches(
            project_root, path
        )
        dirty_state = read_worktree_dirty(path)
        is_dirty = bool(dirty_state.get("dirty"))
        metadata = build_worktree_metadata(
            path=path, branch=branch, head_sha=entry.get("head")
        )
        has_open_pr = bool(branch) and branch in open_pr_branches
        has_active_lease = bool(branch) and branch in leased_branches
        has_active_lock = bool(branch) and branch in active_issue_branches
        ttl_expired = is_ttl_expired(
            last_used_at=metadata.get("last_used_at"), now=now, ttl_hours=ttl_hours
        )
        classification = classify_worktree(
            workflow_type=metadata["workflow_type"],
            is_dirty=is_dirty,
            has_open_pr=has_open_pr,
            has_active_lease=has_active_lease,
            has_active_issue_lock=has_active_lock,
            is_detached=bool(entry.get("detached")),
            branch_gone=branch is None and not entry.get("detached"),
            ttl_expired=ttl_expired,
            is_protected=is_protected,
        )
        metadata["cleanup_eligibility"] = classification
        worktrees.append(
            {
                **metadata,
                "detached": bool(entry.get("detached")),
                "dirty": is_dirty,
                "dirty_files": dirty_state.get("dirty_files", []),
                "has_open_pr": has_open_pr,
                "has_active_lease": has_active_lease,
                "has_active_issue_lock": has_active_lock,
                "is_protected": is_protected,
                "classification": classification,
                "removable": is_removable(classification),
            }
        )

    counts: dict[str, int] = {}
    for wt in worktrees:
        counts[wt["classification"]] = counts.get(wt["classification"], 0) + 1
    removable = [wt for wt in worktrees if wt["removable"]]

    return {
        "project_root": project_root,
        "worktrees": worktrees,
        "counts": counts,
        "removable_candidates": removable,
        "removable_count": len(removable),
        "total": len(worktrees),
        "git_worktree_list": git_worktree_list(project_root),
    }
CLASSIFICATIONS = frozenset({
    "active_open_pr",
    "active_issue_work",
    "dirty_local_worktree",
    "clean_stale_removable",
    "detached_review_leftover",
    "orphan_directory",
    "unsafe_unknown",
})

PRESERVE_CLASSIFICATIONS = frozenset({
    "active_open_pr",
    "active_issue_work",
    "dirty_local_worktree",
    "unsafe_unknown",
})

DISPOSITIONS = frozenset({
    "removed_intentionally",
    "preserved_exists",
    "preserved_missing_explained",
    "not_registered_worktree",
    "unsafe_unknown",
})





def normalize_path(path: str) -> str:
    return os.path.normpath((path or "").strip())


def relative_branches_path(project_root: str, path: str) -> str:
    root = normalize_path(project_root)
    normalized = normalize_path(path)
    if normalized.startswith(root + os.sep):
        return normalized[len(root) + 1 :]
    return normalized.replace("\\", "/")


def parse_worktree_list_porcelain(porcelain: str) -> list[dict[str, Any]]:
    """Parse ``git worktree list --porcelain`` into worktree records."""
    entries: list[dict[str, Any]] = []
    current: dict[str, Any] = {}
    for raw in (porcelain or "").splitlines():
        line = raw.strip()
        if not line:
            if current:
                entries.append(current)
                current = {}
            continue
        if line.startswith("worktree "):
            if current:
                entries.append(current)
            current = {"path": line.split(" ", 1)[1].strip()}
        elif line.startswith("HEAD "):
            current["head_sha"] = line.split(" ", 1)[1].strip()
        elif line.startswith("branch "):
            current["branch"] = line.split(" ", 1)[1].strip().removeprefix("refs/heads/")
        elif line == "detached":
            current["detached"] = True
        elif line == "bare":
            current["bare"] = True
    if current:
        entries.append(current)
    return entries


def list_branches_directories(project_root: str, dir_names: list[str] | None = None) -> list[str]:
    """Return relative ``branches/<name>`` paths for first-level directories."""
    branches_root = os.path.join(project_root, "branches")
    if dir_names is not None:
        return sorted(
            f"branches/{name}"
            for name in dir_names
            if name and not name.startswith(".")
        )
    if not os.path.isdir(branches_root):
        return []
    names: list[str] = []
    for entry in sorted(os.listdir(branches_root)):
        full = os.path.join(branches_root, entry)
        if entry.startswith(".") or not os.path.isdir(full):
            continue
        names.append(f"branches/{entry}")
    return names


def _untracked_dirty(porcelain: str) -> bool:
    return any(line.startswith("??") for line in (porcelain or "").splitlines())


def classify_branches_entry(
    *,
    rel_path: str,
    worktree_record: dict[str, Any] | None,
    worktree_state: dict[str, Any] | None,
    open_pr_branches: set[str] | None = None,
    active_lock_branches: set[str] | None = None,
    active_issue_branches: set[str] | None = None,
) -> str:
    """Classify a ``branches/`` directory for cleanup policy."""
    open_pr_branches = open_pr_branches or set()
    active_lock_branches = active_lock_branches or set()
    active_issue_branches = active_issue_branches or set()
    state = worktree_state or {}
    record = worktree_record or {}

    branch_name = (record.get("branch") or "").strip()
    folder_name = rel_path.split("/", 1)[-1] if "/" in rel_path else rel_path
    inferred_branch = folder_name.replace("-", "/") if "/" not in folder_name else folder_name

    candidate_branches = {b for b in (branch_name, inferred_branch) if b}
    open_folder_names = {branch_worktree_folder(b) for b in open_pr_branches}
    if folder_name in open_folder_names or any(
        b in open_pr_branches for b in candidate_branches
    ):
        return "active_open_pr"
    if any(b in active_lock_branches or b in active_issue_branches for b in candidate_branches):
        return "active_issue_work"

    dirty_tracked = bool(state.get("dirty_files"))
    dirty_untracked = bool(state.get("dirty_untracked"))
    if dirty_tracked or dirty_untracked:
        return "dirty_local_worktree"

    if not record:
        return "orphan_directory"

    if record.get("detached") and REVIEW_WORKTREE_RE.search(rel_path):
        return "detached_review_leftover"

    if state.get("exists") and state.get("clean"):
        return "clean_stale_removable"

    return "unsafe_unknown"


def capture_cleanup_snapshot(
    project_root: str,
    *,
    branch_dirs: list[str] | None = None,
    worktree_porcelain: str | None = None,
    open_pr_branches: set[str] | None = None,
    active_lock_branches: set[str] | None = None,
    active_issue_branches: set[str] | None = None,
    issue_lock_path: str | None = None,
) -> dict[str, Any]:
    """Capture audit snapshot for ``branches/`` dirs and registered worktrees."""
    root = normalize_path(project_root)
    rel_dirs = list_branches_directories(root, branch_dirs)
    worktrees = parse_worktree_list_porcelain(worktree_porcelain or "")
    worktree_by_rel: dict[str, dict[str, Any]] = {}
    for wt in worktrees:
        rel = relative_branches_path(root, wt.get("path") or "")
        if rel.startswith("branches/"):
            worktree_by_rel[rel] = wt

    lock_branches = set(active_lock_branches or [])
    lock = None
    if issue_lock_path:
        from merged_cleanup_reconcile import read_issue_lock

        lock = read_issue_lock(issue_lock_path)
        if lock and lock.get("branch_name"):
            lock_branches.add(str(lock["branch_name"]))

    entries: list[dict[str, Any]] = []
    for rel_path in rel_dirs:
        abs_path = os.path.join(root, rel_path)
        wt_record = worktree_by_rel.get(rel_path)
        state = read_local_worktree_state(abs_path) if os.path.isdir(abs_path) else {
            "exists": False,
            "clean": None,
            "dirty_files": [],
        }
        if state.get("exists"):
            status_res = state.get("porcelain_status")
            if status_res is None and os.path.isdir(abs_path):
                import subprocess

                proc = subprocess.run(
                    ["git", "-C", abs_path, "status", "--porcelain"],
                    capture_output=True,
                    text=True,
                    check=False,
                )
                status_res = proc.stdout or ""
            state["dirty_untracked"] = _untracked_dirty(status_res or "")
            state["dirty_files"] = state.get("dirty_files") or parse_dirty_tracked_files(
                status_res or ""
            )
            state["clean"] = not state["dirty_files"] and not state["dirty_untracked"]

        classification = classify_branches_entry(
            rel_path=rel_path,
            worktree_record=wt_record,
            worktree_state=state,
            open_pr_branches=open_pr_branches,
            active_lock_branches=lock_branches,
            active_issue_branches=active_issue_branches,
        )
        entries.append(
            {
                "path": rel_path,
                "absolute_path": abs_path,
                "classification": classification,
                "registered_worktree": bool(wt_record),
                "worktree_record": wt_record or None,
                "worktree_state": state,
                "preserve": classification in PRESERVE_CLASSIFICATIONS,
            }
        )

    return {
        "project_root": root,
        "branch_directory_count": len(rel_dirs),
        "registered_branches_worktree_count": len(worktree_by_rel),
        "entries": entries,
        "worktrees": worktrees,
    }


def _index_snapshot_entries(snapshot: dict[str, Any]) -> dict[str, dict[str, Any]]:
    return {entry["path"]: entry for entry in snapshot.get("entries") or []}


def _removal_paths(removal_log: list[dict[str, Any]] | None) -> dict[str, dict[str, Any]]:
    indexed: dict[str, dict[str, Any]] = {}
    for item in removal_log or []:
        rel = (item.get("path") or "").strip().replace("\\", "/")
        if rel:
            indexed[rel] = item
    return indexed


def reconcile_cleanup_audit(
    before: dict[str, Any],
    after: dict[str, Any],
    removal_log: list[dict[str, Any]] | None = None,
    *,
    explained_missing: dict[str, str] | None = None,
    concurrent_mutations: list[str] | None = None,
) -> dict[str, Any]:
    """Reconcile before/after snapshots to exactly one disposition per path."""
    before_index = _index_snapshot_entries(before)
    after_index = _index_snapshot_entries(after)
    removals = _removal_paths(removal_log)
    explained = {
        (k or "").strip().replace("\\", "/"): (v or "").strip()
        for k, v in (explained_missing or {}).items()
    }
    concurrent = {
        (p or "").strip().replace("\\", "/")
        for p in (concurrent_mutations or [])
    }

    rows: list[dict[str, Any]] = []
    for path, before_entry in sorted(before_index.items()):
        after_entry = after_index.get(path)
        after_exists = bool(after_entry and after_entry.get("worktree_state", {}).get("exists"))
        classification = before_entry.get("classification") or "unsafe_unknown"
        preserve = bool(before_entry.get("preserve")) or classification in PRESERVE_CLASSIFICATIONS
        removal = removals.get(path)
        explanation = explained.get(path, "")

        if removal:
            disposition = "removed_intentionally"
            reasons = []
        elif after_exists:
            disposition = "preserved_exists"
            reasons = []
        elif explanation:
            disposition = "preserved_missing_explained"
            reasons = [explanation]
        elif not before_entry.get("registered_worktree"):
            disposition = "not_registered_worktree"
            reasons = ["directory was not a registered git worktree at audit start"]
        elif path in concurrent:
            disposition = "preserved_missing_explained"
            reasons = ["removed or mutated by another session during cleanup"]
        elif preserve:
            disposition = "unsafe_unknown"
            reasons = [
                f"preserved classification '{classification}' disappeared without "
                "removal log or explanation"
            ]
        else:
            disposition = "unsafe_unknown"
            reasons = [
                "clean/removable path disappeared without removal log entry"
            ]

        rows.append(
            {
                "path": path,
                "classification": classification,
                "preserve": preserve,
                "disposition": disposition,
                "removed_intentionally": disposition == "removed_intentionally",
                "removal_record": removal,
                "after_exists": after_exists,
                "reasons": reasons,
            }
        )

    for path, removal in removals.items():
        if path not in before_index:
            rows.append(
                {
                    "path": path,
                    "classification": "unsafe_unknown",
                    "preserve": False,
                    "disposition": "unsafe_unknown",
                    "removed_intentionally": True,
                    "removal_record": removal,
                    "after_exists": path in after_index,
                    "reasons": ["removal log references path absent from before snapshot"],
                }
            )

    counts = {
        "initial_count": len(before_index),
        "removed_count": sum(1 for r in rows if r["disposition"] == "removed_intentionally"),
        "preserved_count": sum(1 for r in rows if r["disposition"] == "preserved_exists"),
        "missing_unexplained_count": sum(
            1
            for r in rows
            if r["disposition"] == "unsafe_unknown"
            and r.get("preserve")
            and not r.get("after_exists")
        ),
        "missing_explained_count": sum(
            1 for r in rows if r["disposition"] == "preserved_missing_explained"
        ),
        "final_count": len(after_index),
        "orphan_directory_count": sum(
            1 for r in rows if r["disposition"] == "not_registered_worktree"
        ),
    }
    expected_final = (
        counts["initial_count"]
        - counts["removed_count"]
        - counts["missing_explained_count"]
    )
    counts["count_reconciles"] = counts["final_count"] == expected_final

    return {
        "rows": rows,
        "counts": counts,
        "removal_log_complete": _removal_log_complete(before_index, after_index, removals),
    }


def _removal_log_complete(
    before_index: dict[str, dict[str, Any]],
    after_index: dict[str, dict[str, Any]],
    removals: dict[str, dict[str, Any]],
) -> bool:
    for path, before_entry in before_index.items():
        if path in after_index:
            continue
        classification = before_entry.get("classification") or ""
        if classification == "clean_stale_removable" and path not in removals:
            return False
    return True


def assess_cleanup_audit_integrity(reconciliation: dict[str, Any]) -> dict[str, Any]:
    """Fail closed when preserved worktrees vanish or counts do not reconcile."""
    reasons: list[str] = []
    counts = reconciliation.get("counts") or {}

    if counts.get("missing_unexplained_count"):
        reasons.append(
            f"{counts['missing_unexplained_count']} preserved worktree(s) missing "
            "without explanation"
        )

    for row in reconciliation.get("rows") or []:
        if not row.get("preserve") or row.get("after_exists"):
            continue
        if row.get("disposition") == "removed_intentionally":
            continue
        message = (
            f"preserved worktree {row.get('path')} missing "
            f"({row.get('disposition')})"
        )
        if message not in reasons:
            reasons.append(message)
        for item in row.get("reasons") or []:
            if item not in reasons:
                reasons.append(item)

    if not counts.get("count_reconciles"):
        reasons.append(
            "final directory count does not reconcile with initial minus removed "
            f"(initial={counts.get('initial_count')}, removed={counts.get('removed_count')}, "
            f"final={counts.get('final_count')})"
        )

    if reconciliation.get("removal_log_complete") is False:
        reasons.append("removal log omits one or more removed clean-stale worktrees")

    for row in reconciliation.get("rows") or []:
        removal = row.get("removal_record") or {}
        if row.get("disposition") == "removed_intentionally":
            if not removal.get("method"):
                reasons.append(f"removal log for {row.get('path')} missing method")
            if not removal.get("pre_removal_proof"):
                reasons.append(f"removal log for {row.get('path')} missing pre-removal proof")

    block = bool(reasons)
    return {
        "block": block,
        "proven": not block,
        "reasons": reasons,
        "counts": counts,
        "safe_next_action": (
            "capture before/after snapshots, record every removal with proof, and "
            "explain any preserved path that disappears"
            if block
            else "proceed"
        ),
    }


def build_cleanup_reconciliation_table(reconciliation: dict[str, Any]) -> dict[str, Any]:
    """Return the operator-facing reconciliation summary table."""
    counts = dict(reconciliation.get("counts") or {})
    return {
        "initial_count": counts.get("initial_count", 0),
        "removed_count": counts.get("removed_count", 0),
        "preserved_count": counts.get("preserved_count", 0),
        "missing_unexplained_count": counts.get("missing_unexplained_count", 0),
        "missing_explained_count": counts.get("missing_explained_count", 0),
        "final_count": counts.get("final_count", 0),
        "count_reconciles": counts.get("count_reconciles", False),
    }


def _read_worktree_porcelain(project_root: str) -> str:
    import subprocess

    try:
        res = subprocess.run(
            ["git", "-C", project_root, "worktree", "list", "--porcelain"],
            capture_output=True,
            text=True,
            check=False,
        )
    except OSError:
        return ""
    return res.stdout if res.returncode == 0 else ""


def capture_branches_worktree_snapshot(
    project_root: str,
    *,
    open_pr_branches: list[str] | None = None,
    active_lock_branch: str | None = None,
    leased_paths: list[str] | None = None,
    issue_lock_path: str | None = None,
) -> dict[str, Any]:
    """MCP-facing snapshot capture for live ``branches/`` cleanup audits."""
    root = normalize_path(project_root)
    lock_branches = set()
    if active_lock_branch:
        lock_branches.add(active_lock_branch)
    issue_branches = set()
    for path in leased_paths or []:
        rel = relative_branches_path(root, path)
        if rel.startswith("branches/"):
            issue_branches.add(rel.split("/", 1)[-1].replace("-", "/"))
    return capture_cleanup_snapshot(
        root,
        worktree_porcelain=_read_worktree_porcelain(root),
        open_pr_branches=set(open_pr_branches or []),
        active_lock_branches=lock_branches,
        active_issue_branches=issue_branches,
        issue_lock_path=issue_lock_path,
    )


def assess_worktree_cleanup_integrity(
    *,
    before: dict[str, Any],
    after: dict[str, Any],
    removals: list[dict[str, Any]] | None = None,
    explained_missing: dict[str, str] | None = None,
) -> dict[str, Any]:
    """MCP-facing integrity assessment over before/after cleanup snapshots."""
    reconciliation = reconcile_cleanup_audit(
        before,
        after,
        removals,
        explained_missing=explained_missing,
    )
    integrity = assess_cleanup_audit_integrity(reconciliation)
    return {
        **integrity,
        "integrity_passed": integrity.get("proven", False),
        "reconciliation": reconciliation,
        "reconciliation_table": build_cleanup_reconciliation_table(reconciliation),
        "rows": reconciliation.get("rows") or [],
    }


_RECON_INITIAL_RE = re.compile(r"initial count\s*:\s*(\d+)", re.I)
_RECON_REMOVED_RE = re.compile(r"removed count\s*:\s*(\d+)", re.I)
_RECON_PRESERVED_RE = re.compile(r"preserved count\s*:\s*(\d+)", re.I)
_RECON_MISSING_RE = re.compile(r"missing-unexplained count\s*:\s*(\d+)", re.I)
_RECON_FINAL_RE = re.compile(r"final count\s*:\s*(\d+)", re.I)
_WORKTREE_LIST_RE = re.compile(r"git worktree list|worktree list proof", re.I)


def assess_cleanup_audit_final_report(report_text: str) -> dict[str, Any]:
    """Validate cleanup final report includes reconciliation proof (#404)."""
    text = report_text or ""
    reasons: list[str] = []
    for pattern in (
        _RECON_INITIAL_RE,
        _RECON_REMOVED_RE,
        _RECON_PRESERVED_RE,
        _RECON_MISSING_RE,
        _RECON_FINAL_RE,
    ):
        if not pattern.search(text):
            reasons.append(
                f"cleanup report missing field matching /{pattern.pattern}/"
            )
    if not _WORKTREE_LIST_RE.search(text):
        reasons.append("final verification missing git worktree list proof")
    proven = not reasons
    return {"proven": proven, "block": not proven, "reasons": reasons}