#!/usr/bin/env python3
"""Read-only scanner for CSS custom property problems. Standard library only.
Usage: python3 cssvar_check.py PATH [PATH ...] [--strict]
Prints one summary line, then 'file:line level code message'. Never prints source text.
Exit 1 on any error, or on any warning with --strict."""
import os, re, sys
EXT = {'.css', '.scss', '.sass', '.less', '.html', '.htm', '.vue', '.svelte', '.jsx', '.tsx', '.js', '.ts', '.astro'}
SKIP = {'node_modules', 'dist', 'build', '.git', '.next', 'vendor'}
FONT_REL = re.compile(r'^\s*-?[\d.]+(em|ex|ch|lh|cap|ic)\b')

def files(paths):
    for p in paths:
        if os.path.isfile(p):
            yield p
            continue
        for root, dirs, names in os.walk(p):
            dirs[:] = [d for d in dirs if d not in SKIP]
            for n in sorted(names):
                if os.path.splitext(n)[1].lower() in EXT:
                    yield os.path.join(root, n)

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

def line_of(t, i):
    return t.count('\n', 0, i) + 1

def var_calls(t):
    """Yield (index, name, has_fallback, fallback_text) for each var( call."""
    for m in re.finditer(r'var\(\s*(--[\w-]+)', t):
        depth, i, comma = 1, m.end(), None
        while i < len(t) and depth:
            c = t[i]
            if c == '(':
                depth += 1
            elif c == ')':
                depth -= 1
            elif c == ',' and depth == 1 and comma is None:
                comma = i
            i += 1
        fb = t[comma + 1:i - 1] if comma is not None else None
        yield m.start(), m.group(1), comma is not None, fb

def block_at(t, start):
    """Return (body, end) of the {...} block whose '{' is at or after start."""
    i = t.find('{', start)
    if i < 0:
        return '', start
    depth, j = 0, i
    while j < len(t):
        if t[j] == '{':
            depth += 1
        elif t[j] == '}':
            depth -= 1
            if depth == 0:
                return t[i + 1:j], j
        j += 1
    return t[i + 1:], len(t)

def main(argv):
    strict = '--strict' in argv
    paths = [a for a in argv if not a.startswith('--')]
    if not paths:
        print('usage: cssvar_check.py PATH [PATH ...] [--strict]')
        return 2
    out, decl, used, reg, texts = [], {}, {}, {}, {}
    def add(f, ln, lvl, code, msg):
        out.append((f, ln, lvl, code, msg))
    nfiles = 0
    for f in files(paths):
        try:
            raw = open(f, encoding='utf-8', errors='replace').read()
        except OSError:
            continue
        nfiles += 1
        t = strip_comments(raw) if os.path.splitext(f)[1].lower() in {'.css', '.scss', '.sass', '.less', '.html', '.htm', '.vue', '.svelte', '.astro'} else raw
        texts[f] = t
        for m in re.finditer(r'(?<![\w-])(--[\w-]+)\s*:', t):
            decl.setdefault(m.group(1), []).append((f, line_of(t, m.start())))
        for m in re.finditer(r'''['"](--[\w-]+)['"]\s*[:,)]''', t):
            decl.setdefault(m.group(1), []).append((f, line_of(t, m.start())))
        for idx, name, has_fb, fb in var_calls(t):
            used.setdefault(name, []).append((f, line_of(t, idx), has_fb))
            if fb is not None and re.match(r'\s*--[\w-]+\s*(,|$)', fb) and 'var(' not in fb.split(',')[0]:
                add(f, line_of(t, idx), 'error', 'fallback-not-var',
                    'fallback starts with a bare --name: invalid. Nest var(--a, var(--b, x))')
        for m in re.finditer(r'@property\s+(--[\w-]+)\s*\{', t):
            body, _ = block_at(t, m.start())
            ln = line_of(t, m.start())
            d = dict((k.strip(), v.strip()) for k, v in re.findall(r'([\w-]+)\s*:\s*([^;]+);?', body))
            syn = d.get('syntax')
            valid = syn is not None and 'inherits' in d and (syn.strip('"\' ') == '*' or 'initial-value' in d) and not FONT_REL.match(d.get('initial-value', ''))
            if valid:
                reg[m.group(1)] = (f, ln)
            if syn is None or 'inherits' not in d:
                add(f, ln, 'error', 'property-missing-descriptor', '@property needs both syntax and inherits or the whole rule is ignored')
            elif syn.strip('"\' ') != '*' and 'initial-value' not in d:
                add(f, ln, 'error', 'property-no-initial', 'initial-value is required unless syntax is "*": the rule is ignored without it')
            elif 'initial-value' in d and FONT_REL.match(d['initial-value']):
                add(f, ln, 'error', 'property-initial-relative', 'initial-value must be computationally independent: no em, ex, ch, lh')
            elif 'initial-value' in d and re.match(r'^\s*-?[\d.]+rem\b', d['initial-value']):
                add(f, ln, 'info', 'property-initial-rem', 'rem initial-value: verify it is accepted in every target browser')
        for m in re.finditer(r'@(media|container|supports|import)\b([^{;]*)', t):
            if 'var(' in m.group(2):
                add(f, line_of(t, m.start()), 'error', 'var-in-at-rule', 'var() does not work in media or container query conditions or at-rule preludes')
        for m in re.finditer(r'(?m)^\s*var\(\s*--[\w-]+[^)]*\)\s*:', t):
            add(f, line_of(t, m.start()), 'error', 'var-in-property-name', 'var() cannot make up a property name')
        for m in re.finditer(r'@keyframes\s+[\w-]+', t):
            body, _ = block_at(t, m.start())
            for n in set(re.findall(r'(?<![\w-])(--[\w-]+)\s*:', body)):
                if n not in reg:
                    add(f, line_of(t, m.start()), 'info', 'keyframes-sets-custom-prop', n + ' set inside @keyframes and not registered: steps instead of tweens, and browsers differ')
    for f, t in texts.items():
        for m in re.finditer(r'transition(?:-property)?\s*:\s*([^;{}]*)', t):
            for n in re.findall(r'(?<![\w-])(--[\w-]+)', m.group(1)):
                if n not in reg:
                    add(f, line_of(t, m.start()), 'warn', 'transition-unregistered', n + ' is transitioned but has no @property: it will snap, with no error')
    for n, uses in sorted(used.items()):
        if n not in decl and n not in reg:
            for f, ln, has_fb in uses:
                if not has_fb:
                    add(f, ln, 'warn', 'undefined-var', n + ' is used with no fallback and declared nowhere in the scanned files: the property becomes invalid at computed-value time')
    for n, ds in sorted(decl.items()):
        if n not in used and n not in reg:
            f, ln = ds[0]
            if os.path.splitext(f)[1].lower() in {'.css', '.scss', '.sass', '.less'}:
                add(f, ln, 'info', 'unused-var', n + ' is declared and never read via var() in the scanned files')
    lower = {}
    for n in list(decl) + list(used):
        lower.setdefault(n.lower(), set()).add(n)
    for k, s in sorted(lower.items()):
        if len(s) > 1:
            n = sorted(s)[0]
            f, ln = (decl.get(n) or [(next(iter(texts), ''), 1)])[0]
            add(f, ln, 'warn', 'name-case-clash', 'names differ only by case (' + ', '.join(sorted(s)) + '): custom property names are case-sensitive')
    graph = {}
    for f, t in texts.items():
        for m in re.finditer(r'(?<![\w-])(--[\w-]+)\s*:\s*([^;{}]*)', t):
            for _, n, has_fb, _fb in var_calls(m.group(2)):
                graph.setdefault(m.group(1), set()).add(n)
    seen, done = set(), set()
    def dfs(n, stack):
        if n in stack:
            return stack[stack.index(n):] + [n]
        if n in done:
            return None
        stack.append(n)
        for nb in sorted(graph.get(n, ())):
            c = dfs(nb, stack)
            if c:
                return c
        stack.pop()
        done.add(n)
        return None
    for n in sorted(graph):
        c = dfs(n, [])
        if c and frozenset(c) not in seen:
            seen.add(frozenset(c))
            f, ln = decl[n][0]
            add(f, ln, 'warn', 'cycle', 'dependency cycle ' + ' -> '.join(c) + ': every property in a cycle is invalid at computed-value time (scope-blind: confirm they share a scope)')
    order = {'error': 0, 'warn': 1, 'info': 2}
    out.sort(key=lambda r: (r[0], r[1], order[r[2]]))
    ne = sum(1 for r in out if r[2] == 'error')
    nw = sum(1 for r in out if r[2] == 'warn')
    ni = sum(1 for r in out if r[2] == 'info')
    print('%d errors, %d warnings, %d info, %d files' % (ne, nw, ni, nfiles))
    for f, ln, lvl, code, msg in out:
        print('%s:%d %-5s %s  %s' % (f, ln, lvl, code, msg))
    return 1 if ne or (strict and nw) else 0

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