#!/usr/bin/env python3
"""nesting_lint.py - read-only checker for native CSS nesting.
Usage: nesting_lint.py FILE.css|FILE.html [...] [--legacy] [--strict] [--max-depth N]
Standard library only. Reads files, writes nothing, exits 1 on errors (or warnings with --strict).
--legacy also flags syntax that needs Chrome 120 / Safari 17.2 / Firefox 117 (bare type selectors)."""
import re, sys

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

class Rule:
    def __init__(self, prelude, line, parent):
        self.prelude, self.line, self.parent = prelude, line, parent
        self.items = []            # ("decl", line) | ("rule", Rule)
    @property
    def at(self): return self.prelude.startswith("@")
    def depth(self):
        d, p = 0, self.parent
        while p is not None:
            if p.prelude and not p.at: d += 1
            p = p.parent
        return d

def parse(css, base=0):
    root = Rule("", base + 1, None)
    stack, buf, line, bstart = [root], "", base + 1, base + 1
    quote, paren = None, 0
    for c in css:
        if quote:
            buf += c
            if c == quote: quote = None
            if c == "\n": line += 1
            continue
        if c in "\"'": quote = c; buf += c; continue
        if c == "(": paren += 1
        elif c == ")": paren -= 1
        if c == "\n": line += 1
        if not buf.strip(): bstart = line
        if c == "{" and paren == 0:
            r = Rule(" ".join(buf.split()), bstart, stack[-1])
            stack[-1].items.append(("rule", r)); stack.append(r); buf = ""
        elif c == ";" and paren == 0:
            if buf.strip() and not buf.strip().startswith("@"): stack[-1].items.append(("decl", bstart))
            buf = ""
        elif c == "}" and paren == 0:
            if buf.strip() and ":" in buf: stack[-1].items.append(("decl", bstart))
            buf = ""
            if len(stack) > 1: stack.pop()
        else:
            buf += c
    return root

def split_top(s):
    parts, d, cur = [], 0, ""
    for c in s:
        d += (c in "([") - (c in ")]")
        if c == "," and d == 0: parts.append(cur.strip()); cur = ""
        else: cur += c
    parts.append(cur.strip())
    return [p for p in parts if p]

def walk(rule, out, path, opts):
    sel_parent = rule if (rule.prelude and not rule.at) else None
    d = 0
    seen_rule = False
    for kind, x in rule.items:
        if kind == "rule":
            seen_rule = True
            r = x
            nested_in_style = style_ancestor(r) is not None
            if not r.at:
                sels = split_top(r.prelude)
                if nested_in_style:
                    anc = style_ancestor(r)
                    for s in sels:
                        if re.search(r"&[A-Za-z_\-]", s):
                            out.append((r.line, "error", "amp-ident", s, "'&' directly followed by a name is not CSS (Sass habit). Write the full selector, e.g. '.card__title' or '&.is-open'"))
                        if opts["legacy"] and re.match(r"[A-Za-z]", s) and "&" not in s:
                            out.append((r.line, "warn", "bare-type", s, "bare type selector needs Chrome 120, Safari 17.2, Firefox 117. Write '& " + s + "'"))
                    if any("&" in s for s in sels) and any("::" in a for a in split_top(anc.prelude)):
                        out.append((r.line, "error", "amp-pseudo-element", r.prelude, "'&' cannot extend a pseudo-element parent (" + anc.prelude[:30] + "). Move the rule out"))
                    plist = split_top(anc.prelude)
                    if len(plist) > 1 and any(re.search(r"#[\w-]", a) for a in plist) and any("&" in s for s in sels):
                        out.append((r.line, "warn", "id-list-amp", anc.prelude[:50], "'&' acts like :is(list); one id gives every match id specificity. Split the list"))
                    if r.depth() + 1 == opts["max"] + 1:
                        out.append((r.line, "warn", "depth", r.prelude, "nesting depth %d exceeds %d (house limit, not a spec rule). Flatten with a class" % (r.depth() + 1, opts["max"])))
                else:
                    if any("&" in s for s in sels):
                        out.append((r.line, "info", "amp-top-level", r.prelude, "'&' outside a parent matches :scope (usually :root). Probably unintended"))
            walk(r, out, path, opts)
        else:  # decl
            if seen_rule and sel_parent is not None:
                out.append((x, "warn", "decl-after-rule", sel_parent.prelude, "declaration after a nested rule. Engines before Chrome/Edge 130, Safari 18.2, Firefox 132 apply it as if above. Move it above the nested rules"))

def style_ancestor(r):
    p = r.parent
    while p is not None:
        if p.prelude and not p.at: return p
        p = p.parent
    return None

def check(path, opts):
    text = open(path, encoding="utf-8", errors="replace").read()
    chunks = [(text, 0)]
    if path.lower().endswith((".html", ".htm")):
        chunks = [(m.group(1), text.count("\n", 0, m.start(1))) for m in re.finditer(r"<style[^>]*>(.*?)</style>", text, re.S | re.I)]
    out = []
    for css, base in chunks:
        css = strip_comments(css)
        if re.search(r"^\s*//|[;{]\s*//|\$[\w-]+\s*:|@(mixin|include|extend)\b", css, re.M):
            out.append((base + 1, "error", "scss-syntax", path, "SCSS syntax ('//', $var, @mixin) is not native CSS. Compile it or rewrite"))
        walk(parse(css, base), out, path, opts)
    return out

def main(argv):
    opts = {"legacy": "--legacy" in argv, "max": 3}
    if "--max-depth" in argv: opts["max"] = int(argv[argv.index("--max-depth") + 1])
    files = [a for i, a in enumerate(argv) if not a.startswith("--") and (i == 0 or argv[i - 1] != "--max-depth")]
    if not files: print(__doc__); return 2
    rows = []
    for f in files:
        try: rows += [(f,) + r for r in check(f, opts)]
        except OSError as e: print("cannot read %s: %s" % (f, e)); return 2
    order = {"error": 0, "warn": 1, "info": 2}
    rows.sort(key=lambda r: (order[r[2]], r[0], r[1]))
    n = {k: sum(1 for r in rows if r[2] == k) for k in order}
    print("nesting_lint: %d error, %d warn, %d info in %d file(s)" % (n["error"], n["warn"], n["info"], len(files)))
    for f, ln, lvl, code, sel, fix in rows:
        print("%s:%d %s %s | %s | %s" % (f, ln, lvl, code, sel[:60], fix))
    return 1 if n["error"] or (("--strict" in argv) and n["warn"]) else 0

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