#!/usr/bin/env python3
"""aria_check.py: static checks for accessible names and ARIA roles in HTML.
Usage: python3 aria_check.py FILE.html [FILE2.html ...] [--strict]
Reads only the files given. Prints one summary line then one line per finding:
  file:line  level  code  message
Exit 1 if any error (or any warning with --strict). Standard library only.
It cannot see attributes added by scripts or names computed at runtime."""
import sys, re
from html.parser import HTMLParser

VOID = {"area","base","br","col","embed","hr","img","input","link","meta","source","track","wbr"}
NAMED_ROLES = {"button","link","checkbox","radio","switch","tab","menuitem","menuitemcheckbox",
    "menuitemradio","option","treeitem","textbox","searchbox","combobox","listbox","slider",
    "spinbutton","progressbar","meter","dialog","alertdialog","img","tooltip"}
FROM_CONTENT = {"button","link","checkbox","radio","switch","tab","menuitem","menuitemcheckbox",
    "menuitemradio","option","treeitem","tooltip"}
VALID_ROLES = set("""alert alertdialog application article banner blockquote button caption cell checkbox code
columnheader combobox complementary contentinfo definition deletion dialog directory document emphasis feed figure
form generic grid gridcell group heading img insertion link list listbox listitem log main mark marquee math meter
menu menubar menuitem menuitemcheckbox menuitemradio navigation none note option paragraph presentation progressbar
radio radiogroup region row rowgroup rowheader scrollbar search searchbox separator slider spinbutton status strong
subscript superscript switch tab table tablist tabpanel term textbox time timer toolbar tooltip tree treegrid treeitem""".split())
ABSTRACT = {"command","composite","input","landmark","range","roletype","section","sectionhead","select","structure","widget","window"}
REQUIRED = {"checkbox":["aria-checked"],"switch":["aria-checked"],"radio":["aria-checked"],
    "menuitemcheckbox":["aria-checked"],"menuitemradio":["aria-checked"],"slider":["aria-valuenow"],
    "scrollbar":["aria-controls","aria-valuenow"],"combobox":["aria-expanded"],"heading":["aria-level"]
    }
IMPLICIT = {"button":"button","nav":"navigation","main":"main","ul":"list","ol":"list","li":"listitem",
    "img":"img","aside":"complementary","form":"form","table":"table","dialog":"dialog","hr":"separator",
    "header":"banner","footer":"contentinfo","h1":"heading","h2":"heading","h3":"heading","h4":"heading",
    "h5":"heading","h6":"heading","select":"combobox","textarea":"textbox","fieldset":"group"}
NONNAMEABLE = {"div","span","p","b","i","em","strong","small","u","code","br","blockquote"}
BTN_TYPES = {"button","submit","reset","image"}

class Node:
    def __init__(s, tag, attrs, line, parent):
        s.tag, s.attrs, s.line, s.parent, s.kids, s.text = tag, dict(attrs), line, parent, [], []
    def get(s, k): return s.attrs.get(k)
    def has(s, k): return k in s.attrs

class P(HTMLParser):
    def __init__(s):
        super().__init__(convert_charrefs=True)
        s.root = Node("#root", [], 0, None); s.cur = s.root; s.all = []
    def handle_starttag(s, tag, attrs):
        n = Node(tag, [(k, v if v is not None else "") for k, v in attrs], s.getpos()[0], s.cur)
        s.cur.kids.append(n); s.all.append(n)
        if tag not in VOID: s.cur = n
    def handle_startendtag(s, tag, attrs):
        n = Node(tag, [(k, v if v is not None else "") for k, v in attrs], s.getpos()[0], s.cur)
        s.cur.kids.append(n); s.all.append(n)
    def handle_endtag(s, tag):
        n = s.cur
        while n is not s.root and n.tag != tag: n = n.parent
        if n is not s.root: s.cur = n.parent
    def handle_data(s, d): s.cur.text.append(d)

def inner_text(n):
    out = list(n.text)
    for k in n.kids:
        if k.get("aria-hidden") == "true" or k.has("hidden"): continue
        if k.tag == "img": out.append(k.get("alt") or "")
        elif k.tag == "svg" and k.get("aria-label"): out.append(k.get("aria-label"))
        else: out.append(inner_text(k))
    return re.sub(r"\s+", " ", " ".join(out)).strip()

def role_of(n):
    r = (n.get("role") or "").split(" ")[0].lower()
    if r: return r
    if n.tag == "a" and n.has("href"): return "link"
    if n.tag == "input":
        t = (n.get("type") or "text").lower()
        return {"checkbox":"checkbox","radio":"radio","range":"slider","number":"spinbutton",
                "search":"searchbox"}.get(t, "button" if t in BTN_TYPES else "" if t=="hidden" else "textbox")
    return IMPLICIT.get(n.tag, "")

def focusable(n):
    if n.has("hidden") or (n.get("tabindex") or "").startswith("-"):
        return False
    if n.has("disabled"): return False
    if n.tag in ("button","select","textarea","summary"): return True
    if n.tag == "input": return (n.get("type") or "").lower() != "hidden"
    if n.tag == "a": return n.has("href")
    return n.has("tabindex")

def check(path):
    f = []
    src = open(path, encoding="utf-8", errors="replace").read()
    p = P(); p.feed(src); nodes = p.all
    ids = {n.get("id"): n for n in nodes if n.get("id")}
    labels_for = {n.get("for") for n in nodes if n.tag == "label" and n.get("for")}
    def add(n, lvl, code, msg): f.append((n.line, lvl, code, msg))
    for n in nodes:
        r = role_of(n); explicit = (n.get("role") or "").strip().lower()
        al, alb = n.get("aria-label"), n.get("aria-labelledby")
        # roles
        if explicit:
            first = explicit.split(" ")[0]
            if first in ABSTRACT: add(n, "error", "role-abstract", f'role="{first}" is abstract and must not be used')
            elif first not in VALID_ROLES: add(n, "error", "role-invalid", f'role="{first}" is not a WAI-ARIA role')
            elif IMPLICIT.get(n.tag) == first or (n.tag == "a" and n.has("href") and first == "link"):
                lvl = "info" if first == "list" else "warn"
                add(n, lvl, "role-redundant", f'<{n.tag}> already has role {first}' + (" (list-style:none can justify it in Safari; verify)" if first == "list" else ""))
            for req in REQUIRED.get(first, []):
                if not (n.has(req) or (n.tag == "input" and first in ("checkbox","radio","slider"))
                        or (n.tag in ("h1","h2","h3","h4","h5","h6") and first == "heading")):
                    add(n, "error", "role-required-attr", f'role="{first}" needs {req}')
        # prohibited naming
        if (al or alb) and ((n.tag in NONNAMEABLE and not explicit) or explicit in ("generic","none","presentation","paragraph")):
            add(n, "error", "name-on-generic", f'aria-label/aria-labelledby on <{n.tag}> with no naming role: ignored or inconsistent across screen readers')
        # labelledby / describedby targets
        for attr in ("aria-labelledby", "aria-describedby"):
            for i in (n.get(attr) or "").split():
                t = ids.get(i)
                if t is None: add(n, "error", "idref-missing", f'{attr} points to id "{i}" which does not exist')
                elif attr == "aria-labelledby" and t.get("aria-labelledby") and t is not n:
                    add(n, "warn", "labelledby-chained", f'target "{i}" itself uses aria-labelledby; chains are not followed')
        # hidden focusable
        if n.get("aria-hidden") == "true":
            def anyfoc(x): return focusable(x) or any(anyfoc(k) for k in x.kids)
            if anyfoc(n): add(n, "error", "hidden-focusable", 'aria-hidden="true" on or around focusable content: use inert or remove from tab order')
        # names
        tag, typ = n.tag, (n.get("type") or "").lower()
        needs = (tag == "button" or (tag == "a" and n.has("href")) or (tag == "input" and typ in BTN_TYPES)
                 or (explicit.split(" ")[0] in NAMED_ROLES and tag not in ("input","select","textarea")))
        if needs and n.get("aria-hidden") != "true":
            text = inner_text(n)
            if tag == "input": text = n.get("value") or n.get("alt") or ""
            has_name = bool(alb) or bool((al or "").strip()) or bool(text)
            if not has_name and n.get("title"):
                add(n, "warn", "name-title-only", 'name comes from title only (not shown on touch or keyboard, unreliable)')
            elif not has_name:
                add(n, "error", "name-missing", f'<{tag}{" role="+explicit if explicit else ""}> has no text, aria-label, aria-labelledby or alt')
            elif al and text and not alb:
                a, t = re.sub(r"\W+", " ", al.lower()).strip(), re.sub(r"\W+", " ", text.lower()).strip()
                if t and t not in a:
                    add(n, "warn", "label-in-name", f'aria-label "{al}" does not contain visible text "{text}" (SC 2.5.3, speech-input users)')
        if tag in ("input","select","textarea") and typ not in BTN_TYPES and typ != "hidden":
            wrapped = False; q = n.parent
            while q is not None:
                if q.tag == "label": wrapped = True; break
                q = q.parent
            if not (alb or (al or "").strip() or wrapped or (n.get("id") in labels_for)):
                code = "label-title-only" if (n.get("title") or n.get("placeholder")) else "field-unlabelled"
                lvl = "warn" if code == "label-title-only" else "error"
                add(n, lvl, code, f'<{tag}{" type="+typ if typ else ""}> has no label element, aria-label or aria-labelledby' + (' (title or placeholder only)' if lvl == "warn" else ""))
        if tag == "img" and not n.has("alt") and not alb and not al:
            add(n, "error", "img-alt-missing", '<img> has no alt attribute (use alt="" if decorative)')
        if tag == "svg" and explicit == "img" and not (al or alb or any(k.tag == "title" for k in n.kids)):
            add(n, "error", "name-missing", '<svg role="img"> has no aria-label, aria-labelledby or <title>')
    return sorted(f)

def main(argv):
    strict = "--strict" in argv; files = [a for a in argv if not a.startswith("--")]
    if not files: print(__doc__); return 2
    allf = []
    for p in files:
        try: allf += [(p,) + x for x in check(p)]
        except OSError as e: print(f"cannot read {p}: {e}"); return 2
    order = {"error": 0, "warn": 1, "info": 2}
    allf.sort(key=lambda x: (order[x[2]], x[0], x[1]))
    c = {k: sum(1 for x in allf if x[2] == k) for k in order}
    print(f'{c["error"]} errors, {c["warn"]} warnings, {c["info"]} info')
    for p, line, lvl, code, msg in allf: print(f"{p}:{line}  {lvl}  {code}  {msg}")
    return 1 if c["error"] or (strict and c["warn"]) else 0

if __name__ == "__main__": sys.exit(main(sys.argv[1:]))
