"""Workflow scope ownership and production-guard hardening (#683).

Implements fail-closed enforcement so sessions cannot:

* mutate source/tests on the root/control checkout (including temporary
  diagnostic edits) without binding an issue-backed ``branches/`` worktree;
* continue out-of-scope source work while locked to a different issue;
* disable, skip, or conceal production root/branches/porcelain guards solely
  because pytest/unittest is loaded.

This module is pure assessment + small durable ledger helpers. Callers gather
live facts (lock, branch, porcelain, worktree path) and pass them in. Existing
root_checkout_guard / author_mutation_worktree assessors remain authoritative;
this module composes typed blockers with exact recovery actions.

Do **not** reintroduce the rejected #681 / ``300a4ca`` patterns:

* early-return from workspace verification under ``_preflight_in_test_mode()``
* porcelain filtering that strips ``*.py`` lines under pytest
"""

from __future__ import annotations

import os
import re
import threading
from typing import Any

import author_mutation_worktree
from reviewer_worktree import parse_dirty_tracked_files

# ── force-on / test isolation ────────────────────────────────────────────────

# When set, production root/branches/scope guards MUST run even under pytest.
# Unit tests that only need preflight-order isolation leave this unset and
# use GITEA_TEST_PORCELAIN / fixtures; real-entrypoint proof sets this to "1".
FORCE_PRODUCTION_GUARDS_ENV = "GITEA_TEST_FORCE_PRODUCTION_GUARDS"

# Existing force signals also mean "exercise production dirtiness paths".
_FORCE_DIRTY_ENV = "GITEA_TEST_FORCE_DIRTY"
_FORCE_PORCELAIN_ENV = "GITEA_TEST_PORCELAIN"

# ── typed blocker kinds ──────────────────────────────────────────────────────

BLOCKER_ROOT_DIAGNOSTIC_EDIT = "root_diagnostic_edit"
BLOCKER_MISSING_ISSUE_SCOPE = "missing_issue_scope"
BLOCKER_OUT_OF_SCOPE_ISSUE = "out_of_scope_issue"
BLOCKER_MISSING_WORKTREE = "missing_issue_worktree"
BLOCKER_UNRECORDED_FAILURE = "unrecorded_workflow_failure"
BLOCKER_PRODUCTION_GUARD = "production_guard_violation"

BLOCKER_KINDS = frozenset(
    {
        BLOCKER_ROOT_DIAGNOSTIC_EDIT,
        BLOCKER_MISSING_ISSUE_SCOPE,
        BLOCKER_OUT_OF_SCOPE_ISSUE,
        BLOCKER_MISSING_WORKTREE,
        BLOCKER_UNRECORDED_FAILURE,
        BLOCKER_PRODUCTION_GUARD,
    }
)

_NEXT_ACTIONS: dict[str, str] = {
    BLOCKER_ROOT_DIAGNOSTIC_EDIT: (
        "Stop editing the control/root checkout. Preserve or discard root WIP "
        "durably, restore root to clean master, lock or create the owning issue, "
        "bind branches/issue-<N>-*, set GITEA_AUTHOR_WORKTREE to that worktree, "
        "then re-run the mutation."
    ),
    BLOCKER_MISSING_ISSUE_SCOPE: (
        "Select or create the owning Gitea issue, claim/lock it "
        "(gitea_mark_issue + gitea_lock_issue), bind branches/issue-<N>-* "
        "from clean master, then re-run the mutation from that worktree."
    ),
    BLOCKER_OUT_OF_SCOPE_ISSUE: (
        "Stop. The active issue lock does not own this work. Release or finish "
        "the current issue lease, then select/create and lock the correct "
        "owning issue, bind its branches/issue-<N>-* worktree, and re-run."
    ),
    BLOCKER_MISSING_WORKTREE: (
        "Bind an issue-backed worktree under branches/ (scripts/worktree-start "
        "or git worktree add branches/issue-<N>-*), set GITEA_AUTHOR_WORKTREE / "
        "worktree_path to that path, keep the control checkout clean on master, "
        "then re-run the mutation."
    ),
    BLOCKER_UNRECORDED_FAILURE: (
        "Record the workflow/tool failure durably first (issue comment or "
        "workflow_scope_guard.record_workflow_failure), then continue only "
        "inside the owning issue-backed worktree."
    ),
    BLOCKER_PRODUCTION_GUARD: (
        "Resolve the production guard violation: clean or isolate the control "
        "checkout, bind the owning issue worktree under branches/, and re-run "
        "with production guards active."
    ),
}

_ISSUE_IN_BRANCH_RE = re.compile(r"issue-(\d+)", re.IGNORECASE)

# In-process durable failure ledger (also written via optional sink callback).
_ledger_lock = threading.Lock()
_failure_ledger: list[dict[str, Any]] = []


class ProductionGuardError(RuntimeError):
    """Fail-closed production guard with typed blocker metadata (#683)."""

    def __init__(
        self,
        message: str,
        *,
        blocker_kind: str,
        exact_next_action: str | None = None,
        reasons: list[str] | None = None,
        details: dict[str, Any] | None = None,
    ) -> None:
        super().__init__(message)
        kind = (blocker_kind or "").strip()
        if kind not in BLOCKER_KINDS:
            kind = BLOCKER_PRODUCTION_GUARD
        self.blocker_kind = kind
        self.exact_next_action = (
            (exact_next_action or "").strip() or _NEXT_ACTIONS[kind]
        )
        self.reasons = list(reasons or [message])
        self.details = dict(details or {})


def production_guards_forced() -> bool:
    """True when the explicit #683 force-on flag requests production guards."""
    return (os.environ.get(FORCE_PRODUCTION_GUARDS_ENV) or "").strip().lower() in {
        "1",
        "true",
        "yes",
        "on",
    }


def purity_order_forced() -> bool:
    """True when tests force preflight-order dirtiness paths (legacy flags)."""
    if os.environ.get(_FORCE_DIRTY_ENV):
        return True
    # GITEA_TEST_PORCELAIN present (even empty) means dirtiness paths are live.
    if os.environ.get(_FORCE_PORCELAIN_ENV) is not None:
        return True
    return False


def production_guards_active(*, in_test_mode: bool) -> bool:
    """Whether production root/branches/scope guards must execute.

    Production (non-test) always active. Under pytest, active when either the
    explicit #683 force-on flag or legacy dirty/porcelain force signals are
    set — never skip production enforcement solely because tests are running
    when force-on is requested.
    """
    if production_guards_forced() or purity_order_forced():
        return True
    return not bool(in_test_mode)


def extract_issue_number_from_branch(branch_name: str | None) -> int | None:
    """Return the first issue-N number embedded in a branch name, if any."""
    text = (branch_name or "").strip()
    if not text:
        return None
    match = _ISSUE_IN_BRANCH_RE.search(text)
    if not match:
        return None
    try:
        return int(match.group(1))
    except ValueError:
        return None


def is_source_or_test_path(path: str) -> bool:
    """True for tracked source/test paths that must not land as root WIP."""
    p = (path or "").replace("\\", "/").lstrip("./")
    if not p:
        return False
    if p.startswith("tests/") or "/tests/" in f"/{p}":
        return True
    if p.endswith((".py", ".pyi", ".toml", ".cfg", ".ini", ".sh")):
        return True
    if p in {"requirements.txt", "pyproject.toml", "setup.py", "setup.cfg"}:
        return True
    return False


def dirty_source_files(porcelain_status: str) -> list[str]:
    """Tracked dirty paths that count as source/test contamination."""
    dirty = parse_dirty_tracked_files(porcelain_status or "")
    return [p for p in dirty if is_source_or_test_path(p)]


def assess_issue_scope_ownership(
    *,
    locked_issue_number: int | None,
    target_issue_number: int | None = None,
    branch_name: str | None = None,
    role_kind: str | None = None,
    require_lock_for_author: bool = False,
) -> dict[str, Any]:
    """Fail closed when the session issue lock does not own the attempted work.

    * Author sessions that require a lock fail when none is held.
    * When a lock exists, the target issue (tool argument) and/or the issue
      number embedded in the branch must match the locked issue.
    * Reviewer/merger/reconciler roles are not issue-scope owners of author
      implementation work and skip the author lock requirement.
    """
    role = (role_kind or "").strip().lower()
    locked = locked_issue_number
    if isinstance(locked, str) and locked.isdigit():
        locked = int(locked)
    if locked is not None:
        try:
            locked = int(locked)
        except (TypeError, ValueError):
            locked = None

    target = target_issue_number
    if target is not None:
        try:
            target = int(target)
        except (TypeError, ValueError):
            target = None

    branch_issue = extract_issue_number_from_branch(branch_name)
    reasons: list[str] = []
    blocker_kind: str | None = None

    # Non-author roles do not take author issue locks for implementation.
    if role in {"reviewer", "merger", "reconciler"}:
        return _scope_ok(locked, target, branch_issue)

    if require_lock_for_author and locked is None:
        reasons.append(
            "no owning issue lock is bound for this author session; "
            "source/test mutation requires selecting or creating an owning issue first"
        )
        blocker_kind = BLOCKER_MISSING_ISSUE_SCOPE

    if locked is not None and target is not None and locked != target:
        reasons.append(
            f"session is locked to issue #{locked} but mutation targets issue "
            f"#{target}; out-of-scope until the owning issue is selected"
        )
        blocker_kind = BLOCKER_OUT_OF_SCOPE_ISSUE

    if locked is not None and branch_issue is not None and locked != branch_issue:
        reasons.append(
            f"session is locked to issue #{locked} but workspace branch is for "
            f"issue #{branch_issue}; bind the matching issue-backed worktree"
        )
        blocker_kind = BLOCKER_OUT_OF_SCOPE_ISSUE

    if reasons:
        kind = blocker_kind or BLOCKER_MISSING_ISSUE_SCOPE
        return {
            "proven": False,
            "block": True,
            "blocker_kind": kind,
            "exact_next_action": _NEXT_ACTIONS[kind],
            "reasons": reasons,
            "locked_issue_number": locked,
            "target_issue_number": target,
            "branch_issue_number": branch_issue,
        }
    return _scope_ok(locked, target, branch_issue)


def assess_root_source_mutation(
    *,
    workspace_path: str,
    canonical_repo_root: str,
    porcelain_status: str,
    current_branch: str | None = None,
    locked_issue_number: int | None = None,
    role_kind: str | None = None,
    mutation_task: str | None = None,
) -> dict[str, Any]:
    """Fail closed for diagnostic/source edits on the control/root checkout.

    Allowed only when the active workspace is under ``branches/``. Dirty
    tracked source/test files on the control checkout always block, including
    temporary/diagnostic/test-only intent.

    #749: ``create_issue`` is a pure remote mutation with no local tree write.
    When *mutation_task* is create_issue and the control checkout has no dirty
    source/test files, the missing-worktree signal is suppressed so the
    sanctioned bootstrap path can proceed. Dirty roots and every other task
    still fail closed.
    """
    role = (role_kind or "").strip().lower()
    if role == "reconciler":
        return {
            "proven": True,
            "block": False,
            "blocker_kind": None,
            "exact_next_action": "proceed",
            "reasons": [],
            "dirty_source_files": [],
        }

    root = os.path.realpath(canonical_repo_root or "")
    workspace = os.path.realpath(workspace_path or root or ".")
    under_branches = author_mutation_worktree.is_path_under_branches(workspace, root)
    dirty_src = dirty_source_files(porcelain_status)
    reasons: list[str] = []
    blocker_kind: str | None = None
    create_issue_bootstrap = False

    if not under_branches and workspace == root and dirty_src:
        # Root workspace with source dirtiness is unattributed root WIP.
        # (Clean-root author binding is enforced by branches-only #274.)
        reasons.append(
            "control/root checkout has tracked source or test edits "
            f"(dirty files: {', '.join(dirty_src)}); diagnostic or temporary "
            "edits on the root checkout are forbidden"
        )
        blocker_kind = BLOCKER_ROOT_DIAGNOSTIC_EDIT

    # Lazy import keeps workflow_scope_guard free of circular import at module load.
    try:
        import create_issue_bootstrap as _cib
    except Exception:  # pragma: no cover - import always available in-tree
        _cib = None

    if (
        not under_branches
        and workspace == root
        and not dirty_src
        and role == "author"
    ):
        if _cib is not None and _cib.is_create_issue_task(mutation_task):
            # #749: clean-root create_issue is the sanctioned bootstrap path.
            create_issue_bootstrap = True
        else:
            # Explicit missing-worktree signal for force-on author entrypoints.
            reasons.append(
                "author source/test mutation from the stable control checkout is "
                "forbidden; bind an issue-backed worktree under branches/ first"
            )
            blocker_kind = BLOCKER_MISSING_WORKTREE

    if reasons:
        kind = blocker_kind or BLOCKER_ROOT_DIAGNOSTIC_EDIT
        next_action = _NEXT_ACTIONS[kind]
        if (
            kind == BLOCKER_ROOT_DIAGNOSTIC_EDIT
            and _cib is not None
            and _cib.is_create_issue_task(mutation_task)
        ):
            next_action = _cib.EXACT_NEXT_ACTION_BOOTSTRAP
        return {
            "proven": False,
            "block": True,
            "blocker_kind": kind,
            "exact_next_action": next_action,
            "reasons": reasons,
            "dirty_source_files": dirty_src,
            "workspace_path": workspace,
            "canonical_repo_root": root,
            "under_branches": under_branches,
            "locked_issue_number": locked_issue_number,
            "create_issue_bootstrap": False,
        }
    return {
        "proven": True,
        "block": False,
        "blocker_kind": None,
        "exact_next_action": "proceed",
        "reasons": [],
        "dirty_source_files": dirty_src,
        "workspace_path": workspace,
        "canonical_repo_root": root,
        "under_branches": under_branches,
        "locked_issue_number": locked_issue_number,
        "create_issue_bootstrap": create_issue_bootstrap,
    }


def assess_production_mutation_guards(
    *,
    workspace_path: str,
    canonical_repo_root: str,
    porcelain_status: str,
    current_branch: str | None = None,
    locked_issue_number: int | None = None,
    target_issue_number: int | None = None,
    role_kind: str | None = None,
    require_author_lock: bool = False,
    in_test_mode: bool = False,
    mutation_task: str | None = None,
) -> dict[str, Any]:
    """Compose root + scope production guards when they must be active (#683)."""
    if not production_guards_active(in_test_mode=in_test_mode):
        return {
            "proven": True,
            "block": False,
            "blocker_kind": None,
            "exact_next_action": "proceed",
            "reasons": [],
            "skipped": True,
            "skip_reason": "production guards not active (test isolation without force-on)",
        }

    root_assess = assess_root_source_mutation(
        workspace_path=workspace_path,
        canonical_repo_root=canonical_repo_root,
        porcelain_status=porcelain_status,
        current_branch=current_branch,
        locked_issue_number=locked_issue_number,
        role_kind=role_kind,
        mutation_task=mutation_task,
    )
    if root_assess["block"]:
        return {**root_assess, "skipped": False}

    scope_assess = assess_issue_scope_ownership(
        locked_issue_number=locked_issue_number,
        target_issue_number=target_issue_number,
        branch_name=current_branch,
        role_kind=role_kind,
        require_lock_for_author=require_author_lock,
    )
    if scope_assess["block"]:
        return {**scope_assess, "skipped": False}

    return {
        "proven": True,
        "block": False,
        "blocker_kind": None,
        "exact_next_action": "proceed",
        "reasons": [],
        "skipped": False,
        "root": root_assess,
        "scope": scope_assess,
        "create_issue_bootstrap": bool(root_assess.get("create_issue_bootstrap")),
    }


def raise_if_blocked(assessment: dict[str, Any]) -> None:
    """Raise :class:`ProductionGuardError` when *assessment* blocks."""
    if not assessment or not assessment.get("block"):
        return
    kind = assessment.get("blocker_kind") or BLOCKER_PRODUCTION_GUARD
    reasons = list(assessment.get("reasons") or ["production guard violation"])
    message = (
        f"Workflow scope guard (#683) [{kind}]: {'; '.join(reasons)}. "
        f"exact_next_action: {assessment.get('exact_next_action') or _NEXT_ACTIONS.get(kind, '')}"
    )
    raise ProductionGuardError(
        message,
        blocker_kind=kind,
        exact_next_action=assessment.get("exact_next_action"),
        reasons=reasons,
        details={
            k: v
            for k, v in assessment.items()
            if k
            not in {
                "proven",
                "block",
                "blocker_kind",
                "exact_next_action",
                "reasons",
            }
        },
    )


def block_response(
    assessment: dict[str, Any] | ProductionGuardError | None = None,
    *,
    blocker_kind: str | None = None,
    reasons: list[str] | None = None,
    exact_next_action: str | None = None,
    **extra: Any,
) -> dict[str, Any]:
    """Structured fail-closed tool response with typed blocker fields."""
    if isinstance(assessment, ProductionGuardError):
        kind = assessment.blocker_kind
        reason_list = list(assessment.reasons)
        next_action = assessment.exact_next_action
        extra = {**assessment.details, **extra}
    elif isinstance(assessment, dict) and assessment.get("block"):
        kind = assessment.get("blocker_kind") or BLOCKER_PRODUCTION_GUARD
        reason_list = list(assessment.get("reasons") or [])
        next_action = assessment.get("exact_next_action") or _NEXT_ACTIONS.get(
            kind, _NEXT_ACTIONS[BLOCKER_PRODUCTION_GUARD]
        )
    else:
        kind = (blocker_kind or BLOCKER_PRODUCTION_GUARD).strip()
        if kind not in BLOCKER_KINDS:
            kind = BLOCKER_PRODUCTION_GUARD
        reason_list = list(reasons or ["production guard violation"])
        next_action = exact_next_action or _NEXT_ACTIONS[kind]

    if kind not in BLOCKER_KINDS:
        kind = BLOCKER_PRODUCTION_GUARD
    if not reason_list:
        reason_list = ["production guard violation"]
    next_action = (next_action or "").strip() or _NEXT_ACTIONS[kind]

    out: dict[str, Any] = {
        "success": False,
        "performed": False,
        "blocker_kind": kind,
        "exact_next_action": next_action,
        "reasons": reason_list,
    }
    for key, value in extra.items():
        if key not in out and value is not None:
            out[key] = value
    return out


def format_production_guard_error(assessment: dict[str, Any]) -> str:
    """Single RuntimeError string carrying kind + exact next action."""
    kind = assessment.get("blocker_kind") or BLOCKER_PRODUCTION_GUARD
    reasons = "; ".join(assessment.get("reasons") or ["production guard violation"])
    next_action = assessment.get("exact_next_action") or _NEXT_ACTIONS.get(
        kind, _NEXT_ACTIONS[BLOCKER_PRODUCTION_GUARD]
    )
    return (
        f"Workflow scope guard (#683) [{kind}]: {reasons}. "
        f"exact_next_action: {next_action}"
    )


# ── durable failure recording ────────────────────────────────────────────────


def record_workflow_failure(
    *,
    kind: str,
    detail: str,
    issue_number: int | None = None,
    task: str | None = None,
    sink: Any | None = None,
) -> dict[str, Any]:
    """Record a workflow/tool failure before source edits continue (#683 AC8).

    *sink* may be a callable ``sink(record)`` (e.g. tests) or omitted for the
    in-process ledger only. Returns the durable record.
    """
    record = {
        "kind": (kind or "workflow_failure").strip() or "workflow_failure",
        "detail": (detail or "").strip(),
        "issue_number": issue_number,
        "task": task,
        "pid": os.getpid(),
    }
    with _ledger_lock:
        _failure_ledger.append(dict(record))
    if callable(sink):
        sink(record)
    return record


def clear_workflow_failure_ledger() -> None:
    """Test helper: reset the in-process failure ledger."""
    with _ledger_lock:
        _failure_ledger.clear()


def workflow_failure_ledger() -> list[dict[str, Any]]:
    """Copy of durable in-process failure records."""
    with _ledger_lock:
        return [dict(r) for r in _failure_ledger]


def assess_durable_failure_recorded(
    *,
    require_record: bool,
    pending_source_mutation: bool,
) -> dict[str, Any]:
    """Block source mutation when a workflow failure was not recorded first."""
    if not require_record or not pending_source_mutation:
        return {
            "proven": True,
            "block": False,
            "blocker_kind": None,
            "exact_next_action": "proceed",
            "reasons": [],
        }
    with _ledger_lock:
        has_record = bool(_failure_ledger)
    if has_record:
        return {
            "proven": True,
            "block": False,
            "blocker_kind": None,
            "exact_next_action": "proceed",
            "reasons": [],
        }
    return {
        "proven": False,
        "block": True,
        "blocker_kind": BLOCKER_UNRECORDED_FAILURE,
        "exact_next_action": _NEXT_ACTIONS[BLOCKER_UNRECORDED_FAILURE],
        "reasons": [
            "workflow/tool failure triggered a need for source changes but no "
            "durable failure record exists yet"
        ],
    }


def porcelain_preserves_python_paths(porcelain_status: str) -> bool:
    """Regression helper: dirty ``*.py`` lines must remain visible (#683)."""
    text = porcelain_status or ""
    for line in text.splitlines():
        stripped = line.strip()
        if stripped.endswith(".py") or ".py " in stripped or stripped.endswith(".py"):
            # Any py path present proves no silent strip of all *.py lines.
            if " M " in f" {stripped}" or stripped[:1] in "MADRCTU" or len(line) >= 4:
                return True
    # Empty porcelain is fine; integrity means we did not strip when present.
    return ".py" not in text


def assert_no_pytest_porcelain_filter(source_text: str) -> list[str]:
    """Static check: production reader must not strip ``*.py`` under pytest."""
    findings: list[str] = []
    lowered = source_text or ""
    if "endswith(\".py\")" in lowered or "endswith('.py')" in lowered:
        if "pytest" in lowered and "porcelain" in lowered.lower():
            findings.append(
                "production porcelain reader must not filter *.py under pytest "
                "(rejected 300a4ca pattern)"
            )
    if "if \"pytest\" in sys.modules" in lowered and "porcelain" in lowered.lower():
        if ".py" in lowered and ("join" in lowered or "endswith" in lowered):
            findings.append(
                "test-mode porcelain filtering of source files is forbidden (#683)"
            )
    return findings


def _scope_ok(
    locked: int | None,
    target: int | None,
    branch_issue: int | None,
) -> dict[str, Any]:
    return {
        "proven": True,
        "block": False,
        "blocker_kind": None,
        "exact_next_action": "proceed",
        "reasons": [],
        "locked_issue_number": locked,
        "target_issue_number": target,
        "branch_issue_number": branch_issue,
    }
