#!/usr/bin/env python3
"""Run SX test specs against the Python SX evaluator.

The Python evaluator parses and evaluates test specs — SX tests itself.
This script provides only platform functions (error catching, reporting).

Usage:
    python shared/sx/tests/run.py                    # run all available specs
    python shared/sx/tests/run.py eval               # run only test-eval.sx
    python shared/sx/tests/run.py eval parser router  # run specific specs
    python shared/sx/tests/run.py --legacy           # run monolithic test.sx
"""
from __future__ import annotations

import os
import sys

_HERE = os.path.dirname(os.path.abspath(__file__))
_PROJECT = os.path.abspath(os.path.join(_HERE, "..", "..", ".."))
sys.path.insert(0, _PROJECT)

from shared.sx.parser import parse_all
from shared.sx.ref.sx_ref import eval_expr as _eval, trampoline as _trampoline, call_lambda as _call_lambda
from shared.sx.types import Symbol, Keyword, Lambda, NIL, Component, Island

# --- Test state ---
suite_stack: list[str] = []
passed = 0
failed = 0
test_num = 0


def try_call(thunk):
    """Call an SX thunk, catching errors."""
    try:
        _trampoline(_eval([thunk], env))
        return {"ok": True}
    except Exception as e:
        return {"ok": False, "error": str(e)}


def report_pass(name):
    global passed, test_num
    test_num += 1
    passed += 1
    full_name = " > ".join(suite_stack + [name])
    print(f"ok {test_num} - {full_name}")


def report_fail(name, error):
    global failed, test_num
    test_num += 1
    failed += 1
    full_name = " > ".join(suite_stack + [name])
    print(f"not ok {test_num} - {full_name}")
    print(f"  # {error}")


def push_suite(name):
    suite_stack.append(name)


def pop_suite():
    suite_stack.pop()


# --- Parser platform functions ---

def sx_parse(source):
    """Parse SX source string into list of AST expressions."""
    return parse_all(source)


def sx_serialize(val):
    """Serialize an AST value to SX source text."""
    if val is None or val is NIL:
        return "nil"
    if isinstance(val, bool):
        return "true" if val else "false"
    if isinstance(val, (int, float)):
        return str(val)
    if isinstance(val, str):
        escaped = val.replace("\\", "\\\\").replace('"', '\\"')
        return f'"{escaped}"'
    if isinstance(val, Symbol):
        return val.name
    if isinstance(val, Keyword):
        return f":{val.name}"
    if isinstance(val, list):
        inner = " ".join(sx_serialize(x) for x in val)
        return f"({inner})"
    if isinstance(val, dict):
        parts = []
        for k, v in val.items():
            parts.append(f":{k}")
            parts.append(sx_serialize(v))
        return "{" + " ".join(parts) + "}"
    return str(val)


def make_symbol(name):
    return Symbol(name)


def make_keyword(name):
    return Keyword(name)


def symbol_name(sym):
    if isinstance(sym, Symbol):
        return sym.name
    return str(sym)


def keyword_name(kw):
    if isinstance(kw, Keyword):
        return kw.name
    return str(kw)


# --- Render platform function ---

def render_html(sx_source):
    """Parse SX source and render to HTML via the bootstrapped evaluator."""
    try:
        from shared.sx.ref.sx_ref import render_to_html as _render_to_html
    except ImportError:
        raise RuntimeError("render-to-html not available — sx_ref.py not built")
    exprs = parse_all(sx_source)
    # Use Env (not flat dict) so tests exercise the real scope chain path.
    render_env = _Env(dict(env))
    result = ""
    for expr in exprs:
        result += _render_to_html(expr, render_env)
    return result


# --- Render SX (aser) platform function ---

def render_sx(sx_source):
    """Parse SX source and serialize to SX wire format via the bootstrapped evaluator."""
    try:
        from shared.sx.ref.sx_ref import aser as _aser, serialize as _serialize
    except ImportError:
        raise RuntimeError("aser not available — sx_ref.py not built")
    exprs = parse_all(sx_source)
    # Use Env (not flat dict) so tests exercise the real scope chain path.
    # Using dict(env) hides bugs where merge() drops Env parent scopes.
    render_env = _Env(dict(env))
    result = ""
    for expr in exprs:
        val = _aser(expr, render_env)
        if isinstance(val, str):
            result += val
        elif val is None or val is NIL:
            pass
        else:
            result += _serialize(val)
    return result


# --- Signal platform primitives ---
# Implements the signal runtime platform interface for testing signals.sx

class Signal:
    """A reactive signal container."""
    __slots__ = ("value", "subscribers", "deps")

    def __init__(self, value):
        self.value = value
        self.subscribers = []  # list of callables
        self.deps = []         # list of Signal (for computed)


class TrackingContext:
    """Tracks signal dependencies during effect/computed evaluation."""
    __slots__ = ("notify_fn", "deps")

    def __init__(self, notify_fn):
        self.notify_fn = notify_fn
        self.deps = []


_tracking_context = [None]  # mutable cell


def _make_signal(value):
    s = Signal(value)
    return s


def _signal_p(x):
    return isinstance(x, Signal)


def _signal_value(s):
    return s.value


def _signal_set_value(s, v):
    s.value = v
    return NIL


def _signal_subscribers(s):
    return list(s.subscribers)


def _signal_add_sub(s, fn):
    if fn not in s.subscribers:
        s.subscribers.append(fn)
    return NIL


def _signal_remove_sub(s, fn):
    if fn in s.subscribers:
        s.subscribers.remove(fn)
    return NIL


def _signal_deps(s):
    return list(s.deps)


def _signal_set_deps(s, deps):
    s.deps = list(deps)
    return NIL


def _set_tracking_context(ctx):
    _tracking_context[0] = ctx
    return NIL


def _get_tracking_context():
    return _tracking_context[0] or NIL


def _make_tracking_context(notify_fn):
    return TrackingContext(notify_fn)


def _tracking_context_deps(ctx):
    if isinstance(ctx, TrackingContext):
        return ctx.deps
    return []


def _tracking_context_add_dep(ctx, s):
    if isinstance(ctx, TrackingContext) and s not in ctx.deps:
        ctx.deps.append(s)
    return NIL


def _tracking_context_notify_fn(ctx):
    if isinstance(ctx, TrackingContext):
        return ctx.notify_fn
    return NIL


def _identical(a, b):
    return a is b


def _island_p(x):
    return isinstance(x, Island)


def _make_island(name, params, has_children, body, closure):
    return Island(
        name=name,
        params=list(params),
        has_children=has_children,
        body=body,
        closure=dict(closure) if isinstance(closure, dict) else {},
    )


# --- Spec registry ---

SPECS = {
    "eval":          {"file": "test-eval.sx",          "needs": []},
    "parser":        {"file": "test-parser.sx",        "needs": ["sx-parse"]},
    "router":        {"file": "test-router.sx",        "needs": []},
    "render":        {"file": "test-render.sx",        "needs": ["render-html"]},
    "aser":          {"file": "test-aser.sx",          "needs": ["render-sx"]},
    "deps":          {"file": "test-deps.sx",          "needs": []},
    "engine":        {"file": "test-engine.sx",        "needs": []},
    "orchestration": {"file": "test-orchestration.sx", "needs": []},
    "signals":       {"file": "test-signals.sx",       "needs": ["make-signal"]},
    "types":         {"file": "test-types.sx",         "needs": []},
}

REF_DIR = os.path.join(_HERE, "..", "ref")


def eval_file(filename, env):
    """Load and evaluate an SX file."""
    filepath = os.path.join(REF_DIR, filename)
    if not os.path.exists(filepath):
        print(f"# SKIP {filename} (file not found)")
        return
    with open(filepath) as f:
        src = f.read()
    exprs = parse_all(src)
    for expr in exprs:
        _trampoline(_eval(expr, env))


# --- Build env ---
from shared.sx.env import Env as _Env

env = _Env({
    "try-call": try_call,
    "report-pass": report_pass,
    "report-fail": report_fail,
    "push-suite": push_suite,
    "pop-suite": pop_suite,
    # Parser platform functions
    "sx-parse": sx_parse,
    "sx-serialize": sx_serialize,
    "make-symbol": make_symbol,
    "make-keyword": make_keyword,
    "symbol-name": symbol_name,
    "keyword-name": keyword_name,
    # Render platform functions
    "render-html": render_html,
    "render-sx": render_sx,
    # Extra primitives needed by spec modules (router.sx, deps.sx)
    "for-each-indexed": "_deferred",  # replaced below
    "dict-set!": "_deferred",
    "dict-has?": "_deferred",
    "dict-get": "_deferred",
    "append!": "_deferred",
    "inc": lambda n: n + 1,
    # Component accessor for affinity (Phase 7)
    "component-affinity": lambda c: getattr(c, 'affinity', 'auto'),
    # Signal platform primitives
    "make-signal": _make_signal,
    "signal?": _signal_p,
    "signal-value": _signal_value,
    "signal-set-value!": _signal_set_value,
    "signal-subscribers": _signal_subscribers,
    "signal-add-sub!": _signal_add_sub,
    "signal-remove-sub!": _signal_remove_sub,
    "signal-deps": _signal_deps,
    "signal-set-deps!": _signal_set_deps,
    "set-tracking-context!": _set_tracking_context,
    "get-tracking-context": _get_tracking_context,
    "make-tracking-context": _make_tracking_context,
    "tracking-context-deps": _tracking_context_deps,
    "tracking-context-add-dep!": _tracking_context_add_dep,
    "tracking-context-notify-fn": _tracking_context_notify_fn,
    "identical?": _identical,
    # Island platform primitives
    "island?": _island_p,
    "make-island": _make_island,
    "component-name": lambda c: getattr(c, 'name', ''),
    "component-params": lambda c: list(getattr(c, 'params', [])),
    "component-body": lambda c: getattr(c, 'body', NIL),
    "component-closure": lambda c: dict(getattr(c, 'closure', {})),
    "component-has-children?": lambda c: getattr(c, 'has_children', False),
})


def _call_sx(fn, args, caller_env):
    """Call an SX lambda or native function with args."""
    if isinstance(fn, Lambda):
        return _trampoline(_call_lambda(fn, list(args), caller_env))
    return fn(*args)


def _for_each_indexed(fn, coll):
    """for-each-indexed that respects set! in lambda closures.

    The hand-written evaluator copies envs on lambda calls, which breaks
    set! mutation of outer scope. We eval directly in the closure dict
    to match the bootstrapped semantics (cell-based mutation).
    """
    if isinstance(fn, Lambda):
        closure = fn.closure
        for i, item in enumerate(coll or []):
            # Bind params directly in the closure (no copy)
            for p, v in zip(fn.params, [i, item]):
                closure[p] = v
            _trampoline(_eval(fn.body, closure))
    else:
        for i, item in enumerate(coll or []):
            fn(i, item)
    return NIL


def _dict_set(d, k, v):
    if isinstance(d, dict):
        d[k] = v
    return NIL


def _dict_has(d, k):
    return isinstance(d, dict) and k in d


def _dict_get(d, k):
    if isinstance(d, dict):
        return d.get(k, NIL)
    return NIL


def _append_mut(lst, item):
    if isinstance(lst, list):
        lst.append(item)
    return NIL


env["for-each-indexed"] = _for_each_indexed
env["dict-set!"] = _dict_set
env["dict-has?"] = _dict_has
env["dict-get"] = _dict_get
env["append!"] = _append_mut


def _load_router_from_bootstrap(env):
    """Load router functions from the bootstrapped sx_ref.py.

    The hand-written evaluator can't run router.sx faithfully because:
    1. set! inside lambda closures doesn't propagate to outer scopes
    2. Deep recursive function chains exceed Python stack depth
    The bootstrapped code compiles to native Python, avoiding both issues.

    Build sx_ref.py with --spec-modules=router to include these functions.
    """
    try:
        from shared.sx.ref.sx_ref import (
            # Original route matching
            split_path_segments,
            parse_route_pattern,
            match_route_segments,
            match_route,
            find_matching_route,
            make_route_segment,
            # SX URL conversion
            sx_url_to_path,
            _fn_to_segment,
            # Relative URL resolution
            resolve_relative_url,
            relative_sx_url_p,
            _normalize_relative,
            _count_leading_dots,
            _strip_trailing_close,
            _last_index_of,
            _pop_sx_url_level,
            _pop_sx_url_levels,
            # Keyword operations
            _extract_innermost,
            _find_keyword_value,
            _find_kw_in_tokens,
            _set_keyword_in_content,
            _replace_kw_in_tokens,
            _is_delta_value_p,
            _apply_delta,
            _apply_kw_pairs,
            _apply_keywords_to_url,
            _parse_relative_body,
            _split_pos_kw,
            # URL special forms
            parse_sx_url,
            url_special_form_p,
            url_special_form_name,
            url_special_form_inner,
            _url_special_forms,
        )
        env["split-path-segments"] = split_path_segments
        env["parse-route-pattern"] = parse_route_pattern
        env["match-route-segments"] = match_route_segments
        env["match-route"] = match_route
        env["find-matching-route"] = find_matching_route
        env["make-route-segment"] = make_route_segment
        env["sx-url-to-path"] = sx_url_to_path
        env["_fn-to-segment"] = _fn_to_segment
        env["resolve-relative-url"] = resolve_relative_url
        env["relative-sx-url?"] = relative_sx_url_p
        env["_normalize-relative"] = _normalize_relative
        env["_count-leading-dots"] = _count_leading_dots
        env["_strip-trailing-close"] = _strip_trailing_close
        env["_last-index-of"] = _last_index_of
        env["_pop-sx-url-level"] = _pop_sx_url_level
        env["_pop-sx-url-levels"] = _pop_sx_url_levels
        env["_extract-innermost"] = _extract_innermost
        env["_find-keyword-value"] = _find_keyword_value
        env["_find-kw-in-tokens"] = _find_kw_in_tokens
        env["_set-keyword-in-content"] = _set_keyword_in_content
        env["_replace-kw-in-tokens"] = _replace_kw_in_tokens
        env["_is-delta-value?"] = _is_delta_value_p
        env["_apply-delta"] = _apply_delta
        env["_apply-kw-pairs"] = _apply_kw_pairs
        env["_apply-keywords-to-url"] = _apply_keywords_to_url
        env["_parse-relative-body"] = _parse_relative_body
        env["_split-pos-kw"] = _split_pos_kw
        env["parse-sx-url"] = parse_sx_url
        env["url-special-form?"] = url_special_form_p
        env["url-special-form-name"] = url_special_form_name
        env["url-special-form-inner"] = url_special_form_inner
        env["_url-special-forms"] = _url_special_forms
    except ImportError:
        # Fallback: eval router.sx directly (may fail on set!/recursion)
        eval_file("router.sx", env)


def _load_deps_from_bootstrap(env):
    """Load deps functions from the bootstrapped sx_ref.py."""
    try:
        from shared.sx.ref.sx_ref import (
            scan_refs,
            scan_components_from_source,
            transitive_deps,
            compute_all_deps,
            components_needed,
            page_component_bundle,
            page_css_classes,
            scan_io_refs,
            transitive_io_refs,
            compute_all_io_refs,
            component_pure_p,
            render_target,
            page_render_plan,
        )
        env["scan-refs"] = scan_refs
        env["scan-components-from-source"] = scan_components_from_source
        env["transitive-deps"] = transitive_deps
        env["compute-all-deps"] = compute_all_deps
        env["components-needed"] = components_needed
        env["page-component-bundle"] = page_component_bundle
        env["page-css-classes"] = page_css_classes
        env["scan-io-refs"] = scan_io_refs
        env["transitive-io-refs"] = transitive_io_refs
        env["compute-all-io-refs"] = compute_all_io_refs
        env["component-pure?"] = component_pure_p
        env["render-target"] = render_target
        env["page-render-plan"] = page_render_plan
        env["test-env"] = lambda: env
    except ImportError:
        eval_file("deps.sx", env)
        env["test-env"] = lambda: env


def _load_engine_from_bootstrap(env):
    """Load engine pure functions from the bootstrapped sx_ref.py."""
    try:
        from shared.sx.ref.sx_ref import (
            parse_time,
            parse_trigger_spec,
            default_trigger,
            parse_swap_spec,
            parse_retry_spec,
            next_retry_ms,
            filter_params,
        )
        env["parse-time"] = parse_time
        env["parse-trigger-spec"] = parse_trigger_spec
        env["default-trigger"] = default_trigger
        env["parse-swap-spec"] = parse_swap_spec
        env["parse-retry-spec"] = parse_retry_spec
        env["next-retry-ms"] = next_retry_ms
        env["filter-params"] = filter_params
    except ImportError:
        eval_file("engine.sx", env)


def _load_orchestration(env):
    """Load orchestration.sx with mocked platform functions for testing.

    Orchestration defines many browser-wiring functions (DOM, fetch, etc.)
    but the Phase 7c/7d tests only exercise the cache, optimistic, and
    offline functions.  Lambda bodies referencing DOM/fetch are never called,
    so we only need to mock the functions actually invoked by the tests:
    now-ms, log-info, log-warn, execute-action, try-rerender-page.
    """
    _mock_ts = [1000]  # mutable so mock can advance time

    def _mock_now_ms():
        return _mock_ts[0]

    def _noop(*_a, **_kw):
        return NIL

    def _mock_execute_action(action, payload, on_success, on_error):
        """Mock: immediately calls on_success with payload as 'server truth'."""
        _call_sx(on_success, [payload], env)
        return NIL

    def _dict_delete(d, k):
        if isinstance(d, dict) and k in d:
            del d[k]
        return NIL

    env["now-ms"] = _mock_now_ms
    env["log-info"] = _noop
    env["log-warn"] = _noop
    env["execute-action"] = _mock_execute_action
    env["try-rerender-page"] = _noop
    env["persist-offline-data"] = _noop
    env["retrieve-offline-data"] = lambda: NIL
    env["dict-delete!"] = _dict_delete
    # DOM / browser stubs (never called by tests, but referenced in lambdas
    # that the evaluator might try to resolve at call time)
    for stub in [
        "try-parse-json", "dom-dispatch", "dom-query-selector",
        "dom-get-attribute", "dom-set-attribute", "dom-set-text-content",
        "dom-append", "dom-insert-html-adjacent", "dom-remove",
        "dom-outer-html", "dom-inner-html", "dom-create-element",
        "dom-set-inner-html", "dom-morph", "dom-get-tag",
        "dom-query-selector-all", "dom-add-event-listener",
        "dom-set-timeout", "dom-prevent-default", "dom-closest",
        "dom-matches", "dom-get-id", "dom-set-id", "dom-form-data",
        "dom-is-form", "browser-location-href", "browser-push-state",
        "browser-replace-state", "sx-hydrate-elements", "render-to-dom",
        "hoist-head-elements-full", "url-pathname",
    ]:
        if stub not in env:
            env[stub] = _noop

    # Load engine.sx first (orchestration depends on it)
    _load_engine_from_bootstrap(env)
    # Load orchestration.sx
    eval_file("orchestration.sx", env)


def _load_forms_from_bootstrap(env):
    """Load forms functions (including streaming protocol) from sx_ref.py."""
    try:
        from shared.sx.ref.sx_ref import (
            stream_chunk_id,
            stream_chunk_bindings,
            normalize_binding_key,
            bind_stream_chunk,
            validate_stream_data,
        )
        env["stream-chunk-id"] = stream_chunk_id
        env["stream-chunk-bindings"] = stream_chunk_bindings
        env["normalize-binding-key"] = normalize_binding_key
        env["bind-stream-chunk"] = bind_stream_chunk
        env["validate-stream-data"] = validate_stream_data
    except ImportError:
        eval_file("forms.sx", env)


def _load_signals(env):
    """Load signals.sx spec — defines signal, deref, reset!, swap!, etc.

    The hand-written evaluator doesn't support &rest in define/fn,
    so we override swap! with a native implementation after loading.
    """
    # callable? is needed by effect (to check if return value is cleanup fn)
    env["callable?"] = lambda x: callable(x) or isinstance(x, Lambda)
    eval_file("signals.sx", env)

    # Override signal functions that need to call Lambda subscribers.
    # The hand-written evaluator's Lambda objects can't be called directly
    # from Python — they need _call_lambda. So we provide native versions
    # of functions that bridge native→Lambda calls.

    def _call_sx_fn(fn, args):
        """Call an SX function (Lambda or native) from Python."""
        if isinstance(fn, Lambda):
            return _trampoline(_call_lambda(fn, list(args), env))
        if callable(fn):
            return fn(*args)
        return NIL

    def _flush_subscribers(s):
        for sub in list(s.subscribers):
            _call_sx_fn(sub, [])
        return NIL

    def _notify_subscribers(s):
        batch_depth = env.get("*batch-depth*", 0)
        if batch_depth and batch_depth > 0:
            batch_queue = env.get("*batch-queue*", [])
            if s not in batch_queue:
                batch_queue.append(s)
            return NIL
        _flush_subscribers(s)
        return NIL
    env["notify-subscribers"] = _notify_subscribers
    env["flush-subscribers"] = _flush_subscribers

    def _reset_bang(s, value):
        if not isinstance(s, Signal):
            return NIL
        old = s.value
        if old is not value:
            s.value = value
            _notify_subscribers(s)
        return NIL
    env["reset!"] = _reset_bang

    def _swap_bang(s, f, *args):
        if not isinstance(s, Signal):
            return NIL
        old = s.value
        all_args = [old] + list(args)
        new_val = _call_sx_fn(f, all_args)
        if old is not new_val:
            s.value = new_val
            _notify_subscribers(s)
        return NIL
    env["swap!"] = _swap_bang

    def _computed(compute_fn):
        s = Signal(NIL)

        def recompute():
            # Unsubscribe from old deps
            for dep in s.deps:
                if recompute in dep.subscribers:
                    dep.subscribers.remove(recompute)
            s.deps = []

            # Create tracking context
            ctx = TrackingContext(recompute)
            prev = _tracking_context[0]
            _tracking_context[0] = ctx

            new_val = _call_sx_fn(compute_fn, [])

            _tracking_context[0] = prev
            s.deps = list(ctx.deps)

            old = s.value
            s.value = new_val
            if old is not new_val:
                _flush_subscribers(s)

        recompute()
        return s
    env["computed"] = _computed

    def _effect(effect_fn):
        deps = []
        disposed = [False]
        cleanup_fn = [None]

        def run_effect():
            if disposed[0]:
                return NIL
            # Run previous cleanup
            if cleanup_fn[0]:
                _call_sx_fn(cleanup_fn[0], [])
                cleanup_fn[0] = None

            # Unsubscribe from old deps
            for dep in deps:
                if run_effect in dep.subscribers:
                    dep.subscribers.remove(run_effect)
            deps.clear()

            # Track new deps
            ctx = TrackingContext(run_effect)
            prev = _tracking_context[0]
            _tracking_context[0] = ctx

            result = _call_sx_fn(effect_fn, [])

            _tracking_context[0] = prev
            deps.extend(ctx.deps)

            # If effect returns a callable, it's cleanup
            if callable(result) or isinstance(result, Lambda):
                cleanup_fn[0] = result

            return NIL

        run_effect()

        def dispose():
            disposed[0] = True
            if cleanup_fn[0]:
                _call_sx_fn(cleanup_fn[0], [])
            for dep in deps:
                if run_effect in dep.subscribers:
                    dep.subscribers.remove(run_effect)
            deps.clear()
            return NIL

        return dispose
    env["effect"] = _effect

    def _batch(thunk):
        depth = env.get("*batch-depth*", 0)
        env["*batch-depth*"] = depth + 1
        _call_sx_fn(thunk, [])
        env["*batch-depth*"] = env["*batch-depth*"] - 1
        if env["*batch-depth*"] == 0:
            queue = env.get("*batch-queue*", [])
            env["*batch-queue*"] = []
            # Collect unique subscribers across all queued signals
            seen = set()
            pending = []
            for s in queue:
                for sub in s.subscribers:
                    sub_id = id(sub)
                    if sub_id not in seen:
                        seen.add(sub_id)
                        pending.append(sub)
            # Notify each unique subscriber exactly once
            for sub in pending:
                _call_sx_fn(sub, [])
        return NIL
    env["batch"] = _batch


def _load_types(env):
    """Load types.sx spec — gradual type system."""
    from shared.sx.types import Component

    def _component_param_types(c):
        return getattr(c, 'param_types', None)

    def _component_set_param_types(c, d):
        c.param_types = d

    env["component-param-types"] = _component_param_types
    env["component-set-param-types!"] = _component_set_param_types

    # test-prim-types: a minimal type registry for testing
    def _test_prim_types():
        return {
            "+": "number", "-": "number", "*": "number", "/": "number",
            "mod": "number", "abs": "number", "floor": "number",
            "ceil": "number", "round": "number", "min": "number",
            "max": "number", "parse-int": "number", "parse-float": "number",
            "=": "boolean", "!=": "boolean", "<": "boolean", ">": "boolean",
            "<=": "boolean", ">=": "boolean",
            "str": "string", "string-length": "number",
            "substring": "string", "upcase": "string", "downcase": "string",
            "trim": "string", "split": "list", "join": "string",
            "string-contains?": "boolean", "starts-with?": "boolean",
            "ends-with?": "boolean", "replace": "string",
            "not": "boolean", "nil?": "boolean", "number?": "boolean",
            "string?": "boolean", "list?": "boolean", "dict?": "boolean",
            "boolean?": "boolean", "symbol?": "boolean", "empty?": "boolean",
            "list": "list", "first": "any", "rest": "list", "nth": "any",
            "last": "any", "cons": "list", "append": "list",
            "reverse": "list", "len": "number", "contains?": "boolean",
            "flatten": "list", "concat": "list", "slice": "list",
            "range": "list", "sort": "list", "sort-by": "list",
            "map": "list", "filter": "list", "reduce": "any",
            "some": "boolean", "every?": "boolean",
            "dict": "dict", "assoc": "dict", "dissoc": "dict",
            "get": "any", "keys": "list", "vals": "list",
            "has-key?": "boolean", "merge": "dict",
        }

    env["test-prim-types"] = _test_prim_types

    # test-prim-param-types: param type signatures for primitive call checking
    def _test_prim_param_types():
        # Each entry: {"positional": [["name", "type"|None], ...], "rest-type": "type"|None}
        return {
            "+":  {"positional": [],                                       "rest-type": "number"},
            "-":  {"positional": [["a", "number"]],                        "rest-type": "number"},
            "*":  {"positional": [],                                       "rest-type": "number"},
            "/":  {"positional": [["a", "number"], ["b", "number"]],       "rest-type": None},
            "mod": {"positional": [["a", "number"], ["b", "number"]],      "rest-type": None},
            "sqrt": {"positional": [["x", "number"]],                      "rest-type": None},
            "pow": {"positional": [["x", "number"], ["n", "number"]],      "rest-type": None},
            "abs": {"positional": [["x", "number"]],                       "rest-type": None},
            "floor": {"positional": [["x", "number"]],                     "rest-type": None},
            "ceil": {"positional": [["x", "number"]],                      "rest-type": None},
            "round": {"positional": [["x", "number"]],                     "rest-type": "number"},
            "min": {"positional": [],                                      "rest-type": "number"},
            "max": {"positional": [],                                      "rest-type": "number"},
            "clamp": {"positional": [["x", "number"], ["lo", "number"], ["hi", "number"]], "rest-type": None},
            "inc": {"positional": [["n", "number"]],                       "rest-type": None},
            "dec": {"positional": [["n", "number"]],                       "rest-type": None},
            "<":  {"positional": [["a", "number"], ["b", "number"]],       "rest-type": None},
            ">":  {"positional": [["a", "number"], ["b", "number"]],       "rest-type": None},
            "<=": {"positional": [["a", "number"], ["b", "number"]],       "rest-type": None},
            ">=": {"positional": [["a", "number"], ["b", "number"]],       "rest-type": None},
            "odd?": {"positional": [["n", "number"]],                      "rest-type": None},
            "even?": {"positional": [["n", "number"]],                     "rest-type": None},
            "zero?": {"positional": [["n", "number"]],                     "rest-type": None},
            "upper": {"positional": [["s", "string"]],                     "rest-type": None},
            "upcase": {"positional": [["s", "string"]],                    "rest-type": None},
            "lower": {"positional": [["s", "string"]],                     "rest-type": None},
            "downcase": {"positional": [["s", "string"]],                  "rest-type": None},
            "string-length": {"positional": [["s", "string"]],             "rest-type": None},
            "substring": {"positional": [["s", "string"], ["start", "number"], ["end", "number"]], "rest-type": None},
            "string-contains?": {"positional": [["s", "string"], ["needle", "string"]], "rest-type": None},
            "trim": {"positional": [["s", "string"]],                      "rest-type": None},
            "split": {"positional": [["s", "string"]],                     "rest-type": "string"},
            "join": {"positional": [["sep", "string"], ["coll", "list"]],  "rest-type": None},
            "replace": {"positional": [["s", "string"], ["old", "string"], ["new", "string"]], "rest-type": None},
            "index-of": {"positional": [["s", "string"], ["needle", "string"]], "rest-type": "number"},
            "starts-with?": {"positional": [["s", "string"], ["prefix", "string"]], "rest-type": None},
            "ends-with?": {"positional": [["s", "string"], ["suffix", "string"]], "rest-type": None},
            "concat": {"positional": [],                                   "rest-type": "list"},
            "range": {"positional": [["start", "number"], ["end", "number"]], "rest-type": "number"},
            "first": {"positional": [["coll", "list"]],                    "rest-type": None},
            "last": {"positional": [["coll", "list"]],                     "rest-type": None},
            "rest": {"positional": [["coll", "list"]],                     "rest-type": None},
            "nth": {"positional": [["coll", "list"], ["n", "number"]],     "rest-type": None},
            "cons": {"positional": [["x", None], ["coll", "list"]],        "rest-type": None},
            "append": {"positional": [["coll", "list"]],                   "rest-type": None},
            "append!": {"positional": [["coll", "list"]],                  "rest-type": None},
            "reverse": {"positional": [["coll", "list"]],                  "rest-type": None},
            "flatten": {"positional": [["coll", "list"]],                  "rest-type": None},
            "chunk-every": {"positional": [["coll", "list"], ["n", "number"]], "rest-type": None},
            "zip-pairs": {"positional": [["coll", "list"]],                "rest-type": None},
            "keys": {"positional": [["d", "dict"]],                        "rest-type": None},
            "vals": {"positional": [["d", "dict"]],                        "rest-type": None},
            "merge": {"positional": [],                                    "rest-type": "dict"},
            "has-key?": {"positional": [["d", "dict"]],                    "rest-type": None},
            "assoc": {"positional": [["d", "dict"]],                       "rest-type": None},
            "dissoc": {"positional": [["d", "dict"]],                      "rest-type": None},
            "dict-set!": {"positional": [["d", "dict"]],                   "rest-type": None},
            "format-date": {"positional": [["date-str", "string"], ["fmt", "string"]], "rest-type": None},
            "format-decimal": {"positional": [["val", "number"]],          "rest-type": "number"},
            "parse-datetime": {"positional": [["s", "string"]],            "rest-type": None},
            "pluralize": {"positional": [["count", "number"]],             "rest-type": "string"},
            "escape": {"positional": [["s", "string"]],                    "rest-type": None},
            "strip-tags": {"positional": [["s", "string"]],                "rest-type": None},
            "symbol-name": {"positional": [["sym", "symbol"]],             "rest-type": None},
            "keyword-name": {"positional": [["kw", "keyword"]],            "rest-type": None},
            "sx-parse": {"positional": [["source", "string"]],             "rest-type": None},
        }

    env["test-prim-param-types"] = _test_prim_param_types
    env["test-env"] = lambda: env

    # Platform functions needed by types.sx check-body-walk
    if "env-get" not in env:
        env["env-get"] = lambda e, k: e.get(k) if hasattr(e, 'get') else None
    if "env-has?" not in env:
        env["env-has?"] = lambda e, k: k in e
    if "dict-has?" not in env:
        env["dict-has?"] = lambda d, k: k in d if isinstance(d, dict) else False
    if "dict-get" not in env:
        env["dict-get"] = lambda d, k, *default: d.get(k, default[0] if default else None) if isinstance(d, dict) else (default[0] if default else None)
    # types.sx uses component-has-children (no ?), test runner has component-has-children?
    if "component-has-children" not in env:
        env["component-has-children"] = lambda c: getattr(c, 'has_children', False)
    # types.sx uses map-dict for record type resolution
    if "map-dict" not in env:
        from shared.sx.types import Lambda as _Lambda
        def _map_dict(fn, d):
            result = {}
            for k, v in d.items():
                if isinstance(fn, _Lambda):
                    result[k] = _trampoline(_eval([fn, k, v], env))
                else:
                    result[k] = fn(k, v)
            return result
        env["map-dict"] = _map_dict

    # Try bootstrapped types first, fall back to eval
    try:
        from shared.sx.ref.sx_ref import (
            subtype_p, type_union, narrow_type,
            infer_type, check_component_call, check_component,
            check_all, build_type_registry, type_any_p,
            type_never_p, type_nullable_p, nullable_base,
            narrow_exclude_nil, narrow_exclude,
        )
        env["subtype?"] = subtype_p
        env["type-union"] = type_union
        env["narrow-type"] = narrow_type
        env["infer-type"] = infer_type
        env["check-component-call"] = check_component_call
        env["check-component"] = check_component
        env["check-all"] = check_all
        env["build-type-registry"] = build_type_registry
        env["type-any?"] = type_any_p
        env["type-never?"] = type_never_p
        env["type-nullable?"] = type_nullable_p
        env["nullable-base"] = nullable_base
        env["narrow-exclude-nil"] = narrow_exclude_nil
        env["narrow-exclude"] = narrow_exclude
    except ImportError:
        eval_file("types.sx", env)


def main():
    global passed, failed, test_num

    args = sys.argv[1:]

    # Legacy mode
    if args and args[0] == "--legacy":
        print("TAP version 13")
        eval_file("test.sx", env)
    else:
        # Determine which specs to run
        specs_to_run = args if args else list(SPECS.keys())

        print("TAP version 13")

        # Always load framework first
        eval_file("test-framework.sx", env)

        for spec_name in specs_to_run:
            spec = SPECS.get(spec_name)
            if not spec:
                print(f"# SKIP unknown spec: {spec_name}")
                continue

            # Check platform requirements
            can_run = True
            for need in spec["needs"]:
                if need not in env:
                    print(f"# SKIP {spec_name} (missing: {need})")
                    can_run = False
                    break
            if not can_run:
                continue

            # Load prerequisite spec modules
            if spec_name == "eval":
                _load_forms_from_bootstrap(env)
            if spec_name == "router":
                _load_router_from_bootstrap(env)
            if spec_name == "deps":
                _load_deps_from_bootstrap(env)
            if spec_name == "engine":
                _load_engine_from_bootstrap(env)
            if spec_name == "orchestration":
                _load_orchestration(env)
            if spec_name == "signals":
                _load_signals(env)
            if spec_name == "types":
                _load_types(env)

            print(f"# --- {spec_name} ---")
            eval_file(spec["file"], env)

            # Reset render state after render/aser tests to avoid leaking
            # into subsequent specs (bootstrapped evaluator checks render_active)
            if spec_name in ("render", "aser"):
                try:
                    from shared.sx.ref.sx_ref import set_render_active_b
                    set_render_active_b(False)
                except ImportError:
                    pass

    # Summary
    print()
    print(f"1..{test_num}")
    print(f"# tests {passed + failed}")
    print(f"# pass  {passed}")
    if failed > 0:
        print(f"# fail  {failed}")
        sys.exit(1)


if __name__ == "__main__":
    main()
