#!/usr/bin/env python3
"""Read-only checker for CSS container queries. Standard library only.
Usage: python3 cq_check.py a.css b.html ... [--strict]   (pass every stylesheet that declares or uses containers; exit 1 with --strict on any warning)
Output: file:line  level  code  message. Static CSS only: it cannot see which elements match, flex or grid sizing, or JavaScript."""
import re, sys, os

GROUPS = {"media", "supports", "container", "layer", "scope", "starting-style", "document"}

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

def sources(path):
    """Yield (text, first_line) chunks of CSS from a file. HTML-like files yield each <style> block."""
    raw = open(path, encoding="utf-8", errors="replace").read()
    if path.lower().endswith((".html", ".htm", ".vue", ".svelte", ".astro", ".php")):
        for m in re.finditer(r"<style\b[^>]*>(.*?)</style>", raw, flags=re.S | re.I):
            yield m.group(1), raw.count("\n", 0, m.start(1)) + 1
    else:
        yield raw, 1

def split_top(s, sep=","):
    out, d, cur, q = [], 0, "", None
    for ch in s:
        if q:
            cur += ch
            if ch == q: q = None
            continue
        if ch in "\"'": q = ch; cur += ch; continue
        if ch in "([": d += 1
        elif ch in ")]": d -= 1
        if ch == sep and d == 0: out.append(cur.strip()); cur = ""
        else: cur += ch
    if cur.strip(): out.append(cur.strip())
    return out

def parse(text, line0=1):
    """Return (rules, stmts). rule = dict(sel, decls[(prop,val,important,line)], line, ctx[(name,params)]).
    stmt = dict(name, params, line, ctx) for @import, @layer a,b; and similar. Nested CSS is flattened."""
    text = strip_comments(text)
    rules, stmts, n = [], [], len(text)
    anon = [0]
    def line(i): return line0 + text.count("\n", 0, i)
    def skip_str(i):
        q = text[i]; i += 1
        while i < n and text[i] != q:
            i += 2 if text[i] == "\\" else 1
        return i
    def skip_paren(i):
        d = 0
        while i < n:
            c = text[i]
            if c in "\"'": i = skip_str(i)
            elif c == "(": d += 1
            elif c == ")":
                d -= 1
                if d == 0: return i
            i += 1
        return n - 1
    def match_brace(i):
        d = 0
        while i < n:
            c = text[i]
            if c in "\"'": i = skip_str(i)
            elif c == "{": d += 1
            elif c == "}":
                d -= 1
                if d == 0: return i
            i += 1
        return n - 1
    def add_decl(rule, s, ln):
        if rule is None or ":" not in s: return
        p, v = s.split(":", 1)
        p, v = p.strip().lower(), v.strip()
        imp = bool(re.search(r"!\s*important\s*$", v, re.I))
        if imp: v = re.sub(r"!\s*important\s*$", "", v, flags=re.I).strip()
        if p and re.match(r"^(--[\w-]+|[a-z-]+)$", p): rule["decls"].append((p, v, imp, ln))
    def block(a, b, ctx, parent, rule):
        i = start = a
        while i < b:
            c = text[i]
            if c in "\"'": i = skip_str(i)
            elif c == "(": i = skip_paren(i)
            elif c == ";":
                s = text[start:i].strip()
                if s.startswith("@"):
                    m = re.match(r"@([\w-]+)\s*(.*)", s, re.S)
                    stmts.append(dict(name=m.group(1).lower(), params=m.group(2).strip(), line=line(start), ctx=list(ctx)))
                else: add_decl(rule, s, line(start))
                start = i + 1
            elif c == "{":
                pre = text[start:i].strip(); end = match_brace(i); ln = line(start + (len(text[start:i]) - len(text[start:i].lstrip())))
                if pre.startswith("@"):
                    m = re.match(r"@([\w-]+)\s*(.*)", pre, re.S); nm, pr = m.group(1).lower(), m.group(2).strip()
                    if nm in GROUPS:
                        if nm == "layer" and not pr:
                            anon[0] += 1; pr = "<anon%d>" % anon[0]
                        sub = dict(sel=parent, decls=[], line=ln, ctx=ctx + [(nm, pr)]) if parent is not None else None
                        if sub is not None: rules.append(sub)
                        if nm == "layer" and parent is None:
                            stmts.append(dict(name="layer-block", params=pr, line=ln, ctx=list(ctx)))
                        block(i + 1, end, ctx + [(nm, pr)], parent, sub)
                else:
                    sels = split_top(pre)
                    if parent is not None:
                        sels = [(x.replace("&", parent) if "&" in x else parent + " " + x) for x in sels]
                    joined = ", ".join(sels)
                    r = dict(sel=joined, decls=[], line=ln, ctx=list(ctx)); rules.append(r)
                    block(i + 1, end, ctx, joined, r)
                i = end; start = end + 1
            i += 1
        s = text[start:b].strip()
        if s:
            if s.startswith("@"):
                m = re.match(r"@([\w-]+)\s*(.*)", s, re.S)
                if m: stmts.append(dict(name=m.group(1).lower(), params=m.group(2).strip(), line=line(start), ctx=list(ctx)))
            else: add_decl(rule, s, line(start))
    block(0, n, [], None, None)
    return rules, stmts

def collect(paths):
    """Parse every file in the order given. Returns list of (path, rules, stmts)."""
    out = []
    for p in paths:
        R, S = [], []
        for chunk, l0 in sources(p):
            r, s = parse(chunk, l0); R += r; S += s
        out.append((p, R, S))
    return out

def balanced_remove(value, func):
    """Remove every func(...) call (balanced parentheses) from value."""
    out, i = "", 0
    pat = re.compile(r"\b" + re.escape(func) + r"\(")
    while True:
        m = pat.search(value, i)
        if not m: out += value[i:]; return out
        out += value[i:m.start()]
        d, j = 0, m.end() - 1
        while j < len(value):
            if value[j] == "(": d += 1
            elif value[j] == ")":
                d -= 1
                if d == 0: break
            j += 1
        i = j + 1

CAP = 8
BAD_DISPLAY = {"inline", "contents", "table", "table-cell"}   # the four values measured in Chromium 153
SHRINK_DISPLAY = {"inline-block", "inline-flex", "inline-grid", "inline-table"}
KEYWORDS = {"not", "and", "or"}

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

def declared(r):
    """Return (names, types, has_type_decl) for one rule's container declarations."""
    names, types, has = [], set(), False
    for p, v, imp, ln in r["decls"]:
        if p == "container-name":
            names += [x for x in v.split() if x != "none"]
        elif p == "container-type":
            has = True; types |= {x for x in v.split() if x != "normal"}
        elif p == "container":
            a, _, b = v.partition("/")
            names += [x for x in a.split() if x != "none"]
            if "/" in v:
                has = True; types |= {x for x in b.split() if x != "normal"}
    return names, types, has

def query_parts(params):
    """(names, is_style_or_scroll, mentions_height) for an @container prelude."""
    head = params.split("(", 1)[0]
    words = [w for w in head.split() if w.lower() not in KEYWORDS]
    sty = bool(re.search(r"\b(style|scroll-state)\s*\(", params))
    hgt = (not sty) and bool(re.search(r"\b(?:min-|max-)?(?:height|block-size)\b", params))
    return words, sty, hgt

def main(argv):
    paths, strict = [], False
    for a in argv:
        if a == "--strict": strict = True
        else: paths.append(a)
    if not paths:
        print(__doc__.split("\n")[1]); return 2
    out = []
    def add(f, ln, lvl, code, msg): out.append((f, ln, lvl, code, msg))
    data = collect(paths)
    for path in paths:
        if path.lower().endswith((".html", ".htm", ".vue", ".svelte", ".astro", ".php")) and not list(sources(path)):
            add(path, 1, "info", "no-style-block", "no <style> block found; pass the linked .css files as well")
    cont = []            # (file, line, [selectors], names, types, has_type_decl, rule)
    queries = []         # (file, line, params, rule)
    units = []
    all_names, all_types, container_sels = set(), set(), set()
    name_types, sel_names = {}, {}
    for path, rules, stmts in data:
        for r in rules:
            if not r["decls"]: continue
            names, types, has = declared(r)
            if names or has:
                sels = [norm(s) for s in split_top(r["sel"] or "")]
                cont.append((path, r["line"], sels, names, types, has, r))
                all_names |= set(names); all_types |= types
                for n in names: name_types.setdefault(n, set()).update(types)
                if types: container_sels |= set(sels)
                for s in sels: sel_names.setdefault(s, set()).update(names)
            for n, p in r["ctx"]:
                if n == "container": queries.append((path, r["line"], p, r))
            for p, v, imp, ln in r["decls"]:
                if re.search(r"(?<![\w.])-?[\d.]+cq(?:w|h|i|b|min|max)\b", v): units.append((path, ln, r["sel"], v))
    for path, ln, sels, names, types, has, r in cont:
        props = {p: v.strip() for p, v, i, l in r["decls"]}
        label = ", ".join(sels)[:50]
        if names and not types and not any(s in container_sels for s in sels):
            add(path, ln, "warn", "name-no-type", f"{label}: container name without container-type inline-size or size; a size query never matches. Use `container: name / inline-size`")
        disp = props.get("display", "").split()[0] if props.get("display") else ""
        if types and disp in BAD_DISPLAY:
            add(path, ln, "warn", "bad-display", f"{label}: container-type on display: {disp} does not make a container (measured, Chromium 153); use a block, flex, grid or inline-block box")
        if "size" in types and not any(k in props for k in ("height", "block-size", "aspect-ratio", "min-height", "min-block-size")):
            add(path, ln, "warn", "size-no-height", f"{label}: container-type size with no height, block-size or aspect-ratio collapses to 0 high; use inline-size unless you query height")
        if "inline-size" in types and "size" not in types:
            w = props.get("width", props.get("inline-size", ""))
            shrink = props.get("float", "none") not in ("none", "") or disp in SHRINK_DISPLAY or props.get("position") in ("absolute", "fixed") or w in ("fit-content", "max-content", "min-content")
            if shrink and w in ("", "auto", "fit-content", "max-content", "min-content"):
                add(path, ln, "warn", "shrink-to-fit", f"{label}: inline-size container in a shrink-to-fit box collapses to 0 wide; give it a width, flex-basis or width: 100%")
    for path, ln, params, r in queries:
        words, sty, hgt = query_parts(params)
        sels = [norm(s) for s in split_top(r["sel"] or "")]
        if sty: continue
        if words:
            for w in words:
                if w not in all_names:
                    add(path, ln, "warn", "query-unknown-name", f"@container {w} names a container that no scanned file declares; the query never matches")
                elif hgt and name_types.get(w) and "size" not in name_types[w]:
                    add(path, ln, "warn", "height-query-inline-size", f"@container {w} queries height but that container is inline-size only; use container-type size")
        else:
            if not all_types:
                add(path, ln, "warn", "query-no-container", "unnamed @container query and no container-type declared in the scanned files; it never matches")
            elif hgt and "size" not in all_types:
                add(path, ln, "warn", "height-query-inline-size", "@container queries height but every container is inline-size; use container-type size")
        for s in sels:
            if s in container_sels and (not words or any(w in sel_names.get(s, ()) for w in words)):
                add(path, ln, "warn", "self-style", f"{s} is a container and cannot be styled by its own query{' (unless another container is an ancestor)' if not words else ''}; style a child, or wrap it")
                break
    if units and not all_types:
        f, ln, sel, v = units[0]
        add(f, ln, "info", "cq-unit-no-container", f"{sel}: container query unit in `{v[:30]}` with no container-type in the scanned files; it falls back to small viewport units")
    order = {"error": 0, "warn": 1, "info": 2}
    out.sort(key=lambda t: (order[t[2]], t[3], t[0], t[1]))
    seen, counts = {}, {"error": 0, "warn": 0, "info": 0}
    for f, ln, lvl, code, msg in out:
        counts[lvl] += 1; seen[code] = seen.get(code, 0) + 1
        if seen[code] <= CAP: print(f"{f}:{ln}  {lvl}  {code}  {msg}")
    for code, c in seen.items():
        if c > CAP: print(f"...  {code}: +{c - CAP} more")
    print(f"containers {len(cont)} (names {sorted(all_names) or 'none'}), container rules {len(queries)}, container units {len(units)}")
    print(f"errors {counts['error']}, warnings {counts['warn']}, info {counts['info']}")
    return 1 if counts["error"] or (strict and counts["warn"]) else 0

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