"""Tests for aser (SX wire format) error propagation.

Verifies that evaluation errors inside control flow forms (case, cond, if,
when, let, begin) propagate correctly — they must throw, not silently
produce wrong output or fall through to :else branches.

This test file targets the production bug where a case body referencing an
undefined symbol was silently swallowed, causing the case to appear to fall
through to :else instead of raising an error.
"""
from __future__ import annotations

import pytest

from shared.sx.ref.sx_ref import (
    aser,
    sx_parse as parse_all,
    make_env,
    eval_expr,
    trampoline,
    serialize as sx_serialize,
)
from shared.sx.types import NIL, EvalError


def _render_sx(source: str, env=None) -> str:
    """Parse SX source and serialize via aser (sync)."""
    if env is None:
        env = make_env()
    exprs = parse_all(source)
    result = ""
    for expr in exprs:
        val = aser(expr, env)
        if isinstance(val, str):
            result += val
        elif val is None or val is NIL:
            pass
        else:
            result += sx_serialize(val)
    return result


# ---------------------------------------------------------------------------
# Case — matched branch errors must throw, not fall through
# ---------------------------------------------------------------------------

class TestCaseErrorPropagation:
    def test_matched_branch_undefined_symbol_throws(self):
        """If the matched case body references an undefined symbol, the aser
        must throw — NOT silently skip to :else."""
        with pytest.raises(Exception, match="Undefined symbol"):
            _render_sx('(case "x" "x" undefined_sym :else "fallback")')

    def test_else_branch_error_throws(self):
        with pytest.raises(Exception, match="Undefined symbol"):
            _render_sx('(case "miss" "x" "ok" :else undefined_sym)')

    def test_matched_branch_nested_error_throws(self):
        """Error inside a tag within the matched body must propagate."""
        with pytest.raises(Exception, match="Undefined symbol"):
            _render_sx('(case "a" "a" (div (p undefined_nested)) :else (p "index"))')

    def test_unmatched_correctly_falls_through(self):
        """Verify :else works when no clause matches (happy path)."""
        result = _render_sx('(case "miss" "x" "found" :else "fallback")')
        assert "fallback" in result

    def test_matched_branch_succeeds(self):
        """Verify the happy path: matched branch evaluates normally."""
        result = _render_sx('(case "ok" "ok" (p "matched") :else "fallback")')
        assert "matched" in result


# ---------------------------------------------------------------------------
# Cond — matched branch errors must throw
# ---------------------------------------------------------------------------

class TestCondErrorPropagation:
    def test_matched_branch_error_throws(self):
        with pytest.raises(Exception, match="Undefined symbol"):
            _render_sx('(cond true undefined_cond_sym :else "fallback")')

    def test_else_branch_error_throws(self):
        with pytest.raises(Exception, match="Undefined symbol"):
            _render_sx('(cond false "skip" :else undefined_cond_sym)')


# ---------------------------------------------------------------------------
# If / When — body errors must throw
# ---------------------------------------------------------------------------

class TestIfWhenErrorPropagation:
    def test_if_true_branch_error_throws(self):
        with pytest.raises(Exception, match="Undefined symbol"):
            _render_sx('(if true undefined_if_sym "fallback")')

    def test_when_body_error_throws(self):
        with pytest.raises(Exception, match="Undefined symbol"):
            _render_sx('(when true undefined_when_sym)')


# ---------------------------------------------------------------------------
# Let — binding or body errors must throw
# ---------------------------------------------------------------------------

class TestLetErrorPropagation:
    def test_binding_error_throws(self):
        with pytest.raises(Exception, match="Undefined symbol"):
            _render_sx('(let ((x undefined_let_sym)) (p x))')

    def test_body_error_throws(self):
        with pytest.raises(Exception, match="Undefined symbol"):
            _render_sx('(let ((x 1)) (p undefined_let_body_sym))')


# ---------------------------------------------------------------------------
# Begin/Do — body errors must throw
# ---------------------------------------------------------------------------

class TestBeginErrorPropagation:
    def test_do_body_error_throws(self):
        with pytest.raises(Exception, match="Undefined symbol"):
            _render_sx('(do "ok" undefined_do_sym)')


# ---------------------------------------------------------------------------
# Sync aser: components serialize WITHOUT expansion (by design)
# ---------------------------------------------------------------------------

class TestSyncAserComponentSerialization:
    """The sync aser serializes component calls as SX wire format without
    expanding the body. This is correct — expansion only happens in the
    async path with expand_components=True."""

    def test_component_in_case_serializes_without_expanding(self):
        """Sync aser should serialize the component call, not expand it."""
        result = _render_sx(
            '(do (defcomp ~broken (&key title) (div (p title) (p no_such_helper)))'
            '    (case "slug" "slug" (~broken :title "test") '
            '                :else "index"))'
        )
        # Component call is serialized as SX, not expanded — no error
        assert "~broken" in result

    def test_working_component_in_case_serializes(self):
        result = _render_sx(
            '(do (defcomp ~working (&key title) (div (p title)))'
            '    (case "ok" "ok" (~working :title "hello") '
            '               :else "index"))'
        )
        assert "~working" in result

    def test_unmatched_case_falls_through_correctly(self):
        result = _render_sx(
            '(do (defcomp ~page (&key x) (div x))'
            '    (case "miss" "hit" (~page :x "found") '
            '                :else "index"))'
        )
        assert "index" in result


# ---------------------------------------------------------------------------
# Async aser with expand_components=True — the production path
# ---------------------------------------------------------------------------

class TestAsyncAserComponentExpansion:
    """Tests the production code path: async aser with component expansion
    enabled. Errors in expanded component bodies must propagate, not be
    silently swallowed."""

    def _async_render(self, source: str) -> str:
        """Render via the async aser with component expansion enabled."""
        import asyncio
        from shared.sx.ref.sx_ref import async_aser, _expand_components_cv
        exprs = parse_all(source)
        env = make_env()

        async def run():
            token = _expand_components_cv.set(True)
            try:
                result = ""
                for expr in exprs:
                    val = await async_aser(expr, env, None)
                    if isinstance(val, str):
                        result += val
                    elif val is None or val is NIL:
                        pass
                    else:
                        result += sx_serialize(val)
                return result
            finally:
                _expand_components_cv.reset(token)

        return asyncio.run(run())

    def test_expanded_component_with_undefined_symbol_throws(self):
        """When expand_components is True and the component body references
        an undefined symbol, the error must propagate — not be swallowed."""
        with pytest.raises(Exception, match="Undefined symbol"):
            self._async_render(
                '(do (defcomp ~broken (&key title) '
                '      (div (p title) (p no_such_helper)))'
                '    (case "slug" "slug" (~broken :title "test") '
                '                :else "index"))'
            )

    def test_expanded_working_component_succeeds(self):
        result = self._async_render(
            '(do (defcomp ~working (&key title) (div (p title)))'
            '    (case "ok" "ok" (~working :title "hello") '
            '               :else "index"))'
        )
        assert "hello" in result

    def test_expanded_unmatched_falls_through(self):
        result = self._async_render(
            '(do (defcomp ~page (&key x) (div x))'
            '    (case "miss" "hit" (~page :x "found") '
            '                :else "index"))'
        )
        assert "index" in result

    def test_hand_written_aser_also_propagates(self):
        """Test the hand-written _aser in async_eval.py (the production
        path used by page rendering)."""
        import asyncio
        from shared.sx.async_eval import (
            async_eval_slot_to_sx, RequestContext,
        )
        from shared.sx.ref.sx_ref import aser

        env = make_env()
        # Define the component via sync aser
        for expr in parse_all(
            '(defcomp ~broken (&key title) (div (p title) (p no_such_helper)))'
        ):
            aser(expr, env)

        case_expr = parse_all(
            '(case "slug" "slug" (~broken :title "test") :else "index")'
        )[0]
        ctx = RequestContext()

        with pytest.raises(Exception, match="Undefined symbol"):
            asyncio.run(async_eval_slot_to_sx(case_expr, dict(env), ctx))
