#!/usr/bin/env python3
"""Flag common form problems in HTML files: labels, autocomplete, types, grouping. Read-only.
python3 forms_check.py form.html [more.html ...]"""
import re, sys
from collections import defaultdict
from html.parser import HTMLParser

# WHATWG autofill field names (verify against the current spec before relying on this list)
TOKENS = set("""name honorific-prefix given-name additional-name family-name honorific-suffix nickname
username new-password current-password one-time-code organization-title organization street-address
address-line1 address-line2 address-line3 address-level4 address-level3 address-level2 address-level1
country country-name postal-code cc-name cc-given-name cc-additional-name cc-family-name cc-number cc-exp
cc-exp-month cc-exp-year cc-csc cc-type transaction-currency transaction-amount language bday bday-day
bday-month bday-year sex url photo tel tel-country-code tel-national tel-area-code tel-local
tel-local-prefix tel-local-suffix tel-extension email impp webauthn""".split())
PREFIX = {"shipping", "billing", "home", "work", "mobile", "fax", "pager"}
GUESS = [(r"e-?mail", "email", "email"), (r"full.?name", "name", None), (r"phone|mobile|tel", "tel", "tel"), (r"post_?code|zip", "postal-code", None),
         (r"card.?num|cc.?num", "cc-number", None), (r"pass(word)?", "current-password or new-password", "password"),
         (r"first.?name|given", "given-name", None), (r"last.?name|surname|family", "family-name", None),
         (r"address|street", "street-address or address-line1", None), (r"city|town", "address-level2", None)]
SKIP = {"hidden", "submit", "button", "reset", "image"}

class Forms(HTMLParser):
    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.flags, self.controls, self.label_for = [], [], set()
        self.in_label = self.in_fieldset = 0
        self.legend_seen, self.radios = [], defaultdict(list)
        self.forms, self.buttons = [], []

    def handle_starttag(self, tag, attrs):
        a = {k: (v or "") for k, v in attrs}
        line = self.getpos()[0]
        if tag == "label":
            self.in_label += 1
            if a.get("for"):
                self.label_for.add(a["for"])
        elif tag == "fieldset":
            self.in_fieldset += 1
            self.legend_seen.append([line, False])
        elif tag == "legend" and self.legend_seen:
            self.legend_seen[-1][1] = True
        elif tag == "form":
            self.forms.append([line, False, a])
        elif tag == "button":
            self.buttons.append((line, a.get("type")))
            if self.forms and a.get("type", "submit") == "submit":
                self.forms[-1][1] = True
        elif tag in {"input", "select", "textarea"}:
            t = a.get("type", "text").lower() if tag == "input" else tag
            if t in {"submit", "image"} and self.forms:
                self.forms[-1][1] = True
            if t in SKIP:
                return
            self.controls.append((line, tag, t, a, self.in_label > 0, self.in_fieldset > 0))

    def handle_endtag(self, tag):
        if tag == "label" and self.in_label:
            self.in_label -= 1
        elif tag == "fieldset" and self.in_fieldset:
            self.in_fieldset -= 1
            ln, ok = self.legend_seen.pop()
            if not ok:
                self.flags.append((ln, "error", "fieldset-without-legend", ""))

    def finish(self):
        for line, tag, t, a, wrapped, grouped in self.controls:
            ident = a.get("name", "") + " " + a.get("id", "")
            label = wrapped or a.get("id") in self.label_for or a.get("aria-label") or a.get("aria-labelledby")
            if not label:
                self.flags.append((line, "error", "no-label", f"{tag} {ident.strip()}" + (" (placeholder is not a label)" if a.get("placeholder") else "")))
            if not a.get("name") and t not in {"radio", "checkbox"}:
                self.flags.append((line, "warn", "no-name", f"{tag} {ident.strip()} will not submit or autofill"))
            ac = a.get("autocomplete")
            if ac and ac not in {"on", "off"}:
                parts = ac.lower().split()
                field = [p for p in parts if not p.startswith("section-") and p not in PREFIX]
                if not field or field[-1] not in TOKENS:
                    self.flags.append((line, "error", "bad-autocomplete", f'"{ac}" is not a recognised token'))
            for pattern, token, want_type in GUESS:
                if re.search(pattern, ident, re.I):
                    if not ac:
                        self.flags.append((line, "warn", "missing-autocomplete", f"{ident.strip()}: try {token}"))
                    if want_type and t != want_type:
                        self.flags.append((line, "warn", "type-mismatch", f"{ident.strip()}: type={t}, expected {want_type}"))
                    if t == "number":
                        self.flags.append((line, "warn", "number-for-identifier", f"{ident.strip()}: type=number is for quantities"))
                    break
            if ac == "off" and (t == "password" or re.search(r"user|e-?mail|login", ident, re.I)):
                self.flags.append((line, "warn", "autocomplete-off-on-login", "on a login-type field; browsers may ignore it and it blocks password managers"))
            if t in {"radio", "checkbox"} and a.get("name"):
                self.radios[(t, a["name"])].append((line, grouped))
        for (t, name), items in self.radios.items():
            if len(items) > 1 and not all(g for _, g in items):
                self.flags.append((items[0][0], "error", "group-without-fieldset", f"{len(items)} {t} inputs named {name}"))
        for line, has_submit, a in self.forms:
            if not has_submit:
                self.flags.append((line, "warn", "form-without-submit", "no submit button found"))
        typeless = [l for l, t in self.buttons if t is None]
        if len(self.buttons) > 1 and typeless:
            for l in typeless:
                self.flags.append((l, "warn", "button-without-type", "defaults to submit inside a form"))

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