"""Check the citation register in docs/TESS-LITERATURE.md.

Run from the repository root:

    python research/tess-timing/scripts/check_tess_refs.py
    python research/tess-timing/scripts/check_tess_refs.py --fetch

The offline pass parses the register table, rejects duplicate or missing keys,
requires a resolvable identifier for every entry, validates identifier syntax,
and rejects placeholder markers. The ``--fetch`` pass additionally resolves each
identifier over the network: DOIs through the Crossref API, arXiv identifiers
through the arXiv abstract page, and other URLs with an HTTP request.

This script is a citation check, not an analysis. It does not read the physics
records, and it makes no network request unless ``--fetch`` is given.
"""

from __future__ import annotations

import argparse
import json
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import date
from pathlib import Path

PACKAGE_ROOT = Path(__file__).resolve().parents[1]
REPO_ROOT = PACKAGE_ROOT.parents[1]
DEFAULT_REGISTER = REPO_ROOT / "docs" / "TESS-LITERATURE.md"

HEADER = ("Key", "Citation", "Identifier", "Accessed", "Supports")

KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")

DOI_RE = re.compile(r"^https://doi\.org/10\.\d{4,9}/\S+$")
ARXIV_NEW_RE = re.compile(
    r"^https://arxiv\.org/(?:abs|pdf)/\d{4}\.\d{4,5}(?:v\d+)?$"
)
ARXIV_OLD_RE = re.compile(
    r"^https://arxiv\.org/(?:abs|pdf)/[a-z-]+(?:\.[A-Z]{2})?/\d{7}(?:v\d+)?$"
)
PUBLISHER_RE = re.compile(
    r"^https://[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?"
    r"\.[A-Za-z]{2,}(?::\d+)?(?:/[^\s]*)?$"
)

PLACEHOLDERS = (
    "lorem ipsum",
    "citation needed",
    "placeholder",
    "to be determined",
    "tbd",
    "todo",
    "unverified",
)

USER_AGENT = (
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
    "AppleWebKit/537.36 (KHTML, like Gecko) "
    "Chrome/126.0 Safari/537.36"
)


class RegisterError(ValueError):
    """Raised when the register is structurally invalid."""


@dataclass(frozen=True)
class Entry:
    key: str
    citation: str
    identifier: str
    accessed: str
    supports: str


def _table_rows(text: str) -> list[tuple[str, ...]]:
    lines = text.splitlines()
    header_index = None
    for index, line in enumerate(lines):
        cells = _split_row(line)
        if cells == HEADER:
            header_index = index
            break
    if header_index is None:
        raise RegisterError(
            "register table header not found: expected "
            + " | ".join(HEADER)
        )

    rows: list[tuple[str, ...]] = []
    for line in lines[header_index + 2 :]:
        if not line.strip().startswith("|"):
            break
        cells = _split_row(line)
        if len(cells) != len(HEADER):
            raise RegisterError(
                f"row has {len(cells)} cells, expected {len(HEADER)}: {line}"
            )
        rows.append(cells)
    return rows


def _split_row(line: str) -> tuple[str, ...]:
    stripped = line.strip()
    if not stripped.startswith("|") or not stripped.endswith("|"):
        return ()
    return tuple(cell.strip() for cell in stripped[1:-1].split("|"))


def parse_register_text(text: str) -> list[Entry]:
    rows = _table_rows(text)
    if not rows:
        raise RegisterError("register table has no entries")

    entries: list[Entry] = []
    seen: set[str] = set()
    for cells in rows:
        key = cells[0]
        if not key:
            raise RegisterError("missing key in register row")
        if not KEY_RE.match(key):
            raise RegisterError(f"invalid key syntax: {key!r}")
        if key in seen:
            raise RegisterError(f"duplicate key: {key}")
        seen.add(key)
        entries.append(Entry(*cells))
    return entries


def parse_register(path: Path = DEFAULT_REGISTER) -> list[Entry]:
    try:
        text = path.read_text(encoding="utf-8")
    except FileNotFoundError as exc:
        raise RegisterError(f"register not found: {path}") from exc
    return parse_register_text(text)


def identifier_kind(identifier: str) -> str | None:
    if DOI_RE.match(identifier):
        return "doi"
    if ARXIV_NEW_RE.match(identifier) or ARXIV_OLD_RE.match(identifier):
        return "arxiv"
    if PUBLISHER_RE.match(identifier):
        return "url"
    return None


def _contains_placeholder(value: str) -> bool:
    lowered = value.lower()
    return any(marker in lowered for marker in PLACEHOLDERS)


def validate_entries(entries: list[Entry]) -> None:
    errors: list[str] = []
    seen: set[str] = set()
    for entry in entries:
        if not entry.key:
            errors.append("missing key")
        elif entry.key in seen:
            errors.append(f"{entry.key}: duplicate key")
        seen.add(entry.key)

        if not entry.citation:
            errors.append(f"{entry.key}: missing citation")
        elif _contains_placeholder(entry.citation):
            errors.append(f"{entry.key}: placeholder marker in citation")

        if not entry.identifier:
            errors.append(f"{entry.key}: missing identifier")
        elif identifier_kind(entry.identifier) is None:
            errors.append(
                f"{entry.key}: invalid identifier syntax: {entry.identifier}"
            )

        if not entry.accessed:
            errors.append(f"{entry.key}: missing access date")
        elif not DATE_RE.match(entry.accessed):
            errors.append(
                f"{entry.key}: invalid access date: {entry.accessed}"
            )
        else:
            try:
                date.fromisoformat(entry.accessed)
            except ValueError:
                errors.append(
                    f"{entry.key}: invalid access date: {entry.accessed}"
                )

        if not entry.supports:
            errors.append(f"{entry.key}: missing support statement")
        elif _contains_placeholder(entry.supports):
            errors.append(f"{entry.key}: placeholder marker in support statement")

    if errors:
        raise RegisterError("; ".join(errors))


def _request(url: str) -> urllib.request.Request:
    return urllib.request.Request(
        url,
        headers={
            "User-Agent": USER_AGENT,
            "Accept": "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8",
        },
    )


def check_doi(identifier: str) -> tuple[bool, str]:
    doi = identifier.split("doi.org/", 1)[1]
    api = "https://api.crossref.org/works/" + urllib.parse.quote(doi)
    try:
        with urllib.request.urlopen(_request(api), timeout=40) as response:
            payload = json.load(response)
            titles = payload.get("message", {}).get("title", ["untitled"])
            title = titles[0] if titles else "untitled"
            return True, f"OK Crossref: {title[:90]}"
    except Exception as exc:  # noqa: BLE001 - report any network or API failure
        return False, f"FAIL {type(exc).__name__}: {exc}"


def check_arxiv(identifier: str) -> tuple[bool, str]:
    try:
        with urllib.request.urlopen(_request(identifier), timeout=40) as response:
            body = response.read().decode("utf-8", "replace")
        match = re.search(r'<meta name="citation_title" content="([^"]+)"', body)
        title = match.group(1) if match else "untitled"
        return True, f"OK arXiv: {title[:90]}"
    except Exception as exc:  # noqa: BLE001 - report any network failure
        return False, f"FAIL {type(exc).__name__}: {exc}"


def check_url(identifier: str) -> tuple[bool, str]:
    try:
        with urllib.request.urlopen(_request(identifier), timeout=40) as response:
            return True, f"OK HTTP {response.status}"
    except urllib.error.HTTPError as exc:
        if exc.code in {403, 429}:
            return True, f"WARN HTTP {exc.code} (scripted request blocked)"
        return False, f"FAIL HTTP {exc.code}"
    except Exception as exc:  # noqa: BLE001 - report any network failure
        return False, f"FAIL {type(exc).__name__}: {exc}"


def check_identifier(identifier: str) -> tuple[bool, str, str]:
    kind = identifier_kind(identifier)
    if kind == "doi":
        ok, message = check_doi(identifier)
    elif kind == "arxiv":
        ok, message = check_arxiv(identifier)
    elif kind == "url":
        ok, message = check_url(identifier)
    else:
        return False, "unknown", f"FAIL invalid identifier syntax: {identifier}"
    return ok, kind, message


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(
        description=(
            "Check the TESS citation register. Offline by default; "
            "--fetch resolves every identifier."
        )
    )
    parser.add_argument("--register", type=Path, default=DEFAULT_REGISTER)
    parser.add_argument(
        "--fetch",
        action="store_true",
        help="resolve every identifier over the network",
    )
    args = parser.parse_args(argv)

    try:
        entries = parse_register(args.register)
        validate_entries(entries)
    except RegisterError as exc:
        print(f"status=FAIL {exc}")
        return 1

    citations = len({entry.citation for entry in entries})
    identifiers = len({entry.identifier for entry in entries})
    print(
        f"entries={len(entries)} citations={citations} "
        f"identifiers={identifiers}"
    )

    failures = 0
    if args.fetch:
        for entry in entries:
            ok, kind, message = check_identifier(entry.identifier)
            print(f"{entry.key}\t{kind}\t{message}\t{entry.identifier}")
            if not ok:
                failures += 1
        print(f"fetch_failures={failures}")

    if failures:
        print("status=FAIL")
        return 1
    print("status=OK")
    return 0


if __name__ == "__main__":
    sys.exit(main())
