#!/usr/bin/env python3
"""Read-only checker for Flexbox and Grid layout CSS. Standard library only.
Usage: python3 layout_check.py file.css [page.html ...] [--limit 288] [--strict]   (exit 1 on any error)
Output: file:line  level  code  message. Reads static CSS and <style> blocks. It cannot see computed layout, content 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

PX_LIMIT = 288      # 320 CSS px reflow width minus 2 x 16px gutters (default, adjust with --limit)
WIDTH_LIMIT = 320   # a fixed width above this overflows a 320px viewport
CAP = 8             # lines printed per code

def px(v):
    m = re.fullmatch(r"(-?[\d.]+)(px|rem|em)", v.strip())
    if not m: return None
    return float(m.group(1)) * (1 if m.group(2) == "px" else 16)

def wide_only(ctx):
    return any(n in ("media", "container") and re.search(r"min-width|width\s*>=?|\(\s*[\d.]+\w*\s*<=?\s*width", p) for n, p in ctx)

def main(argv):
    global PX_LIMIT
    paths, strict = [], False
    it = iter(argv)
    for a in it:
        if a == "--strict": strict = True
        elif a == "--limit": PX_LIMIT = float(next(it))
        else: paths.append(a)
    if not paths:
        print(__doc__.split("\n")[1]); return 2
    out, areas_def, areas_use = [], set(), []
    def add(f, ln, lvl, code, msg): out.append((f, ln, lvl, code, msg))
    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")
    for path, rules, stmts in collect(paths):
        for r in rules:
            sel, ctx = r["sel"] or "", r["ctx"]
            props = {p: v for p, v, i, l in r["decls"]}
            for p, v, imp, ln in r["decls"]:
                if p in ("grid-template-columns", "grid-auto-columns", "grid-template"):
                    cols = v.split("/", 1)[-1] if p == "grid-template" and "/" in v else v
                    if p == "grid-template" and "/" not in v: cols = ""
                    flat = balanced_remove(cols, "minmax")
                    if re.search(r"(?<![\w.])\d*\.?\d+fr\b", flat):
                        add(path, ln, "warn", "fr-no-min", f"{sel}: bare fr track in `{v[:60]}` grows with content; use minmax(0, 1fr) unless content sizing is wanted")
                    for m in re.finditer(r"repeat\(\s*auto-(?:fit|fill)\s*,\s*minmax\(\s*([\d.]+(?:px|rem|em))", cols):
                        n = px(m.group(1))
                        if n and n > PX_LIMIT: add(path, ln, "warn", "autofit-overflow", f"{sel}: track minimum {m.group(1)} ({n:g}px) exceeds {PX_LIMIT:g}px; use minmax(min(100%, {m.group(1)}), 1fr)")
                    if not wide_only(ctx) and p != "grid-auto-columns":
                        rest = balanced_remove(balanced_remove(cols, "minmax"), "repeat")
                        tot = sum(float(x) for x in re.findall(r"(?<![\w.])([\d.]+)px", rest))
                        for m in re.finditer(r"repeat\(\s*(\d+)\s*,\s*([\d.]+)px\s*\)", cols): tot += int(m.group(1)) * float(m.group(2))
                        if tot > PX_LIMIT: add(path, ln, "warn", "fixed-tracks", f"{sel}: fixed tracks total {tot:g}px outside a min-width query; exceeds {PX_LIMIT:g}px")
                if p in ("width", "min-width") and v.strip() == "100vw":
                    add(path, ln, "warn", "vw-width", f"{sel}: {p}: 100vw includes the scrollbar; use 100% or inline-size: 100%")
                elif p in ("width", "min-width"):
                    n = px(v)
                    if n and n > WIDTH_LIMIT and not wide_only(ctx): add(path, ln, "warn", "fixed-width", f"{sel}: {p}: {v} exceeds a {WIDTH_LIMIT}px viewport; use max-width or a min-width query")
                if p in ("overflow-x", "overflow") and v.split()[0:1] == ["hidden"] and any(x.strip() in ("html", "body", ":root") for x in sel.split(",")):
                    add(path, ln, "warn", "root-overflow-hidden", f"{sel}: overflow hidden on the root hides overflow instead of fixing its cause; find the widest element first")
                if p in ("height", "min-height") and v.strip() == "100vh" and not any(q == p and "vh" in w and w.strip() != "100vh" for q, w, i, l in r["decls"]):
                    add(path, ln, "info", "vh-height", f"{sel}: {p}: 100vh; on mobile use 100dvh with a 100vh fallback (verify before relying on this)")
                if p == "text-overflow" and "ellipsis" in v:
                    note = "same rule sets min-width: 0 or overflow" if (props.get("min-width") == "0" or "overflow" in props) else "no min-width: 0 here"
                    add(path, ln, "info", "ellipsis-review", f"{sel}: ellipsis ({note}); if this element sits inside a flex or grid item, that item needs min-width: 0")
                if p == "grid-template-areas" or (p == "grid-template" and '"' in v):
                    rows = re.findall(r"\"([^\"]*)\"|'([^']*)'", v)
                    rows = [(a or b).split() for a, b in rows]
                    if rows:
                        if len({len(x) for x in rows}) > 1:
                            add(path, ln, "error", "areas-ragged", f"{sel}: grid-template-areas rows have {sorted({len(x) for x in rows})} cells; the whole declaration is invalid and ignored")
                        else:
                            cells = {}
                            for y, row in enumerate(rows):
                                for x, name in enumerate(row):
                                    if name != ".": cells.setdefault(name, []).append((x, y))
                            for name, pts in cells.items():
                                xs, ys = [a for a, b in pts], [b for a, b in pts]
                                if (max(xs) - min(xs) + 1) * (max(ys) - min(ys) + 1) != len(pts):
                                    add(path, ln, "error", "areas-not-rect", f"{sel}: area '{name}' is not a rectangle; the declaration is invalid and ignored")
                            areas_def.update(cells)
                if p == "grid-area" and re.fullmatch(r"[a-zA-Z_][\w-]*", v.strip()) and v.strip() not in ("auto", "inherit", "initial", "unset", "revert"):
                    areas_use.append((path, ln, sel, v.strip()))
    if areas_def:
        for path, ln, sel, name in areas_use:
            if name not in areas_def: add(path, ln, "warn", "area-unknown", f"{sel}: grid-area '{name}' is in no grid-template-areas scanned (a custom ident here creates an implicit named line, not an area)")
    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"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:]))
