#!/usr/bin/env python3
"""Read-only checker for viewport unit problems in CSS and HTML.
Usage: vu_check.py FILE [FILE ...] [--min-vh 50] [--legacy] [--strict]
Prints: file:line  level  code  selector: message. Last line gives counts.
--min-vh N  ignore plain vh values below N (default 50).
--legacy    also report svh/lvh/dvh heights that have no plain vh fallback.
Pass CSS and HTML together to enable the viewport-fit checks.
Exit 1 on any error (or any warning with --strict). Standard library only."""
import re, sys, os

NUM = r"(?:\d+\.?\d*|\.\d+)"
HEIGHT = {"height", "min-height", "max-height", "block-size", "min-block-size", "max-block-size"}
SHIFT = re.compile(r"^(font-size|line-height|letter-spacing|gap|row-gap|column-gap|margin(-[a-z]+)*|padding(-[a-z]+)*)$")

def strip_comments(t):
    return re.sub(r"/\*.*?\*/", lambda m: re.sub(r"[^\n]", " ", m.group()), t, flags=re.S)

def parse(text, base=0):
    i, n, out = 0, len(text), []
    while i < n:
        j = i
        while j < n and text[j] not in "{;}": j += 1
        if j >= n: break
        if text[j] == "{":
            sel = text[i:j].strip(); depth, k = 1, j + 1
            while k < n and depth:
                depth += (text[k] == "{") - (text[k] == "}"); k += 1
            body = text[j + 1:k - 1]
            if sel.startswith("@") and not sel.startswith(("@font-face", "@page")):
                out += parse(body, base + j + 1)
            elif not sel.startswith("@"):
                out.append((sel, decls(body, base + j + 1), base + i))
            i = k
        else:
            i = j + 1
    return out

def decls(body, off):
    res, depth, start = [], 0, 0
    for p, ch in enumerate(body + ";"):
        if ch == "(": depth += 1
        elif ch == ")": depth -= 1
        elif ch == ";" and depth == 0:
            d = body[start:p]
            if ":" in d:
                k, v = d.split(":", 1)
                res.append((k.strip().lower(), v.strip(), off + start + (len(d) - len(d.lstrip()))))
            start = p + 1
    return res

PLAIN = re.compile(rf"(?<![a-z])({NUM})vh\b")
SMALL = re.compile(rf"{NUM}[sld]vh\b")
DYN_Y = re.compile(rf"{NUM}dv(?:h|b|min|max)\b")
ENV_FB = re.compile(r"env\(\s*safe-area-inset-[a-z]+\s*,")

def norm(sel): return re.sub(r"\s+", " ", sel.strip())

def scan_css(text, line0, rep, o):
    clean = strip_comments(text)
    line = lambda off: line0 + clean.count("\n", 0, off)
    rules = parse(clean)
    units = {}
    for sel, ds, _ in rules:
        for k, v, _o in ds:
            if k in HEIGHT:
                u = units.setdefault((norm(sel), k), {"plain": False, "small": False})
                u["plain"] |= bool(PLAIN.search(v)); u["small"] |= bool(SMALL.search(v))
    for sel, ds, _ in rules:
        for k, v, off in ds:
            ln = line(off)
            if k in HEIGHT:
                big = [float(m.group(1)) for m in PLAIN.finditer(v) if float(m.group(1)) >= o["min_vh"]]
                if big and not units[(norm(sel), k)]["small"]:
                    rep(ln, "warn", "vh-tall", sel, f"{k}: {v} resolves to the large viewport, so content can sit behind mobile browser bars. Use svh or dvh (step 3)")
                if o["legacy"] and SMALL.search(v) and not units[(norm(sel), k)]["plain"]:
                    rep(ln, "info", "no-fallback", sel, f"{k}: {v} has no plain vh fallback for browsers without svh, lvh and dvh")
            if SHIFT.match(k) and DYN_Y.search(v):
                rep(ln, "warn", "dv-shift", sel, f"{k}: {v} follows the dynamic viewport and changes while the user scrolls. Use rem, or svh if it must track height")
            if ENV_FB.search(v):
                rep(ln, "warn", "env-fallback", sel, f"{k}: env(safe-area-inset-*) is always defined, so its fallback never applies. Use max(env(...), N)")
            if re.search(r"env\(\s*safe-area-inset", v): o["env_css"] = True

def scan_html(text, rep, o):
    for m in re.finditer(r"<meta\b[^>]*name\s*=\s*[\"']viewport[\"'][^>]*>", text, flags=re.I):
        c = re.search(r"content\s*=\s*[\"']([^\"']*)", m.group(), flags=re.I)
        o["metas"].append((text.count("\n", 0, m.start()) + 1, (c.group(1) if c else "").lower().replace(" ", "")))
    for m in re.finditer(r"<style\b[^>]*>(.*?)</style>", text, flags=re.S | re.I):
        scan_css(m.group(1), text.count("\n", 0, m.start(1)) + 1, rep, o)

def main(argv):
    o = {"min_vh": 50.0, "legacy": "--legacy" in argv, "env_css": False, "metas": []}
    files, strict, i = [], "--strict" in argv, 0
    while i < len(argv):
        a = argv[i]
        if a == "--min-vh" and i + 1 < len(argv): o["min_vh"] = float(argv[i + 1]); i += 2; continue
        if not a.startswith("--"): files.append(a)
        i += 1
    if not files: print(__doc__); return 2
    res, has_css, has_html = [], False, False
    for f in files:
        try: t = open(f, encoding="utf-8", errors="replace").read()
        except OSError as e: print(f"{f}: cannot read ({e.strerror})"); return 2
        rep = lambda ln, lv, code, sel, msg, f=f: res.append((f, ln, lv, code, sel, msg))
        if f.lower().endswith((".html", ".htm")): has_html = True; scan_html(t, rep, o)
        else: has_css = True; scan_css(t, 1, rep, o)
    if has_css or o["env_css"]:
        for ln, c in o["metas"]:
            fit = "viewport-fit=cover" in c
            if fit and not o["env_css"]:
                res.append((files[0], ln, "warn", "cover-no-inset", "meta viewport", "viewport-fit=cover lets content run under the notch and rounded corners, and no env(safe-area-inset-*) is used"))
            if not fit and o["env_css"]:
                res.append((files[0], ln, "info", "inset-no-cover", "meta viewport", "env(safe-area-inset-*) is used but viewport-fit=cover is absent, so the browser insets content itself"))
    for f, ln, lv, code, sel, msg in sorted(res, key=lambda r: (r[0], r[1])):
        print(f"{os.path.basename(f)}:{ln}  {lv}  {code}  {sel.strip()[:40]}: {msg}")
    e = sum(r[2] == "error" for r in res); w = sum(r[2] == "warn" for r in res); n = sum(r[2] == "info" for r in res)
    print(f"{e} errors, {w} warnings, {n} info")
    return 1 if e or (strict and w) else 0

sys.exit(main(sys.argv[1:]))
