"""Stable-control vs dev/test runtime classification and mutation gates (#615).

``docs/architecture/mcp-stable-control-runtime-policy-adr.md`` states the policy:
real Gitea mutations may only be performed by the **stable control runtime**,
while MCP server development happens in isolated ``branches/`` worktrees and
optional dev/test runtimes. The ADR alone is not enforcement — a daemon
relaunched from a feature worktree still holds production credentials and will
happily mutate production issues.

This module supplies the runtime half of that policy:

* :func:`classify_runtime_mode` decides whether the running process is a
  ``stable-control``, ``dev-test``, or ``unknown`` runtime.
* :func:`build_runtime_report` collects the reporting fields the ADR requires
  (mode, SHA, branch, checkout path, process root, workspace, binding, dirty
  files, alignment, and whether real mutations are allowed).
* :func:`assess_runtime_mutation_gate` turns that report into a fail-closed
  mutation gate.
* The post-transport-flap helpers keep namespace re-proving **per namespace**,
  so proving the author namespace never implies the reviewer, merger, or
  reconciler namespace is callable.

Every assessment is pure: callers inject the observed facts, so the logic is
unit-testable without a git checkout or a live daemon. Only the thin
:func:`observe_runtime` reader touches the filesystem.
"""

from __future__ import annotations

import os
import subprocess

# Operator declaration of the runtime this process is. An explicit, valid
# declaration wins over inference — an operator running a packaged release
# layout may have no git checkout to infer from.
ENV_RUNTIME_MODE = "GITEA_MCP_RUNTIME_MODE"
# Escape hatch mirroring the #420 parity gate: disables enforcement only.
ENV_DISABLE = "GITEA_MCP_DISABLE_RUNTIME_MODE_GATE"

RUNTIME_MODE_STABLE = "stable-control"
RUNTIME_MODE_DEV_TEST = "dev-test"
RUNTIME_MODE_UNKNOWN = "unknown"

VALID_RUNTIME_MODES = frozenset(
    {RUNTIME_MODE_STABLE, RUNTIME_MODE_DEV_TEST, RUNTIME_MODE_UNKNOWN}
)

# Branches a stable control checkout is allowed to sit on. Anything else is a
# development checkout by definition (the global worktree rule keeps the
# control checkout on a stable branch).
STABLE_BRANCHES = frozenset({"master", "main", "dev"})

# Path segment that marks an isolated development worktree.
DEV_WORKTREE_SEGMENT = "branches"

BLOCKER_DEV_TEST_PRODUCTION = "dev_test_runtime_targets_production"
BLOCKER_UNKNOWN_RUNTIME = "unknown_runtime_mode"
BLOCKER_DIRTY_STABLE_RUNTIME = "dirty_stable_runtime_checkout"
BLOCKER_DEV_WORKTREE_LAUNCH = "runtime_launched_from_dev_worktree"
BLOCKER_UNSAFE_ALIGNMENT = "unsafe_process_root_workspace_alignment"
BLOCKER_NAMESPACE_NOT_REPROVEN = "namespace_not_reproven_after_flap"

# Namespaces that must each be re-proven independently after a transport flap.
WORKFLOW_NAMESPACES = ("author", "reviewer", "merger", "reconciler")


def gate_disabled() -> bool:
    """Whether the runtime-mode gate is disabled by env escape hatch."""
    return bool((os.environ.get(ENV_DISABLE) or "").strip())


def declared_runtime_mode() -> str | None:
    """Return the operator-declared runtime mode, if a valid one is set.

    An unset or unrecognised value returns ``None`` so classification falls
    back to inference rather than trusting a typo.
    """
    value = (os.environ.get(ENV_RUNTIME_MODE) or "").strip().lower()
    if value in VALID_RUNTIME_MODES:
        return value
    return None


def _path_segments(path: str) -> list[str]:
    return [seg for seg in os.path.normpath(path).split(os.sep) if seg]


def launched_from_dev_worktree(process_root: str | None) -> bool:
    """Whether *process_root* sits inside a ``branches/`` development worktree."""
    if not process_root:
        return False
    return DEV_WORKTREE_SEGMENT in _path_segments(process_root)


def classify_runtime_mode(
    *,
    process_root: str | None,
    checkout_branch: str | None,
    is_git_checkout: bool = True,
    declared_mode: str | None = None,
) -> dict:
    """Classify the runtime this process is serving from.

    ``declared_mode`` (normally :func:`declared_runtime_mode`) is authoritative
    when supplied and valid. Otherwise the mode is inferred:

    * no resolvable root, or a root that is not a git checkout → ``unknown``
      (a packaged deployment must declare its mode explicitly);
    * a root inside a ``branches/`` worktree → ``dev-test``;
    * an unreadable branch → ``unknown``;
    * a stable branch (``master``/``main``/``dev``) → ``stable-control``;
    * any other branch → ``dev-test``.

    Returns the mode plus the discriminating facts and human-readable reasons.
    """
    reasons: list[str] = []
    dev_worktree = launched_from_dev_worktree(process_root)

    if declared_mode in VALID_RUNTIME_MODES:
        reasons.append(
            f"runtime mode declared by operator via {ENV_RUNTIME_MODE}="
            f"{declared_mode}"
        )
        return _mode_result(declared_mode, dev_worktree, True, reasons)

    if not process_root:
        reasons.append(
            "runtime process root could not be resolved; runtime mode is "
            "indeterminate"
        )
        return _mode_result(RUNTIME_MODE_UNKNOWN, dev_worktree, False, reasons)

    if not is_git_checkout:
        reasons.append(
            f"runtime process root '{process_root}' is not a git checkout and "
            f"no {ENV_RUNTIME_MODE} declaration was supplied"
        )
        return _mode_result(RUNTIME_MODE_UNKNOWN, dev_worktree, False, reasons)

    if dev_worktree:
        reasons.append(
            f"runtime was launched from development worktree '{process_root}' "
            f"(inside '{DEV_WORKTREE_SEGMENT}/')"
        )
        return _mode_result(RUNTIME_MODE_DEV_TEST, True, False, reasons)

    branch = (checkout_branch or "").strip()
    if not branch:
        reasons.append(
            f"runtime checkout branch at '{process_root}' could not be read "
            f"(detached HEAD or unreadable); runtime mode is indeterminate"
        )
        return _mode_result(RUNTIME_MODE_UNKNOWN, False, False, reasons)

    if branch in STABLE_BRANCHES:
        reasons.append(
            f"runtime checkout '{process_root}' is on stable branch '{branch}'"
        )
        return _mode_result(RUNTIME_MODE_STABLE, False, False, reasons)

    reasons.append(
        f"runtime checkout '{process_root}' is on development branch "
        f"'{branch}', not a stable branch "
        f"({', '.join(sorted(STABLE_BRANCHES))})"
    )
    return _mode_result(RUNTIME_MODE_DEV_TEST, False, False, reasons)


def _mode_result(mode, dev_worktree, declared, reasons) -> dict:
    return {
        "runtime_mode": mode,
        "dev_worktree_launched": bool(dev_worktree),
        "declared": bool(declared),
        "reasons": list(reasons),
    }


def build_runtime_report(
    *,
    process_root: str | None,
    checkout_branch: str | None,
    runtime_head: str | None,
    active_task_workspace: str | None = None,
    canonical_repository_root: str | None = None,
    repository_slug: str | None = None,
    profile: str | None = None,
    authenticated_identity: str | None = None,
    dirty_files: list[str] | tuple[str, ...] | None = None,
    workspace_roots_aligned: bool | None = None,
    is_git_checkout: bool = True,
    declared_mode: str | None = None,
) -> dict:
    """Build the ADR-required runtime report (#615 acceptance criterion 6).

    ``real_mutations_allowed`` is the summary bit: it is true only when the
    corresponding mutation gate finds nothing to block on for a production
    target.
    """
    classification = classify_runtime_mode(
        process_root=process_root,
        checkout_branch=checkout_branch,
        is_git_checkout=is_git_checkout,
        declared_mode=declared_mode,
    )
    report = {
        "runtime_mode": classification["runtime_mode"],
        "runtime_mode_declared": classification["declared"],
        "runtime_mode_reasons": classification["reasons"],
        "dev_worktree_launched": classification["dev_worktree_launched"],
        "runtime_git_sha": runtime_head,
        "runtime_branch": checkout_branch,
        "runtime_checkout_path": process_root,
        "mcp_process_root": process_root,
        "active_task_workspace": active_task_workspace,
        "canonical_repository_root": canonical_repository_root,
        "repository_slug": repository_slug,
        "profile": profile,
        "authenticated_identity": authenticated_identity,
        "dirty_files": sorted(dirty_files or []),
        "workspace_roots_aligned": workspace_roots_aligned,
        "gate_enforced": not gate_disabled(),
    }
    gate = assess_runtime_mutation_gate(report)
    report["real_mutations_allowed"] = not gate["block"]
    report["mutation_block_reasons"] = gate["reasons"]
    return report


def assess_runtime_mutation_gate(
    report: dict,
    *,
    target_is_production: bool = True,
    namespace: str | None = None,
    namespace_reproof: dict | None = None,
) -> dict:
    """Fail-closed mutation gate for the runtime a mutation would execute in.

    Blocks when (acceptance criterion 7):

    * the runtime is ``dev-test`` and the mutation targets the production
      repository;
    * the runtime mode is ``unknown``;
    * the stable runtime checkout is dirty;
    * the runtime was launched from a development worktree;
    * process-root / workspace alignment is unsafe;
    * (criterion 8) the namespace has not been re-proven since a transport flap.

    The disabled escape hatch never blocks; the caller decides read-vs-mutate
    before calling.
    """
    reasons: list[str] = []
    blockers: list[str] = []

    if gate_disabled():
        return _gate_result(False, blockers, reasons, disabled=True)

    mode = (report or {}).get("runtime_mode")

    if mode == RUNTIME_MODE_UNKNOWN:
        blockers.append(BLOCKER_UNKNOWN_RUNTIME)
        reasons.append(
            "runtime mode is 'unknown'; a runtime that cannot prove it is the "
            f"stable control runtime must not mutate production (declare "
            f"{ENV_RUNTIME_MODE} or run from a stable checkout)"
        )

    if mode == RUNTIME_MODE_DEV_TEST and target_is_production:
        blockers.append(BLOCKER_DEV_TEST_PRODUCTION)
        reasons.append(
            "runtime mode is 'dev-test' and the mutation targets the "
            "production repository; dev/test runtimes must not mutate real "
            "issues or PRs (ADR: stable control runtime vs dev runtime)"
        )

    if report.get("dev_worktree_launched") and target_is_production:
        blockers.append(BLOCKER_DEV_WORKTREE_LAUNCH)
        reasons.append(
            f"runtime was launched from a '{DEV_WORKTREE_SEGMENT}/' development "
            f"worktree ('{report.get('mcp_process_root')}'); production "
            f"mutations require the promoted stable control runtime"
        )

    dirty = list(report.get("dirty_files") or [])
    if mode == RUNTIME_MODE_STABLE and dirty:
        blockers.append(BLOCKER_DIRTY_STABLE_RUNTIME)
        reasons.append(
            "stable control runtime checkout is dirty "
            f"({len(dirty)} file(s): {', '.join(dirty[:5])}"
            f"{'...' if len(dirty) > 5 else ''}); the control plane must run "
            "promoted, unmodified code"
        )

    if report.get("workspace_roots_aligned") is False:
        blockers.append(BLOCKER_UNSAFE_ALIGNMENT)
        reasons.append(
            "process-root / active-workspace alignment is unsafe; the runtime "
            "and the task workspace disagree about which checkout is being "
            "mutated"
        )

    if namespace:
        reproof = assess_namespace_reproof(namespace_reproof, namespace)
        if reproof["reproof_required"] and not reproof["proven"]:
            blockers.append(BLOCKER_NAMESPACE_NOT_REPROVEN)
            reasons.extend(reproof["reasons"])

    return _gate_result(bool(blockers), blockers, reasons)


def _gate_result(block, blockers, reasons, *, disabled=False) -> dict:
    return {
        "block": bool(block),
        "blocker_kinds": list(blockers),
        "blocker_kind": blockers[0] if blockers else None,
        "reasons": list(reasons),
        "gate_disabled": bool(disabled),
    }


def runtime_block_reasons(
    report: dict,
    *,
    target_is_production: bool = True,
    namespace: str | None = None,
    namespace_reproof: dict | None = None,
) -> list[str]:
    """Block reasons for a mutation gate (empty when the mutation may proceed)."""
    gate = assess_runtime_mutation_gate(
        report,
        target_is_production=target_is_production,
        namespace=namespace,
        namespace_reproof=namespace_reproof,
    )
    return gate["reasons"]


def runtime_report_payload(report: dict, gate: dict | None = None) -> dict:
    """Structured recovery payload for permission-block responses."""
    gate = gate or assess_runtime_mutation_gate(report)
    return {
        "kind": "runtime_mode_block",
        "runtime_mode": report.get("runtime_mode"),
        "runtime_git_sha": report.get("runtime_git_sha"),
        "runtime_branch": report.get("runtime_branch"),
        "runtime_checkout_path": report.get("runtime_checkout_path"),
        "blocker_kind": gate.get("blocker_kind"),
        "blocker_kinds": list(gate.get("blocker_kinds") or []),
        "reasons": list(gate.get("reasons") or []),
        "recovery": [
            "Real workflow mutations run only on the promoted stable control "
            "runtime (see docs/architecture/"
            "mcp-stable-control-runtime-policy-adr.md).",
            "Operator action: promote the intended revision into the stable "
            "runtime and reload it — see "
            "docs/stable-runtime-promotion-runbook.md.",
            "Normal author/reviewer/merger/reconciler sessions must not kill, "
            "restart, or relaunch the MCP server themselves.",
        ],
    }


def format_runtime_mode(report: dict) -> str:
    """One-line human summary for logs / runtime context."""
    mode = report.get("runtime_mode") or RUNTIME_MODE_UNKNOWN
    sha = report.get("runtime_git_sha")
    branch = report.get("runtime_branch") or "unknown-branch"
    short = sha[:12] if sha else "unknown-sha"
    suffix = (
        "" if report.get("real_mutations_allowed", True) else " (mutations blocked)"
    )
    return f"{mode} at {short} on {branch}{suffix}"


# ---------------------------------------------------------------------------
# Post-transport-flap namespace re-proving (#615 acceptance criterion 8)
#
# A transport flap (#584) drops every gitea-* namespace at once. Proving the
# author namespace afterwards says nothing about the reviewer, merger, or
# reconciler namespace, so proof is tracked per namespace and a flap
# invalidates all of them.
# ---------------------------------------------------------------------------

REQUIRED_NAMESPACE_PROOF_STEPS = (
    "whoami",
    "runtime_context",
    "capability_resolved",
)


def new_reproof_state() -> dict:
    """Return an empty post-flap re-proving state."""
    return {"flap_at": None, "namespaces": {}}


def record_transport_flap(state: dict | None, *, at: str) -> dict:
    """Record a transport flap: every namespace must be re-proven after *at*.

    Existing per-namespace proofs are kept for audit but no longer satisfy the
    gate, because they were recorded before the flap.
    """
    result = dict(state or new_reproof_state())
    result["flap_at"] = at
    result["namespaces"] = dict(result.get("namespaces") or {})
    return result


def record_namespace_proof(
    state: dict | None,
    namespace: str,
    *,
    at: str,
    whoami: bool = False,
    runtime_context: bool = False,
    capability_resolved: bool = False,
    stale_runtime_reported: bool = False,
) -> dict:
    """Record proof steps completed for exactly one namespace.

    A namespace whose proof reported a reconnect/restart/stale-runtime gate is
    never counted as proven, regardless of which steps ran.
    """
    result = dict(state or new_reproof_state())
    namespaces = dict(result.get("namespaces") or {})
    namespaces[(namespace or "").strip()] = {
        "at": at,
        "whoami": bool(whoami),
        "runtime_context": bool(runtime_context),
        "capability_resolved": bool(capability_resolved),
        "stale_runtime_reported": bool(stale_runtime_reported),
    }
    result["namespaces"] = namespaces
    return result


def assess_namespace_reproof(state: dict | None, namespace: str) -> dict:
    """Whether *namespace* is re-proven after the most recent transport flap.

    ``reproof_required`` is false when no flap has been recorded — this gate
    only speaks to post-flap proof and never invents a requirement.
    """
    ns = (namespace or "").strip()
    store = state or {}
    flap_at = store.get("flap_at")
    reasons: list[str] = []

    if not flap_at:
        return {
            "namespace": ns,
            "reproof_required": False,
            "proven": True,
            "flap_at": None,
            "proof_at": None,
            "missing_steps": [],
            "reasons": reasons,
        }

    entry = (store.get("namespaces") or {}).get(ns)
    if not entry:
        reasons.append(
            f"MCP namespace '{ns}' has not been re-proven since the transport "
            f"flap at {flap_at}; run whoami, runtime context, and capability "
            f"resolve for '{ns}' itself (proof of another namespace does not "
            f"transfer)"
        )
        return {
            "namespace": ns,
            "reproof_required": True,
            "proven": False,
            "flap_at": flap_at,
            "proof_at": None,
            "missing_steps": list(REQUIRED_NAMESPACE_PROOF_STEPS),
            "reasons": reasons,
        }

    proof_at = entry.get("at")
    if proof_at is not None and str(proof_at) < str(flap_at):
        reasons.append(
            f"MCP namespace '{ns}' proof at {proof_at} predates the transport "
            f"flap at {flap_at}; re-prove the namespace before mutating"
        )
        return {
            "namespace": ns,
            "reproof_required": True,
            "proven": False,
            "flap_at": flap_at,
            "proof_at": proof_at,
            "missing_steps": list(REQUIRED_NAMESPACE_PROOF_STEPS),
            "reasons": reasons,
        }

    missing = [step for step in REQUIRED_NAMESPACE_PROOF_STEPS if not entry.get(step)]
    if missing:
        reasons.append(
            f"MCP namespace '{ns}' post-flap proof is incomplete; missing: "
            f"{', '.join(missing)}"
        )
    if entry.get("stale_runtime_reported"):
        missing = missing or ["stale_runtime_clear"]
        reasons.append(
            f"MCP namespace '{ns}' reported a reconnect/restart/stale-runtime "
            f"gate during re-proving; mutation stays blocked until the "
            f"namespace reconnects cleanly"
        )

    return {
        "namespace": ns,
        "reproof_required": True,
        "proven": not missing,
        "flap_at": flap_at,
        "proof_at": proof_at,
        "missing_steps": list(missing),
        "reasons": reasons,
    }


def unproven_namespaces(
    state: dict | None, namespaces=WORKFLOW_NAMESPACES
) -> list[str]:
    """Return the namespaces still requiring post-flap re-proving."""
    out = []
    for ns in namespaces:
        assessment = assess_namespace_reproof(state, ns)
        if assessment["reproof_required"] and not assessment["proven"]:
            out.append(ns)
    return out


# ---------------------------------------------------------------------------
# Promotion records (#615 acceptance criterion 4 / 10)
# ---------------------------------------------------------------------------

PROMOTION_REQUIRED_FIELDS = (
    "previous_runtime_sha",
    "promoted_runtime_sha",
    "source_branch",
    "source_pr",
    "restart_method",
    "health_check_proof",
    "identity_proof",
    "profile_proof",
    "workspace_proof",
    "mutation_capability_proof",
    "rollback_instructions",
)


def assess_promotion_record(record: dict | None) -> dict:
    """Validate an operator promotion record against the ADR checklist.

    A promotion that does not record both the previous and the promoted SHA is
    not a promotion — it is an undocumented restart.
    """
    data = record or {}
    missing = [
        field
        for field in PROMOTION_REQUIRED_FIELDS
        if not str(data.get(field) or "").strip()
    ]
    reasons = []
    if missing:
        reasons.append(
            "promotion record is incomplete; missing: " + ", ".join(missing)
        )
    previous = str(data.get("previous_runtime_sha") or "").strip()
    promoted = str(data.get("promoted_runtime_sha") or "").strip()
    if previous and promoted and previous == promoted:
        reasons.append(
            "promotion record lists the same previous and promoted SHA "
            f"({previous[:12]}); nothing was promoted"
        )
    return {
        "valid": not reasons,
        "missing_fields": missing,
        "reasons": reasons,
    }


# ---------------------------------------------------------------------------
# Filesystem observation (the only impure helper)
# ---------------------------------------------------------------------------


def _git_capture(root: str, *args: str) -> str | None:
    if not root:
        return None
    try:
        res = subprocess.run(
            ["git", "-C", root, *args],
            capture_output=True,
            text=True,
            check=False,
        )
    except Exception:
        return None
    if res.returncode != 0:
        return None
    return (res.stdout or "").strip() or None


def observe_dirty_files(process_root: str | None) -> list[str]:
    """Read the live dirty-file list at *process_root*.

    Split out from :func:`observe_runtime` because dirtiness is the one runtime
    fact that legitimately changes during a process lifetime. The mutation gate
    must re-read it per call rather than trust a startup snapshot, or a checkout
    that goes dirty after the snapshot is never blocked again (#615).
    """
    if not process_root:
        return []
    porcelain = _git_capture(process_root, "status", "--porcelain") or ""
    return [line[3:].strip() for line in porcelain.splitlines() if line.strip()]


def observe_runtime(process_root: str | None) -> dict:
    """Read the runtime facts classification needs from *process_root*.

    Returns ``checkout_branch``, ``runtime_head``, ``is_git_checkout``, and
    ``dirty_files``. Every read failure degrades to ``None``/empty rather than
    raising, so a runtime that cannot be inspected classifies as ``unknown``
    instead of crashing the caller.
    """
    empty = {
        "checkout_branch": None,
        "runtime_head": None,
        "is_git_checkout": False,
        "dirty_files": [],
    }
    if not process_root:
        return empty
    if not _git_capture(process_root, "rev-parse", "--show-toplevel"):
        return empty
    branch = _git_capture(process_root, "rev-parse", "--abbrev-ref", "HEAD")
    if branch == "HEAD":  # detached HEAD has no branch name
        branch = None
    dirty = observe_dirty_files(process_root)
    return {
        "checkout_branch": branch,
        "runtime_head": _git_capture(process_root, "rev-parse", "HEAD"),
        "is_git_checkout": True,
        "dirty_files": dirty,
    }
