"""Tests for OCaml kernel ↔ Python page helper IO bridge.

Verifies that:
1. Helper injection registers functions in the OCaml kernel
2. The kernel can call helpers via (io-request "helper" ...)
3. aser_slot expands components that use helpers
4. Caching eliminates redundant IO round-trips

Usage:
    pytest shared/sx/tests/test_ocaml_helpers.py -v
"""

import asyncio
import os
import sys
import time
import unittest

_project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))
if _project_root not in sys.path:
    sys.path.insert(0, _project_root)

from shared.sx.ocaml_bridge import OcamlBridge, OcamlBridgeError, _DEFAULT_BIN, _escape


def _skip_if_no_binary():
    bin_path = os.path.abspath(_DEFAULT_BIN)
    if not os.path.isfile(bin_path):
        raise unittest.SkipTest(
            f"OCaml binary not found at {bin_path}. "
            f"Build with: cd hosts/ocaml && eval $(opam env) && dune build"
        )


class TestHelperInjection(unittest.IsolatedAsyncioTestCase):
    """Test that page helpers can be injected into the OCaml kernel."""

    @classmethod
    def setUpClass(cls):
        _skip_if_no_binary()

    async def asyncSetUp(self):
        self.bridge = OcamlBridge()
        await self.bridge.start()
        # Load spec + adapter (needed for aser)
        spec_dir = os.path.join(_project_root, "spec")
        web_dir = os.path.join(_project_root, "web")
        for f in ["parser.sx", "render.sx"]:
            path = os.path.join(spec_dir, f)
            if os.path.isfile(path):
                async with self.bridge._lock:
                    await self.bridge._send(f'(load "{_escape(path)}")')
                    await self.bridge._read_until_ok(ctx=None)
        adapter = os.path.join(web_dir, "adapter-sx.sx")
        if os.path.isfile(adapter):
            async with self.bridge._lock:
                await self.bridge._send(f'(load "{_escape(adapter)}")')
                await self.bridge._read_until_ok(ctx=None)

    async def asyncTearDown(self):
        await self.bridge.stop()

    async def _inject_test_helper(self, name: str, nargs: int):
        """Inject a single helper proxy into the kernel."""
        param_names = " ".join(chr(97 + i) for i in range(nargs))
        arg_list = " ".join(chr(97 + i) for i in range(nargs))
        sx_def = f'(define {name} (fn ({param_names}) (helper "{name}" {arg_list})))'
        async with self.bridge._lock:
            await self.bridge._send(f'(load-source "{_escape(sx_def)}")')
            await self.bridge._read_until_ok(ctx=None)

    async def test_helper_call_returns_value(self):
        """Injected helper can be called and returns IO result."""
        # The "helper" native binding is already in the kernel.
        # Define a test helper that calls (helper "json-encode" value)
        await self._inject_test_helper("json-encode", 1)

        # Call it via eval — should yield io-request, Python dispatches
        result = await self.bridge.eval(
            '(json-encode "hello")',
            ctx={"_helper_service": "sx"}
        )
        self.assertIn("hello", result)

    async def test_helper_with_two_args(self):
        """Helper with 2 args works (e.g. highlight pattern)."""
        # Define a 2-arg test helper via the generic helper binding
        sx_def = '(define test-two-args (fn (a b) (helper "json-encode" (str a ":" b))))'
        async with self.bridge._lock:
            await self.bridge._send(f'(load-source "{_escape(sx_def)}")')
            await self.bridge._read_until_ok(ctx=None)

        result = await self.bridge.eval(
            '(test-two-args "hello" "world")',
            ctx={"_helper_service": "sx"}
        )
        self.assertIn("hello:world", result)

    async def test_undefined_helper_errors(self):
        """Calling an uninjected helper raises an error."""
        with self.assertRaises(OcamlBridgeError) as cm:
            await self.bridge.eval('(nonexistent-helper "arg")')
        self.assertIn("Undefined symbol", str(cm.exception))

    async def test_helper_in_aser(self):
        """Helper works inside aser — result inlined in SX output."""
        await self._inject_test_helper("json-encode", 1)

        # aser a component-like expression that calls the helper
        result = await self.bridge.aser(
            '(div :class "test" (json-encode "hello"))',
            ctx={"_helper_service": "sx"}
        )
        # The aser should evaluate json-encode and inline the result
        self.assertIn("div", result)
        self.assertIn("hello", result)

    async def test_helper_in_aser_slot_component(self):
        """aser_slot expands component containing helper call."""
        await self._inject_test_helper("json-encode", 1)

        # Define a component that calls the helper
        async with self.bridge._lock:
            await self.bridge._send(
                '(load-source "(defcomp ~test/code-display (&key code) '
                '(pre (code code)))")'
            )
            await self.bridge._read_until_ok(ctx=None)

        # aser_slot should expand the component, evaluating the body
        result = await self.bridge.aser_slot(
            '(~test/code-display :code (json-encode "test-value"))',
            ctx={"_helper_service": "sx"}
        )
        # Should contain expanded HTML tags, not component call
        self.assertIn("pre", result)
        self.assertIn("test-value", result)
        # Should NOT contain the component call
        self.assertNotIn("~test/code-display", result)


class TestBatchIO(unittest.IsolatedAsyncioTestCase):
    """Test that batchable helper calls are collected and resolved concurrently."""

    @classmethod
    def setUpClass(cls):
        _skip_if_no_binary()

    async def asyncSetUp(self):
        self.bridge = OcamlBridge()
        await self.bridge.start()
        spec_dir = os.path.join(_project_root, "spec")
        web_dir = os.path.join(_project_root, "web")
        for f in ["parser.sx", "render.sx"]:
            path = os.path.join(spec_dir, f)
            if os.path.isfile(path):
                async with self.bridge._lock:
                    await self.bridge._send(f'(load "{_escape(path)}")')
                    await self.bridge._read_until_ok(ctx=None)
        adapter = os.path.join(web_dir, "adapter-sx.sx")
        if os.path.isfile(adapter):
            async with self.bridge._lock:
                await self.bridge._send(f'(load "{_escape(adapter)}")')
                await self.bridge._read_until_ok(ctx=None)

    async def asyncTearDown(self):
        await self.bridge.stop()

    async def test_batch_highlight_calls(self):
        """Multiple highlight calls in aser_slot are batched, not sequential."""
        # Map highlight to json-encode (available without Quart app)
        sx = '(define highlight (fn (a b) (helper "json-encode" a)))'
        async with self.bridge._lock:
            await self.bridge._send(f'(load-source "{_escape(sx)}")')
            await self.bridge._read_until_ok(ctx=None)

        comp = (
            '(defcomp ~test/batch (&key)'
            '  (div (p (highlight "aaa" "x"))'
            '       (p (highlight "bbb" "x"))'
            '       (p (highlight "ccc" "x"))))'
        )
        async with self.bridge._lock:
            await self.bridge._send(f'(load-source "{_escape(comp)}")')
            await self.bridge._read_until_ok(ctx=None)

        result = await self.bridge.aser_slot(
            '(~test/batch)', ctx={"_helper_service": "sx"})
        # All 3 values present — placeholders replaced
        self.assertIn("aaa", result)
        self.assertIn("bbb", result)
        self.assertIn("ccc", result)
        # No placeholder markers remaining
        self.assertNotIn("\u00ab", result)  # «
        self.assertNotIn("\u00bb", result)  # »

    async def test_batch_faster_than_sequential(self):
        """Batched IO should be faster than N sequential round-trips."""
        sx = '(define highlight (fn (a b) (helper "json-encode" a)))'
        async with self.bridge._lock:
            await self.bridge._send(f'(load-source "{_escape(sx)}")')
            await self.bridge._read_until_ok(ctx=None)

        calls = " ".join(f'(p (highlight "v{i}" "x"))' for i in range(10))
        comp = f'(defcomp ~test/perf (&key) (div {calls}))'
        async with self.bridge._lock:
            await self.bridge._send(f'(load-source "{_escape(comp)}")')
            await self.bridge._read_until_ok(ctx=None)

        t0 = time.monotonic()
        result = await self.bridge.aser_slot(
            '(~test/perf)', ctx={"_helper_service": "sx"})
        elapsed = time.monotonic() - t0

        # All 10 values present
        for i in range(10):
            self.assertIn(f"v{i}", result)
        # Should complete in under 2 seconds (batched, not 10 × round-trip)
        self.assertLess(elapsed, 2.0,
            f"10 batched IO calls took {elapsed:.1f}s (target: <2s)")


class TestHelperIOPerformance(unittest.IsolatedAsyncioTestCase):
    """Test that helper IO round-trips are fast enough for production."""

    @classmethod
    def setUpClass(cls):
        _skip_if_no_binary()

    async def asyncSetUp(self):
        self.bridge = OcamlBridge()
        await self.bridge.start()
        spec_dir = os.path.join(_project_root, "spec")
        web_dir = os.path.join(_project_root, "web")
        for f in ["parser.sx", "render.sx"]:
            path = os.path.join(spec_dir, f)
            if os.path.isfile(path):
                async with self.bridge._lock:
                    await self.bridge._send(f'(load "{_escape(path)}")')
                    await self.bridge._read_until_ok(ctx=None)
        adapter = os.path.join(web_dir, "adapter-sx.sx")
        if os.path.isfile(adapter):
            async with self.bridge._lock:
                await self.bridge._send(f'(load "{_escape(adapter)}")')
                await self.bridge._read_until_ok(ctx=None)

    async def asyncTearDown(self):
        await self.bridge.stop()

    async def test_sequential_helper_calls_timing(self):
        """Measure round-trip time for sequential helper calls."""
        # Inject json-encode as a fast helper
        param_names = "a"
        sx_def = '(define json-encode (fn (a) (helper "json-encode" a)))'
        async with self.bridge._lock:
            await self.bridge._send(f'(load-source "{_escape(sx_def)}")')
            await self.bridge._read_until_ok(ctx=None)

        # Time 20 sequential calls (simulating a page with 20 highlight calls)
        n_calls = 20
        calls = " ".join(f'(json-encode "{i}")' for i in range(n_calls))
        expr = f'(list {calls})'

        start = time.monotonic()
        result = await self.bridge.eval(expr, ctx={"_helper_service": "sx"})
        elapsed = time.monotonic() - start

        # Should complete in under 5 seconds (generous for 20 IO round-trips)
        self.assertLess(elapsed, 5.0,
            f"20 helper IO round-trips took {elapsed:.1f}s (target: <5s)")

    async def test_aser_slot_with_many_helper_calls(self):
        """aser_slot with multiple helper calls completes in reasonable time."""
        sx_def = '(define json-encode (fn (a) (helper "json-encode" a)))'
        async with self.bridge._lock:
            await self.bridge._send(f'(load-source "{_escape(sx_def)}")')
            await self.bridge._read_until_ok(ctx=None)

        # Define a component with multiple helper calls
        comp_def = (
            '(defcomp ~test/multi-helper (&key)'
            '  (div'
            '    (p (json-encode "a"))'
            '    (p (json-encode "b"))'
            '    (p (json-encode "c"))'
            '    (p (json-encode "d"))'
            '    (p (json-encode "e"))))'
        )
        async with self.bridge._lock:
            await self.bridge._send(f'(load-source "{_escape(comp_def)}")')
            await self.bridge._read_until_ok(ctx=None)

        start = time.monotonic()
        result = await self.bridge.aser_slot(
            '(~test/multi-helper)',
            ctx={"_helper_service": "sx"}
        )
        elapsed = time.monotonic() - start

        self.assertIn("div", result)
        self.assertLess(elapsed, 3.0,
            f"aser_slot with 5 helpers took {elapsed:.1f}s (target: <3s)")


class TestAserSlotClientAffinity(unittest.IsolatedAsyncioTestCase):
    """Test that :affinity :client components are NOT expanded by aser_slot."""

    @classmethod
    def setUpClass(cls):
        _skip_if_no_binary()

    async def asyncSetUp(self):
        self.bridge = OcamlBridge()
        await self.bridge.start()
        spec_dir = os.path.join(_project_root, "spec")
        web_dir = os.path.join(_project_root, "web")
        for f in ["parser.sx", "render.sx"]:
            path = os.path.join(spec_dir, f)
            if os.path.isfile(path):
                async with self.bridge._lock:
                    await self.bridge._send(f'(load "{_escape(path)}")')
                    await self.bridge._read_until_ok(ctx=None)
        adapter = os.path.join(web_dir, "adapter-sx.sx")
        if os.path.isfile(adapter):
            async with self.bridge._lock:
                await self.bridge._send(f'(load "{_escape(adapter)}")')
                await self.bridge._read_until_ok(ctx=None)

    async def asyncTearDown(self):
        await self.bridge.stop()

    async def test_client_affinity_not_expanded(self):
        """Components with :affinity :client stay as calls in aser_slot."""
        comp_def = (
            '(defcomp ~test/client-only () :affinity :client'
            '  (div "browser-only-content"))'
        )
        async with self.bridge._lock:
            await self.bridge._send(f'(load-source "{_escape(comp_def)}")')
            await self.bridge._read_until_ok(ctx=None)

        result = await self.bridge.aser_slot('(~test/client-only)')
        # Should remain as a component call, NOT expanded
        self.assertIn("~test/client-only", result)
        self.assertNotIn("browser-only-content", result)

    async def test_server_affinity_expanded(self):
        """Components with :affinity :server are expanded by regular aser."""
        comp_def = (
            '(defcomp ~test/server-only (&key label) :affinity :server'
            '  (div :class "server" label))'
        )
        async with self.bridge._lock:
            await self.bridge._send(f'(load-source "{_escape(comp_def)}")')
            await self.bridge._read_until_ok(ctx=None)

        result = await self.bridge.aser(
            '(~test/server-only :label "hello")')
        # Should be expanded — div with class, not component call
        self.assertIn("div", result)
        self.assertIn("server", result)
        self.assertNotIn("~test/server-only", result)

    async def test_auto_affinity_not_expanded_by_aser(self):
        """Default affinity components are NOT expanded by regular aser."""
        comp_def = (
            '(defcomp ~test/auto-comp (&key label)'
            '  (div "auto-content" label))'
        )
        async with self.bridge._lock:
            await self.bridge._send(f'(load-source "{_escape(comp_def)}")')
            await self.bridge._read_until_ok(ctx=None)

        result = await self.bridge.aser(
            '(~test/auto-comp :label "hi")')
        # Should remain as component call
        self.assertIn("~test/auto-comp", result)

    async def test_island_not_expanded_by_aser_slot(self):
        """Islands are NEVER expanded server-side, even with expand-all."""
        island_def = (
            '(defisland ~test/reactive-isle (&key label)'
            '  (div (deref (signal 0)) label))'
        )
        async with self.bridge._lock:
            await self.bridge._send(f'(load-source "{_escape(island_def)}")')
            await self.bridge._read_until_ok(ctx=None)

        result = await self.bridge.aser_slot(
            '(~test/reactive-isle :label "hello")')
        # Island should be serialized as a call, NOT expanded
        self.assertIn("~test/reactive-isle", result)
        # Body content (deref, signal) should NOT appear
        self.assertNotIn("deref", result)
        self.assertNotIn("signal", result)

    async def test_island_preserved_inside_expanded_component(self):
        """Island calls survive inside aser_slot-expanded components."""
        src = (
            '(defisland ~test/inner-isle (&key v) (span (deref (signal v))))'
            '(defcomp ~test/outer-comp (&key title)'
            '  (div (h1 title) (~test/inner-isle :v 42)))'
        )
        async with self.bridge._lock:
            await self.bridge._send(f'(load-source "{_escape(src)}")')
            await self.bridge._read_until_ok(ctx=None)

        result = await self.bridge.aser_slot(
            '(~test/outer-comp :title "Test")')
        # Outer component expanded
        self.assertNotIn("~test/outer-comp", result)
        self.assertIn("div", result)
        self.assertIn("Test", result)
        # Inner island preserved as call
        self.assertIn("~test/inner-isle", result)

    async def test_auto_affinity_expanded_by_aser_slot(self):
        """Default affinity components ARE expanded by aser_slot."""
        comp_def = (
            '(defcomp ~test/auto-comp2 (&key label)'
            '  (div "expanded" label))'
        )
        async with self.bridge._lock:
            await self.bridge._send(f'(load-source "{_escape(comp_def)}")')
            await self.bridge._read_until_ok(ctx=None)

        result = await self.bridge.aser_slot(
            '(~test/auto-comp2 :label "hi")')
        # Should be expanded
        self.assertIn("div", result)
        self.assertIn("expanded", result)
        self.assertNotIn("~test/auto-comp2", result)


if __name__ == "__main__":
    unittest.main()
