#!/usr/bin/env python3
"""Scan CSS/HTML/JS for scroll-driven animation faults. Report only; never edits files.
Usage: sda_check.py <paths...> [--summary] [--max N]   Exit 0 clean, 1 findings, 2 bad args.
Standard library only. Codes are listed in SKILL.md step 1."""
import os, re, sys
from collections import Counter
CSS = {'.css', '.scss', '.sass', '.less'}
MARK = {'.html', '.htm', '.vue', '.svelte', '.astro', '.php', '.mdx'}
JS = {'.js', '.jsx', '.ts', '.tsx', '.mjs'}
SKIP = {'node_modules', '.git', 'dist', 'build', '.next', 'vendor'}
LAYOUT = re.compile(r'^(width|height|top|left|right|bottom|margin.*|padding.*|inset.*|font-size|border-width|(min|max)-(width|height))$')

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

def parse(text, base=0):
    """Yield (ctx, selector, decls, line). decls = [(prop, value, line)]."""
    text = strip_comments(text)
    out, stack, buf, line, depth, q = [], [], [], 1, 0, None
    start = 1
    def flush_decl():
        s = ''.join(buf).strip(); buf.clear()
        if s and stack and ':' in s:
            p, v = s.split(':', 1)
            stack[-1][2].append((p.strip().lower(), v.strip(), start + base))
    for ch in text:
        if ch == '\n': line += 1
        if q:
            buf.append(ch)
            if ch == q: q = None
            continue
        if ch in '"\'': q = ch; buf.append(ch); continue
        if ch == '(': depth += 1
        elif ch == ')': depth = max(0, depth - 1)
        if depth == 0 and ch == '{':
            sel = ' '.join(''.join(buf).split()); buf.clear()
            stack.append([sel, line + base, [], tuple(s[0] for s in stack)])
            start = line
        elif depth == 0 and ch == ';':
            flush_decl(); start = line
        elif depth == 0 and ch == '}':
            flush_decl()
            if stack:
                s = stack.pop(); out.append((s[3], s[0], s[2], s[1]))
            start = line
        else:
            if not buf and not ch.isspace(): start = line
            buf.append(ch)
    return out

def files(paths):
    for p in paths:
        if os.path.isfile(p): yield p; continue
        for d, ds, fs in os.walk(p):
            ds[:] = [x for x in ds if x not in SKIP]
            for f in sorted(fs):
                if '.min.' in f: continue
                yield os.path.join(d, f)

def style_blocks(text):
    for m in re.finditer(r'<style[^>]*>(.*?)</style>', text, flags=re.S | re.I):
        yield m.group(1), text.count('\n', 0, m.start(1))

def main(argv):
    paths, summary, mx = [], False, 60
    i = 0
    while i < len(argv):
        a = argv[i]
        if a == '--summary': summary = True
        elif a == '--max':
            i += 1
            try: mx = int(argv[i])
            except (IndexError, ValueError): print('--max needs a number'); return 2
        elif a.startswith('-'): print('unknown option ' + a); return 2
        else: paths.append(a)
        i += 1
    if not paths: print(__doc__.strip().splitlines()[1]); return 2
    rules, js, nscan = [], [], 0
    for f in files(paths):
        ext = os.path.splitext(f)[1].lower()
        try: txt = open(f, encoding='utf-8', errors='replace').read()
        except OSError: continue
        if ext in CSS: nscan += 1; rules += [(f,) + r for r in parse(txt)]
        elif ext in MARK:
            nscan += 1
            for blk, off in style_blocks(txt): rules += [(f,) + r for r in parse(blk, off)]
        elif ext in JS: nscan += 1; js.append((f, txt))
    F = []  # (file, line, code, msg)
    kf = {}
    defined, referenced, scope_seen, rm_seen, tl_seen = set(), [], False, False, False
    for f, ctx, sel, decls, ln in rules:
        if sel.lower().startswith('@keyframes') or sel.lower().startswith('@-webkit-keyframes'):
            kf[sel.split()[-1]] = (f, ln, decls, True)
        if 'prefers-reduced-motion' in sel.lower() or any('prefers-reduced-motion' in c.lower() for c in ctx): rm_seen = True
    kfbody = {}
    for f, ctx, sel, decls, ln in rules:
        if any(c.lower().startswith(('@keyframes', '@-webkit-keyframes')) for c in ctx):
            name = [c for c in ctx if c.lower().startswith(('@keyframes', '@-webkit-keyframes'))][0].split()[-1]
            kfbody.setdefault(name, []).extend((f, d) for d in decls)
    for f, ctx, sel, decls, ln in rules:
        props = [d[0] for d in decls]
        for p, v, l in decls:
            if p in ('view-timeline-name', 'scroll-timeline-name') or p in ('view-timeline', 'scroll-timeline'):
                m = re.match(r'(--[\w-]+)', v)
                if m: defined.add(m.group(1))
            if p == 'timeline-scope': scope_seen = True; defined.update(re.findall(r'--[\w-]+', v))
        tl = [d for d in decls if d[0] == 'animation-timeline']
        if not tl: continue
        tl_seen = True
        p, v, l = tl[0]
        idx = props.index('animation-timeline')
        if not any('@supports' in c.lower() and re.search(r'(animation|scroll|view)-timeline', c) for c in ctx):
            F.append((f, l, 'S01', 'animation-timeline has no @supports guard'))
        for j, (p2, v2, l2) in enumerate(decls):
            if p2 == 'animation' and j > idx:
                F.append((f, l2, 'S02', 'animation shorthand after animation-timeline resets it; move the shorthand above'))
        shorthand = [d for d in decls if d[0] == 'animation']
        sv = ' '.join(d[1] for d in shorthand)
        fill = ' '.join(d[1] for d in decls if d[0] == 'animation-fill-mode') + ' ' + sv
        if re.search(r'\binfinite\b', sv) or any(d[0] == 'animation-iteration-count' and 'infinite' in d[1] for d in decls):
            F.append((f, l, 'S03', 'infinite iteration gives zero duration on a scroll timeline'))
        if not re.search(r'\bboth\b', fill):
            F.append((f, l, 'S04', 'fill mode is not both; element shows base style outside the range'))
        for n in re.findall(r'--[\w-]+', v): referenced.append((f, l, n))
        names = re.findall(r'animation-name\s*:\s*([\w-]+)', ' '.join(f'{d[0]}: {d[1]}' for d in decls))
        names += [t for t in re.split(r'[\s,]+', sv) if t in kfbody]
        for n in set(names):
            for kf_f, d in kfbody.get(n, []):
                if LAYOUT.match(d[0]):
                    F.append((kf_f, d[2], 'S08', f'keyframes {n} animates {d[0]} on a scroll timeline; use transform or opacity'))
        for p3, v3, l3 in decls:
            if p3 in ('animation-range', 'animation-range-start', 'animation-range-end') and not re.search(r'\b(cover|contain|entry|exit|entry-crossing|exit-crossing)\b', v3) and v3.strip() not in ('normal', ''):
                F.append((f, l3, 'S11', 'animation-range without a range name; percentages measure the whole timeline'))
    for f, l, n in referenced:
        if n not in defined: F.append((f, l, 'S05', f'timeline {n} is not defined in the scanned files'))
    if referenced and not scope_seen:
        f, l, n = referenced[0]
        F.append((f, l, 'S06', f'{len(referenced)} named timeline reference(s), no timeline-scope; each must sit under the element that defines it'))
    if tl_seen and not rm_seen:
        f = next(x[0] for x in rules if any(d[0] == 'animation-timeline' for d in x[3]))
        F.append((f, 1, 'S07', 'no prefers-reduced-motion rule in any scanned file'))
    if tl_seen:
        ov = [(f, d[2]) for f, ctx, sel, decls, ln in rules for d in decls if re.match(r'overflow(-[xy])?$', d[0]) and re.search(r'\b(hidden|auto|scroll)\b', d[1])]
        for f, l in ov[:3]: F.append((f, l, 'S10', 'overflow makes this a scroll container; if it wraps an animated element it is the nearest scroller (use clip if it should not scroll)'))
    for f, txt in js:
        for m in re.finditer(r'new\s+(ScrollTimeline|ViewTimeline)\b|timeline\s*:\s*new\s+(Scroll|View)Timeline', txt):
            if not re.search(r"['\"]?(ScrollTimeline|ViewTimeline)['\"]?\s+in\s+window|typeof\s+(Scroll|View)Timeline|CSS\.supports\([^)]*animation-timeline", txt):
                F.append((f, txt.count('\n', 0, m.start()) + 1, 'S09', 'ScrollTimeline/ViewTimeline used with no feature check'))
            break
    F = sorted(set(F), key=lambda x: (x[0], x[1], x[2]))
    if not F:
        print(f'{nscan} files scanned: clean'); return 0
    if summary:
        by = {}
        for f, l, c, m in F: by.setdefault(f, Counter())[c] += 1
        for f, c in by.items(): print(f + ' ' + ' '.join(f'{k}x{v}' for k, v in sorted(c.items())))
    else:
        for f, l, c, m in F[:mx]: print(f'{f}:{l} {c} {m}')
        if len(F) > mx: print(f'... {len(F) - mx} more (raise --max)')
    print(f'{nscan} files scanned: {len(F)} findings ' + ' '.join(f'{k}x{v}' for k, v in sorted(Counter(x[2] for x in F).items())))
    return 1

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