"""Controller issue-acceptance gate helpers (#500).

Pure validation for controller acceptance comments and final-report claims
that an issue is complete. Does not post comments or close issues.
"""

from __future__ import annotations

import re

ACCEPTANCE_HEADING = "controller issue acceptance"

REQUIRED_FIELDS = (
    "STATE",
    "WHO_IS_NEXT",
    "NEXT_ACTION",
    "NEXT_PROMPT",
    "ISSUE",
    "MERGED_PR",
    "MERGE_COMMIT",
    "ACCEPTANCE_CRITERIA_CHECKED",
    "VALIDATION_REVIEWED",
    "CONTROLLER_DECISION",
    "WHY",
)

ACCEPTED_STATES = frozenset({"accepted"})
REJECTION_STATES = frozenset({
    "more-work-required",
    "more_work_required",
    "needs-tests",
    "needs_tests",
    "needs-docs",
    "needs_docs",
    "needs-feature-enhancement",
    "needs_feature_enhancement",
    "needs-follow-up-issue",
    "needs_follow_up_issue",
    "blocked",
})

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

_FIELD_RE = re.compile(r"^\s*(?:[-*]\s*)?([A-Z][A-Z0-9_ ]+)\s*:\s*(.*)$")
_FULL_SHA_RE = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE)
_ISSUE_REF_RE = re.compile(r"#\d+")
_PR_REF_RE = re.compile(r"#\d+")
_CHECKED_ITEM_RE = re.compile(r"\[[xX]\]")
_UNCHECKED_ITEM_RE = re.compile(r"\[[\s]\]")

_CLAIMS_ISSUE_COMPLETE_RE = re.compile(
    r"\bissue\s+(?:is\s+)?(?:complete|completed|accepted|closed\s+as\s+complete|fully\s+satisfied)\b|"
    r"\bissue\s+acceptance\s*:\s*accepted\b|"
    r"\bcontroller\s+acceptance\s*:\s*(?:accepted|complete)\b",
    re.IGNORECASE,
)
_MERGE_ONLY_COMPLETE_RE = re.compile(
    r"(?:pr\s+merged|merged\s+pr|merge\s+result\s*:\s*merged).{0,120}"
    r"(?:issue\s+(?:is\s+)?(?:complete|closed|accepted)|issue\s+complete)",
    re.IGNORECASE | re.DOTALL,
)
_CLAIMS_CONTROLLER_ACCEPTANCE_RE = re.compile(
    r"controller\s+issue\s+acceptance|controller\s+acceptance\s+(?:posted|complete|pending)",
    re.IGNORECASE,
)
_PENDING_ACCEPTANCE_RE = re.compile(
    r"controller\s+acceptance\s+(?:pending|required|not\s+(?:yet\s+)?(?:performed|complete))",
    re.IGNORECASE,
)


def render_controller_acceptance_template() -> str:
    """Return the canonical controller issue-acceptance comment template."""
    return """## Controller Issue Acceptance

STATE:
<accepted | more-work-required | needs-tests | needs-docs | needs-feature-enhancement | needs-follow-up-issue | blocked>

WHO_IS_NEXT:
<author | reviewer | merger | reconciler | controller | user>

NEXT_ACTION:
<one sentence>

NEXT_PROMPT:
<paste-ready prompt for the next LLM>

ISSUE:
#...

MERGED_PR:
#...

MERGE_COMMIT:
<40-character SHA>

ACCEPTANCE_CRITERIA_CHECKED:
- [x] ...
- [ ] ...

VALIDATION_REVIEWED:
<tests/proofs reviewed>

CONTROLLER_DECISION:
<accepted or rejected>

WHY:
<reasoning>

MISSING_WORK:
<none, or exact missing work>

FOLLOW_UP_ISSUES:
<none, or issue list to create>

BLOCKERS:
<none, or exact blockers>

LAST_UPDATED_BY:
<identity/profile/date>
"""


def extract_acceptance_fields(text: str | None) -> dict[str, str]:
    """Return upper-case labeled fields from a controller acceptance block."""
    fields: dict[str, str] = {}
    current_key: str | None = None
    for line in (text or "").splitlines():
        match = _FIELD_RE.match(line)
        if match:
            current_key = match.group(1).strip().upper().replace(" ", "_")
            fields[current_key] = match.group(2).strip()
            continue
        stripped = line.strip()
        if current_key and stripped:
            existing = fields.get(current_key, "")
            fields[current_key] = (
                f"{existing}\n{stripped}" if existing else stripped
            )
    return fields


def contains_acceptance_block(text: str | None) -> bool:
    return ACCEPTANCE_HEADING in (text or "").lower()


def _empty_or_placeholder(value: str | None) -> bool:
    value = (value or "").strip().lower()
    return not value or value in {"none", "n/a", "unknown", "tbd", "<...>", "..."}


def _normalize_state(value: str | None) -> str:
    return (value or "").strip().lower().replace(" ", "_").replace("-", "_")


def validate_controller_acceptance_comment(text: str | None) -> dict:
    """Validate a controller issue-acceptance comment."""
    body = text or ""
    if not contains_acceptance_block(body):
        return {
            "valid": False,
            "fields": {},
            "reasons": ["missing Controller Issue Acceptance heading"],
        }

    fields = extract_acceptance_fields(body)
    reasons: list[str] = []

    for field in REQUIRED_FIELDS:
        if _empty_or_placeholder(fields.get(field)):
            reasons.append(f"missing required controller acceptance field: {field}")

    state = _normalize_state(fields.get("STATE"))
    if state and state not in ACCEPTED_STATES and state not in REJECTION_STATES:
        reasons.append(
            "STATE must be accepted or a rejection path "
            "(more-work-required, needs-tests, needs-docs, "
            "needs-feature-enhancement, needs-follow-up-issue, blocked)"
        )

    actor = (fields.get("WHO_IS_NEXT") or "").strip().lower()
    if actor and actor not in ALLOWED_NEXT_ACTORS:
        reasons.append(
            "WHO_IS_NEXT must be one of: "
            + ", ".join(sorted(ALLOWED_NEXT_ACTORS))
        )

    if not _ISSUE_REF_RE.search(fields.get("ISSUE") or ""):
        reasons.append("ISSUE must cite an issue number (#N)")
    if not _PR_REF_RE.search(fields.get("MERGED_PR") or ""):
        reasons.append("MERGED_PR must cite a merged PR number (#N)")
    if not _FULL_SHA_RE.search(fields.get("MERGE_COMMIT") or ""):
        reasons.append("MERGE_COMMIT must include a full 40-character SHA")

    criteria = fields.get("ACCEPTANCE_CRITERIA_CHECKED") or ""
    if not _CHECKED_ITEM_RE.search(criteria) and not _UNCHECKED_ITEM_RE.search(criteria):
        reasons.append(
            "ACCEPTANCE_CRITERIA_CHECKED must list checked/unchecked criteria items"
        )

    decision = (fields.get("CONTROLLER_DECISION") or "").strip().lower()
    if state in ACCEPTED_STATES:
        if decision not in {"accepted", "accept"}:
            reasons.append("accepted STATE requires CONTROLLER_DECISION: accepted")
        if not _CHECKED_ITEM_RE.search(criteria):
            reasons.append(
                "accepted STATE requires at least one checked acceptance criterion"
            )
        if _empty_or_placeholder(fields.get("WHY")):
            reasons.append("accepted STATE requires WHY with acceptance rationale")
    elif state in REJECTION_STATES:
        if decision not in {"rejected", "reject", "more_work_required"}:
            reasons.append(
                "rejection STATE requires CONTROLLER_DECISION: rejected"
            )
        if _empty_or_placeholder(fields.get("NEXT_PROMPT")):
            reasons.append(
                "rejection STATE requires a paste-ready NEXT_PROMPT for the next actor"
            )
        missing = fields.get("MISSING_WORK") or ""
        if _empty_or_placeholder(missing):
            reasons.append(
                "rejection STATE requires MISSING_WORK describing what is still needed"
            )

    return {
        "valid": not reasons,
        "fields": fields,
        "reasons": reasons,
    }


def claims_issue_complete(text: str | None) -> bool:
    """Return True when text claims an issue is complete/accepted."""
    return bool(_CLAIMS_ISSUE_COMPLETE_RE.search(text or ""))


def claims_merge_only_issue_complete(text: str | None) -> bool:
    """Return True when text treats PR merge as issue completion."""
    return bool(_MERGE_ONLY_COMPLETE_RE.search(text or ""))


def claims_controller_acceptance_update(text: str | None) -> bool:
    return bool(_CLAIMS_CONTROLLER_ACCEPTANCE_RE.search(text or ""))


def notes_controller_acceptance_pending(text: str | None) -> bool:
    return bool(_PENDING_ACCEPTANCE_RE.search(text or ""))


def validate_final_report_issue_acceptance(report_text: str | None) -> dict:
    """Validate issue-completion and controller-acceptance claims in final reports."""
    text = report_text or ""
    reasons: list[str] = []

    complete_claim = claims_issue_complete(text)
    merge_only = claims_merge_only_issue_complete(text)
    acceptance_claim = claims_controller_acceptance_update(text)
    pending_noted = notes_controller_acceptance_pending(text)
    has_block = contains_acceptance_block(text)

    if merge_only and not (has_block and validate_controller_acceptance_comment(text)["valid"]):
        reasons.append(
            "final report treats PR merge as issue completion without controller acceptance proof"
        )

    if complete_claim and not pending_noted:
        if not has_block:
            reasons.append(
                "final report claims issue complete but includes no Controller Issue Acceptance block"
            )
        else:
            result = validate_controller_acceptance_comment(text)
            if not result["valid"]:
                reasons.extend(result["reasons"])
            else:
                state = _normalize_state(result["fields"].get("STATE"))
                if state not in ACCEPTED_STATES:
                    reasons.append(
                        "final report claims issue complete but controller STATE is not accepted"
                    )

    if acceptance_claim and has_block:
        result = validate_controller_acceptance_comment(text)
        if not result["valid"]:
            reasons.extend(result["reasons"])

    applicable = complete_claim or merge_only or acceptance_claim or pending_noted
    return {
        "applicable": applicable,
        "valid": not reasons,
        "reasons": reasons,
    }