#!/usr/bin/env python3
"""Read-only checker for dialog, popover and invoker markup. Standard library only.
Usage: python3 dialog_check.py file.html [more.html]   (exit 1 if any error)
Output: file:line  level  code  message
It reads static markup. It cannot see focus behaviour, scripts that open dialogs or CSS."""
import sys
from html.parser import HTMLParser

COMMANDS = {"show-modal", "close", "request-close", "show-popover", "hide-popover", "toggle-popover"}
POPOVER_CMDS = {"show-popover", "hide-popover", "toggle-popover"}
POPOVER_VALUES = {"", "auto", "manual", "hint"}
CLOSEDBY = {"any", "closerequest", "none"}
CANCEL_WORDS = ("cancel", "close", "dismiss", "no thanks", "back")
VOID = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"}

class P(HTMLParser):
    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.out, self.ids = [], {}
        self.dialogs = []       # {line, a, tag, autofocus, close, depth}
        self.invokers = []      # (line, a, tag)
        self.stack = []         # open element frames
    def add(self, line, lvl, code, msg): self.out.append((line, lvl, code, msg))
    def handle_starttag(self, tag, attrs):
        a = {k: (v if v is not None else "") for k, v in attrs}
        line = self.getpos()[0]
        if a.get("id"): self.ids.setdefault(a["id"], (tag, a, line))
        is_dlg = tag == "dialog" or a.get("role", "").lower() in ("dialog", "alertdialog")
        frame = {"tag": tag, "a": a, "line": line, "text": False, "named": False, "dialog": None}
        if is_dlg:
            d = {"line": line, "a": a, "tag": tag, "autofocus": 0, "close": False}
            self.dialogs.append(d); frame["dialog"] = d
            if tag != "dialog":
                self.add(line, "warn", "custom-dialog", f"<{tag} role={a.get('role')}> instead of native dialog: focus handling, inert and Esc must be built by hand")
            if tag == "dialog":
                if "tabindex" in a: self.add(line, "error", "tabindex-on-dialog", "tabindex on dialog (MDN: never set it)")
                if "open" in a: self.add(line, "warn", "open-attribute", "static open attribute opens non-modal; use showModal() or command=show-modal for a modal")
                cb = a.get("closedby")
                if cb is not None and cb.lower() not in CLOSEDBY: self.add(line, "error", "closedby-invalid", f"closedby={cb!r} is not any, closerequest or none")
        enclosing = next((f["dialog"] for f in reversed(self.stack) if f["dialog"]), None)
        if enclosing is not None and "autofocus" in a: enclosing["autofocus"] += 1
        if "popover" in a and a["popover"].lower() not in POPOVER_VALUES:
            self.add(line, "warn", "popover-value", f"popover={a['popover']!r} is not auto, manual or hint")
        if "popovertarget" in a or "commandfor" in a:
            self.invokers.append((line, a, tag))
            if tag not in ("button", "input"): self.add(line, "error", "invoker-not-button", f"{'popovertarget' if 'popovertarget' in a else 'commandfor'} on <{tag}>: use a button")
            if "popovertarget" in a and "aria-expanded" in a:
                self.add(line, "warn", "manual-aria-expanded", "aria-expanded on a popovertarget button: MDN says the relationship is implicit")
        if enclosing is not None and (tag == "button" and a.get("command") in ("close", "request-close")): enclosing["close"] = True
        if tag == "form" and a.get("method", "").lower() == "dialog":
            fr = {"required": False, "buttons": []}; frame["form"] = fr
            if enclosing is not None: enclosing["close"] = True
        if tag in ("button",) or (tag == "input" and a.get("type", "").lower() in ("submit", "button", "reset")):
            frame["named"] = bool(a.get("aria-label") or a.get("aria-labelledby") or a.get("title") or (tag == "input" and a.get("value")))
            form = next((f.get("form") for f in reversed(self.stack) if f.get("form")), None)
            if form is not None: form["buttons"].append((line, a, frame))
        if tag in ("input", "select", "textarea") and "required" in a:
            form = next((f.get("form") for f in reversed(self.stack) if f.get("form")), None)
            if form is not None: form["required"] = True
        if tag not in VOID: self.stack.append(frame)
        elif tag == "img" and a.get("alt", "").strip():
            for f in self.stack:
                if f["tag"] == "button": f["named"] = True
    def handle_endtag(self, tag):
        for i in range(len(self.stack) - 1, -1, -1):
            if self.stack[i]["tag"] == tag:
                f = self.stack[i]; del self.stack[i:]
                if tag == "button" and not (f["text"] or f["named"]):
                    self.add(f["line"], "error", "empty-button", "button has no text or accessible name")
                if "form" in f:
                    fm = f["form"]
                    if fm["required"]:
                        for bl, ba, bf in fm["buttons"]:
                            label = (ba.get("value", "") + " " + ba.get("aria-label", "")).lower()
                            if "formnovalidate" not in ba and (any(w in label for w in CANCEL_WORDS) or bf.get("label_hit")):
                                self.add(bl, "warn", "cancel-blocked-by-required", "cancel button in a method=dialog form with required fields has no formnovalidate")
                break
    def handle_data(self, data):
        if not data.strip(): return
        for f in self.stack:
            if f["tag"] == "button": f["text"] = True; f["label_hit"] = f.get("label_hit") or any(w in data.lower() for w in CANCEL_WORDS)
    def finish(self):
        for d in self.dialogs:
            a = d["a"]
            lb = a.get("aria-labelledby")
            if lb:
                missing = [i for i in lb.split() if i not in self.ids]
                if missing: self.add(d["line"], "error", "labelledby-missing-target", f"aria-labelledby points to missing id {missing[0]!r}")
            elif not a.get("aria-label"):
                self.add(d["line"], "error", "dialog-no-name", "dialog has no aria-labelledby or aria-label")
            if d["autofocus"] > 1: self.add(d["line"], "warn", "multiple-autofocus", f"{d['autofocus']} autofocus elements inside one dialog")
            if d["tag"] == "dialog" and not d["close"]:
                self.add(d["line"], "warn", "no-close-control", "no method=dialog form or command=close button found inside; JS may add one")
        for line, a, tag in self.invokers:
            tid = a.get("popovertarget") or a.get("commandfor")
            if tid not in self.ids:
                self.add(line, "error", "invoker-target-missing", f"target id {tid!r} not found in this file"); continue
            ttag, ta, _ = self.ids[tid]
            cmd = a.get("command")
            if cmd is not None:
                if cmd not in COMMANDS and not cmd.startswith("--"): self.add(line, "error", "command-invalid", f"command={cmd!r} is not a built-in command or a --custom name")
                elif cmd == "show-modal" and ttag != "dialog": self.add(line, "error", "command-target-wrong", f"show-modal needs a dialog, target is <{ttag}>")
                elif cmd in POPOVER_CMDS and "popover" not in ta: self.add(line, "error", "command-target-wrong", f"{cmd} needs an element with the popover attribute")
            elif "popovertarget" in a and "popover" not in ta:
                self.add(line, "error", "invoker-target-wrong", f"popovertarget points to <{ttag}> without a popover attribute")
def main(paths):
    errs = warns = 0
    for path in paths:
        p = P()
        try: p.feed(open(path, encoding="utf-8", errors="replace").read()); p.close()
        except OSError as e: print(f"{path}: cannot read ({e})"); return 2
        p.finish()
        e0, w0 = errs, warns
        for line, lvl, code, msg in sorted(p.out):
            print(f"{path}:{line}  {lvl:5}  {code}  {msg}"); errs += lvl == "error"; warns += lvl == "warn"
        print(f"{path}: {errs - e0} errors, {warns - w0} warnings")
    return 1 if errs else 0

if __name__ == "__main__":
    if len(sys.argv) < 2: print(__doc__); sys.exit(2)
    sys.exit(main(sys.argv[1:]))