#!/usr/bin/env python3
"""kf_check.py: read-only keyboard and focus checker for HTML and CSS (standard library only).

Usage: python3 kf_check.py FILE.html [FILE.css ...] [--strict]
Reads files and prints. Writes nothing, makes no network calls. Exit 0 = no errors, 1 = errors
(or warnings with --strict), 2 = bad input. Static only: it cannot see JavaScript-added attributes
or the cascade, so run scripts/tab_walk.js for the real page.
"""
import re, sys
from html.parser import HTMLParser

CLICK = ("onclick", "@click", "v-on:click", "ng-click", "(click)", "data-action", "x-on:click")
GENERIC = {"div", "span", "li", "td", "p", "img", "section", "article", "i", "svg", "label"}
INTERACTIVE_NESTED = {"a", "button", "select", "textarea", "summary"}
VOID = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"}


class Doc(HTMLParser):
    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.els, self.stack, self.ids, self.errors = [], [], set(), []
        self.main_seen_at = None
        self.focusable_before_main = 0

    def handle_starttag(self, tag, attrs):
        a = {k: (v if v is not None else "") for k, v in attrs}
        line = self.getpos()[0]
        parents = [e for e in self.stack]
        el = {"tag": tag, "a": a, "line": line, "parents": parents}
        self.els.append(el)
        if "id" in a:
            self.ids.add(a["id"])
        if tag == "main" or a.get("role") == "main":
            self.main_seen_at = len(self.els)
        if tag not in VOID:
            self.stack.append(el)

    def handle_endtag(self, tag):
        for i in range(len(self.stack) - 1, -1, -1):
            if self.stack[i]["tag"] == tag:
                del self.stack[i:]
                break


def is_focusable(el):
    a, t = el["a"], el["tag"]
    if "disabled" in a or a.get("type") == "hidden" or "hidden" in a:
        return False
    if "tabindex" in a:
        try:
            return int(a["tabindex"]) >= 0
        except ValueError:
            return False
    return (t == "a" and "href" in a) or t in ("button", "select", "textarea", "summary") or (t == "input")


def check_html(path, out):
    d = Doc()
    d.feed(open(path, encoding="utf-8", errors="replace").read())
    name = path.split("/")[-1]
    focusables = [e for e in d.els if is_focusable(e)]
    for e in d.els:
        a, t, ln = e["a"], e["tag"], e["line"]
        ti = a.get("tabindex")
        if ti is not None:
            try:
                if int(ti) > 0:
                    out.append((0, "%s:%d" % (name, ln), "error", "tabindex-positive", "<%s tabindex=\"%s\"> overrides DOM order. APG advises against positive values. Use 0 or -1 and fix the DOM order" % (t, ti)))
            except ValueError:
                out.append((0, "%s:%d" % (name, ln), "warn", "tabindex-invalid", "<%s tabindex=\"%s\"> is not an integer" % (t, ti)))
        has_click = any(k in a for k in CLICK)
        role = a.get("role", "")
        if has_click and t in GENERIC and not is_focusable(e):
            out.append((0, "%s:%d" % (name, ln), "error", "click-no-keyboard", "<%s> has a click handler and cannot take focus. Use <button> or <a href>, or see references/patterns.md" % t))
        elif has_click and t == "a" and "href" not in a and "tabindex" not in a:
            out.append((0, "%s:%d" % (name, ln), "error", "click-no-keyboard", "<a> without href and tabindex is not focusable. Use <button> for actions"))
        elif role in ("button", "link", "tab", "menuitem", "checkbox", "switch") and t in GENERIC and not is_focusable(e):
            out.append((0, "%s:%d" % (name, ln), "error", "role-no-focus", "role=\"%s\" on <%s> is not keyboard reachable. Prefer the native element" % (role, t)))
        elif ti == "0" and t in GENERIC and not role and not has_click:
            out.append((1, "%s:%d" % (name, ln), "warn", "tabindex-no-role", "<%s tabindex=\"0\"> is focusable with no role and no handler. Remove it unless it is a scroll region that needs focus" % t))
        if t in INTERACTIVE_NESTED or (t == "input" and a.get("type") != "hidden"):
            for p in e["parents"]:
                if p["tag"] in INTERACTIVE_NESTED and not (t == "summary" and p["tag"] == "details"):
                    out.append((0, "%s:%d" % (name, ln), "error", "nested-interactive", "<%s> inside <%s> (line %d): the inner control may be unreachable or announced wrongly" % (t, p["tag"], p["line"])))
                    break
    # skip link and bypass
    first = focusables[0] if focusables else None
    has_main = d.main_seen_at is not None
    if first and first["tag"] == "a" and first["a"].get("href", "").startswith("#") and len(first["a"]["href"]) > 1:
        tid = first["a"]["href"][1:]
        if tid not in d.ids:
            out.append((0, "%s:%d" % (name, first["line"]), "error", "skip-target-missing", "skip link points to #%s, which does not exist" % tid))
    else:
        nav_first = any(e["tag"] == "nav" or e["a"].get("role") == "navigation" for e in d.els[:d.main_seen_at or len(d.els)])
        if nav_first:
            out.append((2, name, "info", "skip-link-missing", "no skip link before navigation. axe passes bypass with a main landmark (%s here), but a skip link helps keyboard users" % ("present" if has_main else "ABSENT: add <main> or a skip link")))
    return d


def parse_css(text):
    text = re.sub(r"/\*.*?\*/", lambda m: re.sub(r"[^\n]", " ", m.group(0)), text, flags=re.S)
    rules, i, n = [], 0, len(text)
    while i < n:
        j = text.find("{", i)
        if j < 0:
            break
        head = text[i:j].strip()
        depth, k = 1, j + 1
        while k < n and depth:
            depth += {"{": 1, "}": -1}.get(text[k], 0)
            k += 1
        body = text[j + 1:k - 1]
        line = text.count("\n", 0, j) + 1
        if head.startswith("@media") or head.startswith("@supports") or head.startswith("@layer"):
            for r in parse_css(body):
                rules.append((r[0], r[1], line + r[2] - 1))
        elif not head.startswith("@"):
            decls = {}
            for dcl in body.split(";"):
                if ":" in dcl:
                    p, v = dcl.split(":", 1)
                    decls[p.strip().lower()] = v.strip().lower()
            for sel in head.split(","):
                rules.append((re.sub(r"\s+", " ", sel.strip()), decls, line))
        i = k
    return rules


def outline_off(v):
    return v is not None and re.fullmatch(r"(none|0|0px)(\s+.*)?", v.strip()) is not None


def check_css(path, out, all_rules):
    name = path.split("/")[-1]
    rules = parse_css(open(path, encoding="utf-8", errors="replace").read())
    all_rules.extend((name, *r) for r in rules)
    return rules


def css_findings(all_rules, out):
    scroll_pad = any(k.startswith("scroll-padding") or k.startswith("scroll-margin") for _, _, d, _ in all_rules for k in d)
    GLOBAL = (":focus-visible", ":focus", "*:focus-visible", "*:focus")

    def replaces(r, base):
        d = r[2]
        if ":focus" not in r[1] or not (r[1] in GLOBAL or (base and r[1].startswith(base))):
            return False
        ol = d.get("outline")
        return bool((ol and not outline_off(ol)) or d.get("outline-color") or d.get("outline-style") not in (None, "none")
                    or d.get("box-shadow") or d.get("border") or d.get("border-color") or d.get("background")
                    or d.get("background-color") or d.get("text-decoration"))

    for name, sel, d, ln in all_rules:
        off = outline_off(d.get("outline")) or outline_off(d.get("outline-style")) or d.get("outline-width") in ("0", "0px")
        if off and ":not(:focus-visible)" not in sel and "::" not in sel:
            base = re.sub(r":(focus-visible|focus-within|focus|hover|active)", "", sel).strip()
            last = base.split()[-1] if base else "*"
            focusy = ":focus" in sel or re.match(r"^(\*|a|button|input|select|textarea|summary)(\[.*\])?$|^\[tabindex", last)
            repl = [r for r in all_rules if replaces(r, base)]
            if not repl and focusy:
                out.append((0, "%s:%d" % (name, ln), "error", "outline-removed", "%s removes the outline and no :focus or :focus-visible rule replaces it (SC 2.4.7)" % sel[:50]))
            elif not repl:
                out.append((2, "%s:%d" % (name, ln), "info", "outline-removed-check", "%s removes the outline. Fine for a non-focusable box, an error if the element takes focus" % sel[:50]))
            elif all(("box-shadow" in r[2] and (not r[2].get("outline") or outline_off(r[2]["outline"])) and not r[2].get("border")) for r in repl):
                out.append((1, "%s:%d" % (name, ln), "warn", "focus-shadow-only", "%s is replaced by box-shadow only, which forced-colours mode overrides. Keep an outline (Soueidan)" % sel[:50]))
        if ":focus-visible" in sel or ":focus" in sel:
            m = re.search(r"(\d+(?:\.\d+)?)px", d.get("outline", "") + " " + d.get("outline-width", ""))
            if m and float(m.group(1)) < 2:
                out.append((2, "%s:%d" % (name, ln), "info", "focus-ring-thin", "outline %spx. AAA SC 2.4.13 asks for a 2 CSS px perimeter at 3:1; AA does not" % m.group(1)))
        if d.get("position", "") in ("sticky", "fixed") and re.search(r"header|nav|banner|sticky|fixed|cookie|consent|toolbar|bar", sel) and not scroll_pad:
            out.append((1, "%s:%d" % (name, ln), "warn", "sticky-no-scroll-padding", "%s is %s and no scroll-padding exists in any file: focused controls can sit under it (SC 2.4.11). Set scroll-padding-top on html" % (sel[:40], d["position"])))
            scroll_pad = True


def main(argv):
    strict = "--strict" in argv
    files = [a for a in argv if not a.startswith("--")]
    if not files:
        print(__doc__)
        return 2
    out, all_rules = [], []
    for f in files:
        try:
            if f.lower().endswith((".css",)):
                check_css(f, out, all_rules)
            else:
                check_html(f, out)
                for m in re.finditer(r"<style[^>]*>(.*?)</style>", open(f, encoding="utf-8", errors="replace").read(), re.S | re.I):
                    all_rules.extend((f.split("/")[-1], *r) for r in parse_css(m.group(1)))
        except OSError as e:
            print("error: %s" % e)
            return 2
    css_findings(all_rules, out)
    c = {"error": 0, "warn": 0, "info": 0}
    for o in out:
        c[o[2]] += 1
    print("%d errors, %d warnings, %d info" % (c["error"], c["warn"], c["info"]))
    for o in sorted(out, key=lambda x: (x[0], x[1])):
        print("%s  %s  %s  %s" % (o[1], o[2], o[3], o[4]))
    return 1 if c["error"] or (strict and c["warn"]) else 0


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