#!/usr/bin/env python3
"""live_check.py: static checks for live regions and status messages in HTML, JSX, TSX, Vue, Svelte, Angular templates.
Usage: python3 live_check.py FILE [FILE ...] [--strict]
Prints a summary line, then one line per finding: file:line  level  code  message
Exit 1 on any error (or any warning with --strict). Standard library only.
It reads text, not a DOM: tags split by '>' inside JSX braces can be missed. It cannot see runtime behaviour."""
import re, sys

TAG = re.compile(r"<([A-Za-z][\w.-]*)((?:[^<>]|=>)*?)(/?)>", re.S)
COND_BEFORE = re.compile(r"(&&|\?|\|\||:)\s*\(?\s*$")
NAMEY = re.compile(r"""(class|className|id)\s*=\s*\{?["'`][^"'`]*\b(toast|snackbar|flash-?message|notification|inline-?error|form-?error|alert-?banner)""", re.I)
CONTAINERS = {"main", "body", "form", "nav", "table", "section", "article", "ul", "ol", "aside"}

def attr(s, name):
    m = re.search(name + r"""\s*=\s*\{?\s*["'`]([^"'`]*)["'`]""", s)
    return m.group(1).lower() if m else None

def scan(path, src):
    out = []; n_live = 0; n_assertive = 0
    lines_upto = lambda pos: src.count("\n", 0, pos) + 1
    for m in TAG.finditer(src):
        tag, body = m.group(1), m.group(2)
        line = lines_upto(m.start())
        live = attr(body, "aria-live"); role = attr(body, "role")
        is_live = live in ("polite", "assertive") or role in ("status", "alert", "log", "timer", "marquee")
        if not is_live:
            if NAMEY.search(body) and not role and live is None and "aria-hidden" not in body:
                out.append((line, "warn", "toast-no-role", f"<{tag}> looks like a toast or message but has no role=status, role=alert or aria-live"))
            continue
        n_live += 1
        if live == "assertive" or role == "alert": n_assertive += 1
        before = src[max(0, m.start() - 80):m.start()]
        if COND_BEFORE.search(before) or re.search(r"""(?:^|\s)(v-if|\*ngIf|x-if|@if)\b""", body):
            out.append((line, "error", "live-conditional", f"<{tag}> is rendered conditionally, so the region and its text arrive together and are often not announced. Render it always and change only its text"))
        if re.search(r"(?:^|\s)hidden(?:\s|$|=)(?!\s*\{?\s*false)", body) or re.search(r"aria-hidden\s*=\s*\{?[\"']?true", body) or re.search(r"display\s*:\s*[\"']?none|\bv-show\b", body):
            out.append((line, "error", "live-hidden", f"<{tag}> is hidden or toggled with display:none, aria-hidden or v-show. It leaves the accessibility tree, so updates are lost"))
        ls = src.rfind("\n", 0, m.start()) + 1; le = src.find("\n", m.end()); lt = src[ls:le if le > 0 else len(src)]
        if re.search(r"innerHTML|insertAdjacentHTML|\.append\(|\.prepend\(|createElement", lt):
            out.append((line, "warn", "live-injected", f"<{tag}> is created by script together with its text. Add an empty region at load and set its text later"))
        if (role == "alert" and live == "polite") or (role == "status" and live == "assertive"):
            out.append((line, "warn", "live-conflict", f'role="{role}" with aria-live="{live}" overrides the role default. Pick one'))
        elif (role == "alert" and live == "assertive") or (role == "status" and live == "polite"):
            out.append((line, "info", "live-redundant", f'aria-live="{live}" repeats the implicit value of role="{role}"'))
        if tag.lower() in CONTAINERS:
            out.append((line, "warn", "live-container", f"aria-live or a live role on <{tag}> re-reads every change inside it. Use a small dedicated region"))
        if role in ("log", "timer", "marquee"):
            out.append((line, "info", "live-weak-role", f'role="{role}" has weaker support than status or alert (verify with the target screen reader)'))
        rel = attr(body, "aria-relevant")
        if rel and rel not in ("additions", "additions text"):
            out.append((line, "info", "live-relevant", f'aria-relevant="{rel}" has partial support (verify before relying on it)'))
    if n_live > 2:
        out.append((1, "warn", "live-region-count", f"{n_live} live regions in this file. Keep one polite and one assertive region and reuse them"))
    if n_assertive > 1:
        out.append((1, "warn", "live-assertive-many", f"{n_assertive} assertive or alert regions. Assertive speech cuts off other announcements"))
    for m in re.finditer(r"\.ariaNotify\s*\(", src):
        out.append((lines_upto(m.start()), "info", "notify-used", "ariaNotify() found. Keep a live-region fallback for older browsers (verify support)"))
    return out, n_live

def main(argv):
    strict = "--strict" in argv; files = [a for a in argv if not a.startswith("--")]
    if not files: print(__doc__); return 2
    res = []; total_live = 0; notify = []
    for p in files:
        try: src = open(p, encoding="utf-8", errors="replace").read()
        except OSError as e: print(f"cannot read {p}: {e}"); return 2
        f, n = scan(p, src); total_live += n; res += [(p,) + x for x in f]
        if re.search(r"\.ariaNotify\s*\(", src): notify.append(p)
    if notify and total_live == 0:
        res.append((notify[0], 1, "warn", "notify-no-fallback", "ariaNotify() is used and no live region exists in the files scanned"))
    order = {"error": 0, "warn": 1, "info": 2}
    res.sort(key=lambda x: (order[x[2]], x[0], x[1]))
    c = {k: sum(1 for x in res if x[2] == k) for k in order}
    print(f'{c["error"]} errors, {c["warn"]} warnings, {c["info"]} info, {total_live} live regions')
    for p, line, lvl, code, msg in res: 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:]))
