#!/usr/bin/env python3
"""Scan CSS/HTML/JS for CSS anchor positioning faults. Report only; never edits files.
Usage: anchor_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'}
INSET = re.compile(r'^(top|left|right|bottom|inset(-(block|inline)(-(start|end))?)?|inset-(block|inline)-(start|end))$')
PHYS = {'left','right','top','bottom','span-left','span-right','span-top','span-bottom','x-start','x-end','y-start','y-end','span-x-start','span-x-end','span-y-start','span-y-end','x-self-start','x-self-end','y-self-start','y-self-end'}
LOGI = {'block-start','block-end','inline-start','inline-end','span-block-start','span-block-end','span-inline-start','span-inline-end','start','end','span-start','span-end','self-start','self-end','span-self-start','span-self-end','block-self-start','block-self-end','inline-self-start','inline-self-end'}
TRIG = ('popovertarget', 'commandfor', 'interestfor', 'aria-describedby', 'aria-details', 'aria-controls', 'aria-labelledby')

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, marks, 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; marks.append((f, txt))
            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 = []
    names_def, names_use, tries_def, tries_use = {}, [], {}, []
    pos_set = {}
    for f, ctx, sel, decls, ln in rules:
        if sel.lower().startswith('@position-try'):
            tries_def[sel.split()[-1]] = ln
    for f, ctx, sel, decls, ln in rules:
        if sel.startswith('@') or any(c.startswith('@keyframes') for c in ctx): continue
        d = {}
        for p, v, l in decls: d.setdefault(p, (v, l))
        pos = d.get('position', ('', 0))[0].lower()
        user = [p for p in d if p in ('position-anchor', 'position-area', 'anchor-center') or any(re.search(r'\banchor(-size)?\(', d[p][0]) for _ in [0])]
        used_anchor = 'position-anchor' in d or 'position-area' in d or any('anchor(' in v for v, _ in d.values()) or 'anchor-size(' in ' '.join(v for v, _ in d.values())
        if 'anchor-name' in d:
            for n in re.findall(r'--[\w-]+', d['anchor-name'][0]):
                names_def.setdefault(n, (f, d['anchor-name'][1], sel))
        if 'position-anchor' in d:
            for n in re.findall(r'--[\w-]+', d['position-anchor'][0]): names_use.append((f, d['position-anchor'][1], n))
        for p, (v, l) in d.items():
            for n in re.findall(r'anchor(?:-size)?\(\s*(--[\w-]+)', v): names_use.append((f, l, n))
        if used_anchor and pos not in ('absolute', 'fixed'):
            F.append((f, min(l for _, l in d.values()), 'A01', f'{sel[:40]}: anchor properties need position absolute or fixed; position is "{pos or "not set here"}" (fine for a [popover] or dialog, whose UA style is fixed; verify)'))
        if ('position-area' in d or 'position-anchor' in d) and 'position-try-fallbacks' not in d and 'position-try' not in d:
            F.append((f, (d.get('position-area') or d.get('position-anchor'))[1], 'A03', f'{sel[:40]}: no position-try-fallbacks; it overflows at the viewport edge'))
        if 'position-area' in d:
            toks = re.findall(r'[a-z-]+', d['position-area'][0].lower())
            if any(t in PHYS for t in toks) and any(t in LOGI for t in toks):
                F.append((f, d['position-area'][1], 'A05', f'position-area "{d["position-area"][0]}" mixes physical and logical values and is invalid'))
        for p, (v, l) in d.items():
            for m in re.finditer(r'\banchor\(\s*(?:--[\w-]+\s+)?([a-z-]+)', v):
                if not INSET.match(p):
                    F.append((f, l, 'A06', f'anchor() is valid only in inset properties, not {p}; use anchor-size() for sizes'))
                elif m.group(1) in ('top', 'bottom') and p in ('left', 'right', 'inset-inline-start', 'inset-inline-end') or m.group(1) in ('left', 'right') and p in ('top', 'bottom', 'inset-block-start', 'inset-block-end'):
                    F.append((f, l, 'A06', f'anchor({m.group(1)}) does not match the axis of {p}'))
        for p in ('position-try-fallbacks', 'position-try'):
            if p in d:
                for n in re.findall(r'(?<![\w-])(--[\w-]+)', d[p][0]): tries_use.append((f, d[p][1], n))
    for f, l, n in names_use:
        if n not in names_def: F.append((f, l, 'A04', f'{n} is used but no anchor-name defines it in the scanned files'))
    used = {n for _, _, n in names_use}
    for n, (f, l, sel) in names_def.items():
        if n not in used: F.append((f, l, 'A04', f'anchor-name {n} is never referenced'))
        elif re.match(r'^[.\w\[\]=\-"]+$', sel) and not sel.startswith('#'):
            F.append((f, l, 'A02', f'{n} is static on "{sel[:30]}"; if several elements match, every positioned element binds to the last in tree order'))
    for f, l, n in tries_use:
        if n not in tries_def: F.append((f, l, 'A12', f'{n} has no @position-try block in the scanned files'))
    pops, refs = {}, set()
    for f, txt in marks:
        for m in re.finditer(r'<(\w+)([^>]*)>', txt):
            attrs = m.group(2)
            ln = txt.count('\n', 0, m.start()) + 1
            if re.search(r'(^|\s)popover(\s|=|$|/)', attrs, re.I):
                idm = re.search(r'\bid\s*=\s*["\']([^"\']+)', attrs)
                pops[(f, ln)] = idm.group(1) if idm else None
            for a in TRIG:
                for r in re.finditer(a + r'\s*=\s*["\']([^"\']+)', attrs, re.I):
                    refs.update(r.group(1).split())
    for (f, ln), pid in pops.items():
        if pid is None or pid not in refs:
            F.append((f, ln, 'A10', f'popover {("#" + pid) if pid else "without id"} has no invoker or aria link; anchoring is visual only'))
    lib = re.compile(r'@floating-ui|floating-ui|popper\.js|@popperjs|from\s+[\'"]tippy')
    for f, txt in js:
        m = lib.search(txt)
        if m: F.append((f, txt.count('\n', 0, m.start()) + 1, 'I01', 'JS positioning library import; candidate for native anchor positioning'))
    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:]))
