"""Fail-closed guard against manual MCP daemon process killing (#630).

Workflow recovery must use sanctioned reconnect/restart paths only: host
auto-reconnect, an operator-owned restart, or the documented client relaunch. A
session that instead runs ``pkill -f mcp_server.py`` has manipulated the very
host processes its own proof depends on.

Incident origin: a session ran ``ps aux | grep mcp_server``, then
``pkill -f mcp_server.py``, waited for the IDE to respawn the daemons, called
MCP tools, and closed issue #601. Nothing distinguished that closure from one
performed over a sanctioned runtime, and unrelated namespaces may have been
killed as collateral damage.

Partial detection already existed — ``native_mcp_preference.classify_command_path``
flags ``kill``/``pkill`` near ``mcp_server`` as an MCP-server touch, and
``review_workflow_boundary`` classifies a pre-review ``pkill`` as MCP repair
activity — but neither wrote a durable marker nor failed closed on the
review / merge / close mutations that followed.

This module mirrors ``stable_branch_push_guard`` (#671) deliberately: same
contamination-marker shape, same gated-task set, same reconciler-only clear.
Like that guard it is **pure** — callers gather the raw facts (the proposed
command line, known MCP pids, the durable marker, the process environment) and
pass them in, so one implementation serves prompts, MCP gates and tests.
Nothing here kills, spawns or inspects a process, performs I/O, or reads
durable state.

Design rules honoured (from the #630 acceptance criteria):

* Detect ``pkill -f mcp_server.py``, ``pkill -f gitea_mcp_server``, broad
  ``pkill -f mcp``, ``killall`` equivalents, and ``kill <pid>`` of a known MCP
  daemon pid.
* Detect a pattern broad enough to take unrelated namespaces as collateral
  damage (``pkill -f python``) even when it never names MCP.
* Never flag read-only inspection (``ps aux | grep mcp_server``), a sanctioned
  client reconnect, or process management unrelated to the daemons. A bare
  ``kill <pid>`` with no MCP linkage is reported as *ambiguous*, never as
  contamination, so ordinary subprocess work is not false-blocked.
* Never accept operator authorization from a tool argument. Authorization is
  read from the process environment only — which an in-session worker cannot
  set for an already-running daemon. A self-assertable ``operator_authorized``
  argument was rejected in the PR #710 review (finding F1) and is not
  reintroduced here.
* Contamination is never clearable by the same worker session; only a
  reconciler (audit) role may clear it or bypass the gate.
"""

from __future__ import annotations

import os
import re
from typing import Any, Iterable, Iterator

# Single source of truth for both the redactor and the gated-mutation set: the
# #671 guard already owns them, so the two contamination models can never drift
# apart on which mutations a contaminated session may still perform.
from stable_branch_push_guard import (  # noqa: F401  (CONTAMINATION_GATED_TASKS re-exported)
    CONTAMINATION_GATED_TASKS,
    redact_command,
)

CONTAMINATION_KIND = "manual_daemon_kill"

#: The session killed (or pattern-matched) an MCP daemon process directly.
REASON_MANUAL_DAEMON_KILL = "manual_daemon_kill"
#: The pattern was broad enough to sweep unrelated MCP namespaces.
REASON_BROAD_PROCESS_KILL = "broad_process_kill"

#: Operator authorization is read from this environment variable ONLY. It is
#: set outside the workflow session by the operator who owns host maintenance;
#: an in-session worker cannot set it for an already-running daemon. The value
#: is an audit reference (ticket, change id, or operator note) and is recorded
#: on the marker. Never accept this from a tool argument (#710 finding F1).
OPERATOR_AUTHORIZATION_ENV = "GITEA_OPERATOR_DAEMON_MAINTENANCE_AUTHORIZATION"

REMEDIATION = (
    "Manual MCP daemon process killing is not a sanctioned workflow recovery. "
    "Stop, leave the host processes alone, and recover through the client "
    "reconnect / relaunch path (see docs/mcp-namespace-eof-recovery.md) or an "
    "operator-owned restart. This session is workflow-contaminated until a "
    "reconciler audits it; review, merge, close and completion mutations fail "
    "closed until then."
)

# ── command tokenising ────────────────────────────────────────────────────────

# Split a compound command line into simple commands on shell separators so
# ``ps aux | grep mcp_server`` is analysed segment by segment and its harmless
# inspection half never reaches the kill classifier. The background separator
# ``&`` is a separator too: without it ``sleep 1 & pkill -f mcp_server.py`` was
# a single segment whose command position held ``sleep``, so the kill was never
# classified (#787).
#
# Splitting is *quote-aware*, and a regex alternation cannot express that, so
# the scan below replaces the earlier ``_SEGMENT_SPLIT_RE`` pattern. A separator
# only separates where it is syntactically active: outside single and double
# quotes, and not backslash-escaped. Without that, adding ``&`` made every
# benign mention of the canonical kill string classify as a real kill — a commit
# message quoting ``sleep 1 & pkill -f mcp_server.py``, an ``echo`` of the same
# sentence, a ``grep`` for it — and a false contamination marker fails review,
# merge, close and completion mutations closed until a reconciler clears it (PR
# #789 review finding F1). Quote-awareness is not specific to ``&``: it also
# retires the same false-positive class that ``;`` and ``|`` carried before #787.
_SEPARATOR_CHARS = frozenset("|&;\n")

#: Two-character logical separators, consumed whole so ``&&`` and ``||`` are
#: never split into single characters leaving a stray operator behind.
_LOGICAL_SEPARATORS = ("&&", "||")

_KILL_VERBS = frozenset({"kill", "pkill", "killall"})

# Tokens that may legitimately precede the kill verb in command position.
_COMMAND_PREFIXES = frozenset({
    "sudo", "command", "exec", "time", "nohup", "env", "builtin",
})

# Matches the daemon process names: ``mcp_server``/``mcp-server`` (optionally
# ``gitea_``-prefixed, optionally ``.py``) or a standalone ``mcp`` token.
# ``mcpfoo`` deliberately does not match.
_MCP_TARGET_RE = re.compile(
    r"(?:gitea[_-])?mcp[_-]?server|(?<![\w-])mcp(?![\w-])",
    re.IGNORECASE,
)

# Patterns broad enough that matching them would kill unrelated MCP namespaces
# (and unrelated tooling) as collateral damage.
_BROAD_PATTERN_RE = re.compile(
    r"^(?:python[\d.]*|node|uv|venv|java|ruby|perl|\.|\.\*|\*|%)$",
    re.IGNORECASE,
)

# ``pkill``/``killall`` flags that consume the following token as their value,
# so it is not mistaken for a process pattern.
_VALUE_FLAGS = frozenset({
    "-u", "-U", "-g", "-G", "-P", "-t", "-s", "-F", "-M", "-N", "-r",
    "--signal", "--uid", "--euid", "--group", "--parent", "--session",
    "--terminal", "--ns", "--nslist", "--pidfile", "--older",
})

# Sanctioned recovery language — informational only. Its presence never
# suppresses a detected kill; a session that describes a reconnect *and* runs
# ``pkill`` is still contaminated.
_SANCTIONED_RECOVERY_RE = re.compile(
    r"/mcp\s+reconnect|client\s+reconnect|reconnect\s+the\s+(?:ide|client)|"
    r"relaunch\s+the\s+(?:ide|client)|ide\s+restart|operator[- ]owned\s+restart",
    re.IGNORECASE,
)


def _clean(value: str | None) -> str:
    return (value or "").strip()


def _iter_active(text: str) -> Iterator[tuple[int, str]]:
    """Yield ``(index, char)`` for every *syntactically active* character.

    Active means outside single and double quotes and not backslash-escaped —
    the positions where a shell metacharacter actually carries its meaning.
    Quoted runs, the quote characters themselves, and escaped characters are
    skipped, so a separator written inside a commit message or a ``grep``
    pattern is literal text rather than syntax. A backslash escapes nothing
    inside single quotes, matching POSIX.

    An unterminated quote swallows the rest of the line, exactly as it does for
    the shell — which would reject such a command as a syntax error rather than
    run its tail, so nothing executable hides behind it.
    """
    quote: str | None = None
    index = 0
    end = len(text)
    while index < end:
        char = text[index]
        if quote == "'":
            if char == "'":
                quote = None
            index += 1
        elif quote == '"':
            if char == "\\" and index + 1 < end:
                index += 2
            else:
                if char == '"':
                    quote = None
                index += 1
        elif char == "\\" and index + 1 < end:
            index += 2
        elif char in ("'", '"'):
            quote = char
            index += 1
        else:
            yield index, char
            index += 1


def _is_redirection(command: str, index: int, active: frozenset[int]) -> bool:
    """Is the ``&``/``|`` at *index* part of a redirection, not a separator?

    ``2>&1`` and ``>&2`` put the character immediately after a redirection
    operator, and ``&>log`` immediately before one; in neither position does it
    separate commands. Without this, ``a 2>&1`` split into ``['a 2>', '1']``
    (PR #789 review finding F3).
    """
    previous = command[index - 1] if index else ""
    if previous in ("<", ">") and (index - 1) in active:
        return True
    return (
        command[index] == "&"
        and command[index + 1:index + 2] == ">"
        and (index + 1) in active
    )


def _closes_leading_paren(body: str) -> bool:
    """Does *body* end with the active ``)`` matching a stripped leading ``(``?"""
    if not body.endswith(")"):
        return False
    depth = 0
    for index, char in _iter_active(body):
        if char == "(":
            depth += 1
        elif char == ")":
            if depth == 0:
                return index == len(body) - 1
            depth -= 1
    return False


def _strip_subshell(segment: str) -> str:
    """Remove subshell wrappers so ``(pkill -f mcp_server.py)`` is classified.

    The parentheses are shell syntax, not part of the simple command, so a
    wrapped kill otherwise put ``(pkill`` in command position and never
    reached the kill classifier (#787). Nested wrappers are unwrapped too.

    A trailing ``)`` is removed only when it closes a leading ``(`` this call
    stripped. Removing one unconditionally mangled balanced command
    substitution — ``kill $(pgrep -f myapp)`` became ``kill $(pgrep -f myapp``
    (PR #789 review finding F3). An unmatched leading ``(`` is still dropped on
    its own, because splitting a wrapped compound orphans the opening half.
    """
    stripped = segment.strip()
    while stripped.startswith("("):
        body = stripped[1:].strip()
        if _closes_leading_paren(body):
            body = body[:-1].strip()
        stripped = body
    return stripped


def _split_segments(command: str) -> list[str]:
    """Split *command* into simple commands on syntactically active separators."""
    active = frozenset(index for index, _ in _iter_active(command))
    segments: list[str] = []
    start = 0
    index = 0
    end = len(command)
    while index < end:
        char = command[index]
        if (
            char not in _SEPARATOR_CHARS
            or index not in active
            or (char in "&|" and _is_redirection(command, index, active))
        ):
            index += 1
            continue
        width = (
            2
            if command[index:index + 2] in _LOGICAL_SEPARATORS
            and (index + 1) in active
            else 1
        )
        segments.append(command[start:index])
        index += width
        start = index
    segments.append(command[start:])
    return [seg for seg in (_strip_subshell(seg) for seg in segments) if seg]


def is_sanctioned_recovery(text: str | None) -> bool:
    """True when *text* describes a sanctioned reconnect/restart path.

    Informational only: this never downgrades a detected process kill.
    """
    return bool(_SANCTIONED_RECOVERY_RE.search(_clean(text)))


# ── kill classification ───────────────────────────────────────────────────────

def _analyse_kill_segment(
    segment: str,
    *,
    mcp_pids: frozenset[str],
) -> dict[str, Any] | None:
    """Classify one command segment, or return None when it is not a kill."""
    tokens = segment.split()
    idx = 0
    # Skip env assignments and harmless command prefixes (``sudo pkill ...``).
    while idx < len(tokens) and (tokens[idx] in _COMMAND_PREFIXES or "=" in tokens[idx]):
        idx += 1
    if idx >= len(tokens):
        return None

    verb = os.path.basename(tokens[idx]).lower()
    if verb not in _KILL_VERBS:
        return None

    operands: list[str] = []
    skip_next = False
    for token in tokens[idx + 1:]:
        if skip_next:
            skip_next = False
            continue
        if token.startswith("-"):
            if token in _VALUE_FLAGS:
                skip_next = True
            continue
        operands.append(token)

    names_mcp = bool(_MCP_TARGET_RE.search(segment))

    def _result(
        *,
        reason_class: str | None,
        contamination: bool,
        ambiguous: bool,
        reason: str,
    ) -> dict[str, Any]:
        return {
            "verb": verb,
            "operands": operands,
            "reason_class": reason_class,
            "contamination": contamination,
            "ambiguous": ambiguous,
            "reason": reason,
        }

    if verb in {"pkill", "killall"}:
        if names_mcp:
            return _result(
                reason_class=REASON_MANUAL_DAEMON_KILL,
                contamination=True,
                ambiguous=False,
                reason=(
                    f"'{verb}' targets the MCP daemon process pattern; this is "
                    "manual daemon killing, not a sanctioned recovery"
                ),
            )
        broad = [op for op in operands if _BROAD_PATTERN_RE.match(op)]
        if broad:
            return _result(
                reason_class=REASON_BROAD_PROCESS_KILL,
                contamination=True,
                ambiguous=False,
                reason=(
                    f"'{verb}' pattern {broad[0]!r} is broad enough to kill "
                    "unrelated MCP namespaces as collateral damage"
                ),
            )
        if not operands:
            return _result(
                reason_class=None,
                contamination=False,
                ambiguous=True,
                reason=f"'{verb}' with no resolvable pattern; target unknown",
            )
        return _result(
            reason_class=None,
            contamination=False,
            ambiguous=False,
            reason=(
                f"'{verb}' targets {operands!r}, which does not name an MCP "
                "daemon or a broad pattern"
            ),
        )

    # ``kill`` — pid-addressed.
    pids = [op for op in operands if op.isdigit()]
    hits = sorted(set(pids) & mcp_pids, key=int)
    if hits:
        return _result(
            reason_class=REASON_MANUAL_DAEMON_KILL,
            contamination=True,
            ambiguous=False,
            reason=(
                "'kill' targets known MCP daemon pid(s) "
                f"{', '.join(hits)}; this is manual daemon killing"
            ),
        )
    if names_mcp:
        return _result(
            reason_class=REASON_MANUAL_DAEMON_KILL,
            contamination=True,
            ambiguous=False,
            reason="'kill' resolves its target from an MCP daemon process lookup",
        )
    if not pids:
        return _result(
            reason_class=None,
            contamination=False,
            ambiguous=True,
            reason="'kill' with no resolvable numeric pid; target unknown",
        )
    return _result(
        reason_class=None,
        contamination=False,
        ambiguous=True,
        reason=(
            f"'kill' targets pid(s) {', '.join(pids)}, which are not known MCP "
            "daemon pids; pass mcp_pids to resolve the ambiguity"
        ),
    )


def classify_recovery_command(
    command: str | None = None,
    *,
    mcp_pids: Iterable[Any] | None = None,
) -> dict[str, Any]:
    """Classify a proposed command for manual MCP daemon kill intent (#630).

    Pure classification; operator authorization is applied separately by
    :func:`assess_recovery_command`.
    """
    text = _clean(command)
    pid_set = frozenset(
        str(pid).strip() for pid in (mcp_pids or []) if str(pid).strip()
    )

    segments: list[dict[str, Any]] = []
    for raw_segment in _split_segments(text):
        analysed = _analyse_kill_segment(raw_segment, mcp_pids=pid_set)
        if analysed is not None:
            analysed["segment"] = redact_command(raw_segment)
            segments.append(analysed)

    contaminating = [seg for seg in segments if seg["contamination"]]
    return {
        "command_present": bool(text),
        "redacted_command": redact_command(text),
        "process_kill": bool(segments),
        "contamination": bool(contaminating),
        "reason_class": contaminating[0]["reason_class"] if contaminating else None,
        "ambiguous": bool(
            not contaminating and any(seg["ambiguous"] for seg in segments)
        ),
        "sanctioned_recovery": is_sanctioned_recovery(text),
        "segments": segments,
        "reasons": [seg["reason"] for seg in segments],
        "known_mcp_pids": sorted(pid_set, key=lambda p: int(p) if p.isdigit() else 0),
    }


# ── operator authorization ────────────────────────────────────────────────────

def operator_authorization(env: dict[str, str] | None = None) -> dict[str, Any]:
    """Read operator authorization for host daemon maintenance (#630 non-goal 1).

    Authorization comes from :data:`OPERATOR_AUTHORIZATION_ENV` in the process
    environment and from nowhere else. A worker session cannot set an
    environment variable for an already-running daemon, so this cannot be
    self-asserted the way a tool argument could be (#710 finding F1).
    """
    source = env if env is not None else os.environ
    reference = _clean(source.get(OPERATOR_AUTHORIZATION_ENV))
    return {
        "authorized": bool(reference),
        "reference": reference or None,
        "source": OPERATOR_AUTHORIZATION_ENV if reference else None,
        "self_assertable": False,
    }


def assess_recovery_command(
    command: str | None = None,
    *,
    mcp_pids: Iterable[Any] | None = None,
    env: dict[str, str] | None = None,
) -> dict[str, Any]:
    """Classify *command* and apply operator authorization (#630 AC1/AC2)."""
    classification = classify_recovery_command(command, mcp_pids=mcp_pids)
    authorization = operator_authorization(env)
    detected = classification["contamination"]
    contaminated = detected and not authorization["authorized"]
    return {
        "classification": classification,
        "authorization": authorization,
        "contaminated": contaminated,
        "authorized_bypass": bool(detected and authorization["authorized"]),
        "remediation": REMEDIATION if contaminated else None,
    }


# ── contamination record + gate ───────────────────────────────────────────────

def build_contamination_record(
    *,
    reason_class: str,
    command_redacted: str | None = None,
    session_id: str | None = None,
    remote: str | None = None,
    role: str | None = None,
    detail: str | None = None,
    authorization_reference: str | None = None,
) -> dict[str, Any]:
    """Build the durable contamination marker payload (redacted, audit-safe).

    ``reason_class`` is :data:`REASON_MANUAL_DAEMON_KILL` or
    :data:`REASON_BROAD_PROCESS_KILL`. The command is stored already redacted;
    secrets never persist on the marker.
    """
    return {
        "kind": CONTAMINATION_KIND,
        "reason_class": _clean(reason_class) or REASON_MANUAL_DAEMON_KILL,
        "command_summary": redact_command(command_redacted),
        "session_id": _clean(session_id) or None,
        "remote": _clean(remote) or None,
        "role": _clean(role) or None,
        "detail": _clean(detail) or None,
        "authorization_reference": _clean(authorization_reference) or None,
        "cleared_by_reconciler": False,
    }


def assess_contamination_gate(
    marker: dict[str, Any] | None,
    *,
    task: str | None,
    actual_role: str | None,
) -> dict[str, Any]:
    """Fail closed on gated mutations while a contamination marker is live (#630 AC3).

    * No marker → allowed.
    * Reconciler (audit) role → allowed (the sanctioned inspect/clear path).
    * Marker present + ``task`` in :data:`CONTAMINATION_GATED_TASKS` → blocked.
    * Marker present + non-gated task (``comment_issue``, ``lock_issue``) →
      allowed, so the contaminated worker can still post the durable audit
      comment and hand off.
    """
    if not marker or marker.get("cleared_by_reconciler"):
        return {"block": False, "reasons": [], "task": task}

    role = _clean(actual_role).lower()
    if role == "reconciler":
        return {
            "block": False,
            "reasons": [],
            "task": task,
            "detail": "reconciler audit path is exempt from the contamination gate",
        }

    task_name = _clean(task)
    if task_name and task_name in CONTAMINATION_GATED_TASKS:
        summary = marker.get("command_summary") or marker.get("detail") or "(no summary)"
        reason_class = marker.get("reason_class") or REASON_MANUAL_DAEMON_KILL
        return {
            "block": True,
            "reasons": [
                f"session is workflow-contaminated ({reason_class}): {summary}. "
                f"'{task_name}' is blocked until a reconciler audits and clears "
                "the contamination. " + REMEDIATION
            ],
            "task": task_name,
        }

    return {"block": False, "reasons": [], "task": task_name or None}


def format_contamination_gate_error(gate: dict[str, Any]) -> str:
    """Single RuntimeError message for MCP mutation gates."""
    reasons = "; ".join(gate.get("reasons") or ["session workflow-contaminated"])
    return f"Runtime-recovery contamination gate (#630): {reasons}"


# ── final-report rules ────────────────────────────────────────────────────────

# Claims that assert a clean session. While a marker is live these are false and
# must be rejected rather than merely downgraded.
_CLEAN_CLAIM_RE = re.compile(
    r"\bclean\s+session\b|\bsession\s+(?:is|was|remains)\s+clean\b|"
    r"\bno\s+contamination\b|\buncontaminated\b|\bcontamination\s*[:=]\s*none\b|"
    r"\bworkflow[- ]clean\b|\bno\s+workflow\s+contamination\b",
    re.IGNORECASE,
)

# Language that actually surfaces the contamination to a reader.
_SURFACED_RE = re.compile(
    r"manual[_ ]daemon[_ ]kill|broad[_ ]process[_ ]kill|daemon\s+process\s+kill|"
    r"contaminated\s+recovery|runtime[- ]recovery\s+contamination|"
    r"workflow[- ]contaminated",
    re.IGNORECASE,
)


def assess_final_report_claim(
    report_text: str | None,
    marker: dict[str, Any] | None,
) -> dict[str, Any]:
    """Reject clean-session claims while contaminated (#630 scope item 4).

    A live marker imposes two obligations on the final report: it must surface
    the contaminated recovery explicitly, and it must not claim the session is
    clean. Either failure blocks.
    """
    if not marker or marker.get("cleared_by_reconciler"):
        return {
            "block": False,
            "reasons": [],
            "contaminated": False,
            "surfaced": None,
            "clean_claim": False,
        }

    text = _clean(report_text)
    surfaced = bool(_SURFACED_RE.search(text))
    clean_claim = bool(_CLEAN_CLAIM_RE.search(text))

    reasons: list[str] = []
    if clean_claim:
        reasons.append(
            "final report claims a clean session while a live "
            f"{marker.get('reason_class') or CONTAMINATION_KIND} contamination "
            "marker exists; the claim is false and must be removed"
        )
    if not surfaced:
        reasons.append(
            "final report does not surface the contaminated runtime recovery; "
            "the report must state that MCP daemon processes were manually "
            "killed and that the session awaits a reconciler audit"
        )

    return {
        "block": bool(reasons),
        "reasons": reasons,
        "contaminated": True,
        "surfaced": surfaced,
        "clean_claim": clean_claim,
        "reason_class": marker.get("reason_class"),
    }
