#!/usr/bin/env python3
"""Pure issue-readiness gate for Studio autonomy."""

from __future__ import annotations

import re
from collections.abc import Iterable
from typing import Any


REQUIRED_HEADERS = (
    "Goal",
    "Acceptance Criteria",
    "Verification",
    "Scope",
    "Out of Scope",
)

TESTABLE_VERBS = re.compile(
    r"\b("
    r"add|append|assert|block|build|check|complete|create|delete|emit|ensure|"
    r"fail|handle|implement|pass|record|reject|remove|render|return|run|show|"
    r"test|update|verify|write"
    r")\b",
    re.IGNORECASE,
)

SENSITIVE_CHANGE_RE = re.compile(
    r"\b("
    r"account\s+change|account\s+settings?|auth|authorization|credential|"
    r"destructive\s+migration|drop\s+table|oauth|password|purge|rm\s+-rf|"
    r"secret|sign-?in|token|wipe"
    r")\b|"
    r"\b(api\s+key|bulk\s+delete|deletion\s+sweep|delete\s+sweep)\b",
    re.IGNORECASE,
)

PAID_RE = re.compile(
    r"\b("
    r"ad\s+spend|billable\s+api|paid\s+(api|media|service|endpoint)|"
    r"premium\s+api|requires?\s+spend"
    r")\b",
    re.IGNORECASE,
)

CONTROL_PLANE_RE = re.compile(
    r"(^|[\s\"'`(=])[\w./-]*autonomy/"
    r"|harness_orchestrator\.sh\b"
    r"|session_workspace\.sh\b"
    r"|dispatch_status\.py\b"
    r"|[\w./-]*autonomy[\w./-]*plist\b"
    r"|(^|[\s\"'`(=])[\w./-]*cost-ledger/",
    re.IGNORECASE,
)

NEGATION_RE = re.compile(
    r"\b(no|not|without|exclude[sd]?|does\s+not|do\s+not|out\s+of\s+scope)\b",
    re.IGNORECASE,
)


def _body(issue: dict[str, Any]) -> str:
    for key in ("body", "description", "markdown"):
        value = issue.get(key)
        if isinstance(value, str):
            return value
    return ""


def _label_names(labels: object) -> set[str]:
    names: set[str] = set()
    if isinstance(labels, dict):
        if "nodes" in labels:
            return _label_names(labels.get("nodes"))
        if "edges" in labels:
            return _label_names(
                [
                    edge.get("node") if isinstance(edge, dict) else edge
                    for edge in labels.get("edges") or []
                ]
            )
        name = str(labels.get("name") or labels.get("label") or "")
        return {name.strip().lower()} if name else names

    if not isinstance(labels, Iterable) or isinstance(labels, (str, bytes)):
        return names
    for label in labels:
        if isinstance(label, str):
            name = label
        elif isinstance(label, dict):
            name = str(label.get("name") or label.get("label") or "")
        else:
            name = str(label)
        if name:
            names.add(name.strip().lower())
    return names


def _flatten(value: Any) -> str:
    if value is None:
        return ""
    if isinstance(value, str):
        return value
    if isinstance(value, dict):
        return "\n".join(_flatten(item) for item in value.values())
    if isinstance(value, Iterable) and not isinstance(value, (bytes, str)):
        return "\n".join(_flatten(item) for item in value)
    return str(value)


def _all_issue_text(issue: dict[str, Any], labels: set[str]) -> str:
    return "\n".join(
        [
            str(issue.get("title") or ""),
            _body(issue),
            "\n".join(labels),
            _flatten(issue.get("paths")),
            _flatten(issue.get("files")),
            _flatten(issue.get("changed_files")),
        ]
    )


def _header_re(header: str) -> re.Pattern[str]:
    escaped = re.escape(header)
    return re.compile(
        rf"(?im)^\s{{0,3}}#{{0,6}}\s*{escaped}\s*(?::.*)?$"
    )


def _has_header(body: str, header: str) -> bool:
    return bool(_header_re(header).search(body))


def _acceptance_criteria(body: str) -> str:
    start = re.search(
        r"(?im)^\s{0,3}#{0,6}\s*Acceptance\ Criteria\s*:?\s*(.*)$", body
    )
    if start is None:
        return ""

    lines = body[start.end() :].splitlines()
    inline = start.group(1).strip()
    collected: list[str] = [inline] if inline else []
    required_names = "|".join(re.escape(name) for name in REQUIRED_HEADERS)
    next_header = re.compile(
        rf"(?im)^\s{{0,3}}#{{0,6}}\s*(?:{required_names})\s*(?::.*)?$"
    )
    for line in lines:
        if next_header.match(line):
            break
        collected.append(line)
    return "\n".join(collected)


def _has_open_pr(issue: dict[str, Any]) -> bool:
    for key in ("open_pr", "open_pr_url", "open_pull_request", "openPullRequest"):
        if issue.get(key):
            return True

    for key in ("open_prs", "pull_requests", "pullRequests", "prs"):
        value = issue.get(key)
        if isinstance(value, dict):
            if "nodes" in value:
                value = value.get("nodes") or []
            elif "edges" in value:
                value = [
                    edge.get("node") if isinstance(edge, dict) else edge
                    for edge in value.get("edges") or []
                ]
            else:
                return True
        if isinstance(value, Iterable) and not isinstance(value, (str, bytes, dict)):
            for item in value:
                if isinstance(item, dict):
                    state = str(item.get("state") or item.get("status") or "").lower()
                    if not state or state in {"open", "opened"}:
                        return True
                elif item:
                    return True
        elif value:
            return True

    pr_state = str(issue.get("pr_state") or issue.get("pull_request_state") or "").lower()
    return pr_state in {"open", "opened"}


def _has_unnegated(pattern: re.Pattern[str], text: str) -> bool:
    for line in text.splitlines():
        if pattern.search(line) and not NEGATION_RE.search(line):
            return True
    return False


def is_ready(issue: dict[str, Any]) -> tuple[bool, list[str]]:
    """Return whether an issue is ready for autonomous build handoff."""
    labels = _label_names(issue.get("labels", []))
    body = _body(issue)
    all_text = _all_issue_text(issue, labels)
    reasons: list[str] = []
    missing_headers: list[str] = []

    for header in REQUIRED_HEADERS:
        if not _has_header(body, header):
            missing_headers.append(header)
            reasons.append(f"missing required header: {header}")

    if "Acceptance Criteria" not in missing_headers:
        acceptance = _acceptance_criteria(body)
        if not acceptance.strip() or not TESTABLE_VERBS.search(acceptance):
            reasons.append("acceptance criteria are not testable")

    if _has_unnegated(SENSITIVE_CHANGE_RE, all_text):
        reasons.append("secrets/auth/account or destructive changes are excluded")

    if _has_unnegated(PAID_RE, all_text) and "spend-ok" not in labels:
        reasons.append("paid media/API work requires spend-ok")

    if CONTROL_PLANE_RE.search(all_text):
        reasons.append("autonomy control-plane paths are excluded")

    if "autonomy-ok" not in labels:
        reasons.append("missing autonomy-ok label")
    if "autonomy-claimed" in labels:
        reasons.append("issue already has autonomy-claimed")
    if "autonomy-blocked" in labels:
        reasons.append("issue has autonomy-blocked")
    if _has_open_pr(issue):
        reasons.append("issue already has an open PR")

    return not reasons, reasons
