"""Tests for godotiq_trace_flow tool."""

from __future__ import annotations

import pytest

from godotiq.session import GodotIQSession
from pathlib import Path

from godotiq.parsers.gd_parser import GdFunction, GdScript, GdSignal, GdSignalConnection, GdSignalEmission
from godotiq.tools.flow import (
    FlowStep,
    FailurePoint,
    _build_signal_listeners,
    _build_trace_flow,
    _detect_failure_points,
    _find_containing_function,
    _find_local_calls,
    _find_trigger,
    _trace_from_function,
)


class TestFixtureValidation:
    """Verify the trace_flow fixture project loads correctly."""

    def test_fixture_session_loads(self, trace_flow_session: GodotIQSession) -> None:
        assert trace_flow_session._index is not None
        assert len(trace_flow_session._gd_scripts) > 0

    def test_fixture_autoloads_declared(self, trace_flow_session: GodotIQSession) -> None:
        autoloads = trace_flow_session._index.autoloads
        assert "Events" in autoloads
        assert "PrinterManager" in autoloads
        assert autoloads["Events"] == "res://events.gd"
        assert autoloads["PrinterManager"] == "res://printer_manager.gd"

    def test_fixture_events_signals(self, trace_flow_session: GodotIQSession) -> None:
        script = trace_flow_session._gd_scripts["res://events.gd"]
        sig_names = [s.name for s in script.signals]
        assert "order_accepted" in sig_names
        assert "printer_reserved" in sig_names

    def test_fixture_order_manager_function(self, trace_flow_session: GodotIQSession) -> None:
        script = trace_flow_session._gd_scripts["res://order_manager.gd"]
        func_names = [f.name for f in script.functions]
        assert "accept_order" in func_names

    def test_fixture_order_manager_emission(self, trace_flow_session: GodotIQSession) -> None:
        script = trace_flow_session._gd_scripts["res://order_manager.gd"]
        emission_names = [e.signal_name for e in script.signal_emissions]
        assert "order_accepted" in emission_names

    def test_fixture_pipeline_controller_connection(self, trace_flow_session: GodotIQSession) -> None:
        script = trace_flow_session._gd_scripts["res://pipeline_controller.gd"]
        assert len(script.signal_connections) > 0
        conn = script.signal_connections[0]
        assert "order_accepted" in conn.signal_source
        assert conn.target == "_on_order_accepted"

    def test_fixture_pipeline_controller_autoload_ref(self, trace_flow_session: GodotIQSession) -> None:
        script = trace_flow_session._gd_scripts["res://pipeline_controller.gd"]
        refs = [r for r in script.autoload_refs if r.autoload_name == "PrinterManager"]
        assert len(refs) > 0
        assert refs[0].access == "reserve_printer"

    def test_fixture_printer_manager_emission(self, trace_flow_session: GodotIQSession) -> None:
        script = trace_flow_session._gd_scripts["res://printer_manager.gd"]
        emission_names = [e.signal_name for e in script.signal_emissions]
        assert "printer_reserved" in emission_names

    def test_fixture_lambda_connection(self, trace_flow_session: GodotIQSession) -> None:
        script = trace_flow_session._gd_scripts["res://edge_lambda.gd"]
        assert len(script.signal_connections) > 0
        conn = script.signal_connections[0]
        assert conn.target.startswith("func")

    def test_fixture_property_access_ref(self, trace_flow_session: GodotIQSession) -> None:
        script = trace_flow_session._gd_scripts["res://edge_property.gd"]
        refs = [r for r in script.autoload_refs if r.autoload_name == "PrinterManager"]
        assert len(refs) > 0
        assert refs[0].access == "name"
        assert refs[0].pattern == "direct"


class TestDataclasses:
    """Verify FlowStep and FailurePoint dataclasses."""

    def test_flow_step_defaults(self) -> None:
        step = FlowStep(file="", function="", line=0, action="")
        assert step.emits == []
        assert step.calls == []

    def test_failure_point_fields(self) -> None:
        fp = FailurePoint(step=0, condition="not x", consequence="stops")
        assert fp.step == 0
        assert fp.condition == "not x"
        assert fp.consequence == "stops"


class TestSignalListenerMap:
    """Tests for _build_signal_listeners."""

    def test_listener_map_has_connection(self, trace_flow_session: GodotIQSession) -> None:
        listeners = _build_signal_listeners(trace_flow_session._gd_scripts)
        assert "order_accepted" in listeners
        files = [f for f, _ in listeners["order_accepted"]]
        assert any("pipeline_controller" in f for f in files)

    def test_listener_map_extracts_signal_name(self, trace_flow_session: GodotIQSession) -> None:
        listeners = _build_signal_listeners(trace_flow_session._gd_scripts)
        # Key should be "order_accepted", not "Events.order_accepted"
        assert "order_accepted" in listeners
        assert "Events.order_accepted" not in listeners

    def test_listener_map_extracts_from_self_ref(self) -> None:
        script = GdScript(
            signal_connections=[
                GdSignalConnection(signal_source="self.my_signal", target="_handler", line=5)
            ]
        )
        listeners = _build_signal_listeners({"res://test.gd": script})
        assert "my_signal" in listeners
        assert listeners["my_signal"] == [("res://test.gd", "_handler")]

    def test_listener_map_skips_lambda_target(self, trace_flow_session: GodotIQSession) -> None:
        listeners = _build_signal_listeners(trace_flow_session._gd_scripts)
        for sig, entries in listeners.items():
            for _, handler in entries:
                assert not handler.startswith("func"), f"Lambda target leaked: {handler}"

    def test_listener_map_skips_qualified_target(self) -> None:
        script = GdScript(
            signal_connections=[
                GdSignalConnection(signal_source="some_signal", target="node.handler", line=5)
            ]
        )
        listeners = _build_signal_listeners({"res://test.gd": script})
        assert "some_signal" not in listeners

    def test_listener_map_multiple_listeners(self) -> None:
        script_a = GdScript(
            signal_connections=[
                GdSignalConnection(signal_source="Events.order_accepted", target="_on_a", line=5)
            ]
        )
        script_b = GdScript(
            signal_connections=[
                GdSignalConnection(signal_source="Events.order_accepted", target="_on_b", line=5)
            ]
        )
        listeners = _build_signal_listeners({"res://a.gd": script_a, "res://b.gd": script_b})
        assert len(listeners["order_accepted"]) == 2


class TestTriggerFinding:
    """Tests for _find_trigger and _find_containing_function."""

    def test_find_trigger_by_function_name(self, trace_flow_session: GodotIQSession) -> None:
        results = _find_trigger("accept_order", trace_flow_session._gd_scripts)
        assert len(results) >= 1
        files = [f for f, _ in results]
        funcs = [fn for _, fn in results]
        assert any("order_manager" in f for f in files)
        assert "accept_order" in funcs

    def test_find_trigger_by_signal_name(self, trace_flow_session: GodotIQSession) -> None:
        results = _find_trigger("order_accepted", trace_flow_session._gd_scripts)
        assert len(results) >= 1
        # Should find accept_order function which emits order_accepted
        funcs = [fn for _, fn in results]
        assert "accept_order" in funcs

    def test_find_trigger_returns_empty_for_unknown(self, trace_flow_session: GodotIQSession) -> None:
        results = _find_trigger("nonexistent_thing", trace_flow_session._gd_scripts)
        assert results == []

    def test_find_trigger_multiple_entries(self) -> None:
        script_a = GdScript(functions=[GdFunction(name="reset", line=1, end_line=3)])
        script_b = GdScript(functions=[GdFunction(name="reset", line=1, end_line=3)])
        results = _find_trigger("reset", {"res://a.gd": script_a, "res://b.gd": script_b})
        assert len(results) == 2

    def test_find_containing_function_correct(self) -> None:
        script = GdScript(
            functions=[
                GdFunction(name="func_a", line=5, end_line=10),
                GdFunction(name="func_b", line=12, end_line=20),
            ]
        )
        assert _find_containing_function(script, 7).name == "func_a"
        assert _find_containing_function(script, 15).name == "func_b"

    def test_find_containing_function_returns_none(self) -> None:
        script = GdScript(
            functions=[GdFunction(name="func_a", line=5, end_line=10)]
        )
        assert _find_containing_function(script, 3) is None


class TestRecursiveTracing:
    """Tests for _trace_from_function."""

    def _make_trace_args(self, session):
        """Build standard args for _trace_from_function from a session."""
        scripts = session._gd_scripts
        listeners = _build_signal_listeners(scripts)
        autoloads = session._index.autoloads
        project_root = session._index.project_root
        return scripts, listeners, autoloads, project_root

    def test_trace_single_step(self, trace_flow_session):
        scripts, listeners, autoloads, root = self._make_trace_args(trace_flow_session)
        # events.gd has no functions that emit or call
        # Use a function that does nothing outgoing: check_events in edge_get_node.gd
        visited = set()
        steps, failures = _trace_from_function(
            "res://edge_get_node.gd", "check_events", scripts, listeners, autoloads,
            visited, 0, 10, {}, root,
        )
        assert len(steps) == 1
        assert steps[0].function == "check_events"

    def test_trace_signal_chain(self, trace_flow_session):
        scripts, listeners, autoloads, root = self._make_trace_args(trace_flow_session)
        visited = set()
        steps, _ = _trace_from_function(
            "res://order_manager.gd", "accept_order", scripts, listeners, autoloads,
            visited, 0, 10, {}, root,
        )
        files_in_flow = {s.file for s in steps}
        assert "res://order_manager.gd" in files_in_flow
        assert "res://pipeline_controller.gd" in files_in_flow
        assert "res://printer_manager.gd" in files_in_flow
        assert len(steps) >= 3

    def test_trace_autoload_call(self, trace_flow_session):
        scripts, listeners, autoloads, root = self._make_trace_args(trace_flow_session)
        visited = set()
        steps, _ = _trace_from_function(
            "res://pipeline_controller.gd", "_on_order_accepted", scripts, listeners, autoloads,
            visited, 0, 10, {}, root,
        )
        assert any(s.file == "res://printer_manager.gd" and s.function == "reserve_printer" for s in steps)

    def test_trace_depth_limit(self, trace_flow_session):
        scripts, listeners, autoloads, root = self._make_trace_args(trace_flow_session)
        visited = set()
        steps, _ = _trace_from_function(
            "res://order_manager.gd", "accept_order", scripts, listeners, autoloads,
            visited, 0, 1, {}, root,
        )
        assert len(steps) == 1
        assert steps[0].function == "accept_order"

    def test_trace_cycle_detection(self, trace_flow_session):
        scripts, listeners, autoloads, root = self._make_trace_args(trace_flow_session)
        visited = {("res://order_manager.gd", "accept_order")}
        steps, _ = _trace_from_function(
            "res://order_manager.gd", "accept_order", scripts, listeners, autoloads,
            visited, 0, 10, {}, root,
        )
        assert steps == []

    def test_trace_script_not_found(self, trace_flow_session):
        scripts, listeners, autoloads, root = self._make_trace_args(trace_flow_session)
        steps, _ = _trace_from_function(
            "res://nonexistent.gd", "foo", scripts, listeners, autoloads,
            set(), 0, 10, {}, root,
        )
        assert steps == []

    def test_trace_function_not_found(self, trace_flow_session):
        scripts, listeners, autoloads, root = self._make_trace_args(trace_flow_session)
        steps, _ = _trace_from_function(
            "res://order_manager.gd", "nonexistent_func", scripts, listeners, autoloads,
            set(), 0, 10, {}, root,
        )
        assert steps == []

    def test_trace_non_direct_autoload_ref_skipped(self, trace_flow_session):
        scripts, listeners, autoloads, root = self._make_trace_args(trace_flow_session)
        visited = set()
        steps, _ = _trace_from_function(
            "res://edge_get_node.gd", "check_events", scripts, listeners, autoloads,
            visited, 0, 10, {}, root,
        )
        # has_node/get_node refs should not produce calls
        assert len(steps) == 1
        assert steps[0].calls == []

    def test_trace_property_access_not_traced(self, trace_flow_session):
        scripts, listeners, autoloads, root = self._make_trace_args(trace_flow_session)
        visited = set()
        steps, _ = _trace_from_function(
            "res://edge_property.gd", "read_name", scripts, listeners, autoloads,
            visited, 0, 10, {}, root,
        )
        assert len(steps) == 1
        assert steps[0].calls == []

    def test_trace_shared_visited_set(self, trace_flow_session):
        scripts, listeners, autoloads, root = self._make_trace_args(trace_flow_session)
        visited = set()
        # First trace from accept_order
        steps1, _ = _trace_from_function(
            "res://order_manager.gd", "accept_order", scripts, listeners, autoloads,
            visited, 0, 10, {}, root,
        )
        # Second trace starting from pipeline_controller — should be empty since already visited
        steps2, _ = _trace_from_function(
            "res://pipeline_controller.gd", "_on_order_accepted", scripts, listeners, autoloads,
            visited, 0, 10, {}, root,
        )
        assert len(steps1) >= 3
        assert len(steps2) == 0  # all already visited


class TestFailureDetection:
    """Tests for _detect_failure_points."""

    def test_detects_if_not_return(self, trace_flow_session):
        script = trace_flow_session._gd_scripts["res://printer_manager.gd"]
        func = next(f for f in script.functions if f.name == "reserve_printer")
        root = trace_flow_session._index.project_root
        failures = _detect_failure_points("res://printer_manager.gd", func, 0, root, {})
        assert len(failures) >= 1
        assert any("has_available" in fp.condition for fp in failures)

    def test_detects_null_check(self, trace_flow_session):
        script = trace_flow_session._gd_scripts["res://printer_manager.gd"]
        func = next(f for f in script.functions if f.name == "check_target")
        root = trace_flow_session._index.project_root
        failures = _detect_failure_points("res://printer_manager.gd", func, 0, root, {})
        assert len(failures) >= 1
        assert any("target" in fp.condition and "null" in fp.condition for fp in failures)

    def test_detects_is_empty(self, trace_flow_session):
        script = trace_flow_session._gd_scripts["res://printer_manager.gd"]
        func = next(f for f in script.functions if f.name == "check_queue")
        root = trace_flow_session._index.project_root
        failures = _detect_failure_points("res://printer_manager.gd", func, 0, root, {})
        assert len(failures) >= 1
        assert any("is_empty" in fp.condition for fp in failures)

    def test_detects_instance_valid(self, trace_flow_session):
        script = trace_flow_session._gd_scripts["res://printer_manager.gd"]
        func = next(f for f in script.functions if f.name == "check_node")
        root = trace_flow_session._index.project_root
        failures = _detect_failure_points("res://printer_manager.gd", func, 0, root, {})
        assert len(failures) >= 1
        assert any("is_instance_valid" in fp.condition for fp in failures)

    def test_no_patterns_returns_empty(self, trace_flow_session):
        script = trace_flow_session._gd_scripts["res://order_manager.gd"]
        func = next(f for f in script.functions if f.name == "accept_order")
        root = trace_flow_session._index.project_root
        failures = _detect_failure_points("res://order_manager.gd", func, 0, root, {})
        assert failures == []

    def test_uses_cached_lines(self, trace_flow_session):
        script = trace_flow_session._gd_scripts["res://printer_manager.gd"]
        func = next(f for f in script.functions if f.name == "reserve_printer")
        root = trace_flow_session._index.project_root
        # Pre-populate cache with fake content (no guard patterns)
        cache = {"res://printer_manager.gd": ["extends Node", "func reserve_printer():", "\tpass"]}
        failures = _detect_failure_points("res://printer_manager.gd", func, 0, root, cache)
        # With fake content there should be no guard pattern matches
        assert failures == []


class TestBuildTraceFlow:
    """Tests for _build_trace_flow top-level entry point."""

    @pytest.mark.asyncio
    async def test_full_chain(self, trace_flow_session):
        result = await _build_trace_flow(trace_flow_session, "accept_order")
        assert result["total_steps"] >= 3
        assert result["files_involved"] >= 3
        assert len(result["flow"]) >= 3
        funcs = [s["function"] for s in result["flow"]]
        assert "accept_order" in funcs

    @pytest.mark.asyncio
    async def test_trigger_not_found(self, trace_flow_session):
        result = await _build_trace_flow(trace_flow_session, "nonexistent")
        assert result["total_steps"] == 0
        assert "error" in result

    @pytest.mark.asyncio
    async def test_empty_trigger(self, trace_flow_session):
        result = await _build_trace_flow(trace_flow_session, "")
        assert "error" in result

    @pytest.mark.asyncio
    async def test_detail_brief(self, trace_flow_session):
        result = await _build_trace_flow(trace_flow_session, "accept_order", detail="brief")
        assert "failure_points" not in result
        for step in result["flow"]:
            assert "emits" not in step
            assert "calls" not in step
            # brief keeps file, function, action
            assert "file" in step
            assert "function" in step
            assert "action" in step

    @pytest.mark.asyncio
    async def test_detail_normal(self, trace_flow_session):
        result = await _build_trace_flow(trace_flow_session, "accept_order", detail="normal")
        assert "failure_points" in result
        for step in result["flow"]:
            assert "emits" in step
            assert "calls" in step

    @pytest.mark.asyncio
    async def test_depth_parameter(self, trace_flow_session):
        result = await _build_trace_flow(trace_flow_session, "accept_order", depth=1)
        # depth=1 means only the first function, no recursion
        assert result["total_steps"] == 1

    @pytest.mark.asyncio
    async def test_signal_trigger(self, trace_flow_session):
        result = await _build_trace_flow(trace_flow_session, "order_accepted")
        assert result["total_steps"] >= 1
        # Should find emitter function
        funcs = [s["function"] for s in result["flow"]]
        assert "accept_order" in funcs

    @pytest.mark.asyncio
    async def test_failure_points_included(self, trace_flow_session):
        result = await _build_trace_flow(trace_flow_session, "accept_order")
        # printer_manager has guard patterns - should appear in failure_points
        assert len(result["failure_points"]) >= 1


class TestLocalCallTracking:
    """Tests for intra-file (local) function call tracking."""

    def _make_trace_args(self, session):
        scripts = session._gd_scripts
        listeners = _build_signal_listeners(scripts)
        autoloads = session._index.autoloads
        project_root = session._index.project_root
        return scripts, listeners, autoloads, project_root

    def test_follows_local_function(self, trace_flow_session):
        """accept_item() calls _process_item() in same file -> trace includes _process_item."""
        scripts, listeners, autoloads, root = self._make_trace_args(trace_flow_session)
        script = scripts["res://chained_manager.gd"]
        func = next(f for f in script.functions if f.name == "accept_item")
        local = _find_local_calls(script, func, "res://chained_manager.gd", root, {})
        assert "_process_item" in local

    def test_local_call_recursive_chain(self, trace_flow_session):
        """accept_item -> _process_item -> _create_task: all three appear in trace."""
        scripts, listeners, autoloads, root = self._make_trace_args(trace_flow_session)
        visited = set()
        steps, _ = _trace_from_function(
            "res://chained_manager.gd", "accept_item", scripts, listeners, autoloads,
            visited, 0, 10, {}, root,
        )
        func_names = [s.function for s in steps]
        assert "accept_item" in func_names
        assert "_process_item" in func_names
        assert "_create_task" in func_names

    def test_local_plus_signal(self, trace_flow_session):
        """Chain reaches _create_task which emits task_completed -> both local calls and emission."""
        scripts, listeners, autoloads, root = self._make_trace_args(trace_flow_session)
        visited = set()
        steps, _ = _trace_from_function(
            "res://chained_manager.gd", "accept_item", scripts, listeners, autoloads,
            visited, 0, 10, {}, root,
        )
        # _create_task should emit task_completed
        create_step = next(s for s in steps if s.function == "_create_task")
        assert "task_completed" in create_step.emits

    def test_ignores_builtin_calls(self, trace_flow_session):
        """Built-in calls like print() are not in the script's function list, so not followed."""
        script = GdScript(
            functions=[
                GdFunction(name="do_work", line=1, end_line=3),
            ]
        )
        # Simulate source with print() call
        cache = {"res://test_builtin.gd": [
            "func do_work():",
            "\tprint('hello')",
            "\tpush_error('fail')",
        ]}
        root = trace_flow_session._index.project_root
        local = _find_local_calls(script, script.functions[0], "res://test_builtin.gd", root, cache)
        assert "print" not in local
        assert "push_error" not in local

    def test_cycle_in_local_calls(self, trace_flow_session):
        """A() -> B() -> A() doesn't infinite loop thanks to visited set."""
        script = GdScript(
            functions=[
                GdFunction(name="func_a", line=1, end_line=2),
                GdFunction(name="func_b", line=3, end_line=4),
            ]
        )
        cache = {"res://cycle.gd": [
            "func func_a():",
            "\tfunc_b()",
            "func func_b():",
            "\tfunc_a()",
        ]}
        scripts = {"res://cycle.gd": script}
        listeners = _build_signal_listeners(scripts)
        root = trace_flow_session._index.project_root
        visited = set()
        steps, _ = _trace_from_function(
            "res://cycle.gd", "func_a", scripts, listeners, {},
            visited, 0, 10, cache, root,
        )
        func_names = [s.function for s in steps]
        assert "func_a" in func_names
        assert "func_b" in func_names
        # Each appears exactly once (cycle prevented)
        assert func_names.count("func_a") == 1
        assert func_names.count("func_b") == 1


class TestServerRegistration:
    """Verify the tool is registered on the MCP server."""

    def test_trace_flow_registered(self):
        from godotiq.server import mcp
        tool_names = [t.name for t in mcp._tool_manager.list_tools()]
        assert "godotiq_trace_flow" in tool_names
