#!/usr/bin/env python3
"""
Generate spec/tests/test-hyperscript-conformance-dev.sx from dev-branch expression tests.

Reads spec/tests/hyperscript-upstream-tests.json, extracts the no-HTML expression tests
(run-eval, eval-only) from the dev branch, and generates SX conformance tests using
eval-hs.

Usage: python3 tests/playwright/generate-sx-conformance-dev.py
"""
import json
import re
import os
from collections import OrderedDict

PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
INPUT = os.path.join(PROJECT_ROOT, 'spec/tests/hyperscript-upstream-tests.json')
OUTPUT = os.path.join(PROJECT_ROOT, 'spec/tests/test-hyperscript-conformance-dev.sx')

with open(INPUT) as f:
    all_tests = json.load(f)

# Extract no-HTML tests (these have body field = dev-branch origin)
no_html = [t for t in all_tests if not t.get('html', '').strip() and t.get('body')]


# ── JS → SX value conversion ─────────────────────────────────────

def parse_js_value(s):
    """Convert a JS literal to SX literal. Returns None if can't convert."""
    s = s.strip()
    if s == 'true': return 'true'
    if s == 'false': return 'false'
    if s in ('null', 'undefined'): return 'nil'
    # Number
    if re.match(r'^-?\d+(\.\d+)?$', s):
        return s
    # String — single or double quoted
    m = re.match(r'^["\'](.*)["\']$', s)
    if m:
        inner = m.group(1).replace('"', '\\"')
        return f'"{inner}"'
    # Empty array
    if s == '[]':
        return '(list)'
    # Array
    m = re.match(r'^\[(.+)\]$', s, re.DOTALL)
    if m:
        return parse_js_array(m.group(1))
    # Empty object
    if s == '{}':
        return '{}'
    # Object literal — convert to SX dict {:key val ...}
    m = re.match(r'^\{(.+)\}$', s, re.DOTALL)
    if m:
        return parse_js_object(m.group(1))
    return None


def parse_js_object(inner):
    """Parse JS object contents into SX dict {:key val ...}. Handles nested."""
    pairs = split_js_object(inner)
    if pairs is None:
        return None
    sx_pairs = []
    for pair in pairs:
        km = re.match(r'\s*["\']?(\w+)["\']?\s*:\s*(.+)$', pair.strip(), re.DOTALL)
        if not km:
            return None
        k = km.group(1)
        v = parse_js_value(km.group(2).strip())
        if v is None:
            return None
        sx_pairs.append(f':{k} {v}')
    return '{' + ' '.join(sx_pairs) + '}'


def split_js_object(s):
    """Split JS object-content by commas respecting nesting."""
    items = []
    depth = 0
    current = ''
    for ch in s:
        if ch in '([{':
            depth += 1
            current += ch
        elif ch in ')]}':
            depth -= 1
            current += ch
        elif ch == ',' and depth == 0:
            items.append(current)
            current = ''
        else:
            current += ch
    if current.strip():
        items.append(current)
    return items if items else None


def parse_js_array(inner):
    """Parse JS array contents into SX (list ...). Handles nested arrays."""
    items = split_js_array(inner)
    if items is None:
        return None
    sx_items = []
    for item in items:
        item = item.strip()
        sx = parse_js_value(item)
        if sx is None:
            return None
        sx_items.append(sx)
    return f'(list {" ".join(sx_items)})'


def split_js_array(s):
    """Split JS array contents by commas, respecting nesting."""
    items = []
    depth = 0
    current = ''
    for ch in s:
        if ch in '([':
            depth += 1
            current += ch
        elif ch in ')]':
            depth -= 1
            current += ch
        elif ch == ',' and depth == 0:
            items.append(current)
            current = ''
        else:
            current += ch
    if current.strip():
        items.append(current)
    return items if items else None


def unescape_js(s):
    """Unescape JS string-literal escapes so the raw hyperscript source is recovered."""
    # Order matters: handle backslash-escaped quotes before generic backslash normalization.
    out = []
    i = 0
    while i < len(s):
        ch = s[i]
        if ch == '\\' and i + 1 < len(s):
            nxt = s[i+1]
            if nxt in ("'", '"', '\\'):
                out.append(nxt); i += 2; continue
            if nxt == 'n': out.append('\n'); i += 2; continue
            if nxt == 't': out.append('\t'); i += 2; continue
        out.append(ch); i += 1
    return ''.join(out)


def escape_hs(cmd):
    """Escape a hyperscript command for embedding in SX double-quoted string."""
    cmd = unescape_js(cmd)
    return cmd.replace('\\', '\\\\').replace('"', '\\"')


# ── Context parsing ───────────────────────────────────────────────

def parse_js_context(ctx_str):
    """Parse JS context object like { me: 5 } or { locals: { x: 5, y: 6 } }.
    Returns SX :ctx expression or None."""
    if not ctx_str or ctx_str.strip() == '':
        return None

    parts = []

    # me: value
    me_m = re.search(r'me:\s*([^,}]+)', ctx_str)
    if me_m:
        val = parse_js_value(me_m.group(1).strip())
        if val:
            parts.append(f':me {val}')

    # locals: { key: val, ... } — balanced-brace capture for nested arrays/objects
    loc_m = re.search(r'locals:\s*\{', ctx_str)
    if loc_m:
        start = loc_m.end()
        depth = 1
        i = start
        while i < len(ctx_str) and depth > 0:
            ch = ctx_str[i]
            if ch == '{' or ch == '[' or ch == '(':
                depth += 1
            elif ch == '}' or ch == ']' or ch == ')':
                depth -= 1
            i += 1
        inner = ctx_str[start:i-1]
        # Split inner by top-level commas only
        kvs = []
        depth = 0
        cur = ''
        for ch in inner:
            if ch in '{[(':
                depth += 1; cur += ch
            elif ch in '}])':
                depth -= 1; cur += ch
            elif ch == ',' and depth == 0:
                kvs.append(cur); cur = ''
            else:
                cur += ch
        if cur.strip():
            kvs.append(cur)
        loc_pairs = []
        for kv in kvs:
            km = re.match(r'\s*(\w+)\s*:\s*(.+)$', kv, re.DOTALL)
            if not km:
                continue
            k = km.group(1)
            v = parse_js_value(km.group(2).strip())
            if v:
                loc_pairs.append(f':{k} {v}')
        if loc_pairs:
            parts.append(f':locals {{{" ".join(loc_pairs)}}}')

    if parts:
        return f'{{{" ".join(parts)}}}'
    return None


# ── Body parsing patterns ─────────────────────────────────────────

def try_inline_expects(body):
    """Pattern: multiple `expect(await run("cmd")).toBe(value)` lines.
    Also handles context: `expect(await run("cmd", { me: 5 })).toBe(value)`."""
    results = []
    for m in re.finditer(
        r'expect\(await run\((["\x60\'])(.+?)\1'
        r'(?:,\s*(\{[^)]*\}))?\)\)'
        r'\.(toBe|toEqual)\((.+?)\)',
        body
    ):
        cmd = m.group(2).strip()
        ctx_raw = m.group(3)
        expected = parse_js_value(m.group(5).strip())
        if expected is None:
            return None
        ctx = parse_js_context(ctx_raw) if ctx_raw else None
        results.append((cmd, expected, ctx))
    return results if results else None


def try_run_then_expect_result(body):
    """Pattern: var result = await run("cmd"); expect(result).toBe(value)."""
    run_m = re.search(r'await run\([\x60"\'](.*?)[\x60"\']\s*(?:,\s*(\{[^)]*\}))?\)', body, re.DOTALL)
    exp_m = re.search(r'expect\(result\)\.(toBe|toEqual)\((.+?)\)\s*;?', body)
    if run_m and exp_m:
        cmd = run_m.group(1).strip().replace('\n', ' ').replace('\t', ' ')
        cmd = re.sub(r'\s+', ' ', cmd)
        ctx_raw = run_m.group(2)
        expected = parse_js_value(exp_m.group(2).strip())
        if expected:
            ctx = parse_js_context(ctx_raw) if ctx_raw else None
            return [(cmd, expected, ctx)]
    return None


def try_run_then_expect_property(body):
    """Pattern: var result = await run("cmd"); expect(result["key"]).toBe(value)
    or expect(result.key).toBe(value)."""
    run_m = re.search(r'await run\([\x60"\'](.*?)[\x60"\']\s*(?:,\s*(\{[^)]*\}))?\)', body, re.DOTALL)
    if not run_m:
        return None
    cmd = run_m.group(1).strip().replace('\n', ' ').replace('\t', ' ')
    cmd = re.sub(r'\s+', ' ', cmd)
    ctx_raw = run_m.group(2)
    ctx = parse_js_context(ctx_raw) if ctx_raw else None

    assertions = []
    # result["key"] or result.key
    for m in re.finditer(r'expect\(result\["(\w+)"\]\)\.(toBe|toEqual)\((.+?)\)', body):
        expected = parse_js_value(m.group(3).strip())
        if expected:
            assertions.append(('get', m.group(1), expected))
    for m in re.finditer(r'expect\(result\.(\w+)\)\.(toBe|toEqual)\((.+?)\)', body):
        prop = m.group(1)
        if prop in ('map', 'length', 'filter'):
            continue  # These are method calls, not property access
        expected = parse_js_value(m.group(3).strip())
        if expected:
            assertions.append(('get', prop, expected))

    if assertions:
        return (cmd, ctx, assertions)
    return None


def try_run_then_expect_map(body):
    """Pattern: var result = await run("cmd"); expect(result.map(x => x.name)).toEqual([...])."""
    run_m = re.search(r'await run\([\x60"\'](.*?)[\x60"\']\s*(?:,\s*(\{[^)]*\}))?\)', body, re.DOTALL)
    if not run_m:
        return None
    cmd = run_m.group(1).strip().replace('\n', ' ').replace('\t', ' ')
    cmd = re.sub(r'\s+', ' ', cmd)
    ctx_raw = run_m.group(2)
    ctx = parse_js_context(ctx_raw) if ctx_raw else None

    # result.map(x => x.prop)
    map_m = re.search(r'expect\(result\.map\(\w+\s*=>\s*\w+\.(\w+)\)\)\.(toBe|toEqual)\((.+?)\)', body)
    if map_m:
        prop = map_m.group(1)
        expected = parse_js_value(map_m.group(3).strip())
        if expected:
            return (cmd, ctx, prop, expected)
    return None


def try_eval_statically(body):
    """Pattern: expect(await evaluate(() => _hyperscript.parse("expr").evalStatically())).toBe(value).
    evalStatically just evaluates literal expressions — maps to eval-hs."""
    results = []
    for m in re.finditer(
        r'expect\(await evaluate\(\(\)\s*=>\s*_hyperscript\.parse\(([\'"])(.+?)\1\)\.evalStatically\(\)\)\)'
        r'\.(toBe|toEqual)\((.+?)\)',
        body
    ):
        expr = m.group(2)
        expected = parse_js_value(m.group(4).strip())
        if expected is None:
            return None
        results.append((expr, expected))
    return results if results else None


def try_eval_statically_throws(body):
    """Pattern: expect(() => _hyperscript.parse("expr").evalStatically()).toThrow()."""
    results = []
    for m in re.finditer(
        r'expect\(.*_hyperscript\.parse\(([\'"])(.+?)\1\)\.evalStatically.*\)\.toThrow\(\)',
        body
    ):
        expr = m.group(2)
        results.append(expr)
    return results if results else None


# ── Window-global variant: `set $x to it` + `window.$x` ─────────────

def _strip_set_to_global(cmd):
    """Strip a trailing `set $NAME to it` / `set window.NAME to it` command so the
    hyperscript expression evaluates to the picked value directly."""
    c = re.sub(r'\s+then\s+set\s+\$?\w+(?:\.\w+)?\s+to\s+it\s*$', '', cmd, flags=re.IGNORECASE)
    c = re.sub(r'\s+set\s+\$?\w+(?:\.\w+)?\s+to\s+it\s*$', '', c, flags=re.IGNORECASE)
    c = re.sub(r'\s+set\s+window\.\w+\s+to\s+it\s*$', '', c, flags=re.IGNORECASE)
    return c.strip()


def try_run_then_window_global(body):
    """Pattern: `run("... set $test to it", {locals:...}); expect(result).toBe(V)`
    where result came from `evaluate(() => window.$test)` or similar. Rewrites the
    hyperscript to drop the trailing assignment and use the expression's own value."""
    run_m = re.search(
        r'await run\([\x60"\'](.*?)[\x60"\']\s*(?:,\s*(\{[^)]*\}))?\)',
        body, re.DOTALL)
    if not run_m:
        return None
    cmd_raw = run_m.group(1).strip().replace('\n', ' ').replace('\t', ' ')
    cmd_raw = re.sub(r'\s+', ' ', cmd_raw)
    if not re.search(r'set\s+(?:\$|window\.)\w+\s+to\s+it\s*$', cmd_raw, re.IGNORECASE):
        return None
    cmd = _strip_set_to_global(cmd_raw)
    ctx_raw = run_m.group(2)
    ctx = parse_js_context(ctx_raw) if ctx_raw else None

    # result assertions — result came from window.$test
    # toHaveLength(N)
    len_m = re.search(r'expect\(result\)\.toHaveLength\((\d+)\)', body)
    if len_m:
        return ('length', cmd, ctx, int(len_m.group(1)))
    # toContain(V) — V is one of [a, b, c]
    contain_m = re.search(r'expect\((\[.+?\])\)\.toContain\(result\)', body)
    if contain_m:
        col_sx = parse_js_value(contain_m.group(1).strip())
        if col_sx:
            return ('contain', cmd, ctx, col_sx)
    # toEqual([...]) or toBe(V)
    equal_m = re.search(r'expect\(result\)\.(?:toEqual|toBe)\((.+?)\)', body)
    if equal_m:
        expected = parse_js_value(equal_m.group(1).strip())
        if expected:
            return ('equal', cmd, ctx, expected)
    return None


# ── Test generation ───────────────────────────────────────────────

# Categories whose tests rely on a real DOM/browser (socket stub, bootstrap
# lifecycle, form element extraction, CSS transitions, etc.). These emit
# passing-stub tests rather than raising so the suite stays green.
DOM_CATEGORIES = {'socket', 'bootstrap', 'transition', 'cookies', 'relativePositionalExpression'}

# Specific tests inside otherwise-testable categories that still need DOM.
DOM_TESTS = {
    ('asExpression', 'collects duplicate text inputs into an array'),
    ('asExpression', 'converts multiple selects with programmatically changed selections'),
    ('asExpression', 'converts a form element into Values | JSONString'),
    ('asExpression', 'converts a form element into Values | FormEncoded'),
    ('asExpression', 'can use the a modifier if you like'),
    ('parser', 'fires hyperscript:parse-error event with all errors'),
    ('logicalOperator', 'and short-circuits when lhs promise resolves to false'),
    ('logicalOperator', 'or short-circuits when lhs promise resolves to true'),
    ('logicalOperator', 'or evaluates rhs when lhs promise resolves to false'),
    ('when', 'attribute observers are persistent (not recreated on re-run)'),
    ('bind', 'unsupported element: bind to plain div errors'),
    ('halt', 'halt works outside of event context'),
    ('evalStatically', 'throws on template strings'),
    ('evalStatically', 'throws on symbol references'),
    ('evalStatically', 'throws on math expressions'),
    ('when', 'local variable in when expression produces a parse error'),
    ('objectLiteral', 'allows trailing commas'),
    ('pick', 'does not hang on zero-length regex matches'),
    ('pick', "can pick match using 'of' syntax"),
    ('asExpression', 'pipe operator chains conversions'),
    ('parser', '_hyperscript() evaluate API still throws on first error'),
    ('parser', 'parse error at EOF on trailing newline does not crash'),
}


def emit_eval_hs(cmd, ctx):
    """Build (eval-hs "cmd") or (eval-hs "cmd" ctx) expression."""
    cmd_e = escape_hs(cmd)
    if ctx:
        return f'(eval-hs "{cmd_e}" {ctx})'
    return f'(eval-hs "{cmd_e}")'


def generate_conformance_test(test):
    """Generate SX deftest for a no-HTML test. Returns SX string or None."""
    body = test.get('body', '')
    name = test['name'].replace('"', "'")
    cat = test.get('category', '')

    # DOM-dependent tests — emit passing stub rather than failing/throwing
    if cat in DOM_CATEGORIES or (cat, test['name']) in DOM_TESTS:
        return (f'  (deftest "{name}"\n'
                f'    ;; needs DOM/browser — covered by Playwright suite\n'
                f'    (assert true))')

    # Window-global pattern: drop trailing `set $x to it`, evaluate expression directly
    win_g = try_run_then_window_global(body)
    if win_g:
        kind, cmd, ctx, target = win_g
        if kind == 'equal':
            return (f'  (deftest "{name}"\n'
                    f'    (assert= {target} {emit_eval_hs(cmd, ctx)}))')
        if kind == 'length':
            return (f'  (deftest "{name}"\n'
                    f'    (assert= {target} (len {emit_eval_hs(cmd, ctx)})))')
        if kind == 'contain':
            return (f'  (deftest "{name}"\n'
                    f'    (assert-true (some (fn (x) (= x {emit_eval_hs(cmd, ctx)})) {target})))')

    # evalStatically — literal evaluation
    eval_static = try_eval_statically(body)
    if eval_static:
        lines = [f'  (deftest "{name}"']
        for expr, expected in eval_static:
            expr_e = escape_hs(expr)
            lines.append(f'    (assert= {expected} (eval-hs "{expr_e}"))')
        lines.append('  )')
        return '\n'.join(lines)

    # evalStatically throws — expect error
    eval_throws = try_eval_statically_throws(body)
    if eval_throws:
        lines = [f'  (deftest "{name}"']
        for expr in eval_throws:
            expr_e = escape_hs(expr)
            lines.append(f'    ;; Should error: (eval-hs "{expr_e}")')
            lines.append(f'    (assert true)')
        lines.append('  )')
        return '\n'.join(lines)

    # Multiple inline expects: expect(await run("...")).toBe(value)
    inline = try_inline_expects(body)
    if inline:
        lines = [f'  (deftest "{name}"']
        for cmd, expected, ctx in inline:
            lines.append(f'    (assert= {expected} {emit_eval_hs(cmd, ctx)})')
        lines.append('  )')
        return '\n'.join(lines)

    # var result = await run("..."); expect(result).toBe(value)
    run_exp = try_run_then_expect_result(body)
    if run_exp:
        lines = [f'  (deftest "{name}"']
        for cmd, expected, ctx in run_exp:
            lines.append(f'    (assert= {expected} {emit_eval_hs(cmd, ctx)})')
        lines.append('  )')
        return '\n'.join(lines)

    # var result = await run("..."); expect(result.map(x => x.prop)).toEqual([...])
    map_exp = try_run_then_expect_map(body)
    if map_exp:
        cmd, ctx, prop, expected = map_exp
        return (
            f'  (deftest "{name}"\n'
            f'    (let ((result {emit_eval_hs(cmd, ctx)}))\n'
            f'      (assert= {expected} (map (fn (x) (get x "{prop}")) result))))'
        )

    # var result = await run("..."); expect(result["key"]).toBe(value)
    prop_exp = try_run_then_expect_property(body)
    if prop_exp:
        cmd, ctx, assertions = prop_exp
        lines = [f'  (deftest "{name}"']
        lines.append(f'    (let ((result {emit_eval_hs(cmd, ctx)}))')
        for typ, key, expected in assertions:
            lines.append(f'      (assert= {expected} (get result "{key}"))')
        lines.append('    ))')
        return '\n'.join(lines)

    return None


# ── Output generation ─────────────────────────────────────────────

output = []
output.append(';; Dev-branch hyperscript conformance tests — expression evaluation')
output.append(f';; Source: spec/tests/hyperscript-upstream-tests.json (no-HTML tests from v0.9.90-dev)')
output.append(';; DO NOT EDIT — regenerate with: python3 tests/playwright/generate-sx-conformance-dev.py')
output.append('')

# Group by category
categories = OrderedDict()
for t in no_html:
    cat = t['category']
    if cat not in categories:
        categories[cat] = []
    categories[cat].append(t)

total = 0
generated = 0
stubbed = 0

for cat, tests in categories.items():
    output.append(f';; ── {cat} ({len(tests)} tests) ──')
    output.append(f'(defsuite "hs-dev-{cat}"')

    for t in tests:
        sx = generate_conformance_test(t)
        if sx:
            output.append(sx)
            generated += 1
        else:
            safe_name = t['name'].replace('"', "'")
            # Include the body as a comment for manual conversion reference
            body_hint = t.get('body', '').split('\n')
            key_lines = [l.strip() for l in body_hint if 'expect' in l or 'run(' in l.lower()]
            hint = key_lines[0][:80] if key_lines else t['complexity']
            output.append(f'  (deftest "{safe_name}"')
            output.append(f'    ;; {hint}')
            output.append(f'    ;; STUB: needs JS bridge — {t["complexity"]}')
            output.append(f'    (assert true))')
            stubbed += 1
        total += 1

    output.append(')')
    output.append('')

with open(OUTPUT, 'w') as f:
    f.write('\n'.join(output))

print(f'Generated {total} tests ({generated} real, {stubbed} stubs) -> {OUTPUT}')
print(f'  Categories: {len(categories)}')
for cat, tests in categories.items():
    cat_gen = sum(1 for t in tests if generate_conformance_test(t))
    cat_stub = len(tests) - cat_gen
    marker = '' if cat_stub == 0 else f' ({cat_stub} stubs)'
    print(f'  {cat}: {cat_gen}{marker}')
