"""Branches-only author mutation worktree guard (#274) with durable resolution (#618).

Author/coder mutations must run from a session-owned worktree under the
project's ``branches/`` directory, never from the stable control checkout.

#618 durable resolution:
- Prefer an explicit validated ``worktree_path`` argument.
- Else derive the workspace from the active author issue lock's worktree.
- Env bindings (``GITEA_ACTIVE_WORKTREE`` / ``GITEA_AUTHOR_WORKTREE``) may bind
  when present and valid.
- Author mutations never silently fall back to the control checkout or master.
- Missing configured bindings fail closed with a clear operator recovery action.
"""

from __future__ import annotations

import os
import subprocess

BASE_BRANCHES = frozenset({"master", "main", "dev"})
ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE"
AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE"
# Author-only: reviewer/merger/reconciler namespaces use role-specific env vars
# via namespace_workspace_binding (#510).

BOUND_WORKTREE_MISSING = "bound_worktree_missing"
BOUND_WORKTREE_MISSING_MESSAGE = (
    "bound worktree missing; operator must recreate or repoint the worktree "
    "and reconnect"
)
OPERATOR_RECOVERY_RECREATE_REPOINT = (
    "Recreate the worktree under branches/ (scripts/worktree-start or "
    "git worktree add), set GITEA_AUTHOR_WORKTREE / GITEA_ACTIVE_WORKTREE "
    "to that path (or pass worktree_path on mutation tools), keep the control "
    "checkout clean on master, then reconnect the author MCP session and re-run."
)


def _normalize_path(path: str) -> str:
    return (path or "").replace("\\", "/").rstrip("/")


def is_path_under_branches(path: str, project_root: str | None = None) -> bool:
    """True when *path* resolves inside ``<project_root>/branches/``."""
    normalized = _normalize_path(path)
    if not normalized:
        return False
    if "/branches/" in f"{normalized}/":
        return True
    if normalized.endswith("/branches"):
        return True
    if project_root:
        root = _normalize_path(os.path.realpath(project_root))
        real = _normalize_path(os.path.realpath(path))
        if real.startswith(f"{root}/"):
            rel = real[len(root) + 1 :]
            return rel == "branches" or rel.startswith("branches/")
    return False


def resolve_mutation_workspace(
    worktree_path: str | None,
    project_root: str,
    *,
    active_worktree_env: str | None = None,
    author_worktree_env: str | None = None,
) -> str:
    """Resolve the workspace path inspected before author mutations.

    Legacy helper: returns the first non-empty candidate path. Prefer
    :func:`resolve_durable_author_worktree` for mutation guards (#618).
    """
    for candidate in (worktree_path, active_worktree_env, author_worktree_env):
        text = (candidate or "").strip()
        if text:
            return os.path.realpath(os.path.abspath(text))
    return os.path.realpath(project_root)


def _realpath_git_common_dir(workspace_path: str, common_dir: str) -> str:
    """Resolve ``git rev-parse --git-common-dir`` relative to *workspace_path*."""
    raw = (common_dir or "").strip()
    if not raw:
        return raw
    if os.path.isabs(raw):
        return os.path.realpath(raw)
    return os.path.realpath(os.path.join(workspace_path, raw))


def resolve_canonical_repo_root(workspace_path: str, fallback_project_root: str) -> str:
    """Return the stable repository root for *workspace_path* via git metadata (#460)."""
    path = (workspace_path or "").strip()
    fallback = os.path.realpath(fallback_project_root)
    if not path:
        return fallback
    try:
        res = subprocess.run(
            ["git", "-C", path, "rev-parse", "--git-common-dir"],
            capture_output=True,
            text=True,
            check=True,
        )
        common = _realpath_git_common_dir(path, res.stdout)
    except Exception:
        return fallback
    if common.endswith(f"{os.sep}.git"):
        return os.path.dirname(common)
    if os.path.basename(common) == ".git":
        return os.path.dirname(common)
    return fallback


def resolve_author_mutation_context(
    worktree_path: str | None,
    process_project_root: str,
    *,
    active_worktree_env: str | None = None,
    author_worktree_env: str | None = None,
) -> dict:
    """Shared workspace resolution for runtime_context and mutation guards (#460)."""
    workspace = resolve_mutation_workspace(
        worktree_path,
        process_project_root,
        active_worktree_env=active_worktree_env,
        author_worktree_env=author_worktree_env,
    )
    process_root = os.path.realpath(process_project_root)
    # Canonical repository identity comes from the MCP process checkout (#460),
    # not from the declared task workspace being validated.
    canonical_root = resolve_canonical_repo_root(process_root, process_root)
    return {
        "workspace_path": workspace,
        "process_project_root": process_root,
        "canonical_repo_root": canonical_root,
        "roots_aligned": canonical_root == process_root,
    }


def assess_workspace_repo_membership(
    *,
    workspace_path: str,
    canonical_repo_root: str,
) -> dict:
    """Fail closed when *workspace_path* is not a git worktree of *canonical_repo_root*."""
    workspace = os.path.realpath(workspace_path)
    root = os.path.realpath(canonical_repo_root)
    reasons: list[str] = []

    if not os.path.exists(workspace):
        reasons.append(f"worktree path '{workspace}' does not exist")
        return _membership_assessment(False, reasons, workspace, root, None)

    if not os.path.isdir(workspace):
        reasons.append(f"worktree path '{workspace}' is not a directory")
        return _membership_assessment(False, reasons, workspace, root, None)

    try:
        res = subprocess.run(
            ["git", "-C", workspace, "rev-parse", "--git-common-dir"],
            capture_output=True,
            text=True,
            check=True,
        )
        common_dir = _realpath_git_common_dir(workspace, res.stdout)
    except Exception:
        reasons.append(f"worktree '{workspace}' is not a valid git repository")
        return _membership_assessment(False, reasons, workspace, root, None)

    expected_dir = os.path.realpath(os.path.join(root, ".git"))
    if common_dir != expected_dir:
        reasons.append(
            f"worktree '{workspace}' does not belong to the target repository '{root}'"
        )
    return _membership_assessment(not reasons, reasons, workspace, root, common_dir)


def _membership_assessment(
    proven: bool,
    reasons: list[str],
    workspace: str,
    root: str,
    common_dir: str | None,
) -> dict:
    return {
        "proven": proven,
        "block": not proven,
        "reasons": reasons,
        "workspace_path": workspace,
        "canonical_repo_root": root,
        "git_common_dir": common_dir,
    }


def format_workspace_repo_membership_error(assessment: dict) -> str:
    workspace = assessment.get("workspace_path") or "(unknown)"
    root = assessment.get("canonical_repo_root") or "(unknown)"
    reasons = "; ".join(assessment.get("reasons") or ["unknown repository membership violation"])
    return (
        f"Branches-only mutation guard (#274): {reasons} (fail closed). "
        f"canonical repository root: {root}; workspace: {workspace}."
    )


def assess_author_mutation_worktree(
    *,
    workspace_path: str,
    project_root: str,
    current_branch: str | None = None,
    base_branches: frozenset[str] | None = None,
) -> dict:
    """Fail closed when author mutations are not rooted under ``branches/``."""
    bases = base_branches or BASE_BRANCHES
    reasons: list[str] = []
    root = os.path.realpath(project_root)
    workspace = os.path.realpath(workspace_path)
    branch = (current_branch or "").strip()

    under_branches = is_path_under_branches(workspace, root)
    if not under_branches:
        if workspace == root:
            reasons.append(
                "author mutation blocked: workspace is the stable control checkout; "
                "create or switch to a session-owned worktree under branches/"
            )
        else:
            reasons.append(
                f"author mutation blocked: workspace '{workspace}' is not under "
                f"'{root}/branches/'; create a branches/<task> worktree first"
            )

    if not under_branches and workspace == root and branch and branch not in bases:
        reasons.append(
            f"control checkout drift: branch '{branch}' is not a stable base "
            f"branch ({'/'.join(sorted(bases))})"
        )

    proven = not reasons
    return {
        "proven": proven,
        "block": not proven,
        "reasons": reasons,
        "project_root": root,
        "workspace_path": workspace,
        "under_branches": is_path_under_branches(workspace, root),
        "current_branch": branch or None,
    }


def format_author_mutation_worktree_error(assessment: dict) -> str:
    """Single RuntimeError message for MCP preflight gates."""
    workspace = assessment.get("workspace_path") or "(unknown)"
    root = assessment.get("project_root") or "(unknown)"
    reasons = "; ".join(assessment.get("reasons") or ["unknown branches-only violation"])
    return (
        f"Branches-only mutation guard (#274): {reasons}. "
        f"project root: {root}; workspace: {workspace}. "
        "Create a session-owned worktree under branches/ before mutating."
    )


# ---------------------------------------------------------------------------
# #618 durable author worktree resolution
# ---------------------------------------------------------------------------


def _abs_real(path: str) -> str:
    return os.path.realpath(os.path.abspath((path or "").strip()))


def assess_path_traversal_safety(
    *,
    path: str,
    canonical_repo_root: str,
) -> dict:
    """Fail closed on traversal/symlink escapes outside the target repository.

    Uses ``realpath`` so intermediate symlinks cannot walk outside
    ``canonical_repo_root``. Author mutation workspaces must also land under
    ``branches/`` of that root (enforced separately by the branches-only guard).
    """
    reasons: list[str] = []
    raw = (path or "").strip()
    if not raw:
        return {
            "proven": False,
            "block": True,
            "reasons": ["worktree path is empty (fail closed)"],
            "workspace_path": None,
            "canonical_repo_root": os.path.realpath(canonical_repo_root),
        }
    if "\x00" in raw:
        return {
            "proven": False,
            "block": True,
            "reasons": ["worktree path contains a null byte (fail closed)"],
            "workspace_path": raw,
            "canonical_repo_root": os.path.realpath(canonical_repo_root),
        }

    root = os.path.realpath(canonical_repo_root)
    # Resolve without requiring existence first: abspath then realpath of parents.
    abs_path = os.path.abspath(raw)
    try:
        real = os.path.realpath(abs_path)
    except OSError as exc:
        return {
            "proven": False,
            "block": True,
            "reasons": [f"worktree path could not be resolved safely: {exc}"],
            "workspace_path": abs_path,
            "canonical_repo_root": root,
        }

    root_norm = _normalize_path(root)
    real_norm = _normalize_path(real)
    if real_norm != root_norm and not real_norm.startswith(f"{root_norm}/"):
        reasons.append(
            f"worktree path '{real}' escapes canonical repository root '{root}' "
            "(traversal/symlink safety, fail closed)"
        )
    return {
        "proven": not reasons,
        "block": bool(reasons),
        "reasons": reasons,
        "workspace_path": real,
        "canonical_repo_root": root,
    }


def list_git_worktree_paths(canonical_repo_root: str) -> list[str]:
    """Return realpaths registered in ``git worktree list --porcelain``."""
    root = os.path.realpath(canonical_repo_root)
    try:
        res = subprocess.run(
            ["git", "-C", root, "worktree", "list", "--porcelain"],
            capture_output=True,
            text=True,
            check=False,
        )
    except Exception:
        return []
    if res.returncode != 0:
        return []
    paths: list[str] = []
    for line in (res.stdout or "").splitlines():
        if line.startswith("worktree "):
            raw = line[len("worktree ") :].strip()
            if raw:
                paths.append(os.path.realpath(raw))
    return paths


def path_in_git_worktree_list(path: str, canonical_repo_root: str) -> bool | None:
    """True/False when inventory is available; None when git inventory fails.

    An empty inventory with a working git root is treated as inconclusive
    (``None``) so unit tests and partial sandboxes are not false-negative
    blocked when ``git worktree list`` is mocked/unavailable.
    """
    root = os.path.realpath(canonical_repo_root)
    try:
        res = subprocess.run(
            ["git", "-C", root, "worktree", "list", "--porcelain"],
            capture_output=True,
            text=True,
            check=False,
        )
    except Exception:
        return None
    if res.returncode != 0:
        return None
    inventory: list[str] = []
    for line in (res.stdout or "").splitlines():
        if line.startswith("worktree "):
            raw = line[len("worktree ") :].strip()
            if raw:
                inventory.append(os.path.realpath(raw))
    if not inventory:
        return None
    return os.path.realpath(path) in inventory


def assess_bound_worktree_existence(
    *,
    configured_path: str,
    binding_source: str,
    canonical_repo_root: str | None = None,
    role_kind: str = "author",
    profile_name: str | None = None,
) -> dict:
    """Fail closed when a configured role-bound worktree path is missing (#618)."""
    raw = (configured_path or "").strip()
    if not raw:
        return {
            "proven": True,
            "block": False,
            "bound_worktree_missing": False,
            "path_exists": None,
            "in_git_worktree_list": None,
            "inspected_git_root": None,
            "reasons": [],
            "configured_path": None,
            "binding_source": binding_source,
            "role_kind": role_kind,
            "profile_name": profile_name,
            "blocker_kind": None,
            "operator_recovery": None,
        }

    try:
        real = _abs_real(raw)
    except OSError:
        real = os.path.abspath(raw)

    path_exists = os.path.isdir(real)
    in_list: bool | None = None
    inspected_git_root: str | None = None
    root = (canonical_repo_root or "").strip()
    if root:
        in_list = path_in_git_worktree_list(real, root) if path_exists else False
    if path_exists:
        try:
            res = subprocess.run(
                ["git", "-C", real, "rev-parse", "--show-toplevel"],
                capture_output=True,
                text=True,
                check=False,
            )
            if res.returncode == 0:
                inspected_git_root = (res.stdout or "").strip() or None
        except Exception:
            inspected_git_root = None

    if path_exists:
        return {
            "proven": True,
            "block": False,
            "bound_worktree_missing": False,
            "path_exists": True,
            "in_git_worktree_list": in_list,
            "inspected_git_root": inspected_git_root,
            "reasons": [],
            "configured_path": real,
            "binding_source": binding_source,
            "role_kind": role_kind,
            "profile_name": profile_name,
            "blocker_kind": None,
            "operator_recovery": None,
        }

    reasons = [
        BOUND_WORKTREE_MISSING_MESSAGE,
        (
            f"role/profile '{profile_name or role_kind}' binding via {binding_source} "
            f"points to '{real}' which does not exist on disk"
        ),
        f"path_exists=false; in_git_worktree_list={in_list}; inspected_git_root=null",
    ]
    return {
        "proven": False,
        "block": True,
        "bound_worktree_missing": True,
        "path_exists": False,
        "in_git_worktree_list": False if in_list is not None else False,
        "inspected_git_root": None,
        "reasons": reasons,
        "configured_path": real,
        "binding_source": binding_source,
        "role_kind": role_kind,
        "profile_name": profile_name,
        "blocker_kind": BOUND_WORKTREE_MISSING,
        "operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT,
    }


def format_bound_worktree_missing_error(assessment: dict) -> str:
    """Canonical operator-facing message for a missing author worktree binding."""
    reasons = list(assessment.get("reasons") or [BOUND_WORKTREE_MISSING_MESSAGE])
    recovery = assessment.get("operator_recovery") or OPERATOR_RECOVERY_RECREATE_REPOINT
    profile = assessment.get("profile_name") or assessment.get("role_kind") or "author"
    source = (
        assessment.get("binding_source")
        or assessment.get("workspace_binding_source")
        or "unknown binding"
    )
    path = (
        assessment.get("configured_path")
        or assessment.get("workspace_path")
        or "(unknown)"
    )
    return (
        f"Author worktree binding unhealthy (#618): {'; '.join(reasons)}. "
        f"role/profile: {profile}; binding_source: {source}; configured_path: {path}. "
        f"Operator recovery: {recovery}"
    )


def assess_lock_worktree_ownership(
    *,
    workspace_path: str,
    session_lock_worktree: str | None,
) -> dict:
    """When a live lock records a worktree, mutation workspace must match it."""
    locked = (session_lock_worktree or "").strip()
    if not locked:
        return {
            "proven": True,
            "block": False,
            "reasons": [],
            "workspace_path": os.path.realpath(workspace_path) if workspace_path else None,
            "lock_worktree_path": None,
        }
    workspace = os.path.realpath(workspace_path)
    locked_real = os.path.realpath(locked)
    if workspace != locked_real:
        return {
            "proven": False,
            "block": True,
            "reasons": [
                f"active author issue lock worktree '{locked_real}' does not match "
                f"mutation workspace '{workspace}' (lock ownership, fail closed)"
            ],
            "workspace_path": workspace,
            "lock_worktree_path": locked_real,
        }
    return {
        "proven": True,
        "block": False,
        "reasons": [],
        "workspace_path": workspace,
        "lock_worktree_path": locked_real,
    }


def resolve_durable_author_worktree(
    *,
    worktree_path: str | None = None,
    worktree: str | None = None,
    process_project_root: str,
    active_worktree_env: str | None = None,
    author_worktree_env: str | None = None,
    session_lock_worktree: str | None = None,
    canonical_repo_root: str | None = None,
    profile_name: str | None = None,
    validate: bool = True,
) -> dict:
    """Resolve author mutation workspace without silent control-checkout fallback (#618).

    Candidate priority:
    1. explicit ``worktree_path`` argument
    2. ``worktree`` argument
    3. ``GITEA_ACTIVE_WORKTREE``
    4. ``GITEA_AUTHOR_WORKTREE``
    5. active author issue lock ``worktree_path``
    6. process project root **only** when it is already under ``branches/``

    Configured bindings that point at a missing path fail closed immediately
    (no demotion to the control checkout). Validation (when *validate*) covers
    existence, traversal/symlink safety, repository identity, branches/
    containment, and lock ownership.
    """
    process_root = os.path.realpath(process_project_root)
    canonical = os.path.realpath(canonical_repo_root or process_root)
    reasons: list[str] = []
    role = "author"

    candidates: list[tuple[str | None, str, bool]] = [
        (worktree_path, "worktree_path argument", False),
        (worktree, "worktree argument", False),
        (active_worktree_env, f"{ACTIVE_WORKTREE_ENV} environment variable", True),
        (author_worktree_env, f"{AUTHOR_WORKTREE_ENV} environment variable", True),
        (session_lock_worktree, "active author issue lock worktree", False),
    ]

    selected_path: str | None = None
    selected_source: str | None = None
    existence: dict | None = None

    for candidate, source, _configured in candidates:
        text = (candidate or "").strip()
        if not text:
            continue
        try:
            real = _abs_real(text)
        except OSError:
            real = os.path.abspath(text)

        existence = assess_bound_worktree_existence(
            configured_path=real,
            binding_source=source,
            canonical_repo_root=canonical,
            role_kind=role,
            profile_name=profile_name,
        )
        if existence["block"]:
            # Missing configured binding: fail closed, never fall back (#618).
            return {
                "proven": False,
                "block": True,
                "workspace_path": real,
                "workspace_binding_source": source,
                "process_project_root": process_root,
                "canonical_repo_root": canonical,
                "bound_worktree_missing": True,
                "path_exists": False,
                "in_git_worktree_list": existence.get("in_git_worktree_list"),
                "inspected_git_root": None,
                "reasons": list(existence.get("reasons") or []),
                "blocker_kind": BOUND_WORKTREE_MISSING,
                "operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT,
                "silent_control_fallback": False,
            }

        selected_path = real
        selected_source = source
        break

    if selected_path is None:
        # No explicit/env/lock binding. Allow process root only when it is a
        # branches/ worktree (MCP launched from the task worktree). Never
        # silently bind the stable control checkout.
        if is_path_under_branches(process_root, canonical):
            selected_path = process_root
            selected_source = "MCP process root under branches/ (session-owned)"
        else:
            return {
                "proven": False,
                "block": True,
                "workspace_path": process_root,
                "workspace_binding_source": "no author worktree binding",
                "process_project_root": process_root,
                "canonical_repo_root": canonical,
                "bound_worktree_missing": False,
                "path_exists": os.path.isdir(process_root),
                "in_git_worktree_list": None,
                "inspected_git_root": None,
                "reasons": [
                    "author mutation blocked: workspace is the stable control checkout; "
                    "author mutation requires an explicit validated worktree_path "
                    "or a worktree derived from the active author issue lock; "
                    "silent fallback to the control checkout/master is forbidden (#618)"
                ],
                "blocker_kind": "author_worktree_unbound_control_checkout",
                "operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT,
                "silent_control_fallback": False,
            }

    workspace = selected_path
    source = selected_source or "unknown"
    path_exists = os.path.isdir(workspace)
    inspected_git_root: str | None = None
    in_list: bool | None = None

    if not validate:
        return {
            "proven": True,
            "block": False,
            "workspace_path": workspace,
            "workspace_binding_source": source,
            "process_project_root": process_root,
            "canonical_repo_root": canonical,
            "bound_worktree_missing": False,
            "path_exists": path_exists,
            "in_git_worktree_list": None,
            "inspected_git_root": None,
            "reasons": [],
            "blocker_kind": None,
            "operator_recovery": None,
            "silent_control_fallback": False,
        }

    # Traversal / symlink safety
    safety = assess_path_traversal_safety(
        path=workspace, canonical_repo_root=canonical
    )
    if safety["block"]:
        reasons.extend(safety["reasons"])
    else:
        workspace = safety["workspace_path"] or workspace

    # Existence + git inventory
    if not path_exists:
        reasons.append(BOUND_WORKTREE_MISSING_MESSAGE)
        reasons.append(f"resolved worktree '{workspace}' does not exist")
    else:
        in_list = path_in_git_worktree_list(workspace, canonical)
        try:
            res = subprocess.run(
                ["git", "-C", workspace, "rev-parse", "--show-toplevel"],
                capture_output=True,
                text=True,
                check=False,
            )
            if res.returncode == 0:
                inspected_git_root = (res.stdout or "").strip() or None
        except Exception:
            inspected_git_root = None
        if in_list is False:
            # Only hard-fail when inventory was obtained and the path is absent.
            reasons.append(
                f"worktree '{workspace}' is not listed in git worktree list for "
                f"'{canonical}' (fail closed)"
            )

    # Repository identity
    if path_exists:
        membership = assess_workspace_repo_membership(
            workspace_path=workspace,
            canonical_repo_root=canonical,
        )
        if membership["block"]:
            reasons.extend(membership["reasons"])

    # branches/ containment
    branches = assess_author_mutation_worktree(
        workspace_path=workspace,
        project_root=canonical,
    )
    if branches["block"]:
        reasons.extend(branches["reasons"])

    # Lock ownership (when a lock worktree is recorded)
    lock_own = assess_lock_worktree_ownership(
        workspace_path=workspace,
        session_lock_worktree=session_lock_worktree,
    )
    if lock_own["block"]:
        reasons.extend(lock_own["reasons"])

    # Forbid resolved control checkout even if somehow selected
    if workspace == canonical or workspace == process_root:
        if not is_path_under_branches(workspace, canonical):
            if not any("control checkout" in r for r in reasons):
                reasons.append(
                    "author mutation blocked: resolved workspace is the stable "
                    "control checkout; silent fallback forbidden (#618)"
                )

    block = bool(reasons)
    bound_missing = any("does not exist" in r or BOUND_WORKTREE_MISSING_MESSAGE in r for r in reasons)
    return {
        "proven": not block,
        "block": block,
        "workspace_path": workspace,
        "workspace_binding_source": source,
        "process_project_root": process_root,
        "canonical_repo_root": canonical,
        "bound_worktree_missing": bound_missing,
        "path_exists": path_exists,
        "in_git_worktree_list": in_list,
        "inspected_git_root": inspected_git_root,
        "reasons": reasons,
        "blocker_kind": BOUND_WORKTREE_MISSING if bound_missing else (
            "author_worktree_validation_failed" if block else None
        ),
        "operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT if block else None,
        "silent_control_fallback": False,
    }


def format_durable_author_worktree_error(assessment: dict) -> str:
    """Format fail-closed error for durable author worktree resolution."""
    if assessment.get("bound_worktree_missing") or assessment.get("blocker_kind") == BOUND_WORKTREE_MISSING:
        return format_bound_worktree_missing_error(assessment)
    workspace = assessment.get("workspace_path") or "(unknown)"
    source = assessment.get("workspace_binding_source") or "unknown"
    reasons = "; ".join(
        assessment.get("reasons") or ["author worktree resolution failed"]
    )
    recovery = assessment.get("operator_recovery") or OPERATOR_RECOVERY_RECREATE_REPOINT
    return (
        f"Durable author worktree resolution blocked (#618): {reasons}. "
        f"workspace: {workspace}; binding_source: {source}. "
        f"Operator recovery: {recovery}"
    )
