"""Tests for Phase 2 IO detection — component purity analysis.

Tests both the hand-written fallback (deps.py) and the bootstrapped
sx_ref.py implementation of IO reference scanning and transitive
IO classification.
"""

import os
import pytest
from shared.sx.parser import parse_all
from shared.sx.types import Component, Macro, Symbol
from shared.sx.deps import (
    _scan_io_refs_fallback,
    _transitive_io_refs_fallback,
    _compute_all_io_refs_fallback,
    compute_all_io_refs,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def make_env(*sx_sources: str) -> dict:
    """Parse and evaluate component definitions into an env dict."""
    from shared.sx.ref.sx_ref import eval_expr as _eval, trampoline as _trampoline
    env: dict = {}
    for source in sx_sources:
        exprs = parse_all(source)
        for expr in exprs:
            _trampoline(_eval(expr, env))
    return env


IO_NAMES = {"fetch-data", "call-action", "app-url", "config", "db-query"}


# ---------------------------------------------------------------------------
# _scan_io_refs_fallback — scan single AST for IO primitives
# ---------------------------------------------------------------------------

class TestScanIoRefs:
    def test_no_io_refs(self):
        env = make_env('(defcomp ~card (&key title) (div :class "p-4" title))')
        comp = env["~card"]
        refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
        assert refs == set()

    def test_direct_io_ref(self):
        env = make_env('(defcomp ~page (&key) (div (fetch-data "posts")))')
        comp = env["~page"]
        refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
        assert refs == {"fetch-data"}

    def test_multiple_io_refs(self):
        env = make_env(
            '(defcomp ~page (&key) (div (fetch-data "x") (config "y")))'
        )
        comp = env["~page"]
        refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
        assert refs == {"fetch-data", "config"}

    def test_io_in_nested_control_flow(self):
        env = make_env(
            '(defcomp ~page (&key show) '
            '  (if show (div (app-url "/")) (span "none")))'
        )
        comp = env["~page"]
        refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
        assert refs == {"app-url"}

    def test_io_in_dict_value(self):
        env = make_env(
            '(defcomp ~wrap (&key) (div {:data (db-query "x")}))'
        )
        comp = env["~wrap"]
        refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
        assert refs == {"db-query"}

    def test_non_io_symbol_ignored(self):
        """Symbols that aren't in the IO set should not be detected."""
        env = make_env(
            '(defcomp ~card (&key) (div (str "hello") (len "world")))'
        )
        comp = env["~card"]
        refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
        assert refs == set()

    def test_component_ref_not_io(self):
        """Component references (~plans/content-addressed-components/name) should not appear as IO refs."""
        env = make_env(
            '(defcomp ~page (&key) (div (~card :title "hi")))',
            '(defcomp ~card (&key title) (div title))',
        )
        comp = env["~page"]
        refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
        assert refs == set()


# ---------------------------------------------------------------------------
# _transitive_io_refs_fallback — follow deps to find all IO refs
# ---------------------------------------------------------------------------

class TestTransitiveIoRefs:
    def test_pure_component(self):
        env = make_env(
            '(defcomp ~card (&key title) (div title))',
        )
        refs = _transitive_io_refs_fallback("~card", env, IO_NAMES)
        assert refs == set()

    def test_direct_io(self):
        env = make_env(
            '(defcomp ~page (&key) (div (fetch-data "posts")))',
        )
        refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
        assert refs == {"fetch-data"}

    def test_transitive_io_through_dep(self):
        """IO ref in a dependency should propagate to the parent."""
        env = make_env(
            '(defcomp ~page (&key) (div (~plans/environment-images/nav)))',
            '(defcomp ~plans/environment-images/nav (&key) (nav (app-url "/home")))',
        )
        refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
        assert refs == {"app-url"}

    def test_multiple_transitive_io(self):
        """IO refs from multiple deps should be unioned."""
        env = make_env(
            '(defcomp ~page (&key) (div (~header) (~footer)))',
            '(defcomp ~header (&key) (nav (app-url "/")))',
            '(defcomp ~footer (&key) (footer (config "site-name")))',
        )
        refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
        assert refs == {"app-url", "config"}

    def test_deep_transitive_io(self):
        """IO refs should propagate through multiple levels."""
        env = make_env(
            '(defcomp ~page (&key) (div (~layout)))',
            '(defcomp ~layout (&key) (div (~sidebar)))',
            '(defcomp ~sidebar (&key) (nav (fetch-data "menu")))',
        )
        refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
        assert refs == {"fetch-data"}

    def test_circular_deps_no_infinite_loop(self):
        """Circular component references should not cause infinite recursion."""
        env = make_env(
            '(defcomp ~a (&key) (div (~b) (app-url "/")))',
            '(defcomp ~b (&key) (div (~a)))',
        )
        refs = _transitive_io_refs_fallback("~a", env, IO_NAMES)
        assert refs == {"app-url"}

    def test_without_tilde_prefix(self):
        """Should auto-add ~ prefix when not provided."""
        env = make_env(
            '(defcomp ~plans/environment-images/nav (&key) (nav (app-url "/")))',
        )
        refs = _transitive_io_refs_fallback("nav", env, IO_NAMES)
        assert refs == {"app-url"}

    def test_missing_dep_component(self):
        """Referencing a component not in env should not crash."""
        env = make_env(
            '(defcomp ~page (&key) (div (~unknown) (fetch-data "x")))',
        )
        refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
        assert refs == {"fetch-data"}

    def test_macro_io_detection(self):
        """IO refs in macros should be detected too."""
        env = make_env(
            '(defmacro ~with-data (body) (list (quote div) (list (quote fetch-data) "x") body))',
            '(defcomp ~page (&key) (div (~with-data (span "hi"))))',
        )
        refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
        assert "fetch-data" in refs


# ---------------------------------------------------------------------------
# _compute_all_io_refs_fallback — batch computation
# ---------------------------------------------------------------------------

class TestComputeAllIoRefs:
    def test_sets_io_refs_on_components(self):
        env = make_env(
            '(defcomp ~page (&key) (div (~plans/environment-images/nav) (fetch-data "x")))',
            '(defcomp ~plans/environment-images/nav (&key) (nav (app-url "/")))',
            '(defcomp ~card (&key title) (div title))',
        )
        _compute_all_io_refs_fallback(env, IO_NAMES)
        assert env["~page"].io_refs == {"fetch-data", "app-url"}
        assert env["~plans/environment-images/nav"].io_refs == {"app-url"}
        assert env["~card"].io_refs == set()

    def test_pure_components_get_empty_set(self):
        env = make_env(
            '(defcomp ~a (&key) (div "hello"))',
            '(defcomp ~b (&key) (span "world"))',
        )
        _compute_all_io_refs_fallback(env, IO_NAMES)
        assert env["~a"].io_refs == set()
        assert env["~b"].io_refs == set()

    def test_transitive_io_via_compute_all(self):
        """Transitive IO refs should be cached on the parent component."""
        env = make_env(
            '(defcomp ~page (&key) (div (~child)))',
            '(defcomp ~child (&key) (div (config "key")))',
        )
        _compute_all_io_refs_fallback(env, IO_NAMES)
        assert env["~page"].io_refs == {"config"}
        assert env["~child"].io_refs == {"config"}


# ---------------------------------------------------------------------------
# Public API dispatch — compute_all_io_refs
# ---------------------------------------------------------------------------

class TestPublicApiIoRefs:
    def test_fallback_mode(self):
        """Public API should work in fallback mode (SX_USE_REF != 1)."""
        env = make_env(
            '(defcomp ~page (&key) (div (fetch-data "x")))',
            '(defcomp ~leaf (&key) (span "pure"))',
        )
        old_val = os.environ.get("SX_USE_REF")
        try:
            os.environ.pop("SX_USE_REF", None)
            compute_all_io_refs(env, IO_NAMES)
            assert env["~page"].io_refs == {"fetch-data"}
            assert env["~leaf"].io_refs == set()
        finally:
            if old_val is not None:
                os.environ["SX_USE_REF"] = old_val

    def test_ref_mode(self):
        """Public API should work with bootstrapped sx_ref.py (SX_USE_REF=1)."""
        env = make_env(
            '(defcomp ~page (&key) (div (fetch-data "x")))',
            '(defcomp ~leaf (&key) (span "pure"))',
        )
        old_val = os.environ.get("SX_USE_REF")
        try:
            os.environ["SX_USE_REF"] = "1"
            compute_all_io_refs(env, IO_NAMES)
            # sx_ref returns lists, compute_all_io_refs converts as needed
            page_refs = env["~page"].io_refs
            leaf_refs = env["~leaf"].io_refs
            # May be list or set depending on backend
            assert "fetch-data" in page_refs
            assert len(leaf_refs) == 0
        finally:
            if old_val is not None:
                os.environ["SX_USE_REF"] = old_val
            else:
                os.environ.pop("SX_USE_REF", None)


# ---------------------------------------------------------------------------
# Bootstrapped sx_ref.py IO functions — direct testing
# ---------------------------------------------------------------------------

class TestSxRefIoFunctions:
    """Test the bootstrapped sx_ref.py IO functions directly."""

    def test_scan_io_refs(self):
        from shared.sx.ref.sx_ref import scan_io_refs
        env = make_env('(defcomp ~page (&key) (div (fetch-data "x") (config "y")))')
        comp = env["~page"]
        refs = scan_io_refs(comp.body, list(IO_NAMES))
        assert set(refs) == {"fetch-data", "config"}

    def test_scan_io_refs_no_match(self):
        from shared.sx.ref.sx_ref import scan_io_refs
        env = make_env('(defcomp ~card (&key title) (div title))')
        comp = env["~card"]
        refs = scan_io_refs(comp.body, list(IO_NAMES))
        assert refs == []

    def test_transitive_io_refs(self):
        from shared.sx.ref.sx_ref import transitive_io_refs
        env = make_env(
            '(defcomp ~page (&key) (div (~plans/environment-images/nav)))',
            '(defcomp ~plans/environment-images/nav (&key) (nav (app-url "/")))',
        )
        refs = transitive_io_refs("~page", env, list(IO_NAMES))
        assert set(refs) == {"app-url"}

    def test_transitive_io_refs_pure(self):
        from shared.sx.ref.sx_ref import transitive_io_refs
        env = make_env('(defcomp ~card (&key) (div "hi"))')
        refs = transitive_io_refs("~card", env, list(IO_NAMES))
        assert refs == []

    def test_compute_all_io_refs(self):
        from shared.sx.ref.sx_ref import compute_all_io_refs as ref_compute
        env = make_env(
            '(defcomp ~page (&key) (div (~plans/environment-images/nav) (fetch-data "x")))',
            '(defcomp ~plans/environment-images/nav (&key) (nav (app-url "/")))',
            '(defcomp ~card (&key) (div "pure"))',
        )
        ref_compute(env, list(IO_NAMES))
        page_refs = env["~page"].io_refs
        nav_refs = env["~plans/environment-images/nav"].io_refs
        card_refs = env["~card"].io_refs
        assert "fetch-data" in page_refs
        assert "app-url" in page_refs
        assert "app-url" in nav_refs
        assert len(card_refs) == 0

    def test_component_pure_p(self):
        from shared.sx.ref.sx_ref import component_pure_p
        env = make_env(
            '(defcomp ~pure-card (&key) (div "hello"))',
            '(defcomp ~io-card (&key) (div (fetch-data "x")))',
        )
        io_list = list(IO_NAMES)
        assert component_pure_p("~pure-card", env, io_list) is True
        assert component_pure_p("~io-card", env, io_list) is False

    def test_component_pure_p_transitive(self):
        """A component is impure if any transitive dep uses IO."""
        from shared.sx.ref.sx_ref import component_pure_p
        env = make_env(
            '(defcomp ~page (&key) (div (~child)))',
            '(defcomp ~child (&key) (div (config "key")))',
        )
        io_list = list(IO_NAMES)
        assert component_pure_p("~page", env, io_list) is False
        assert component_pure_p("~child", env, io_list) is False


# ---------------------------------------------------------------------------
# Parity: fallback vs bootstrapped produce same results
# ---------------------------------------------------------------------------

class TestFallbackVsRefParity:
    """Ensure fallback Python and bootstrapped sx_ref.py agree."""

    def _check_parity(self, *sx_sources: str):
        """Run both implementations and verify io_refs match."""
        from shared.sx.ref.sx_ref import compute_all_io_refs as ref_compute

        # Run fallback
        env_fb = make_env(*sx_sources)
        _compute_all_io_refs_fallback(env_fb, IO_NAMES)

        # Run bootstrapped
        env_ref = make_env(*sx_sources)
        ref_compute(env_ref, list(IO_NAMES))

        # Compare all components
        for key in env_fb:
            if isinstance(env_fb[key], Component):
                fb_refs = env_fb[key].io_refs or set()
                ref_refs = env_ref[key].io_refs
                # Normalize: fallback returns set, ref returns list/set
                assert set(fb_refs) == set(ref_refs), (
                    f"Mismatch for {key}: fallback={fb_refs}, ref={set(ref_refs)}"
                )

    def test_parity_pure_components(self):
        self._check_parity(
            '(defcomp ~a (&key) (div "hello"))',
            '(defcomp ~b (&key) (span (~a)))',
        )

    def test_parity_io_components(self):
        self._check_parity(
            '(defcomp ~page (&key) (div (~header) (fetch-data "x")))',
            '(defcomp ~header (&key) (nav (app-url "/")))',
            '(defcomp ~footer (&key) (footer "static"))',
        )

    def test_parity_deep_chain(self):
        self._check_parity(
            '(defcomp ~a (&key) (div (~b)))',
            '(defcomp ~b (&key) (div (~c)))',
            '(defcomp ~c (&key) (div (config "x")))',
        )

    def test_parity_mixed(self):
        self._check_parity(
            '(defcomp ~layout (&key) (div (~plans/environment-images/nav) (~content) (~footer)))',
            '(defcomp ~plans/environment-images/nav (&key) (nav (app-url "/")))',
            '(defcomp ~content (&key) (main "pure content"))',
            '(defcomp ~footer (&key) (footer (config "name")))',
        )
