#!/usr/bin/env python3
"""Read-only scanner for CSS animation and transition problems. Standard library only.
Usage: python3 motion_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', '.astro'}
SKIP = {'node_modules', 'dist', 'build', '.git', '.next', 'vendor'}
LAYOUT = {'width', 'height', 'min-width', 'max-width', 'min-height', 'max-height', 'top', 'left', 'right', 'bottom',
          'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', 'padding', 'padding-top',
          'padding-right', 'padding-bottom', 'padding-left', 'font-size', 'line-height', 'border-width', 'gap', 'flex-basis'}
BROAD = re.compile(r'(^|,)\s*(\*|html|body|div|span|:root|\*::before|\*::after)\s*(,|$)')

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(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 rules(t):
    """Yield (selector, body, start) for every innermost {...} rule, with the enclosing at-rule prelude chain."""
    stack = []
    for m in re.finditer(r'([^{};]*)\{|\}', t):
        if m.group(0) == '}':
            if stack:
                stack.pop()
            continue
        stack.append((m.group(1).strip(), m.start() + len(m.group(1)) - len(m.group(1).lstrip()), m.end()))
        sel, start, bs = stack[-1]
        depth, j = 1, bs
        while j < len(t) and depth:
            depth += {'{': 1, '}': -1}.get(t[j], 0)
            j += 1
        body = t[bs:j - 1]
        if '{' not in body:
            yield sel, body, start, [s for s, _, _ in stack[:-1]]

def main(argv):
    strict = '--strict' in argv
    paths = [a for a in argv if not a.startswith('--')]
    if not paths:
        print('usage: motion_check.py PATH [PATH ...] [--strict]')
        return 2
    out, kf_def, kf_use, nfiles = [], {}, {}, 0
    anim_files, rm_present, rm_sels, inf = [], False, set(), []
    def add(f, ln, lvl, code, msg):
        out.append((f, ln, lvl, code, msg))
    for f in files(paths):
        try:
            t = strip(open(f, encoding='utf-8', errors='replace').read())
        except OSError:
            continue
        nfiles += 1
        has_motion = bool(re.search(r'\banimation(-name)?\s*:|\btransition(-property)?\s*:|@keyframes', t))
        if has_motion:
            anim_files.append(f)
        if 'prefers-reduced-motion' in t:
            rm_present = True
        for m in re.finditer(r'@keyframes\s+([\w-]+)', t):
            kf_def[m.group(1)] = (f, line_of(t, m.start()))
            depth, j = 0, t.find('{', m.end())
            k = j
            while k < len(t):
                depth += {'{': 1, '}': -1}.get(t[k], 0)
                if depth == 0:
                    break
                k += 1
            body = t[j:k]
            props = set(re.findall(r'(?<![\w-])([a-z-]+)\s*:', body))
            bad = sorted(props & LAYOUT)
            if bad:
                add(f, line_of(t, m.start()), 'warn', 'layout-property-animated', '@keyframes ' + m.group(1) + ' animates ' + ', '.join(bad) + ': layout work each frame')
        for sel, body, start, chain in rules(t):
            ln = line_of(t, start)
            in_rm = any('prefers-reduced-motion' in c for c in chain)
            decls = dict((k.strip(), v.strip()) for k, v in re.findall(r'(?<![\w-])([a-z-]+)\s*:\s*([^;]+)', body))
            tr = decls.get('transition', '')
            tp = decls.get('transition-property', '')
            if 'keyframes' in ' '.join(chain) or sel.endswith('%') or sel in ('from', 'to'):
                continue
            for name in ('animation', 'animation-name'):
                if name in decls:
                    for tok in re.findall(r'(?<![\w.-])([a-zA-Z_][\w-]*)(?![\w-]*\()', decls[name]):
                        if tok not in {'infinite', 'alternate', 'alternate-reverse', 'reverse', 'normal', 'forwards', 'backwards', 'both',
                                       'none', 'paused', 'running', 'ease', 'ease-in', 'ease-out', 'ease-in-out', 'linear', 'step-start',
                                       'step-end', 'initial', 'inherit', 'unset', 'revert', 's', 'ms', 'steps'} and not re.match(r'^\d', tok):
                            kf_use.setdefault(tok, []).append((f, ln))
            if re.search(r'(^|[\s,:])all(\s|,|$)', tr) or tp.strip() == 'all':
                add(f, ln, 'warn', 'transition-all', 'transition on all: list the exact properties')
            tprops = set(re.findall(r'(?<![\w-])([a-z-]+)(?=\s+[\d.]|\s*,|\s*$)', tr)) | set(x.strip() for x in tp.split(','))
            bad = sorted(tprops & LAYOUT)
            if bad:
                add(f, ln, 'warn', 'layout-property-animated', 'transitions ' + ', '.join(bad) + ': layout work each frame')
            for part in [p for p in re.split(r',(?![^()]*\))', tr) if p.strip()]:
                if re.match(r'^\s*[a-z-]+\s+\.?\d+(\.\d+)?\s*(?!\d)(?![\w%])', part + ' ') and not re.search(r'\d(ms|s)\b', part) and not re.match(r'^\s*[a-z-]+\s+0\b', part):
                    add(f, ln, 'error', 'duration-no-unit', 'transition duration has no unit (s or ms): the declaration is invalid')
            for key in ('transition-duration', 'animation-duration'):
                v = decls.get(key, '')
                if re.match(r'^\s*\.?\d+(\.\d+)?\s*(,|$)', v) and not re.match(r'^\s*0\s*$', v):
                    add(f, ln, 'error', 'duration-no-unit', key + ' has no unit (s or ms): the declaration is invalid')
            if re.search(r'(^|[\s,:])(display|content-visibility)(\s|,|$)', tr + ' ' + tp) and 'allow-discrete' not in (tr + decls.get('transition-behavior', '')):
                add(f, ln, 'error', 'discrete-no-allow-discrete', 'display transition without transition-behavior: allow-discrete: it will not animate')
            if 'transition-behavior' in body and tr and body.find('transition-behavior') < body.find('transition:') :
                add(f, ln, 'warn', 'behavior-before-shorthand', 'transition shorthand after transition-behavior resets it to normal: put the longhand after')
            if 'will-change' in decls and BROAD.search(sel):
                add(f, ln, 'warn', 'will-change-broad', 'will-change on a broad selector promotes many layers: scope it to the element that animates')
            elif 'will-change' in decls and not in_rm:
                add(f, ln, 'info', 'will-change-static', 'static will-change keeps a layer alive: apply it just before the motion and remove it after, or confirm it is needed')
            if in_rm and BROAD.search(sel) and re.search(r'(animation|transition)(-duration)?\s*:', body) and '!important' in body:
                add(f, ln, 'warn', 'reduced-motion-blunt-reset', 'global !important reset in reduced motion: it also removes feedback and can break animationend handlers. Reduce per component')
            if (re.search(r'\binfinite\b', decls.get('animation', '') + decls.get('animation-iteration-count', ''))) and 'animation-play-state' not in body:
                inf.append((f, ln, sel))
            if in_rm:
                rm_sels.update(x.strip() for x in sel.split(','))
    for f, ln, sel in inf:
        if not any(x.strip() in rm_sels for x in sel.split(',')):
            add(f, ln, 'warn', 'infinite-no-pause', 'infinite animation, and its selector has no reduced-motion rule and no animation-play-state: check it can stop')
    for name, uses in sorted(kf_use.items()):
        if name not in kf_def:
            f, ln = uses[0]
            add(f, ln, 'warn', 'animation-name-undefined', 'animation-name ' + name + ' has no @keyframes in the scanned files')
    for name, (f, ln) in sorted(kf_def.items()):
        if name not in kf_use:
            add(f, ln, 'info', 'keyframes-unused', '@keyframes ' + name + ' is not referenced in the scanned files')
    if anim_files and not rm_present:
        add(anim_files[0], 1, 'error', 'no-reduced-motion', 'animation or transition code and no prefers-reduced-motion query anywhere in the scanned files')
    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:]))
