"""Fail-closed validation for workflow-changing Gitea comments (#496)."""

from __future__ import annotations

import re
from typing import Any

VALID_ROLES = frozenset({
    "controller",
    "author",
    "reviewer",
    "merger",
    "reconciler",
    "user",
})

_BASE_REQUIRED = ("STATE", "WHO_IS_NEXT", "NEXT_ACTION", "NEXT_PROMPT", "WHY")

_ISSUE_REQUIRED = _BASE_REQUIRED + ("BLOCKERS", "VALIDATION")
_PR_REQUIRED = _BASE_REQUIRED + (
    "ISSUE",
    "HEAD_SHA",
    "REVIEW_STATUS",
    "MERGE_READY",
    "BLOCKERS",
    "VALIDATION",
)
_SUPERSESSION_REQUIRED = _BASE_REQUIRED + (
    "CANONICAL_ITEM",
    "SUPERSEDED_ITEM",
    "CLOSE_OR_KEEP_OPEN",
)

_MACHINE_MARKERS = (
    "<!-- mcp-review-lease:v1 -->",
    "<!-- mcp-conflict-fix-lease:v1 -->",
    "<!-- gitea-issue-claim-heartbeat:v1 -->",
)

_WORKFLOW_TRIGGERS = re.compile(
    r"\b(?:"
    r"blocked|unblocked|ready(?:\s+for\s+(?:review|merge|author))?|"
    r"ready-to-merge|approved|approve|request\s+changes|changes\s+requested|"
    r"superseded|duplicate|canonical|next\s+action|next\s+actor|who\s+is\s+next|"
    r"author\s+should|reviewer\s+should|merger\s+should|reconciler\s+should|"
    r"controller\s+should|issue\s+complete|pr\s+open|pr\s+merged|close\s+this|"
    r"do\s+not\s+merge|needs?\s+rebase|stale\s+approval|contaminated\s+review|"
    r"merge\s+ready|ready\s+for\s+merge"
    r")\b",
    re.IGNORECASE,
)

_CANONICAL_HEADINGS = (
    "## Canonical Issue State",
    "## Canonical PR State",
    "## Canonical Discussion Summary",
)

_FIELD_RE = re.compile(
    r"^([A-Z][A-Z0-9_]*)\s*:\s*(.*)$",
    re.MULTILINE,
)

_VAGUE_NEXT_ACTIONS = frozenset({
    "continue",
    "handle this",
    "fix it",
    "follow up",
    "follow-up",
    "see above",
    "see review",
    "tbd",
    "todo",
    "as needed",
    "proceed",
    "next steps",
    "will check",
    "investigate",
})

_FULL_SHA_RE = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE)
_SHORT_SHA_RE = re.compile(r"\b[0-9a-f]{7,40}\b", re.IGNORECASE)
_PR_REF_RE = re.compile(r"(?:PR\s*#|pull\s*#)\d+|\b#\d{2,}\b", re.IGNORECASE)
_APPROVAL_PROOF_RE = re.compile(
    r"\b(?:approved|approval_at_current_head|APPROVE)\b",
    re.IGNORECASE,
)
_UNBLOCK_RE = re.compile(
    r"\b(?:unblock|until|after|once|when|requires?|must)\b",
    re.IGNORECASE,
)


def _parse_fields(body: str) -> dict[str, str]:
    """Parse KEY: value fields, including values continued on following lines."""
    fields: dict[str, str] = {}
    current_key: str | None = None
    current_lines: list[str] = []

    def _flush() -> None:
        nonlocal current_key, current_lines
        if current_key is not None:
            fields[current_key] = "\n".join(current_lines).strip()
        current_key = None
        current_lines = []

    for line in (body or "").splitlines():
        if line.startswith("## "):
            _flush()
            continue
        match = re.match(r"^([A-Z][A-Z0-9_]*)\s*:\s*(.*)$", line)
        if match:
            _flush()
            current_key = match.group(1).strip().upper()
            rest = match.group(2)
            current_lines = [rest] if rest else []
        elif current_key is not None:
            current_lines.append(line)
    _flush()
    return fields


def _is_machine_generated(body: str) -> bool:
    text = body or ""
    return any(marker in text for marker in _MACHINE_MARKERS)


def _has_canonical_heading(body: str) -> bool:
    return any(h in (body or "") for h in _CANONICAL_HEADINGS)


def is_workflow_changing_comment(body: str) -> bool:
    """True when comment text implies a workflow/state transition."""
    text = (body or "").strip()
    if not text:
        return False
    if _is_machine_generated(text):
        return False
    if _has_canonical_heading(text):
        return True
    if _WORKFLOW_TRIGGERS.search(text):
        return True
    fields = _parse_fields(text)
    if "STATE" in fields or "WHO_IS_NEXT" in fields:
        return True
    return False


def infer_comment_context(body: str, *, explicit: str | None = None) -> str:
    if explicit:
        return explicit
    text = body or ""
    if "## Canonical Discussion Summary" in text:
        return "discussion_summary"
    if "## Canonical PR State" in text:
        return "pr_comment"
    if "## Canonical Issue State" in text:
        return "issue_comment"
    fields = _parse_fields(text)
    if fields.get("CANONICAL_ITEM") or fields.get("SUPERSEDED_ITEM"):
        return "supersession"
    if any(k in fields for k in ("HEAD_SHA", "REVIEW_STATUS", "MERGE_READY", "ISSUE")):
        return "pr_comment"
    return "issue_comment"


def _required_fields_for_context(context: str) -> tuple[str, ...]:
    if context in ("pr_comment", "pr_review"):
        return _PR_REQUIRED
    if context == "supersession":
        return _SUPERSESSION_REQUIRED
    if context == "discussion_summary":
        return _BASE_REQUIRED + ("DECISION", "SUBSTANTIVE_COMMENTS")
    return _ISSUE_REQUIRED


def _is_vague_next_action(value: str) -> bool:
    normalized = re.sub(r"\s+", " ", (value or "").strip().lower())
    normalized = normalized.rstrip(".")
    if not normalized:
        return True
    if normalized in _VAGUE_NEXT_ACTIONS:
        return True
    if len(normalized) < 12:
        return True
    return False


def _next_prompt_ok(value: str) -> bool:
    text = (value or "").strip()
    if len(text) < 40:
        return False
    if text.lower() in {"n/a", "none", "tbd", "todo"}:
        return False
    return True


def _state_value(fields: dict[str, str]) -> str:
    return (fields.get("STATE") or "").strip().lower()


def _suggested_template(context: str) -> str:
    if context == "pr_comment" or context == "pr_review":
        return (
            "## Canonical PR State\n\n"
            "STATE:\n"
            "WHO_IS_NEXT:\n"
            "NEXT_ACTION:\n"
            "NEXT_PROMPT:\n"
            "```text\n<paste-ready prompt>\n```\n"
            "WHAT_HAPPENED:\n"
            "WHY:\n"
            "ISSUE:\n"
            "HEAD_SHA:\n"
            "REVIEW_STATUS:\n"
            "MERGE_READY:\n"
            "BLOCKERS:\n"
            "VALIDATION:\n"
            "LAST_UPDATED_BY:\n"
        )
    if context == "supersession":
        return (
            "## Canonical PR State\n\n"
            "STATE:\nsuperseded\n"
            "WHO_IS_NEXT:\nreconciler\n"
            "NEXT_ACTION:\n"
            "NEXT_PROMPT:\n"
            "WHY:\n"
            "CANONICAL_ITEM:\n"
            "SUPERSEDED_ITEM:\n"
            "CLOSE_OR_KEEP_OPEN:\n"
        )
    if context == "discussion_summary":
        return (
            "## Canonical Discussion Summary\n\n"
            "STATE:\n"
            "WHO_IS_NEXT:\n"
            "DECISION:\n"
            "WHY:\n"
            "SUBSTANTIVE_COMMENTS:\n"
            "NEXT_ACTION:\n"
            "NEXT_PROMPT:\n"
        )
    return (
        "## Canonical Issue State\n\n"
        "STATE:\n"
        "WHO_IS_NEXT:\n"
        "NEXT_ACTION:\n"
        "NEXT_PROMPT:\n"
        "```text\n<paste-ready prompt>\n```\n"
        "WHAT_HAPPENED:\n"
        "WHY:\n"
        "RELATED_PRS:\n"
        "BLOCKERS:\n"
        "VALIDATION:\n"
        "LAST_UPDATED_BY:\n"
    )


def _build_correction_message(
    *,
    missing_fields: list[str],
    vague_fields: list[str],
    extra_reasons: list[str],
    context: str,
) -> str:
    parts = [
        "Canonical comment validation failed (fail closed before posting).",
    ]
    if missing_fields:
        parts.append("Missing fields: " + ", ".join(missing_fields) + ".")
    if vague_fields:
        parts.append("Vague or invalid fields: " + ", ".join(vague_fields) + ".")
    parts.extend(extra_reasons)
    parts.append("Fill the suggested template and retry.")
    parts.append("Suggested template:\n" + _suggested_template(context))
    return "\n".join(parts)


def assess_canonical_comment(
    body: str,
    *,
    context: str | None = None,
    force_workflow: bool = False,
) -> dict[str, Any]:
    """Validate outgoing comment text before a Gitea mutation."""
    text = (body or "").strip()
    ctx = infer_comment_context(text, explicit=context)

    if not text:
        return {
            "allowed": True,
            "is_workflow_comment": False,
            "context": ctx,
            "missing_fields": [],
            "vague_fields": [],
            "correction_message": "",
            "suggested_template": "",
        }

    if _is_machine_generated(text):
        return {
            "allowed": True,
            "is_workflow_comment": False,
            "context": ctx,
            "missing_fields": [],
            "vague_fields": [],
            "correction_message": "",
            "suggested_template": "",
        }

    workflow = force_workflow or is_workflow_changing_comment(text)
    if not workflow:
        return {
            "allowed": True,
            "is_workflow_comment": False,
            "context": ctx,
            "missing_fields": [],
            "vague_fields": [],
            "correction_message": "",
            "suggested_template": "",
        }

    fields = _parse_fields(text)
    required = _required_fields_for_context(ctx)
    missing = [name for name in required if not (fields.get(name) or "").strip()]

    vague: list[str] = []
    extra: list[str] = []

    who = (fields.get("WHO_IS_NEXT") or "").strip().lower()
    if who and who not in VALID_ROLES:
        vague.append("WHO_IS_NEXT")
        extra.append(
            f"WHO_IS_NEXT must be one of: {', '.join(sorted(VALID_ROLES))}."
        )

    next_action = fields.get("NEXT_ACTION") or ""
    if next_action and _is_vague_next_action(next_action):
        vague.append("NEXT_ACTION")

    next_prompt = fields.get("NEXT_PROMPT") or ""
    if not _next_prompt_ok(next_prompt):
        if "NEXT_PROMPT" not in missing:
            vague.append("NEXT_PROMPT")

    state = _state_value(fields)
    blockers = (fields.get("BLOCKERS") or "").strip()
    if "blocked" in state:
        if not blockers or blockers.lower() in {"none", "n/a"}:
            missing.append("BLOCKERS (unblock condition)")
        elif not _UNBLOCK_RE.search(blockers):
            vague.append("BLOCKERS")
            extra.append("BLOCKED state requires an explicit unblock condition in BLOCKERS.")

    if "superseded" in state:
        canon = (fields.get("CANONICAL_ITEM") or fields.get("SUPERSEDED_BY") or "").strip()
        superseded = (fields.get("SUPERSEDED_ITEM") or fields.get("SUPERSEDES") or "").strip()
        if not canon and "CANONICAL_ITEM" not in missing:
            missing.append("CANONICAL_ITEM")
        if not superseded and "SUPERSEDED_ITEM" not in missing:
            missing.append("SUPERSEDED_ITEM")

    if "ready-to-merge" in state or "ready to merge" in state:
        head_sha = fields.get("HEAD_SHA") or ""
        merge_ready = fields.get("MERGE_READY") or ""
        review_status = fields.get("REVIEW_STATUS") or ""
        validation = fields.get("VALIDATION") or ""
        proof_blob = " ".join((head_sha, merge_ready, review_status, validation))
        has_sha = bool(_FULL_SHA_RE.search(proof_blob) or _SHORT_SHA_RE.search(proof_blob))
        has_approval = bool(_APPROVAL_PROOF_RE.search(proof_blob))
        if not has_sha or not has_approval:
            extra.append(
                "ready-to-merge STATE requires approval proof and HEAD_SHA in "
                "REVIEW_STATUS, MERGE_READY, HEAD_SHA, or VALIDATION."
            )

    if ctx in ("pr_comment", "pr_review"):
        head_sha = (fields.get("HEAD_SHA") or "").strip()
        if not head_sha or not _SHORT_SHA_RE.search(head_sha):
            if "HEAD_SHA" not in missing:
                missing.append("HEAD_SHA")

    if ctx == "issue_comment" and _PR_REF_RE.search(text):
        related = (fields.get("RELATED_PRS") or "").strip()
        if not related or related.lower() in {"none", "n/a", "-"}:
            missing.append("RELATED_PRS")

    # #695 AC7: untrusted / offline approval claims cannot certify merger handoff.
    try:
        import review_quarantine as _rq

        for claim_reason in _rq.assess_untrusted_canonical_approval_claim(text):
            extra.append(claim_reason)
    except Exception:
        # Fail closed on import/runtime errors for approval-shaped claims only.
        lower = text.lower()
        if (
            "state:\napproved" in lower
            or "who_is_next:\nmerger" in lower
            or "merge_ready: true" in lower
            or "merge_ready:\ntrue" in lower
            or "ready-to-merge" in lower
        ):
            extra.append(
                "canonical approval/merge-ready claim could not be verified "
                "for native review proof (#695 AC7; fail closed)"
            )

    allowed = not missing and not vague and not extra
    correction = ""
    if not allowed:
        correction = _build_correction_message(
            missing_fields=missing,
            vague_fields=vague,
            extra_reasons=extra,
            context=ctx,
        )

    return {
        "allowed": allowed,
        "is_workflow_comment": True,
        "context": ctx,
        "missing_fields": missing,
        "vague_fields": vague,
        "extra_reasons": extra,
        "correction_message": correction,
        "suggested_template": _suggested_template(ctx) if not allowed else "",
    }