#!/usr/bin/env python3
"""Read-only checker and cascade resolver for CSS cascade layers and specificity. Standard library only.
Usage: python3 cascade_check.py a.css b.css ... [--max-spec 3]   (files in load order; exit 1 on any error)
       python3 cascade_check.py a.css b.css --rank color --sel ".card a" --sel "a.link"   (which declaration wins for the selectors that match one element)
Output: file:line  level  code  message. Static CSS only: it cannot see which selectors match an element, JavaScript or style attributes."""
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
INF = 10 ** 9
MAX_B = 3   # class-level specificity at or above this is flagged (default, --max-spec)

def spec_of(sel):
    """Specificity (a, b, c) of one complex selector (Selectors 4: :where 0, :is/:not/:has max of arguments)."""
    a = b = c = 0
    i, n = 0, len(sel)
    while i < n:
        ch = sel[i]
        if ch == "#": a += 1; i += 1; i = skip_ident(sel, i)
        elif ch == ".": b += 1; i += 1; i = skip_ident(sel, i)
        elif ch == "[":
            j = sel.find("]", i); b += 1; i = (j if j >= 0 else n) + 1
        elif ch == ":":
            dbl = sel.startswith("::", i); i += 2 if dbl else 1
            j = skip_ident(sel, i); name = sel[i:j].lower(); i = j
            arg = None
            if i < n and sel[i] == "(":
                d, k = 0, i
                while k < n:
                    if sel[k] == "(": d += 1
                    elif sel[k] == ")":
                        d -= 1
                        if d == 0: break
                    k += 1
                arg = sel[i + 1:k]; i = k + 1
            if dbl or name in ("before", "after", "first-line", "first-letter"): c += 1
            elif name == "where": pass
            elif name in ("is", "not", "has", "matches", "-webkit-any", "-moz-any") and arg is not None:
                m = max((spec_of(x) for x in split_top(arg)), default=(0, 0, 0)); a += m[0]; b += m[1]; c += m[2]
            elif name in ("nth-child", "nth-last-child") and arg is not None:
                b += 1
                if " of " in arg:
                    m = max((spec_of(x) for x in split_top(arg.split(" of ", 1)[1])), default=(0, 0, 0)); a += m[0]; b += m[1]; c += m[2]
            else: b += 1
        elif ch == "*" or ch in " >+~,\t\n": i += 1
        elif ch.isalpha() or ch in "_-\\|":
            j = skip_ident(sel, i); c += 1; i = j
        else: i += 1
    return (a, b, c)

def skip_ident(s, i):
    while i < len(s) and (s[i].isalnum() or s[i] in "-_\\|%" or ord(s[i]) > 127): i += 1
    return i

def layer_names(ctx):
    path = []
    for n, p in ctx:
        if n == "layer": path += [x for x in p.split(".") if x]
    return path

class Tree:
    def __init__(self): self.kids = {}   # path tuple -> ordered child names list (by parent path)
    def reg(self, path):
        for d in range(1, len(path) + 1):
            par, name = tuple(path[:d - 1]), path[d - 1]
            lst = self.kids.setdefault(par, [])
            if name not in lst: lst.append(name)
    def key(self, path):
        if path is None or not path: return (INF,)
        out = []
        for d in range(1, len(path) + 1):
            par, name = tuple(path[:d - 1]), path[d - 1]
            out.append(self.kids.get(par, []).index(name) if name in self.kids.get(par, []) else INF)
        return tuple(out) + (INF,)

def events(data):
    """Chronological events across files in the order given: (file_idx, line, kind, payload)."""
    ev = []
    for fi, (path, rules, stmts) in enumerate(data):
        for r in rules: ev.append((fi, r["line"], 1, "rule", r))
        for s in stmts: ev.append((fi, s["line"], 0, "stmt", s))
    ev.sort(key=lambda e: (e[0], e[1], e[2]))
    return [(e[0], e[1], e[3], e[4]) for e in ev]

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

def main(argv):
    global MAX_B
    paths, rank_prop, sels = [], None, []
    it = iter(argv)
    for a in it:
        if a == "--rank": rank_prop = next(it).lower()
        elif a == "--sel": sels.append(norm(next(it)))
        elif a == "--max-spec": MAX_B = int(next(it))
        else: paths.append(a)
    if not paths:
        print(__doc__.split("\n")[1]); return 2
    data = collect(paths); T = Tree(); out = []
    def add(f, ln, lvl, code, msg): out.append((f, ln, lvl, code, msg))
    first_seen, used_blocks, first_event_stmt = {}, False, None
    seen_rule = {}; decls = []; n_imp = n_unl = n_rules = 0; hi = []
    for fi, ln, kind, x in events(data):
        f = paths[fi]
        if kind == "stmt":
            if x["name"] == "layer":
                names = [norm(p) for p in x["params"].split(",") if p.strip()]
                if first_event_stmt is None: first_event_stmt = (f, ln)
                for nm in names:
                    p = layer_names(x["ctx"]) + nm.split(".")
                    if tuple(p) in first_seen:
                        add(f, ln, "warn", "order-statement-late", f"@layer {nm} was already ordered at {first_seen[tuple(p)]}; this statement cannot move it")
                    else: first_seen[tuple(p)] = f"{f}:{ln}"
                    T.reg(p)
            elif x["name"] == "layer-block":
                used_blocks = True
                if first_event_stmt is None: first_event_stmt = (f, ln, "block")
                p = layer_names(x["ctx"]) + [q for q in x["params"].split(".") if q]
                if x["params"].startswith("<anon"): add(f, ln, "info", "anonymous-layer", "unnamed layer: rules cannot be added to it later and it cannot be reordered by name")
                first_seen.setdefault(tuple(p), f"{f}:{ln}"); T.reg(p); seen_rule[f] = True
            elif x["name"] == "import":
                p = x["params"]
                m = re.search(r"layer\(\s*([^)]*)\)", p) or re.search(r"\blayer\b(?!\()", p)
                if seen_rule.get(f): add(f, ln, "error", "import-after-rules", "@import after a style rule or layer block is invalid and ignored")
                if m and m.groups(): nm = m.group(1).strip(); T.reg([q for q in nm.split(".") if q]) if nm else None
                if not m: add(f, ln, "warn", "import-no-layer", f"@import {p[:50]} is unlayered: it beats every layered rule. Use layer(name)")
        else:
            if not x["decls"]: continue
            seen_rule[f] = True; n_rules += 1
            path = layer_names(x["ctx"]); T.reg(path) if path else None
            if path == [] : n_unl += 1
            for sel in split_top(x["sel"] or ""):
                sp = spec_of(sel)
                if sp[0] >= 1: add(f, ln, "warn", "id-selector", f"{sel[:60]} has an id selector, specificity {sp}")
                elif sp[1] >= MAX_B: hi.append((f, ln, sel, sp)); add(f, ln, "warn", "high-specificity", f"{sel[:60]} has specificity {sp}, over {MAX_B - 1} class-level parts")
            for p, v, imp, dl in x["decls"]:
                decls.append(dict(prop=p, val=v, imp=imp, path=path, sels=[norm(s) for s in split_top(x["sel"] or "")], file=f, line=dl, order=len(decls)))
                if imp:
                    n_imp += 1
                    if path: add(f, dl, "warn", "important-in-layer", f"{p}: !important in layer {'.'.join(path)}: important order is reversed, an earlier layer beats this one")
    if used_blocks and first_event_stmt and len(first_event_stmt) == 3:
        add(first_event_stmt[0], first_event_stmt[1], "warn", "no-order-statement", "layer blocks appear with no earlier `@layer a, b, c;` statement: order depends on first appearance")
    top = T.kids.get((), [])
    normal = [x for x in top if not re.search(r"important", x, re.I)]
    for i, nm in enumerate(normal):
        if re.search(r"util", nm, re.I) and i != len(normal) - 1:
            loc = first_seen.get((nm,), paths[0] + ":1").rsplit(":", 1)
            add(loc[0], int(loc[1]), "warn", "utilities-not-last", f"layer '{nm}' is not last in the order {top}: later layers beat utilities")
    if rank_prop:
        cand = [d for d in decls if d["prop"] == rank_prop and any(s in d["sels"] for s in sels)] if sels else [d for d in decls if d["prop"] == rank_prop]
        keys = sorted({T.key(d["path"]) for d in cand}); rk = {k: i for i, k in enumerate(keys)}
        def score(d):
            r = rk[T.key(d["path"])]; sp = max(spec_of(s) for s in d["sels"] if (not sels or s in sels))
            return (1 if d["imp"] else 0, -r if d["imp"] else r, sp, d["order"])
        cand.sort(key=score, reverse=True)
        for i, d in enumerate(cand):
            sp = max(spec_of(s) for s in d["sels"] if (not sels or s in sels))
            print(f"{'WINS' if i == 0 else '    '}  {d['file']}:{d['line']}  {d['sels'][0][:40]}  layer={'.'.join(d['path']) or 'unlayered'}  spec={sp}  important={d['imp']}  {d['prop']}: {d['val'][:30]}")
        print(f"{len(cand)} candidates. Style attributes and animations are not modelled; confirm with getComputedStyle.")
        return 0
    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"layers {top or 'none'}; rules {n_rules}, unlayered rules {n_unl}, !important {n_imp}, high-specificity selectors {len(hi)}")
    print(f"errors {counts['error']}, warnings {counts['warn']}, info {counts['info']}")
    return 1 if counts["error"] else 0

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