"""Pre-create issue content gate (#582).

Blocks issue creation when the title/body is a *vague reference* to
out-of-band content the LLM cannot prove it has ("the drafted issue",
"the prepared issue", "the issue we discussed", "the previous draft")
with no durable source pointer.

The rule from #582: an LLM must not fabricate an issue from stale chat
memory. When the requested issue content is a vague reference and no
durable source pointer (existing issue/PR/comment, checked-in file, URL,
or scratchpad path) is present, the gate fails closed and the caller must
return BLOCKED + DIAGNOSE naming the exact vague/missing fields.

This gate intentionally does NOT require a non-empty body: title-only
issue creation remains allowed for callers that explicitly want it. It
only blocks content that is a placeholder reference to something the
current context does not contain.
"""

from __future__ import annotations

import re
import unicodedata

# Vague reference phrases that point at out-of-band draft content. Stored
# normalized (lowercase, punctuation-stripped, whitespace-collapsed) so they
# can be matched against normalized field text.
VAGUE_REFERENCE_PHRASES: tuple[str, ...] = (
    "the drafted issue",
    "drafted issue",
    "the prepared issue",
    "prepared issue",
    "the issue we discussed",
    "the issue we talked about",
    "the one we discussed",
    "the previous draft",
    "previous draft",
    "the draft above",
    "the issue above",
    "as discussed",
    "as we discussed",
    "as previously discussed",
    "per our discussion",
    "per our conversation",
    "per our chat",
    "see above",
    "same as before",
    "the issue from earlier",
    "the issue i drafted",
    "the issue you drafted",
)

# A field that only restates a vague phrase (optionally with a leading verb
# such as "create"/"add"/"open"/"file") is still a vague reference.
_LEADING_VERBS = ("create", "add", "open", "file", "make", "please", "the")

# Durable source pointers: if any of these appears, the content points at a
# retrievable source of truth and is NOT treated as a fabricated reference.
_DURABLE_SOURCE_REGEXES: tuple[re.Pattern[str], ...] = (
    re.compile(r"#\d+"),                                   # #402
    re.compile(r"\b(?:issue|pull|pr)\s+#?\d+", re.I),      # issue 402 / PR #17
    re.compile(r"\bcomment\s+#?\d+", re.I),                # comment 8155
    re.compile(r"https?://\S+", re.I),                     # URL
    re.compile(                                            # checked-in file
        r"[\w./-]+\.(?:py|md|json|txt|sh|ya?ml|toml|cfg|ini|rst)\b", re.I
    ),
    re.compile(r"\bscratchpad\b", re.I),                   # scratchpad path
    re.compile(r"(?:^|\s)/[\w./-]+"),                      # absolute path
)


def normalize_text(text: str) -> str:
    """Lowercase, punctuation-stripped, whitespace-collapsed text."""
    norm = unicodedata.normalize("NFKC", (text or "").strip().lower())
    norm = re.sub(r"[^\w\s]", " ", norm)
    norm = re.sub(r"\s+", " ", norm).strip()
    return norm


def has_durable_source_pointer(text: str) -> bool:
    """True when the raw text references a durable, retrievable source."""
    raw = text or ""
    return any(rx.search(raw) for rx in _DURABLE_SOURCE_REGEXES)


def find_vague_reference(text: str) -> str | None:
    """Return the vague phrase a field is dominated by, else ``None``.

    A field is a vague reference only when it contains a known vague phrase,
    carries no durable source pointer, and the phrase dominates the field
    (the field is essentially just the phrase). Long, specific bodies that
    merely contain "as discussed" in passing are not blocked.
    """
    if has_durable_source_pointer(text):
        return None
    norm = normalize_text(text)
    if not norm:
        return None
    stripped = norm
    for verb in _LEADING_VERBS:
        if stripped.startswith(verb + " "):
            stripped = stripped[len(verb) + 1:]
    for phrase in VAGUE_REFERENCE_PHRASES:
        if phrase in norm and len(stripped) <= len(phrase) + 12:
            return phrase
    return None


def assess_issue_content(
    title: str,
    body: str = "",
    *,
    allow_incomplete: bool = False,
) -> dict:
    """Evaluate whether proposed issue content is durable enough to create.

    Returns a dict with ``complete`` (bool), ``missing_fields``,
    ``vague_fields``, ``reasons``, ``diagnose`` (joined reasons), and
    ``has_durable_source_pointer``. ``allow_incomplete`` is an explicit
    operator override that forces ``complete`` True.
    """
    t = (title or "").strip()
    b = (body or "").strip()
    missing_fields: list[str] = []
    vague_fields: list[str] = []
    reasons: list[str] = []

    if not t:
        missing_fields.append("title")
        reasons.append("issue title is required")
    else:
        phrase = find_vague_reference(t)
        if phrase:
            vague_fields.append("title")
            reasons.append(
                f"title is a vague reference ('{phrase}') with no durable "
                "content or source pointer"
            )

    if b:
        phrase = find_vague_reference(b)
        if phrase:
            vague_fields.append("body")
            reasons.append(
                f"body is a vague reference ('{phrase}') with no durable "
                "content or source pointer"
            )

    complete = allow_incomplete or (not missing_fields and not vague_fields)
    return {
        "complete": complete,
        "missing_fields": missing_fields,
        "vague_fields": vague_fields,
        "reasons": reasons,
        "diagnose": "; ".join(reasons),
        "has_durable_source_pointer": has_durable_source_pointer(b)
        or has_durable_source_pointer(t),
        "override_applied": bool(
            allow_incomplete and (missing_fields or vague_fields)
        ),
    }


def pre_create_issue_content_gate(
    title: str,
    body: str = "",
    *,
    allow_incomplete: bool = False,
) -> dict:
    """Content gate wrapper mirroring the duplicate gate shape.

    ``performed`` is True when the content is durable enough to create (or an
    explicit override was supplied). When False, the caller must return
    BLOCKED + DIAGNOSE and must not create the issue.
    """
    assessment = assess_issue_content(
        title, body, allow_incomplete=allow_incomplete
    )
    assessment["performed"] = assessment["complete"]
    return assessment
