"""Authorship warrant ladder — sidecar classifier (#000026 Phase 3).

The 2026-05-08 `virt-back` query landed STRICT/EVIDENCE-WARRANTED
with the cited evidence being a copyright footer
(``© Russell Ballestrini``). That's an acceptable warrant for the
operator-facing answer but not the strongest the substrate could
have surfaced. The reviewer flagged this as Phase 3: classify the
authorship warrant by **strength**, sidecar-only, so that an
operator who asks "who wrote X" sees not just "X said so" but also
*how* the cited evidence supports the authorship claim.

Six-tier ladder (strongest to weakest):

    Tier 1  AUTHOR_PACKAGE_METADATA
            Explicit ``author = "..."`` / ``maintainer = "..."`` in
            packaging metadata (pyproject.toml, setup.py, package.json,
            Cargo.toml, gemspec, ...). Strongest because the project
            owner literally typed their name into the package manifest.

    Tier 2  AUTHOR_REPOSITORY_OWNER
            Repository URL pattern naming the owner (github.com/<user>,
            gitlab.com/<group>, codeberg.org/<user>, ...). Strongest
            after metadata because the platform's authentication binds
            the URL prefix to a verified account.

    Tier 3  AUTHOR_PAGE_BYLINE
            Article-byline patterns: ``By NAME``, ``Author: NAME``,
            ``<meta name="author" content="NAME">``. Standard web/blog
            attribution; not as strong as packaging because byline is
            unverified.

    Tier 4  AUTHOR_PRIMARY_PAGE_TITLE
            The cited evidence is the project's own primary-source
            page (the answer's entity matches the source title). The
            page belonging to the project is itself a (weak) form of
            authorship attestation — the project page's existence on
            the entity's domain implies authorship.

    Tier 5  AUTHOR_COPYRIGHT_FOOTER
            ``© NAME``, ``Copyright YYYY NAME``. The current `virt-back`
            warrant. Acceptable but weak — copyright applies to site
            chrome, not necessarily to the project's authorship.

    Tier 6  AUTHOR_SECONDARY_SOURCE
            None of the above; the answer rests on a third-party claim
            (Wikipedia article saying X wrote Y, etc.). Weakest because
            the chain of custody runs through an editor.

Sidecar only — never enters the verifier proof path. Surfaces on
the result dict as ``authorship_warrant`` for inspect / providence /
render-line consumers.
"""

from __future__ import annotations

import re
from dataclasses import dataclass, field
from typing import Any


# ---------- Tier registry --------------------------------------------------

TIER_NAMES = (
    "AUTHOR_PACKAGE_METADATA",        # 1
    "AUTHOR_REPOSITORY_OWNER",        # 2
    "AUTHOR_PAGE_BYLINE",             # 3
    "AUTHOR_PRIMARY_PAGE_TITLE",      # 4
    "AUTHOR_COPYRIGHT_FOOTER",        # 5
    "AUTHOR_SECONDARY_SOURCE",        # 6
)

# Map tier name → 1-based rank (lower = stronger).
TIER_RANK: dict[str, int] = {name: i + 1 for i, name in enumerate(TIER_NAMES)}

# Special: NO_AUTHORSHIP_SIGNAL is for cases where the answer doesn't
# actually claim authorship of anything — useful for the sidecar to
# stay quiet rather than falsely report a tier-6 warrant.
NO_AUTHORSHIP_SIGNAL = "NO_AUTHORSHIP_SIGNAL"


# ---------- Detector regexes ----------------------------------------------

# Tier 1 — packaging-metadata patterns. Cover Python (TOML / setup.py),
# JS (package.json), Rust (Cargo.toml), Ruby (.gemspec). All variants
# are TEXT-shape; we don't parse the formats themselves, just look for
# the lexical pattern that says "author = NAME". Two regexes — keeping
# them separate is clearer than a single Frankenstein pattern that
# tries to handle simple-quoted AND TOML-inline-table variants.
#
# (a) Simple key-equals-quoted-name:
#     author = "NAME"          (Python setup.py)
#     "author": "NAME"         (package.json)
#     authors = ["NAME"]       (TOML scalar list)
#     maintainer: NAME         (yaml-ish)
_PACKAGE_METADATA_SIMPLE_RE = re.compile(
    r"""
    [\"']?(?:author|maintainer|authors|maintainers)[\"']?
    \s*[:=]\s*                             # `:` (json/yaml) or `=` (toml/py)
    \[?\s*[\"']                            # optional `[`, then opening quote
    (?P<name>[A-Z][\w\s\.\-']{1,79})       # capture
    [\"']                                  # closing quote
    """,
    re.VERBOSE | re.IGNORECASE,
)

# (b) TOML inline-table form (PEP 621):
#     authors = [{ name = "NAME" }, …]
#     maintainers = [{ name = "NAME" }]
_PACKAGE_METADATA_TOML_INLINE_RE = re.compile(
    r"""
    (?:author|maintainer|authors|maintainers)
    \s*=\s*\[\s*                           # `authors = [`
    \{\s*name\s*=\s*[\"']                  # `{ name = "`
    (?P<name>[A-Z][\w\s\.\-']{1,79})
    [\"']
    """,
    re.VERBOSE | re.IGNORECASE | re.DOTALL,
)

# Tier 2 — repo-owner URL patterns. github.com/<owner>/<repo>, etc.
_REPO_OWNER_RE = re.compile(
    r"\b(?:github\.com|gitlab\.com|codeberg\.org|bitbucket\.org)"
    r"/(?P<owner>[A-Za-z0-9][\w\-]{0,38})/[A-Za-z0-9][\w\-\.]{0,99}",
    re.IGNORECASE,
)

# Tier 3 — page byline patterns. "By NAME", "Author: NAME",
# <meta name="author" content="NAME">. NAME starts with capital letter.
# The prefix ("by", "author", etc.) is case-insensitive via the
# inline `(?i:...)` flag; the name capture stays case-sensitive so
# `[A-Z]` actually means "starts with uppercase" — IGNORECASE on the
# whole regex would defeat the capitalization check.
_BYLINE_PROSE_RE = re.compile(
    r"\b(?i:(?:by|author(?:ed)?(?:\s+by)?|written\s+by|posted\s+by))"
    r"\s*[:\-]?\s*"
    r"(?P<name>[A-Z][\w\.\-']{1,40}(?:\s+[A-Z][\w\.\-']{1,40}){0,3})",
)
_BYLINE_META_RE = re.compile(
    r"<meta\s+[^>]*name\s*=\s*[\"']author[\"'][^>]*content\s*=\s*[\"']"
    r"(?P<name>[^\"'<>]{1,80})",
    re.IGNORECASE,
)

# Tier 5 — copyright footer. "© NAME", "Copyright YYYY NAME".
_COPYRIGHT_RE = re.compile(
    r"(?:©|\bcopyright\b|\(c\))\s*"
    r"(?:\d{4}(?:\s*[\-–]\s*\d{4})?\s*)?"     # optional year / year-year
    r"(?P<name>[A-Z][\w\.\-']{1,40}(?:\s+[A-Z][\w\.\-']{1,40}){0,3})",
    re.IGNORECASE,
)

# Recognize that the answer is a who-question authorship claim. Verb
# list covers create / build / maintain / own / develop variants.
_AUTHORSHIP_QUESTION_HINT_RE = re.compile(
    r"\b(?:"
    r"who\s+(?:wrote|created|authored|made|built|developed|designed|founded|"
        r"maintains?|maintained|owns?|owned|manages?|managed|runs?)"
    r"|author(?:s)?\s+of"
    r"|creator\s+of"
    r"|maintainer\s+of"
    r"|owner\s+of"
    r")\b",
    re.IGNORECASE,
)


# ---------- Result type ---------------------------------------------------


@dataclass
class WarrantClassification:
    """Sidecar verdict for an authorship claim.

    ``tier`` is the strongest tier whose detector fired; ``signals``
    enumerates every detector that matched (a copyright footer plus a
    repo URL might both fire — the tier reflects the strongest, but
    the operator sees the full set in ``signals``).
    """

    tier: str
    tier_rank: int
    signals: list[dict] = field(default_factory=list)
    candidate_names: list[str] = field(default_factory=list)
    note: str = ""

    def to_dict(self) -> dict[str, Any]:
        return {
            "tier": self.tier,
            "tier_rank": self.tier_rank,
            "signals": self.signals,
            "candidate_names": self.candidate_names,
            "note": self.note,
        }


# ---------- Public entrypoint ---------------------------------------------


def diagnose_authorship_warrant(
    *,
    question_text: str | None = None,
    answer_text: str = "",
    cited_evidence_spans: list[str] | None = None,
    cited_source_uris: list[str] | None = None,
    cited_source_titles: list[str] | None = None,
) -> dict[str, Any]:
    """Classify the strongest authorship-warrant signal in the cited
    evidence. Always returns a dict (sidecar contract); the dict's
    ``tier`` field carries the verdict.

    When the question doesn't smell like an authorship question
    (``who wrote X``, ``author of Y``, ``creator of Z``), returns
    ``tier = NO_AUTHORSHIP_SIGNAL`` immediately so the sidecar stays
    quiet on irrelevant queries.

    The function NEVER raises on bad input — silently treats missing
    fields as empty. Sidecar discipline.
    """
    # Filter Nones / non-strings — sidecar must never raise on bad input.
    spans = [s for s in (cited_evidence_spans or []) if isinstance(s, str)]
    uris = [u for u in (cited_source_uris or []) if isinstance(u, str)]
    titles = [t for t in (cited_source_titles or []) if isinstance(t, str)]

    # Quick gate: only fire on questions that smell like authorship.
    if question_text and not _AUTHORSHIP_QUESTION_HINT_RE.search(question_text):
        return WarrantClassification(
            tier=NO_AUTHORSHIP_SIGNAL,
            tier_rank=99,
            note="question does not look like an authorship inquiry",
        ).to_dict()

    signals: list[dict] = []
    candidates: set[str] = set()

    # Tier 1 — package metadata. Both the simple `author = "X"` form
    # and the TOML inline-table `authors = [{ name = "X" }]` form.
    for span in spans:
        for regex in (_PACKAGE_METADATA_TOML_INLINE_RE, _PACKAGE_METADATA_SIMPLE_RE):
            for m in regex.finditer(span):
                name = (m.group("name") or "").strip()
                if name and not _looks_like_noise(name):
                    signals.append({
                        "tier": "AUTHOR_PACKAGE_METADATA",
                        "matched": m.group(0).strip()[:80],
                        "name": name,
                    })
                    candidates.add(name)

    # Tier 2 — repo owner URLs.
    for u in uris:
        m = _REPO_OWNER_RE.search(u)
        if m:
            owner = m.group("owner")
            signals.append({
                "tier": "AUTHOR_REPOSITORY_OWNER",
                "matched": m.group(0),
                "name": owner,
            })
            candidates.add(owner)
    # Also scan span text for inline repo URLs (often present in READMEs).
    for span in spans:
        for m in _REPO_OWNER_RE.finditer(span):
            owner = m.group("owner")
            signals.append({
                "tier": "AUTHOR_REPOSITORY_OWNER",
                "matched": m.group(0),
                "name": owner,
            })
            candidates.add(owner)

    # Tier 3 — page byline (prose + meta).
    for span in spans:
        for m in _BYLINE_PROSE_RE.finditer(span):
            name = m.group("name").strip()
            if not _looks_like_noise(name):
                signals.append({
                    "tier": "AUTHOR_PAGE_BYLINE",
                    "matched": m.group(0).strip()[:80],
                    "name": name,
                })
                candidates.add(name)
        for m in _BYLINE_META_RE.finditer(span):
            name = m.group("name").strip()
            if name and not _looks_like_noise(name):
                signals.append({
                    "tier": "AUTHOR_PAGE_BYLINE",
                    "matched": m.group(0).strip()[:80],
                    "name": name,
                })
                candidates.add(name)

    # Tier 4 — primary-page-title heuristic. Triggers when the answer
    # mentions a title that appears in the cited source titles AND the
    # source URI is on what looks like the entity's primary domain.
    # We approximate "primary domain" loosely: any non-Wikipedia URL
    # whose hostname appears in the cited title set (e.g. site
    # russellballestrini.net → article title contains "Russell
    # Ballestrini" or "russellballestrini").
    primary_hits = _detect_primary_page_title(answer_text, uris, titles)
    for hit in primary_hits:
        signals.append(hit)
        if hit.get("name"):
            candidates.add(hit["name"])

    # Tier 5 — copyright footer.
    for span in spans:
        for m in _COPYRIGHT_RE.finditer(span):
            name = m.group("name").strip()
            if not _looks_like_noise(name):
                signals.append({
                    "tier": "AUTHOR_COPYRIGHT_FOOTER",
                    "matched": m.group(0).strip()[:80],
                    "name": name,
                })
                candidates.add(name)

    # Tier 6 — secondary source. Only fires when there's at least one
    # cited evidence span AND none of tiers 1-5 fired. The answer
    # rests on a third-party claim (e.g. Wikipedia editor wrote that
    # X authored Y). The signals list stays empty for tier-6.
    if not signals:
        if spans or uris:
            return WarrantClassification(
                tier="AUTHOR_SECONDARY_SOURCE",
                tier_rank=TIER_RANK["AUTHOR_SECONDARY_SOURCE"],
                signals=[],
                candidate_names=[],
                note="no direct authorship signal in cited evidence; warrant rests on third-party claim",
            ).to_dict()
        return WarrantClassification(
            tier=NO_AUTHORSHIP_SIGNAL,
            tier_rank=99,
            signals=[],
            candidate_names=[],
            note="no cited evidence to classify",
        ).to_dict()

    # Pick the strongest tier among fired signals.
    best_tier = min(signals, key=lambda s: TIER_RANK[s["tier"]])["tier"]
    return WarrantClassification(
        tier=best_tier,
        tier_rank=TIER_RANK[best_tier],
        signals=signals,
        candidate_names=sorted(candidates),
        note=f"strongest signal: {best_tier}",
    ).to_dict()


# ---------- Helpers --------------------------------------------------------


# Tokens that look name-shaped (capitalized) but are noise rather than
# real authorship — section headers, generic web chrome, etc.
_NOISE_NAMES = frozenset({
    "Author", "Authors", "Maintainer", "Maintainers",
    "Copyright", "All", "Rights", "Reserved",
    "Home", "About", "Contact", "Index", "Page",
    "Project", "Projects", "Repository", "Repositories",
    "Github", "GitHub", "Gitlab", "GitLab", "Bitbucket",
    "License", "Licensed", "Open", "Source",
    "True", "False", "None", "Null",
    "January", "February", "March", "April", "May", "June",
    "July", "August", "September", "October", "November", "December",
})


def _looks_like_noise(name: str) -> bool:
    """Reject capitalized tokens that are obviously not names."""
    if not name:
        return True
    if name in _NOISE_NAMES:
        return True
    # Single-letter or single-token all-caps headings are suspect.
    if len(name) <= 2:
        return True
    return False


def _detect_primary_page_title(
    answer_text: str,
    uris: list[str],
    titles: list[str],
) -> list[dict]:
    """Tier-4: cited evidence is the entity's own primary page.

    Heuristic: extract host from each URI; tokenize the host (drop
    www. + tld); if any token appears in the matching cited title
    AND the host isn't a known third-party indexer (wikipedia.org,
    archive.org, etc.), call it a primary-page hit.
    """
    THIRD_PARTY_HOSTS = {
        "wikipedia.org", "wikidata.org", "archive.org",
        "stackoverflow.com", "stackexchange.com",
        "medium.com", "substack.com", "wordpress.com",
        "blogspot.com", "github.io",  # github.io is mixed; conservative reject
    }
    hits: list[dict] = []
    ans_lower_concat = re.sub(r"[^a-z]", "", answer_text.lower())
    for i, uri in enumerate(uris):
        host = _extract_host(uri)
        if not host:
            continue
        # Reject known third-party platforms.
        if any(host == tp or host.endswith("." + tp) for tp in THIRD_PARTY_HOSTS):
            continue
        # Get the matching title (best-effort by index alignment).
        title = titles[i] if i < len(titles) else ""
        host_tokens = _host_tokens(host)
        title_lower_concat = re.sub(r"[^a-z]", "", title.lower())
        # Match logic: a host token (e.g. "russellballestrini") matches
        # if its letters appear consecutively in the title's
        # concatenated lowercase form (which collapses "Russell
        # Ballestrini" → "russellballestrini"). Same fuzziness against
        # the answer text. This handles compound hostnames where the
        # title splits the words via space/punctuation.
        matched: set[str] = set()
        for ht in host_tokens:
            if ht in title_lower_concat and ht in ans_lower_concat:
                matched.add(ht)
        if matched:
            hits.append({
                "tier": "AUTHOR_PRIMARY_PAGE_TITLE",
                "matched": f"host={host} title={title!r}",
                "name": " ".join(sorted(matched)).title(),
            })
    return hits


def _extract_host(uri: str) -> str:
    """Pull the hostname from a URI string, no urllib needed."""
    m = re.match(r"^[a-z]+://([^/\s]+)", uri or "", re.IGNORECASE)
    if not m:
        return ""
    host = m.group(1).lower()
    if host.startswith("www."):
        host = host[4:]
    return host


def _host_tokens(host: str) -> set[str]:
    """Tokenize hostname into content tokens (drop tld + 'www')."""
    if not host:
        return set()
    parts = host.split(".")
    if len(parts) > 1:
        parts = parts[:-1]  # drop tld
    return {p for p in parts if p and p != "www" and len(p) > 2}


__all__ = [
    "TIER_NAMES",
    "TIER_RANK",
    "NO_AUTHORSHIP_SIGNAL",
    "WarrantClassification",
    "diagnose_authorship_warrant",
]
