#!/usr/bin/env python3
"""logical_check.py - find physical-direction CSS that should be logical.

Read-only unless --fix is passed. Standard library only. Python 3.8+.
Usage: logical_check.py PATH... [--summary] [--max N] [--fix] [--block]
Exit: 0 clean, 1 findings, 2 usage error.
Codes: A = auto-fixable swap, M = manual shorthand, R = physical-only (review),
O = declaration inside an RTL override block (review, never auto-fixed),
I = information.
"""
import os, re, sys

CSS_EXT = {'.css', '.scss', '.sass', '.less', '.pcss', '.postcss'}
MARKUP_EXT = {'.html', '.htm', '.vue', '.svelte', '.astro', '.php', '.njk', '.hbs'}
JS_EXT = {'.js', '.jsx', '.ts', '.tsx', '.mjs', '.mdx'}
SKIP_DIRS = {'node_modules', 'dist', 'build', '.git', 'vendor', '.next', '.nuxt',
             'coverage', '.cache', 'out', '.svelte-kit'}

LONG = {
    'margin-left': 'margin-inline-start', 'margin-right': 'margin-inline-end',
    'padding-left': 'padding-inline-start', 'padding-right': 'padding-inline-end',
    'border-left': 'border-inline-start', 'border-right': 'border-inline-end',
    'border-left-width': 'border-inline-start-width', 'border-right-width': 'border-inline-end-width',
    'border-left-style': 'border-inline-start-style', 'border-right-style': 'border-inline-end-style',
    'border-left-color': 'border-inline-start-color', 'border-right-color': 'border-inline-end-color',
    'border-top-left-radius': 'border-start-start-radius',
    'border-top-right-radius': 'border-start-end-radius',
    'border-bottom-left-radius': 'border-end-start-radius',
    'border-bottom-right-radius': 'border-end-end-radius',
    'scroll-margin-left': 'scroll-margin-inline-start', 'scroll-margin-right': 'scroll-margin-inline-end',
    'scroll-padding-left': 'scroll-padding-inline-start', 'scroll-padding-right': 'scroll-padding-inline-end',
    'left': 'inset-inline-start', 'right': 'inset-inline-end',
}
BLOCK = {
    'margin-top': 'margin-block-start', 'margin-bottom': 'margin-block-end',
    'padding-top': 'padding-block-start', 'padding-bottom': 'padding-block-end',
    'border-top': 'border-block-start', 'border-bottom': 'border-block-end',
    'top': 'inset-block-start', 'bottom': 'inset-block-end',
}
VALUE_MAP = {
    'text-align': {'left': 'start', 'right': 'end'},
    'float': {'left': 'inline-start', 'right': 'inline-end'},
    'clear': {'left': 'inline-start', 'right': 'inline-end'},
}
TW = [('scroll-ml', 'scroll-ms'), ('scroll-mr', 'scroll-me'), ('scroll-pl', 'scroll-ps'),
      ('scroll-pr', 'scroll-pe'), ('rounded-tl', 'rounded-ss'), ('rounded-tr', 'rounded-se'),
      ('rounded-bl', 'rounded-es'), ('rounded-br', 'rounded-ee'), ('rounded-l', 'rounded-s'),
      ('rounded-r', 'rounded-e'), ('border-l', 'border-s'), ('border-r', 'border-e'),
      ('text-left', 'text-start'), ('text-right', 'text-end'), ('float-left', 'float-start'),
      ('float-right', 'float-end'), ('clear-left', 'clear-start'), ('clear-right', 'clear-end'),
      ('ml', 'ms'), ('mr', 'me'), ('pl', 'ps'), ('pr', 'pe'), ('left', 'start'), ('right', 'end')]
TW_RE = re.compile(r'(?<![\w./:\[-])((?:[a-z0-9-]+:)*)(-?)(' + '|'.join(re.escape(a) for a, _ in TW) +
                   r')(-[\w./%\[\]-]+)?(?![\w-])')
TW_MAP = dict(TW)
RTL_SEL = re.compile(r'\[dir\s*=\s*["\']?rtl|:dir\(\s*rtl|\.rtl\b|\brtl\s|:lang\(\s*(ar|he|fa|ur)|\[lang\^?=["\']?(ar|he|fa|ur)', re.I)
CAMEL = {
    'marginLeft': 'marginInlineStart', 'marginRight': 'marginInlineEnd',
    'paddingLeft': 'paddingInlineStart', 'paddingRight': 'paddingInlineEnd',
    'borderLeft': 'borderInlineStart', 'borderRight': 'borderInlineEnd',
    'borderLeftWidth': 'borderInlineStartWidth', 'borderRightWidth': 'borderInlineEndWidth',
    'borderLeftColor': 'borderInlineStartColor', 'borderRightColor': 'borderInlineEndColor',
    'borderLeftStyle': 'borderInlineStartStyle', 'borderRightStyle': 'borderInlineEndStyle',
    'borderTopLeftRadius': 'borderStartStartRadius', 'borderTopRightRadius': 'borderStartEndRadius',
    'borderBottomLeftRadius': 'borderEndStartRadius', 'borderBottomRightRadius': 'borderEndEndRadius',
}
CAMEL_RE = re.compile(r'(?<![\w$.])(' + '|'.join(CAMEL) + r')(?=["\']?\s*:)')
CAMEL_VAL_RE = re.compile(r'(?<![\w$.])(textAlign|float|clear)(["\']?\s*:\s*["\'])(left|right)(["\'])')


def blank_comments(text, ext):
    def keep_nl(m):
        return re.sub(r'[^\n]', ' ', m.group(0))
    text = re.sub(r'/\*.*?\*/', keep_nl, text, flags=re.S)
    if ext in {'.scss', '.sass', '.less'}:
        text = re.sub(r'(?m)(^|\s)//[^\n]*', keep_nl, text)
    if ext in MARKUP_EXT:
        text = re.sub(r'<!--.*?-->', keep_nl, text, flags=re.S)
    return text


def split_top(value):
    out, depth, cur = [], 0, ''
    for ch in value.strip():
        if ch == '(':
            depth += 1
        elif ch == ')':
            depth -= 1
        if ch.isspace() and depth == 0:
            if cur:
                out.append(cur)
                cur = ''
        else:
            cur += ch
    if cur:
        out.append(cur)
    return out


def analyse_decl(chunk, base, in_rtl, opts, add):
    """chunk: declaration text; base: its offset in file; add(offset, code, msg, fix)."""
    props = dict(LONG)
    if opts['block']:
        props.update(BLOCK)
    m = re.search(r'(?:^|[\s"\'{(])(?P<p>[a-z-]+)\s*:\s*(?P<v>[^;{}]*)$', chunk, re.I)
    if not m:
        return
    prop, val = m.group('p').lower(), m.group('v').strip()
    if opts.get('_markup'):
        val = re.split(r'["\'<>]', val)[0].strip()
    val_l = re.sub(r'\s*!important\s*$', '', val, flags=re.I)
    po = base + m.start('p')
    code_prefix = 'O' if in_rtl else 'A'
    tag = ' [rtl override: review, do not auto-fix]' if in_rtl else ''
    if prop in props and not (prop in ('left', 'right') and not re.match(r'^(-?[\d.]|auto|calc|var|0|inherit|initial|unset)', val_l)):
        add(po, code_prefix + '01', f'{prop} -> {props[prop]}{tag}',
            None if in_rtl else (po, po + len(prop), props[prop]))
    if prop in VALUE_MAP and val_l.lower() in VALUE_MAP[prop]:
        vo = base + m.start('v')
        add(vo, code_prefix + '02', f'{prop}: {val_l} -> {VALUE_MAP[prop][val_l.lower()]}{tag}',
            None if in_rtl else (vo, vo + len(val_l), VALUE_MAP[prop][val_l.lower()]))
    if prop in ('margin', 'padding', 'inset', 'scroll-margin', 'scroll-padding'):
        t = split_top(val_l)
        if len(t) == 4 and t[1] != t[3]:
            ax = 'inset' if prop == 'inset' else prop
            add(po, 'M01', f'{prop}: {val_l} -> {ax}-block: {t[0]} {t[2]}; {ax}-inline: {t[3]} {t[1]}' + tag, None)
    if prop in ('border-width', 'border-style', 'border-color'):
        t = split_top(val_l)
        if len(t) == 4 and t[1] != t[3]:
            add(po, 'M02', f'{prop}: {val_l} -> split into border-block-* and border-inline-*' + tag, None)
    if prop == 'border-radius' and '/' not in val_l:
        t = split_top(val_l)
        if 1 < len(t) <= 4:
            tl = t[0]; tr = t[1] if len(t) > 1 else t[0]
            br = t[2] if len(t) > 2 else tl
            bl = t[3] if len(t) > 3 else tr
            if tl != tr or bl != br:
                add(po, 'M03', f'border-radius: {val_l} -> border-start-start-radius:{tl}; border-start-end-radius:{tr}; border-end-end-radius:{br}; border-end-start-radius:{bl}' + tag, None)
    # physical-only values (no logical property exists)
    if prop in ('transform', 'translate') and re.search(r'translate(x|3d)?\(|translate\s*:|skewx|scalex|rotate', prop + ':' + val_l, re.I):
        if prop == 'translate' or re.search(r'translate(x|3d)?\(|skewx|scalex|rotate\((?!0)', val_l, re.I):
            add(po, 'R01', f'{prop}: horizontal transform is not flipped by direction')
    if prop in ('background-position', 'background-position-x', 'object-position', 'mask-position') and \
            re.search(r'\b(left|right)\b|[1-9][\d.]*(px|%|em|rem)', val_l, re.I):
        add(po, 'R02', f'{prop}: {val_l} has no logical form')
    if prop in ('background', 'background-image', 'mask-image') and re.search(r'gradient\([^)]*\bto\s+(left|right)\b', val_l, re.I):
        add(po, 'R03', 'gradient direction "to left/right" is not flipped by direction')
    if prop == 'box-shadow':
        for seg in re.split(r',(?![^(]*\))', val_l):
            nums = re.findall(r'(?<![\w.-])(-?[\d.]+)(?:px|em|rem)?(?![\w.-])', re.sub(r'rgba?\([^)]*\)|hsla?\([^)]*\)', '', seg))
            if nums and float(nums[0]) != 0:
                add(po, 'R04', 'box-shadow x-offset is not flipped by direction')
                break
    if prop in ('transform-origin', 'perspective-origin') and re.search(r'\b(left|right)\b', val_l, re.I):
        add(po, 'R05', f'{prop}: {val_l} is physical')
    if prop == 'direction':
        add(po, 'I01', f'direction: {val_l} set in CSS; prefer the dir attribute in HTML')


def scan_css_like(text, ext, opts, add):
    clean = blank_comments(text, ext)
    stack, start, i, n = [], 0, 0, len(clean)
    quote = None
    while i < n:
        ch = clean[i]
        if quote:
            if ch == quote and clean[i - 1] != '\\':
                quote = None
        elif ch in '"\'' and ext in CSS_EXT:
            quote = ch
        elif ch == '{':
            stack.append(clean[start:i].strip())
            start = i + 1
        elif ch in ';}':
            chunk = clean[start:i]
            if chunk.strip():
                in_rtl = any(RTL_SEL.search(s) for s in stack)
                analyse_decl(chunk, start, in_rtl, opts, add)
            if ch == '}' and stack:
                stack.pop()
            start = i + 1
        i += 1
    tail = clean[start:]
    if tail.strip():
        analyse_decl(tail, start, any(RTL_SEL.search(s) for s in stack), opts, add)
    for m in re.finditer(r'(\[dir\s*=\s*["\']?rtl[^\]]*\]|:dir\(\s*rtl\s*\))', clean, re.I):
        add(m.start(), 'I02', 'RTL override selector: check it is still needed once base rules are logical')


def scan_js(text, opts, add):
    if 'react-native' in text:
        add(0, 'I03', 'react-native file: use marginStart/paddingEnd etc., scanner skipped camelCase')
        return
    for m in CAMEL_RE.finditer(text):
        add(m.start(), 'A03', f'{m.group(1)} -> {CAMEL[m.group(1)]}', (m.start(), m.end(), CAMEL[m.group(1)]))
    for m in CAMEL_VAL_RE.finditer(text):
        new = VALUE_MAP[m.group(1).lower() if m.group(1) != 'textAlign' else 'text-align'][m.group(3)]
        add(m.start(3), 'A04', f'{m.group(1)}: {m.group(3)} -> {new}', (m.start(3), m.end(3), new))


def scan_tailwind(text, add):
    for m in TW_RE.finditer(text):
        variants, neg, base, rest = m.group(1), m.group(2), m.group(3), m.group(4) or ''
        if base in ('left', 'right') and not rest:
            continue
        if base in ('ml', 'mr', 'pl', 'pr', 'left', 'right') and not rest:
            continue
        new = variants + neg + TW_MAP[base] + rest
        add(m.start(), 'T01', f'{m.group(0)} -> {new}', (m.start(), m.end(), new))


def scan_html_root(text, add):
    m = re.search(r'<html\b[^>]*>', text, re.I)
    if m and not re.search(r'\bdir\s*=', m.group(0), re.I):
        add(m.start(), 'I04', '<html> has no dir attribute (needed to test RTL)')


def line_of(text, off):
    return text.count('\n', 0, off) + 1


def run(paths, opts):
    files = []
    for p in paths:
        if os.path.isfile(p):
            files.append(p)
        else:
            for root, dirs, fs in os.walk(p):
                dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
                for f in fs:
                    if f.endswith('.min.css'):
                        continue
                    if os.path.splitext(f)[1].lower() in CSS_EXT | MARKUP_EXT | JS_EXT:
                        files.append(os.path.join(root, f))
    results, fixes_total = [], 0
    for path in sorted(files):
        ext = os.path.splitext(path)[1].lower()
        try:
            with open(path, encoding='utf-8') as fh:
                text = fh.read()
        except (UnicodeDecodeError, OSError):
            continue
        found, fixes = [], []
        opts['_markup'] = ext in MARKUP_EXT

        def add(off, code, msg, fix=None):
            found.append((line_of(text, off), code, msg))
            if fix:
                fixes.append(fix)
        if ext in CSS_EXT or ext in MARKUP_EXT:
            scan_css_like(text, ext, opts, add)
        if ext in JS_EXT or ext in MARKUP_EXT:
            scan_js(text, opts, add)
        if ext in JS_EXT | MARKUP_EXT:
            scan_tailwind(text, add)
        if ext in MARKUP_EXT:
            scan_html_root(text, add)
        if opts['fix'] and fixes:
            seen, out = set(), text
            for s, e, r in sorted(set(fixes), reverse=True):
                if (s, e) in seen:
                    continue
                seen.add((s, e))
                out = out[:s] + r + out[e:]
            with open(path, 'w', encoding='utf-8') as fh:
                fh.write(out)
            fixes_total += len(seen)
        for ln, code, msg in sorted(set(found)):
            results.append((path, ln, code, msg))
    return files, results, fixes_total


def main(argv):
    opts = {'fix': False, 'block': False, 'summary': False, 'max': 60}
    paths, i = [], 0
    while i < len(argv):
        a = argv[i]
        if a == '--fix': opts['fix'] = True
        elif a == '--block': opts['block'] = True
        elif a == '--summary': opts['summary'] = True
        elif a == '--max':
            i += 1
            try: opts['max'] = 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()[0]); print('usage: logical_check.py PATH... [--summary] [--max N] [--fix] [--block]'); return 2
    missing = [p for p in paths if not os.path.exists(p)]
    if missing:
        print('path not found: ' + ', '.join(missing)); return 2
    files, res, fixed = run(paths, opts)
    if opts['summary']:
        per = {}
        for p, ln, c, m in res:
            per.setdefault(p, {}).setdefault(c, 0)
            per[p][c] += 1
        for p, d in per.items():
            print(p + ' ' + ' '.join(f'{c}x{n}' for c, n in sorted(d.items())))
    else:
        shown = res if opts['max'] == 0 else res[:opts['max']]
        for p, ln, c, m in shown:
            print(f'{p}:{ln} {c} {m}')
        if len(shown) < len(res):
            print(f'... +{len(res) - len(shown)} more (use --max 0 or --summary)')
    auto = sum(1 for r in res if r[2][0] in 'AT')
    man = sum(1 for r in res if r[2][0] == 'M')
    rev = sum(1 for r in res if r[2][0] == 'R')
    rtl = sum(1 for r in res if r[2][0] == 'O')
    info = sum(1 for r in res if r[2][0] == 'I')
    print(f'{len(files)} files scanned: {auto} auto, {man} manual, {rev} physical-only, {rtl} rtl-override, {info} info' +
          (f'; {fixed} edits written' if opts['fix'] else ''))
    return 1 if (auto + man + rev + rtl) else 0


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