#!/usr/bin/env python3
"""Deterministic APA title-case linter for headings and titles.

Implements the APA title-case rules documented in
``standards/page-design.md`` (§Heading Capitalization). Stdlib only.

Rules
-----
Capitalize:
  - the first word and the last word of a title;
  - the first word after a token ending in a colon or em-dash (or a
    standalone '-' / '—' token);
  - any word of 4 or more letters;
  - all words that are not in the minor-word set.
Lowercase (only when 3 letters or fewer AND not first / last /
after-colon-or-dash):
  - articles, coordinating conjunctions, short prepositions
    (the ``MINOR_WORDS`` constant below).

Hyphenated compounds capitalize BOTH parts (Mid-Project, At-Risk).
Tokens with no alphabetic characters (numbers, "(50", "%)", "&") are
ignored. Surrounding punctuation / parentheses are stripped before the
core word is judged.

Usage
-----
  python scripts/apa_title_case.py --file PAGE.html   # lint headings
  python scripts/apa_title_case.py --text "Some Title"

Exit 0 if clean, 1 if any violation. Never crashes on empty / no-heading
input (exit 0, "no titles found").
"""

import argparse
import re
import sys
from html.parser import HTMLParser

# Minor words: lowercased mid-title only when <= 3 letters.
MINOR_WORDS = {
    # articles
    "a", "an", "the",
    # coordinating conjunctions
    "and", "but", "or", "nor", "so", "yet", "for",
    # short prepositions
    "as", "at", "by", "in", "of", "off", "on", "per", "to", "up", "via", "vs",
}

# Heading tags whose text content is linted.
HEADING_TAGS = {"h1", "h2", "h3", "h4", "summary"}

# Leading / trailing characters stripped before judging a sub-word.
_STRIP_CHARS = "\"'“”‘’()[]{}.,:;!?…—–-"


def _has_alpha(token: str) -> bool:
    return any(c.isalpha() for c in token)


def _strip_core(word: str) -> str:
    """Strip surrounding punctuation / quotes / parens from a word."""
    return word.strip(_STRIP_CHARS)


def _is_acronym(core: str) -> bool:
    """All-caps token of 2+ letters (ASU, MLO, CLO, WCAG, API) — leave alone."""
    letters = [c for c in core if c.isalpha()]
    return len(letters) >= 2 and all(c.isupper() for c in letters)


def _should_be_capital(core_lower: str, *, force_capital: bool) -> bool:
    """Return True if this sub-word must start with a capital letter."""
    if force_capital:
        return True
    if len(core_lower) >= 4:
        return True
    return core_lower not in MINOR_WORDS


def _check_subword(core: str, *, force_capital: bool) -> list:
    """Check a single (already-stripped) sub-word. Returns issue strings."""
    issues = []
    if not core or not _has_alpha(core):
        return issues
    if _is_acronym(core):
        return issues
    first_alpha = next(c for c in core if c.isalpha())
    core_lower = core.lower()
    want_cap = _should_be_capital(core_lower, force_capital=force_capital)
    if want_cap and not first_alpha.isupper():
        issues.append(f"'{core}' should be capitalized")
    elif not want_cap and first_alpha.isupper():
        issues.append(f"'{core}' should be lowercase")
    return issues


def check_title(title: str) -> list:
    """Return a list of human-readable APA title-case issues for ``title``."""
    if title is None:
        return []
    tokens = title.split()
    if not tokens:
        return []

    # Index of last token that carries an alphabetic core (the "last word").
    last_word_idx = -1
    for i, tok in enumerate(tokens):
        if _has_alpha(_strip_core(tok)):
            last_word_idx = i

    issues = []
    after_break = True  # first word forces capital
    seen_first_word = False
    for i, tok in enumerate(tokens):
        core = _strip_core(tok)
        # Determine whether the NEXT word follows a colon/em-dash break.
        ends_break = tok.endswith(":") or tok.endswith("—") or tok.endswith("-")
        standalone_break = tok in {"-", "—", ":"}

        if not _has_alpha(core):
            # Numbers, "(50", "%)", "&", standalone dashes, etc.
            if standalone_break or ends_break:
                after_break = True
            continue

        is_first = not seen_first_word
        is_last = i == last_word_idx
        force_capital = after_break or is_first or is_last
        seen_first_word = True

        # Hyphenated compound: each sub-part capitalized (both forced).
        if "-" in core and any(p for p in core.split("-") if _has_alpha(p)):
            parts = core.split("-")
            for j, part in enumerate(parts):
                part = _strip_core(part)
                if not part or not _has_alpha(part):
                    continue
                # Every part of a hyphenated compound is capitalized.
                issues.extend(_check_subword(part, force_capital=True))
        else:
            issues.extend(_check_subword(core, force_capital=force_capital))

        after_break = ends_break

    return issues


class _HeadingParser(HTMLParser):
    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.results = []  # list of (tag, text)
        self._stack = []   # (tag, [text parts])

    def handle_starttag(self, tag, attrs):
        if tag in HEADING_TAGS:
            self._stack.append((tag, []))

    def handle_endtag(self, tag):
        if self._stack and self._stack[-1][0] == tag:
            t, parts = self._stack.pop()
            text = "".join(parts).strip()
            text = re.sub(r"\s+", " ", text)
            if text:
                self.results.append((t, text))

    def handle_data(self, data):
        if self._stack:
            self._stack[-1][1].append(data)


def lint_html(html: str) -> list:
    """Extract <h1>-<h4> and <summary> text; return (context, title, issues).

    Only headings with at least one issue are included.
    """
    if not html:
        return []
    parser = _HeadingParser()
    try:
        parser.feed(html)
        parser.close()
    except Exception:
        # Be conservative: never crash the caller on malformed HTML.
        pass
    out = []
    for tag, text in parser.results:
        issues = check_title(text)
        if issues:
            out.append((tag, text, issues))
    return out


def _build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        description="APA title-case linter for headings and titles."
    )
    g = p.add_mutually_exclusive_group(required=True)
    g.add_argument("--file", help="HTML file to lint (h1-h4 + summary only)")
    g.add_argument("--text", help="A single title string to lint")
    return p


def main(argv=None) -> int:
    args = _build_parser().parse_args(argv)

    if args.text is not None:
        issues = check_title(args.text)
        if not issues:
            print("OK: no violations")
            return 0
        print(f"{args.text!r}:")
        for msg in issues:
            print(f"  - {msg}")
        return 1

    # --file
    try:
        with open(args.file, "r", encoding="utf-8") as fh:
            html = fh.read()
    except OSError as exc:
        print(f"error: cannot read {args.file}: {exc}", file=sys.stderr)
        return 1

    violations = lint_html(html)
    if not violations:
        print("no titles found" if not html.strip() else "OK: no violations")
        return 0

    for tag, text, issues in violations:
        print(f"<{tag}> {text!r}:")
        for msg in issues:
            print(f"  - {msg}")
    return 1


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