#!/usr/bin/env python3
"""Flag common semantic HTML problems in one or more HTML files. Read-only.
python3 semantic_check.py page.html [more.html ...]"""
import re, sys
from html.parser import HTMLParser

CLICK = {"onclick", "@click", "v-on:click", "(click)", "on:click"}
NON_INTERACTIVE = {"div", "span", "li", "td", "p", "img", "section", "article"}
VOID = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"}

class Check(HTMLParser):
    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.flags, self.stack, self.named = [], [], []
        self.mains = self.h1 = 0
        self.last_h = 0
        self.navs = []
        self.html_lang = None

    def flag(self, level, code, detail):
        self.flags.append((self.getpos()[0], level, code, detail))

    def handle_starttag(self, tag, attrs):
        a = {k: (v or "") for k, v in attrs}
        if tag == "html":
            self.html_lang = a.get("lang", "")
        if tag == "main" or a.get("role") == "main":
            self.mains += 1
        if re.fullmatch(r"h[1-6]", tag):
            n = int(tag[1])
            self.h1 += n == 1
            if self.last_h and n > self.last_h + 1:
                self.flag("error", "heading-skip", f"h{self.last_h} followed by h{n}")
            self.last_h = n
        if tag == "nav":
            self.navs.append(bool(a.get("aria-label") or a.get("aria-labelledby")))
        if tag in NON_INTERACTIVE and CLICK & set(a):
            self.flag("error", "click-on-non-interactive", f"<{tag}> has a click handler; use <button> or <a href>")
        if tag in NON_INTERACTIVE and a.get("role") in {"button", "link"}:
            self.flag("warn", "role-on-non-interactive", f"<{tag} role={a['role']}>; native element preferred")
        if tag == "a":
            href = a.get("href")
            if href is None:
                self.flag("error", "link-without-href", "<a> with no href is not focusable")
            elif href.strip() in {"", "#"} or href.lower().startswith("javascript:"):
                self.flag("error", "link-used-as-button", f'href="{href}"; actions belong in <button>')
        if tag == "img" and "alt" not in a:
            self.flag("error", "img-missing-alt", a.get("src", "")[:60])
        if a.get("tabindex", "").lstrip("-").isdigit() and int(a["tabindex"]) > 0:
            self.flag("warn", "positive-tabindex", f'tabindex="{a["tabindex"]}"')
        if tag in {"a", "button"}:
            if any(t in {"a", "button"} for t, _ in self.stack):
                self.flag("error", "nested-interactive", f"<{tag}> inside another link or button")
            name = a.get("aria-label") or a.get("aria-labelledby") or a.get("title")
            self.named.append([tag, bool(name), self.getpos()[0]])
        if tag == "img" and a.get("alt"):
            for n in self.named:
                n[1] = True
        if tag not in VOID:
            self.stack.append((tag, a))

    def handle_data(self, data):
        if data.strip():
            for n in self.named:
                n[1] = True

    def handle_endtag(self, tag):
        if tag in {"a", "button"} and self.named:
            t, ok, line = self.named.pop()
            if not ok:
                self.flags.append((line, "error", f"{t}-without-name", "no text, alt or aria-label"))
        while self.stack:
            if self.stack.pop()[0] == tag:
                break

    def finish(self):
        if self.html_lang == "":
            self.flags.append((1, "error", "missing-lang", "<html> has no lang"))
        if self.html_lang is not None and self.mains != 1:
            self.flags.append((1, "error", "main-count", f"{self.mains} main landmarks; expected 1"))
        if self.html_lang is not None and self.h1 == 0:
            self.flags.append((1, "warn", "no-h1", "page has no h1"))
        if len(self.navs) > 1 and not all(self.navs):
            self.flags.append((1, "warn", "unlabelled-nav", f"{len(self.navs)} nav elements, not all labelled"))

total = 0
for path in sys.argv[1:] or sys.exit(__doc__):
    c = Check()
    c.feed(open(path, encoding="utf-8", errors="replace").read())
    c.finish()
    for line, level, code, detail in sorted(c.flags):
        print(f"{path}:{line}\t{level}\t{code}\t{detail}")
    errors = sum(f[1] == "error" for f in c.flags)
    print(f"{path}: {errors} errors, {len(c.flags) - errors} warnings")
    total += errors
sys.exit(1 if total else 0)
