"""Golden HTML rendering tests against the OCaml SX kernel.

Loads curated test cases from golden_data.json and verifies the OCaml
kernel produces identical HTML output. Also tests aser and aser-slot
modes.

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

import asyncio
import json
import os
import sys
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

_GOLDEN_PATH = os.path.join(os.path.dirname(__file__), "golden_data.json")


def _load_golden() -> list[dict]:
    """Load golden test data."""
    if not os.path.isfile(_GOLDEN_PATH):
        return []
    with open(_GOLDEN_PATH) as f:
        return json.load(f)


class TestOcamlGoldenRender(unittest.IsolatedAsyncioTestCase):
    """Golden HTML tests — compare OCaml render output to Python-generated HTML."""

    @classmethod
    def setUpClass(cls):
        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"
            )
        cls.golden = _load_golden()
        if not cls.golden:
            raise unittest.SkipTest(
                f"No golden data at {_GOLDEN_PATH}. "
                f"Generate with: python3 shared/sx/tests/generate_golden.py"
            )

    async def asyncSetUp(self):
        self.bridge = OcamlBridge()
        await self.bridge.start()

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

    # Cases with known issues (spec-only functions, attribute order, etc.)
    _RENDER_SKIP = {"filter_even", "void_input", "do_block"}

    async def test_golden_render(self):
        """Each golden case: OCaml render matches Python HTML."""
        passed = 0
        failed = []
        for case in self.golden:
            name = case["name"]
            if name in self._RENDER_SKIP:
                continue
            sx_input = case["sx_input"]
            expected = case["expected_html"]
            try:
                actual = await asyncio.wait_for(
                    self.bridge.render(sx_input), timeout=5.0
                )
                if actual.strip() == expected.strip():
                    passed += 1
                else:
                    failed.append((name, expected, actual))
            except asyncio.TimeoutError:
                failed.append((name, expected, "TIMEOUT"))
                # Bridge may be desynced — stop and restart
                await self.bridge.stop()
                self.bridge = OcamlBridge()
                await self.bridge.start()
            except OcamlBridgeError as e:
                failed.append((name, expected, f"ERROR: {e}"))

        if failed:
            msg_parts = [f"\n{len(failed)} golden render mismatches:\n"]
            for name, expected, actual in failed[:10]:
                msg_parts.append(f"  {name}:")
                msg_parts.append(f"    expected: {expected[:120]}")
                msg_parts.append(f"    actual:   {actual[:120]}")
            self.fail("\n".join(msg_parts))

    # Cases that use spec-only functions or macros with &rest that don't
    # round-trip through aser cleanly (render still works fine).
    # Cases that use spec-only functions, macros with &rest, or trigger
    # known parity issues in aser expansion (render still works fine).
    _ASER_SKIP = {"filter_even", "macro_unless"}
    _ASER_SLOT_SKIP = {"filter_even", "macro_unless", "defcomp_no_optional"}

    async def test_golden_aser(self):
        """Each golden case: OCaml aser produces valid SX wire format."""
        passed = 0
        errors = []
        for case in self.golden:
            name = case["name"]
            if name in self._ASER_SKIP:
                continue
            sx_input = case["sx_input"]
            try:
                result = await self.bridge.aser(sx_input)
                # aser should produce some output (string, not empty)
                if result is not None:
                    passed += 1
                else:
                    errors.append((name, "returned None"))
            except OcamlBridgeError as e:
                errors.append((name, str(e)))

        if errors:
            msg_parts = [f"\n{len(errors)} aser errors:\n"]
            for name, err in errors[:10]:
                msg_parts.append(f"  {name}: {err[:120]}")
            self.fail("\n".join(msg_parts))

    async def test_golden_aser_slot(self):
        """Each golden case: OCaml aser-slot produces valid SX wire format."""
        passed = 0
        errors = []
        for case in self.golden:
            name = case["name"]
            if name in self._ASER_SLOT_SKIP:
                continue
            sx_input = case["sx_input"]
            try:
                result = await self.bridge.aser_slot(sx_input)
                if result is not None:
                    passed += 1
                else:
                    errors.append((name, "returned None"))
            except OcamlBridgeError as e:
                errors.append((name, str(e)))

        if errors:
            msg_parts = [f"\n{len(errors)} aser-slot errors:\n"]
            for name, err in errors[:10]:
                msg_parts.append(f"  {name}: {err[:120]}")
            self.fail("\n".join(msg_parts))

    async def test_aser_slot_expands_components(self):
        """aser-slot expands component calls while aser does not."""
        await self.bridge.load_source(
            '(defcomp ~golden-test (&key label) (span :class "tag" label))'
        )
        # aser should preserve the component call
        aser_result = await self.bridge.aser('(~golden-test :label "Hi")')
        self.assertTrue(
            aser_result.startswith("(~golden-test"),
            f"aser should preserve component call, got: {aser_result}",
        )

        # aser-slot should expand the component
        slot_result = await self.bridge.aser_slot('(~golden-test :label "Hi")')
        self.assertTrue(
            slot_result.startswith("(span"),
            f"aser-slot should expand component, got: {slot_result}",
        )

    async def test_aser_does_not_crash_on_component_call(self):
        """Regression: aser with a component call must not crash.

        This catches the bug where adapter-sx.sx called expand-components?
        without guarding env-has?, causing 'Undefined symbol' on kernels
        that don't bind it or when aser (not aser-slot) is used.
        """
        await self.bridge.load_source(
            '(defcomp ~regress-comp (&key title &rest children) '
            '(div :class "box" (h2 title) children))'
        )
        # aser must succeed (serialize the component call, not expand it)
        result = await self.bridge.aser(
            '(~regress-comp :title "Hello" (p "world"))'
        )
        self.assertIn("~regress-comp", result)
        self.assertIn('"Hello"', result)

    async def test_render_raw_html(self):
        """Regression: raw! must inject HTML without escaping."""
        html = await self.bridge.render('(raw! "<b>bold</b>")')
        self.assertEqual(html, "<b>bold</b>")

    async def test_render_component_with_raw(self):
        """Regression: component using raw! (like ~shared:misc/rich-text)."""
        await self.bridge.load_source(
            '(defcomp ~rich-text (&key html) (raw! html))'
        )
        html = await self.bridge.render('(~rich-text :html "<p>CMS content</p>")')
        self.assertEqual(html, "<p>CMS content</p>")

    async def test_aser_nested_components_no_crash(self):
        """Regression: aser with nested component calls must not crash."""
        await self.bridge.load_source(
            '(defcomp ~outer-reg (&key title &rest children) '
            '(section (h1 title) children))'
        )
        await self.bridge.load_source(
            '(defcomp ~inner-reg (&key text) (span text))'
        )
        result = await self.bridge.aser(
            '(~outer-reg :title "Outer" (~inner-reg :text "Inner"))'
        )
        self.assertIn("~outer-reg", result)
        self.assertIn("~inner-reg", result)

    async def test_render_shell_with_raw(self):
        """Integration: shell component with raw! renders full HTML page.

        The page shell uses raw! extensively for script content, CSS,
        pre-rendered HTML, etc. This catches missing raw! in the renderer.
        """
        await self.bridge.load_source(
            '(defcomp ~test-shell (&key title page-sx css) '
            '(<> (raw! "<!doctype html>") '
            '(html (head (title title) (style (raw! (or css "")))) '
            '(body (script :type "text/sx" (raw! (or page-sx "")))))))'
        )
        html = await self.bridge.render(
            '(~test-shell :title "Test" '
            ':page-sx "(div :class \\"card\\" \\"hello\\")" '
            ':css "body{margin:0}")'
        )
        self.assertIn("<!doctype html>", html)
        self.assertIn("<title>Test</title>", html)
        self.assertIn('(div :class "card" "hello")', html)
        self.assertIn("body{margin:0}", html)

    async def test_render_never_returns_raw_sx(self):
        """The render command must never return raw SX as the response.

        Even if the shell component fails, the bridge.render() should
        either return HTML or raise — never return SX wire format.
        """
        # Component that produces HTML, not SX
        await self.bridge.load_source(
            '(defcomp ~test-page (&key content) '
            '(<> (raw! "<!doctype html>") (html (body (raw! content)))))'
        )
        html = await self.bridge.render(
            '(~test-page :content "(div \\"hello\\")")'
        )
        # Must start with <!doctype, not with (
        self.assertTrue(
            html.startswith("<!doctype"),
            f"render returned SX instead of HTML: {html[:100]}",
        )
        # Must not contain bare SX component calls as visible text
        self.assertNotIn("(~test-page", html)

    async def test_aser_slot_server_affinity_always_expands(self):
        """Server-affinity components expand in both aser and aser-slot."""
        await self.bridge.load_source(
            '(defcomp ~golden-server (&key x) :affinity :server (div x))'
        )
        # Both modes should expand server-affinity components
        aser_result = await self.bridge.aser('(~golden-server :x "test")')
        self.assertTrue(
            "(div" in aser_result,
            f"aser should expand server-affinity, got: {aser_result}",
        )
        slot_result = await self.bridge.aser_slot('(~golden-server :x "test")')
        self.assertTrue(
            "(div" in slot_result,
            f"aser-slot should expand server-affinity, got: {slot_result}",
        )


class TestOcamlCLI(unittest.TestCase):
    """Test the --render and --aser CLI modes."""

    @classmethod
    def setUpClass(cls):
        cls.bin_path = os.path.abspath(_DEFAULT_BIN)
        if not os.path.isfile(cls.bin_path):
            raise unittest.SkipTest("OCaml binary not found")

    def _run_cli(self, mode: str, sx_input: str) -> str:
        import subprocess
        result = subprocess.run(
            [self.bin_path, f"--{mode}"],
            input=sx_input,
            capture_output=True,
            text=True,
            timeout=10,
        )
        if result.returncode != 0:
            raise RuntimeError(f"CLI {mode} failed: {result.stderr}")
        return result.stdout

    def test_cli_render_simple(self):
        html = self._run_cli("render", '(div :class "card" (p "hello"))')
        self.assertEqual(html, '<div class="card"><p>hello</p></div>')

    def test_cli_render_fragment(self):
        html = self._run_cli("render", '(<> (p "a") (p "b"))')
        self.assertEqual(html, "<p>a</p><p>b</p>")

    def test_cli_render_void(self):
        html = self._run_cli("render", "(br)")
        self.assertEqual(html, "<br />")

    def test_cli_render_conditional(self):
        html = self._run_cli("render", '(if true (p "yes") (p "no"))')
        self.assertEqual(html, "<p>yes</p>")

    def test_cli_aser_with_defcomp(self):
        """CLI --aser with component def + call must not crash."""
        sx = ('(do (defcomp ~cli-test (&key title) (div title)) '
              '(~cli-test :title "Hi"))')
        result = self._run_cli("aser", sx)
        self.assertIn("~cli-test", result)

    # Same skip list as the bridge golden tests
    _CLI_RENDER_SKIP = {"filter_even", "void_input", "do_block"}

    def test_cli_golden_render(self):
        """Run all golden cases through CLI --render."""
        golden = _load_golden()
        if not golden:
            self.skipTest("No golden data")
        failed = []
        for case in golden:
            if case["name"] in self._CLI_RENDER_SKIP:
                continue
            try:
                actual = self._run_cli("render", case["sx_input"])
                if actual.strip() != case["expected_html"].strip():
                    failed.append((case["name"], case["expected_html"], actual))
            except Exception as e:
                failed.append((case["name"], case["expected_html"], str(e)))
        if failed:
            msg_parts = [f"\n{len(failed)} CLI golden render mismatches:\n"]
            for name, expected, actual in failed[:10]:
                msg_parts.append(f"  {name}:")
                msg_parts.append(f"    expected: {expected[:120]}")
                msg_parts.append(f"    actual:   {actual[:120]}")
            self.fail("\n".join(msg_parts))


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