"""Dirty-preserving same-claimant author-session rebind (#864).

A registered issue worktree can be dirty while its durable lock owner PID is
provably dead. Ordinary ``gitea_lock_issue`` refuses dirty trees, and dead-session
recovery (#753) also requires cleanliness. This module is the *only* sanctioned
path that rebinds session/lock provenance onto the *same* worktree without
touching tracked or untracked content.

This is SEPARATE from #860 dirty-orphan recovery (PID-less + remote sync).
This operation:

* acts only on an already-registered dirty worktree
* updates only stale lock/session provenance (PID, session pointer, generation,
  heartbeat)
* preserves every tracked/untracked byte
* does NOT sync remote, create recovery worktrees, clean, reset, or change heads
"""

from __future__ import annotations

import hashlib
import json
import os
import subprocess
import tempfile
from datetime import datetime, timezone
from typing import Any, Mapping, Sequence

from author_mutation_worktree import is_path_under_branches
from issue_lock_provenance import (
    SOURCE_DIRTY_SAME_CLAIMANT_REBIND,
    build_sanctioned_lock_provenance,
)
from issue_lock_store import (
    AUTHOR_ISSUE_WORK_LEASE,
    bind_session_lock,
    is_process_alive,
    lock_file_path,
    lock_generation,
    read_lock_file,
)
from reviewer_worktree import parse_dirty_tracked_files

# Outcomes
REBIND_SANCTIONED = "REBIND_SANCTIONED"
REFUSED = "REFUSED"
NO_CANDIDATE = "NO_CANDIDATE"

# Provenance / tool identity
SOURCE_TOOL = SOURCE_DIRTY_SAME_CLAIMANT_REBIND
SOURCE = SOURCE_DIRTY_SAME_CLAIMANT_REBIND

# Journal phases (crash-safe apply)
JOURNAL_PHASE_ASSESSED = "assessed"
JOURNAL_PHASE_PRE_BIND = "pre_bind"
JOURNAL_PHASE_BOUND = "bound"
JOURNAL_PHASE_COMPLETE = "complete"
JOURNAL_PHASE_ALREADY_REBOUND = "already_rebound"

REQUIRED_LOCK_FIELDS = (
    "issue_number",
    "branch_name",
    "worktree_path",
    "remote",
    "org",
    "repo",
)


def _utc_now_iso() -> str:
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def _text(value: Any) -> str:
    return str(value or "").strip()


def _same_realpath(left: str | None, right: str | None) -> bool:
    if not left or not right:
        return False
    try:
        return os.path.realpath(left) == os.path.realpath(right)
    except OSError:
        return left == right


def _lock_claimant(lock: Mapping[str, Any]) -> dict[str, Any]:
    claimant = lock.get("claimant")
    if not isinstance(claimant, Mapping):
        lease = lock.get("work_lease")
        claimant = lease.get("claimant") if isinstance(lease, Mapping) else None
    return dict(claimant) if isinstance(claimant, Mapping) else {}


def _recorded_pid(lock: Mapping[str, Any]) -> Any:
    pid = lock.get("session_pid")
    if pid is None:
        pid = lock.get("pid")
    return pid


def content_fingerprint(path: str) -> str:
    """Return sha256 hex digest of file bytes at *path*.

    Missing or unreadable files raise ``OSError`` / ``FileNotFoundError`` so
    callers fail closed rather than inventing an empty hash.
    """
    digest = hashlib.sha256()
    with open(path, "rb") as handle:
        while True:
            chunk = handle.read(1024 * 1024)
            if not chunk:
                break
            digest.update(chunk)
    return digest.hexdigest()


def parse_dirty_paths(porcelain: str) -> list[str]:
    """Tracked + untracked paths from ``git status --porcelain -uall`` output."""
    paths: list[str] = []
    seen: set[str] = set()
    for line in (porcelain or "").splitlines():
        if not line or len(line) < 4:
            continue
        if line.startswith("??"):
            path = line[3:].strip()
        else:
            path = line[3:].strip()
            if " -> " in path:
                path = path.split(" -> ", 1)[1].strip()
        if not path or path in seen:
            continue
        seen.add(path)
        paths.append(path)
    return paths


def collect_dirty_inventory(worktree_path: str) -> dict[str, Any]:
    """Observe dirty tracked + untracked paths and content fingerprints.

    Uses ``git status --porcelain -uall`` so every untracked file is listed
    individually (not collapsed into a directory).
    """
    path = (worktree_path or "").strip()
    if not path:
        return {
            "worktree_path": path,
            "porcelain_status": "",
            "dirty_paths": [],
            "fingerprints": {},
            "ok": False,
            "reasons": ["worktree path is empty"],
        }

    status_res = subprocess.run(
        ["git", "-C", path, "status", "--porcelain", "-uall"],
        capture_output=True,
        text=True,
        check=False,
    )
    if status_res.returncode != 0:
        err = (status_res.stderr or status_res.stdout or "").strip()
        return {
            "worktree_path": path,
            "porcelain_status": "",
            "dirty_paths": [],
            "fingerprints": {},
            "ok": False,
            "reasons": [f"git status failed in '{path}': {err or 'unknown error'}"],
        }

    porcelain = status_res.stdout or ""
    dirty_paths = parse_dirty_paths(porcelain)
    fingerprints: dict[str, str] = {}
    reasons: list[str] = []
    for rel in dirty_paths:
        abs_path = os.path.join(path, rel)
        if os.path.isdir(abs_path) and not os.path.islink(abs_path):
            # Directories appear only if git reported them; fingerprinting a
            # directory is not defined — fail closed.
            reasons.append(f"dirty path '{rel}' is a directory; cannot fingerprint")
            continue
        try:
            fingerprints[rel] = content_fingerprint(abs_path)
        except OSError as exc:
            reasons.append(f"could not fingerprint '{rel}': {exc}")

    return {
        "worktree_path": os.path.realpath(path),
        "porcelain_status": porcelain,
        "dirty_paths": dirty_paths,
        "fingerprints": fingerprints,
        "ok": not reasons,
        "reasons": reasons,
        "tracked_dirty": parse_dirty_tracked_files(porcelain),
    }


def journal_path(lock_dir: str, issue_number: int) -> str:
    root = (lock_dir or "").strip()
    return os.path.join(root, f".rebind-journal-{int(issue_number)}.json")


def _atomic_write_json(path: str, data: dict[str, Any]) -> None:
    parent = os.path.dirname(path) or "."
    os.makedirs(parent, mode=0o700, exist_ok=True)
    payload = json.dumps(data, indent=2, sort_keys=True) + "\n"
    fd, temp_path = tempfile.mkstemp(prefix=".rebind-j-", suffix=".json", dir=parent)
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as handle:
            handle.write(payload)
            handle.flush()
            os.fsync(handle.fileno())
        os.replace(temp_path, path)
    finally:
        if os.path.exists(temp_path):
            try:
                os.remove(temp_path)
            except OSError:
                pass


def _read_json(path: str) -> dict[str, Any] | None:
    if not path or not os.path.exists(path):
        return None
    try:
        with open(path, encoding="utf-8") as handle:
            data = json.load(handle)
    except (OSError, json.JSONDecodeError):
        return None
    return data if isinstance(data, dict) else None


def _malformed_lock_reasons(lock: Mapping[str, Any]) -> list[str]:
    missing: list[str] = []
    for field in REQUIRED_LOCK_FIELDS:
        if not _text(lock.get(field)):
            missing.append(field)
    pid = _recorded_pid(lock)
    if pid is None or _text(pid) == "":
        missing.append("session_pid/pid")
    else:
        try:
            if int(pid) <= 0:
                missing.append("session_pid/pid")
        except (TypeError, ValueError):
            missing.append("session_pid/pid")
    return missing


def _canonical_under_branches(worktree_path: str, repo_root: str | None) -> tuple[bool, list[str]]:
    """Prove worktree is a realpath under ``<repo_root>/branches/`` with no symlink escape."""
    reasons: list[str] = []
    path = (worktree_path or "").strip()
    if not path:
        return False, ["worktree path is empty"]
    try:
        real = os.path.realpath(path)
    except OSError as exc:
        return False, [f"worktree path could not be realpath-resolved: {exc}"]
    if not os.path.isdir(real):
        reasons.append(f"worktree path '{path}' is not an existing directory")

    root = (repo_root or "").strip()
    if root:
        try:
            root_real = os.path.realpath(root)
        except OSError as exc:
            return False, [f"repo root could not be realpath-resolved: {exc}"]
        if not is_path_under_branches(real, root_real):
            reasons.append(
                f"worktree '{real}' is not under branches/ of repo root '{root_real}' "
                "(unregistered/noncanonical worktree; fail closed)"
            )
        # Symlink escape: the declared path must not resolve outside branches/.
        declared_abs = os.path.abspath(path)
        if os.path.islink(path) or declared_abs != real:
            if not is_path_under_branches(real, root_real):
                reasons.append(
                    f"worktree path '{path}' escapes branches/ via symlink/realpath "
                    f"(resolves to '{real}')"
                )
    else:
        # Without an explicit repo root, still require a /branches/ segment.
        if not is_path_under_branches(real, None):
            reasons.append(
                f"worktree '{real}' is not under a branches/ directory "
                "(unregistered/noncanonical worktree; fail closed)"
            )
    return not reasons, reasons


def assess_dirty_same_claimant_session_rebind(
    *,
    remote: str,
    org: str,
    repo: str,
    issue_number: int,
    branch_name: str,
    worktree_path: str,
    claimant_identity: str | None,
    claimant_profile: str | None,
    old_pid: int | None,
    expected_local_head: str | None,
    expected_remote_head: str | None,
    expected_dirty_paths: Sequence[str] | None,
    expected_fingerprints: Mapping[str, str] | None,
    existing_lock: Mapping[str, Any] | None,
    current_identity: str | None,
    current_profile: str | None,
    role_kind: str | None,
    current_pid: int | None,
    current_branch: str | None,
    local_head: str | None,
    remote_head: str | None,
    porcelain_status: str | None = None,
    dirty_inventory: Mapping[str, Any] | None = None,
    competing_live_locks: Sequence[Mapping[str, Any]] | None = None,
    competing_sessions: Sequence[Mapping[str, Any]] | None = None,
    workflow_lease_active: bool = False,
    authorize_reconciler_execute: bool = False,
    permission_allowed: bool = False,
    repo_root: str | None = None,
) -> dict[str, Any]:
    """Pure assessment: may this dirty same-claimant lock be session-rebound?

    Every pin must agree. ``permission_allowed=True`` alone is never ownership
    proof. Fail closed on live old PID, foreign identity/profile, pin mismatch,
    unregistered/noncanonical worktree, head movement, dirty path/fingerprint
    disagreement, competing ownership, malformed lock, empty PID, wrong role.
    """
    reasons: list[str] = []
    evidence: dict[str, Any] = {
        "issue_number": issue_number,
        "branch_name": branch_name,
        "worktree_path": worktree_path,
        "remote": remote,
        "org": org,
        "repo": repo,
        "old_pid": old_pid,
        "current_pid": current_pid if current_pid is not None else os.getpid(),
        "role_kind": _text(role_kind).lower() or None,
        "permission_allowed": bool(permission_allowed),
    }

    if not existing_lock:
        return _assessment_result(
            NO_CANDIDATE,
            False,
            ["no existing durable lock for this issue; not a rebind candidate"],
            evidence,
        )

    lock = dict(existing_lock)
    if lock.get("issue_number") != issue_number:
        return _assessment_result(
            NO_CANDIDATE,
            False,
            [
                f"existing lock targets issue #{lock.get('issue_number')}, "
                f"not #{issue_number}; not a rebind candidate"
            ],
            evidence,
        )

    missing = _malformed_lock_reasons(lock)
    if missing:
        return _assessment_result(
            REFUSED,
            False,
            [
                "durable lock record is incomplete and cannot prove ownership "
                f"(missing/unusable: {', '.join(missing)})"
            ],
            evidence,
        )

    recorded_pid = _recorded_pid(lock)
    evidence["recorded_pid"] = recorded_pid
    evidence["lock_generation"] = lock_generation(lock)

    # ── Role gate ───────────────────────────────────────────────────────────
    role = _text(role_kind).lower()
    if role in {"reviewer", "merger"}:
        reasons.append(
            f"role '{role}' cannot rebind dirty same-claimant author sessions "
            "(fail closed)"
        )
    elif role == "reconciler":
        if not authorize_reconciler_execute:
            reasons.append(
                "reconciler role requires authorize_reconciler_execute=True "
                "to execute dirty same-claimant rebind (fail closed)"
            )
    elif role == "author":
        pass
    elif role:
        reasons.append(f"role '{role}' is not authorized for dirty same-claimant rebind")
    else:
        reasons.append("role_kind is unknown; dirty same-claimant rebind refused")

    # permission_allowed is explicitly NOT ownership proof
    evidence["note_permission_not_ownership"] = (
        "permission_allowed is not treated as ownership proof"
    )

    # ── Repository / issue / branch / worktree pins ─────────────────────────
    for field, expected in (("remote", remote), ("org", org), ("repo", repo)):
        actual = _text(lock.get(field))
        if actual != _text(expected):
            reasons.append(
                f"lock {field} '{actual}' does not match requested '{_text(expected)}'"
            )

    locked_branch = _text(lock.get("branch_name"))
    if locked_branch != _text(branch_name):
        reasons.append(
            f"lock branch '{locked_branch}' does not match requested "
            f"'{_text(branch_name)}'"
        )

    checked_out = _text(current_branch)
    if not checked_out:
        reasons.append(
            "worktree is not on a named branch (detached HEAD); locked-branch "
            "occupancy could not be proven"
        )
    elif checked_out != locked_branch:
        reasons.append(
            f"worktree is on branch '{checked_out}', not the locked branch "
            f"'{locked_branch}'"
        )

    locked_worktree = _text(lock.get("worktree_path"))
    if not _same_realpath(locked_worktree, worktree_path):
        reasons.append(
            f"lock worktree '{locked_worktree}' does not match declared "
            f"'{_text(worktree_path)}'"
        )
    evidence["locked_worktree_path"] = locked_worktree

    under_ok, under_reasons = _canonical_under_branches(worktree_path, repo_root)
    if not under_ok:
        reasons.extend(under_reasons)

    # ── old_pid pin + liveness ──────────────────────────────────────────────
    if old_pid is None or _text(old_pid) == "":
        reasons.append("old_pid pin is empty; rebind refused (fail closed)")
    else:
        try:
            old_pid_i = int(old_pid)
        except (TypeError, ValueError):
            reasons.append(f"old_pid '{old_pid}' is not a valid PID")
            old_pid_i = None
        if old_pid_i is not None:
            if old_pid_i <= 0:
                reasons.append("old_pid must be a positive integer (fail closed)")
            try:
                recorded_i = int(recorded_pid)
            except (TypeError, ValueError):
                recorded_i = None
            if recorded_i is None or recorded_i != old_pid_i:
                reasons.append(
                    f"old_pid {old_pid_i} does not match lock session_pid/pid "
                    f"{recorded_pid}"
                )
            if is_process_alive(old_pid_i):
                reasons.append(
                    f"old_pid {old_pid_i} is still alive; dirty same-claimant "
                    "rebind requires a provably dead owner (fail closed)"
                )
            evidence["old_pid_alive"] = is_process_alive(old_pid_i)
            if current_pid is not None:
                try:
                    if int(current_pid) == old_pid_i:
                        reasons.append(
                            "old_pid is the current session PID; nothing to rebind"
                        )
                except (TypeError, ValueError):
                    pass

    # ── Claimant identity / profile ─────────────────────────────────────────
    lock_claimant = _lock_claimant(lock)
    locked_identity = _text(lock_claimant.get("username"))
    locked_profile = _text(lock_claimant.get("profile"))
    pin_identity = _text(claimant_identity)
    pin_profile = _text(claimant_profile)
    active_identity = _text(current_identity)
    active_profile = _text(current_profile)
    evidence["locked_identity"] = locked_identity or None
    evidence["locked_profile"] = locked_profile or None

    if not locked_identity or not locked_profile:
        reasons.append(
            "durable lock does not record a claimant identity/profile; "
            "ownership could not be proven"
        )
    if not pin_identity or not pin_profile:
        reasons.append(
            "claimant_identity/claimant_profile pins are required (fail closed)"
        )
    if locked_identity and pin_identity and locked_identity != pin_identity:
        reasons.append(
            f"claimant_identity pin '{pin_identity}' does not match lock "
            f"claimant '{locked_identity}'"
        )
    if locked_profile and pin_profile and locked_profile != pin_profile:
        reasons.append(
            f"claimant_profile pin '{pin_profile}' does not match lock profile "
            f"'{locked_profile}'"
        )

    # Author path: active session must be the same claimant. Reconciler execute
    # may rebind for the recorded claimant when explicitly authorized.
    if role == "author":
        if not active_identity or not active_profile:
            reasons.append(
                "active session identity/profile is unknown; author ownership "
                "could not be proven"
            )
        if locked_identity and active_identity and locked_identity != active_identity:
            reasons.append(
                f"lock claimant '{locked_identity}' does not match active "
                f"identity '{active_identity}' (foreign claimant refused)"
            )
        if locked_profile and active_profile and locked_profile != active_profile:
            reasons.append(
                f"lock profile '{locked_profile}' does not match active profile "
                f"'{active_profile}' (profile mismatch refused)"
            )
        if pin_identity and active_identity and pin_identity != active_identity:
            reasons.append(
                f"claimant_identity pin '{pin_identity}' does not match active "
                f"identity '{active_identity}'"
            )
        if pin_profile and active_profile and pin_profile != active_profile:
            reasons.append(
                f"claimant_profile pin '{pin_profile}' does not match active "
                f"profile '{active_profile}'"
            )

    # ── Heads (must match pins and each other for this rebind class) ────────
    obs_local = _text(local_head)
    obs_remote = _text(remote_head)
    pin_local = _text(expected_local_head)
    pin_remote = _text(expected_remote_head)
    evidence["local_head"] = obs_local or None
    evidence["remote_head"] = obs_remote or None
    evidence["expected_local_head"] = pin_local or None
    evidence["expected_remote_head"] = pin_remote or None

    if not pin_local or not pin_remote:
        reasons.append(
            "expected_local_head and expected_remote_head pins are required "
            "(fail closed)"
        )
    if not obs_local:
        reasons.append("local head SHA could not be determined")
    if not obs_remote:
        reasons.append("remote head SHA could not be determined")
    if pin_local and obs_local and pin_local != obs_local:
        reasons.append(
            f"local head moved or mismatched pin: observed {obs_local}, "
            f"expected {pin_local}"
        )
    if pin_remote and obs_remote and pin_remote != obs_remote:
        reasons.append(
            f"remote head moved or mismatched pin: observed {obs_remote}, "
            f"expected {pin_remote}"
        )
    if obs_local and obs_remote and obs_local != obs_remote:
        # Dirty rebind does not allow unpublished head movement; heads must agree.
        reasons.append(
            f"local head {obs_local} does not match remote head {obs_remote}; "
            "dirty same-claimant rebind requires matching heads (fail closed)"
        )

    # ── Dirty inventory + fingerprint pins ──────────────────────────────────
    inv: dict[str, Any]
    if isinstance(dirty_inventory, Mapping) and dirty_inventory.get("dirty_paths") is not None:
        inv = dict(dirty_inventory)
        if not inv.get("fingerprints") and porcelain_status is not None:
            # Allow fingerprints-only refresh via recompute if needed.
            pass
    elif porcelain_status is not None:
        # Porcelain alone proves path set, not bytes. Fingerprints must come from
        # dirty_inventory (or apply()'s collect_dirty_inventory) — never from the
        # caller's expected_fingerprints pin (that would make the pin tautological).
        dirty_paths_obs = parse_dirty_paths(porcelain_status)
        inv = {
            "porcelain_status": porcelain_status,
            "dirty_paths": dirty_paths_obs,
            "fingerprints": {},
            "ok": True,
            "reasons": [],
        }
    else:
        reasons.append(
            "neither dirty_inventory nor porcelain_status was provided; "
            "dirty state could not be proven"
        )
        inv = {"dirty_paths": [], "fingerprints": {}, "ok": False}

    if inv.get("ok") is False and inv.get("reasons"):
        reasons.extend(list(inv.get("reasons") or []))

    observed_paths = sorted({_text(p) for p in (inv.get("dirty_paths") or []) if _text(p)})
    pin_paths = sorted({_text(p) for p in (expected_dirty_paths or []) if _text(p)})
    evidence["observed_dirty_paths"] = observed_paths
    evidence["expected_dirty_paths"] = pin_paths

    if not pin_paths:
        reasons.append(
            "expected_dirty_paths pin is empty; dirty same-claimant rebind "
            "requires a non-empty dirty inventory pin (fail closed)"
        )
    if set(observed_paths) != set(pin_paths):
        extra = sorted(set(observed_paths) - set(pin_paths))
        missing_p = sorted(set(pin_paths) - set(observed_paths))
        if extra:
            reasons.append(
                f"dirty path set disagreement: unexpected paths {extra}"
            )
        if missing_p:
            reasons.append(
                f"dirty path set disagreement: missing expected paths {missing_p}"
            )

    obs_fps = {
        _text(k): _text(v)
        for k, v in dict(inv.get("fingerprints") or {}).items()
        if _text(k)
    }
    pin_fps = {
        _text(k): _text(v)
        for k, v in dict(expected_fingerprints or {}).items()
        if _text(k)
    }
    evidence["observed_fingerprints"] = obs_fps
    evidence["expected_fingerprints"] = pin_fps

    if not pin_fps:
        reasons.append(
            "expected_fingerprints pin is empty; byte-level pins are required "
            "(fail closed)"
        )
    else:
        for rel, expected_hash in pin_fps.items():
            if rel not in set(pin_paths):
                reasons.append(
                    f"expected_fingerprints contains '{rel}' which is not in "
                    "expected_dirty_paths"
                )
            actual_hash = obs_fps.get(rel)
            if not actual_hash:
                reasons.append(
                    f"fingerprint missing for dirty path '{rel}'"
                )
            elif actual_hash != expected_hash:
                reasons.append(
                    f"fingerprint disagreement for '{rel}': observed "
                    f"{actual_hash}, expected {expected_hash}"
                )
        for rel in obs_fps:
            if rel in set(pin_paths) and rel not in pin_fps:
                reasons.append(
                    f"expected_fingerprints missing pin for observed dirty path '{rel}'"
                )

    # ── Competing ownership ─────────────────────────────────────────────────
    competing: list[dict[str, Any]] = []
    for entry in competing_live_locks or ():
        if not isinstance(entry, Mapping):
            continue
        same_issue = entry.get("issue_number") == issue_number
        same_branch = _text(entry.get("branch_name")) == locked_branch
        if not (same_issue or same_branch):
            continue
        if (
            same_issue
            and same_branch
            and _same_realpath(_text(entry.get("worktree_path")), worktree_path)
        ):
            # The lock we are rebinding is not competition with itself, but a
            # *live* competing owner on the same worktree is still a problem.
            entry_pid = entry.get("pid") or entry.get("session_pid")
            try:
                entry_pid_i = int(entry_pid) if entry_pid is not None else None
            except (TypeError, ValueError):
                entry_pid_i = None
            if entry_pid_i is not None and is_process_alive(entry_pid_i):
                if old_pid is None or entry_pid_i != int(old_pid):
                    competing.append(
                        {
                            "issue_number": entry.get("issue_number"),
                            "branch_name": entry.get("branch_name"),
                            "worktree_path": entry.get("worktree_path"),
                            "pid": entry_pid_i,
                        }
                    )
            continue
        competing.append(
            {
                "issue_number": entry.get("issue_number"),
                "branch_name": entry.get("branch_name"),
                "worktree_path": entry.get("worktree_path"),
                "pid": entry.get("pid") or entry.get("session_pid"),
            }
        )
    if competing:
        described = ", ".join(
            f"issue #{c['issue_number']} branch '{c['branch_name']}' pid={c.get('pid')}"
            for c in competing
        )
        reasons.append(f"competing live lock exists ({described})")
    evidence["competing_live_locks"] = competing

    competing_sess: list[dict[str, Any]] = []
    for entry in competing_sessions or ():
        if not isinstance(entry, Mapping):
            continue
        sess_pid = entry.get("pid") or entry.get("session_pid")
        try:
            sess_pid_i = int(sess_pid) if sess_pid is not None else None
        except (TypeError, ValueError):
            sess_pid_i = None
        if sess_pid_i is None:
            continue
        if current_pid is not None and sess_pid_i == int(current_pid):
            continue
        if old_pid is not None:
            try:
                if sess_pid_i == int(old_pid) and not is_process_alive(sess_pid_i):
                    continue
            except (TypeError, ValueError):
                pass
        if is_process_alive(sess_pid_i) or entry.get("live") is True:
            competing_sess.append(
                {
                    "pid": sess_pid_i,
                    "lock_file_path": entry.get("lock_file_path"),
                }
            )
    if competing_sess:
        reasons.append(
            "competing live session pointer(s) claim this lock: "
            + ", ".join(str(s["pid"]) for s in competing_sess)
        )
    evidence["competing_sessions"] = competing_sess

    if workflow_lease_active:
        reasons.append(
            "workflow lease is active for this scope; dirty same-claimant "
            "rebind refused (fail closed)"
        )
    evidence["workflow_lease_active"] = bool(workflow_lease_active)

    if reasons:
        return _assessment_result(REFUSED, False, reasons, evidence)

    proof = [
        f"registered dirty worktree for issue #{issue_number} on branch "
        f"'{locked_branch}' matches claimant '{locked_identity}' / profile "
        f"'{locked_profile}'; old_pid {recorded_pid} is dead; heads "
        f"{obs_local} match; {len(pin_paths)} dirty paths fingerprint-pinned; "
        "provenance-only rebind sanctioned"
    ]
    return _assessment_result(REBIND_SANCTIONED, True, proof, evidence)


def _assessment_result(
    outcome: str,
    sanctioned: bool,
    reasons: list[str],
    evidence: dict[str, Any],
) -> dict[str, Any]:
    return {
        "outcome": outcome,
        "rebind_sanctioned": sanctioned,
        "is_candidate": outcome != NO_CANDIDATE,
        "reasons": reasons,
        "evidence": evidence,
        "expected_generation": evidence.get("lock_generation"),
    }


def _already_rebound(
    *,
    existing_lock: Mapping[str, Any],
    current_pid: int,
    worktree_path: str,
    expected_fingerprints: Mapping[str, str],
    worktree_for_fps: str,
) -> tuple[bool, list[str]]:
    """Return (True, notes) when lock is already rebound to this session."""
    notes: list[str] = []
    pid = _recorded_pid(existing_lock)
    try:
        pid_i = int(pid) if pid is not None else None
    except (TypeError, ValueError):
        return False, []
    if pid_i != int(current_pid):
        return False, []
    if not _same_realpath(_text(existing_lock.get("worktree_path")), worktree_path):
        return False, []
    # Fingerprints must still match pins (byte preservation).
    for rel, expected in (expected_fingerprints or {}).items():
        abs_path = os.path.join(worktree_for_fps, rel)
        try:
            actual = content_fingerprint(abs_path)
        except OSError as exc:
            notes.append(f"could not re-fingerprint '{rel}' for already_rebound: {exc}")
            return False, notes
        if actual != _text(expected):
            notes.append(
                f"fingerprint drift on already-rebound check for '{rel}'"
            )
            return False, notes
    gen = lock_generation(existing_lock)
    if gen < 1:
        # A never-written generation is suspicious for a completed rebind, but
        # a same-pid lock with matching fingerprints is still "ours".
        notes.append("lock generation is 0; treating same-pid match as rebound")
    return True, notes or ["lock already bound to current session PID"]


def apply_dirty_same_claimant_session_rebind(
    *,
    remote: str,
    org: str,
    repo: str,
    issue_number: int,
    branch_name: str,
    worktree_path: str,
    claimant_identity: str | None,
    claimant_profile: str | None,
    old_pid: int | None,
    expected_local_head: str | None,
    expected_remote_head: str | None,
    expected_dirty_paths: Sequence[str] | None,
    expected_fingerprints: Mapping[str, str] | None,
    existing_lock: Mapping[str, Any] | None,
    current_identity: str | None,
    current_profile: str | None,
    role_kind: str | None,
    current_pid: int | None = None,
    current_branch: str | None,
    local_head: str | None,
    remote_head: str | None,
    porcelain_status: str | None = None,
    dirty_inventory: Mapping[str, Any] | None = None,
    competing_live_locks: Sequence[Mapping[str, Any]] | None = None,
    competing_sessions: Sequence[Mapping[str, Any]] | None = None,
    workflow_lease_active: bool = False,
    authorize_reconciler_execute: bool = False,
    permission_allowed: bool = False,
    repo_root: str | None = None,
    dry_run: bool = False,
    lock_dir: str | None = None,
) -> dict[str, Any]:
    """Assess and (unless dry_run) apply a dirty same-claimant session rebind."""
    pid_now = int(current_pid) if current_pid is not None else os.getpid()
    wt = os.path.realpath((worktree_path or "").strip()) if worktree_path else ""

    # Prefer a live inventory when applying so fingerprints are re-observed.
    inv = dict(dirty_inventory) if isinstance(dirty_inventory, Mapping) else None
    if inv is None and wt:
        inv = collect_dirty_inventory(wt)

    assessment = assess_dirty_same_claimant_session_rebind(
        remote=remote,
        org=org,
        repo=repo,
        issue_number=issue_number,
        branch_name=branch_name,
        worktree_path=worktree_path,
        claimant_identity=claimant_identity,
        claimant_profile=claimant_profile,
        old_pid=old_pid,
        expected_local_head=expected_local_head,
        expected_remote_head=expected_remote_head,
        expected_dirty_paths=expected_dirty_paths,
        expected_fingerprints=expected_fingerprints,
        existing_lock=existing_lock,
        current_identity=current_identity,
        current_profile=current_profile,
        role_kind=role_kind,
        current_pid=pid_now,
        current_branch=current_branch,
        local_head=local_head,
        remote_head=remote_head,
        porcelain_status=porcelain_status
        if porcelain_status is not None
        else (inv or {}).get("porcelain_status"),
        dirty_inventory=inv,
        competing_live_locks=competing_live_locks,
        competing_sessions=competing_sessions,
        workflow_lease_active=workflow_lease_active,
        authorize_reconciler_execute=authorize_reconciler_execute,
        permission_allowed=permission_allowed,
        repo_root=repo_root,
    )

    base_result: dict[str, Any] = {
        "success": False,
        "dry_run": bool(dry_run),
        "outcome": assessment["outcome"],
        "rebind_sanctioned": assessment["rebind_sanctioned"],
        "reasons": list(assessment.get("reasons") or []),
        "evidence": assessment.get("evidence") or {},
        "old_pid": old_pid,
        "new_pid": pid_now,
        "already_rebound": False,
        "dirty_paths": list(expected_dirty_paths or []),
        "fingerprints": dict(expected_fingerprints or {}),
        "local_head": local_head,
        "remote_head": remote_head,
    }

    # Retry-safe: if already rebound to this session, succeed even when assess
    # refuses because old_pid no longer matches the (updated) lock.
    if (
        isinstance(existing_lock, Mapping)
        and expected_fingerprints
        and wt
    ):
        done, notes = _already_rebound(
            existing_lock=existing_lock,
            current_pid=pid_now,
            worktree_path=worktree_path,
            expected_fingerprints=expected_fingerprints,
            worktree_for_fps=wt,
        )
        if done:
            lock_path = _text(existing_lock.get("lock_file_path")) or lock_file_path(
                remote=remote,
                org=org,
                repo=repo,
                issue_number=issue_number,
                lock_dir=lock_dir,
            )
            session_ptr = os.path.join(
                (lock_dir or os.path.dirname(lock_path) or "."),
                f"session-{pid_now}.json",
            )
            return {
                **base_result,
                "success": True,
                "outcome": REBIND_SANCTIONED,
                "rebind_sanctioned": True,
                "already_rebound": True,
                "reasons": notes,
                "lock_path": lock_path,
                "session_pointer": session_ptr,
                "generation_before": lock_generation(existing_lock),
                "generation_after": lock_generation(existing_lock),
                "journal_phase": JOURNAL_PHASE_ALREADY_REBOUND,
            }

    if not assessment["rebind_sanctioned"]:
        return base_result

    if dry_run:
        return {
            **base_result,
            "success": True,
            "message": "dry_run: rebind sanctioned; no lock/session writes performed",
            "generation_before": assessment.get("expected_generation"),
            "generation_after": assessment.get("expected_generation"),
        }

    lock = dict(existing_lock or {})
    gen_before = lock_generation(lock)
    root = (lock_dir or "").strip() or None
    jpath = journal_path(
        root or os.path.dirname(
            _text(lock.get("lock_file_path"))
            or lock_file_path(
                remote=remote, org=org, repo=repo, issue_number=issue_number
            )
        ),
        issue_number,
    )

    journal = {
        "phase": JOURNAL_PHASE_ASSESSED,
        "issue_number": issue_number,
        "branch_name": branch_name,
        "worktree_path": wt,
        "old_pid": old_pid,
        "new_pid": pid_now,
        "expected_generation": gen_before,
        "expected_fingerprints": dict(expected_fingerprints or {}),
        "expected_dirty_paths": list(expected_dirty_paths or []),
        "local_head": local_head,
        "remote_head": remote_head,
        "started_at": _utc_now_iso(),
        "source": SOURCE,
    }
    _atomic_write_json(jpath, journal)

    # Immediate pre-bind fingerprint re-verification.
    pre_fps: dict[str, str] = {}
    for rel in expected_dirty_paths or []:
        abs_path = os.path.join(wt, rel)
        try:
            pre_fps[rel] = content_fingerprint(abs_path)
        except OSError as exc:
            return {
                **base_result,
                "success": False,
                "reasons": [f"pre-bind fingerprint failed for '{rel}': {exc}"],
                "journal_path": jpath,
            }
    for rel, expected in (expected_fingerprints or {}).items():
        if pre_fps.get(rel) != _text(expected):
            return {
                **base_result,
                "success": False,
                "reasons": [
                    f"pre-bind fingerprint drift for '{rel}': "
                    f"observed {pre_fps.get(rel)}, expected {expected}"
                ],
                "journal_path": jpath,
            }

    journal["phase"] = JOURNAL_PHASE_PRE_BIND
    journal["pre_bind_fingerprints"] = pre_fps
    _atomic_write_json(jpath, journal)

    now = _utc_now_iso()
    new_lock = dict(lock)
    new_lock["session_pid"] = pid_now
    new_lock["pid"] = pid_now
    new_lock["last_heartbeat_at"] = now
    new_lock["remote"] = remote
    new_lock["org"] = org
    new_lock["repo"] = repo
    new_lock["issue_number"] = issue_number
    new_lock["branch_name"] = branch_name
    new_lock["worktree_path"] = _text(lock.get("worktree_path")) or wt

    # Preserve work_lease (including expires_at); refresh heartbeat only.
    lease = new_lock.get("work_lease")
    if isinstance(lease, dict):
        lease = dict(lease)
        lease["last_heartbeat_at"] = now
        if not lease.get("operation_type"):
            lease["operation_type"] = AUTHOR_ISSUE_WORK_LEASE
        new_lock["work_lease"] = lease

    claimant = _lock_claimant(lock)
    new_lock["lock_provenance"] = build_sanctioned_lock_provenance(
        tool=SOURCE_TOOL,
        source=SOURCE,
        claimant=claimant or {
            "username": claimant_identity,
            "profile": claimant_profile,
        },
    )
    new_lock["rebind_record"] = {
        "source": SOURCE,
        "old_pid": old_pid,
        "new_pid": pid_now,
        "rebound_at": now,
        "local_head": local_head,
        "remote_head": remote_head,
        "dirty_path_count": len(list(expected_dirty_paths or [])),
        "generation_before": gen_before,
        "evidence": {
            "fingerprints": dict(expected_fingerprints or {}),
            "dirty_paths": list(expected_dirty_paths or []),
        },
    }

    try:
        lock_path = bind_session_lock(
            new_lock,
            lock_dir=root,
            expected_generation=gen_before,
        )
    except Exception as exc:
        journal["phase"] = "bind_failed"
        journal["error"] = str(exc)
        _atomic_write_json(jpath, journal)
        return {
            **base_result,
            "success": False,
            "reasons": [f"bind_session_lock failed: {exc}"],
            "journal_path": jpath,
            "generation_before": gen_before,
        }

    journal["phase"] = JOURNAL_PHASE_BOUND
    journal["lock_path"] = lock_path
    _atomic_write_json(jpath, journal)

    # Post-bind fingerprint verification — every byte unchanged.
    post_fps: dict[str, str] = {}
    for rel in expected_dirty_paths or []:
        abs_path = os.path.join(wt, rel)
        try:
            post_fps[rel] = content_fingerprint(abs_path)
        except OSError as exc:
            return {
                **base_result,
                "success": False,
                "reasons": [
                    f"post-bind fingerprint failed for '{rel}': {exc}; "
                    "lock may be rebound but content verification failed"
                ],
                "lock_path": lock_path,
                "journal_path": jpath,
                "generation_before": gen_before,
            }
    for rel, expected in (expected_fingerprints or {}).items():
        if post_fps.get(rel) != _text(expected):
            return {
                **base_result,
                "success": False,
                "reasons": [
                    f"post-bind fingerprint drift for '{rel}': "
                    f"observed {post_fps.get(rel)}, expected {expected}"
                ],
                "lock_path": lock_path,
                "journal_path": jpath,
                "generation_before": gen_before,
                "fingerprints_after": post_fps,
            }

    # Remove stale session pointer for old_pid when it points at this lock.
    removed_old_pointer = False
    if old_pid is not None and root:
        old_ptr = os.path.join(root, f"session-{int(old_pid)}.json")
        if os.path.exists(old_ptr):
            ptr = _read_json(old_ptr) or {}
            ptr_lock = _text(ptr.get("lock_file_path"))
            if not ptr_lock or os.path.realpath(ptr_lock) == os.path.realpath(lock_path):
                try:
                    os.remove(old_ptr)
                    removed_old_pointer = True
                except OSError:
                    pass

    bound = read_lock_file(lock_path) or new_lock
    gen_after = lock_generation(bound)
    session_ptr = os.path.join(
        root or os.path.dirname(lock_path),
        f"session-{pid_now}.json",
    )

    journal["phase"] = JOURNAL_PHASE_COMPLETE
    journal["completed_at"] = _utc_now_iso()
    journal["generation_after"] = gen_after
    journal["removed_old_session_pointer"] = removed_old_pointer
    journal["post_bind_fingerprints"] = post_fps
    _atomic_write_json(jpath, journal)

    return {
        **base_result,
        "success": True,
        "message": (
            f"Rebound dirty same-claimant author session for issue #{issue_number} "
            f"from dead pid {old_pid} to pid {pid_now}; dirty bytes preserved"
        ),
        "lock_path": lock_path,
        "session_pointer": session_ptr,
        "generation_before": gen_before,
        "generation_after": gen_after,
        "fingerprints": post_fps,
        "fingerprints_before": pre_fps,
        "removed_old_session_pointer": removed_old_pointer,
        "journal_path": jpath,
        "journal_phase": JOURNAL_PHASE_COMPLETE,
        "rebind_record": bound.get("rebind_record") or new_lock.get("rebind_record"),
        "lock_provenance": bound.get("lock_provenance"),
    }


def build_issue_860_regression_fixture_spec() -> dict[str, Any]:
    """Data-only fixture describing the #860 class scenario (no real mutation).

    Claimant jcwalker3 / prgs-author, dead PID, no live session pointer, seven
    dirty paths with fingerprint pins, matching local/remote heads.
    """
    dirty_paths = [
        "dirty_same_claimant_session_rebind.py",
        "issue_lock_provenance.py",
        "task_capability_map.py",
        "gitea_mcp_server.py",
        "tests/test_dirty_same_claimant_session_rebind.py",
        "docs/runbook-dirty-rebind.md",
        "scratch/notes-untracked.txt",
    ]
    # Stable placeholder digests — tests replace with real fingerprints when
    # constructing on-disk fixtures. These exist so the spec is self-describing.
    fingerprints = {
        path: hashlib.sha256(f"issue-860-fixture:{path}".encode()).hexdigest()
        for path in dirty_paths
    }
    head = "a" * 40
    dead = 424860
    return {
        "issue_class": "issue-860-dirty-orphan-class-fixture",
        "description": (
            "Registered dirty worktree, same claimant, dead owner PID, no live "
            "session pointer, seven fingerprint-pinned dirty paths, matching heads. "
            "Data only — does not mutate any real worktree."
        ),
        "remote": "prgs",
        "org": "Scaled-Tech-Consulting",
        "repo": "Gitea-Tools",
        "issue_number": 860,
        "branch_name": "fix/issue-860-dirty-orphan-recovery",
        "worktree_path": "/scratch/branches/fix-issue-860-dirty-orphan-recovery",
        "claimant_identity": "jcwalker3",
        "claimant_profile": "prgs-author",
        "old_pid": dead,
        "old_pid_alive": False,
        "live_session_pointer": None,
        "expected_local_head": head,
        "expected_remote_head": head,
        "expected_dirty_paths": dirty_paths,
        "expected_fingerprints": fingerprints,
        "dirty_path_count": 7,
        "role_kind": "author",
        "notes": [
            "Distinct from #864 apply path: this fixture documents the #860 class "
            "inputs (dead PID + dirty inventory) without remote sync or recovery "
            "worktree creation.",
        ],
    }
