"""Tests for compact tool pipeline contracts.

Validates that the 15 compact tools have correct schemas, envelope formats,
project gates, workflow chaining (next_actions), circuit breaker,
tool path unification, and end-to-end pipeline chaining.
"""
import asyncio
import json
import re
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from autodebug.agent.tools import _tool_envelope, _no_project_envelope, _looks_like_error


# ---------------------------------------------------------------------------
# _tool_envelope format tests
# ---------------------------------------------------------------------------


class TestToolEnvelope:
    """Verify _tool_envelope produces valid structured output."""

    def test_ok_envelope_has_required_fields(self):
        raw = _tool_envelope("ok", "Test passed.", {"key": "val"}, ["Do next thing."], 0.95, "test_tool")
        payload = json.loads(raw)
        assert payload["status"] == "ok"
        assert payload["summary"] == "Test passed."
        assert payload["data"]["key"] == "val"
        assert payload["confidence"] == 0.95
        assert "Do next thing." in payload["next_actions"]

    def test_error_envelope(self):
        raw = _tool_envelope("error", "Something broke.", {}, ["Fix it."], 0.0, "test_tool")
        payload = json.loads(raw)
        assert payload["status"] == "error"
        assert payload["confidence"] == 0.0

    def test_partial_envelope(self):
        raw = _tool_envelope("partial", "Partial.", {}, [], 0.5, "test_tool")
        payload = json.loads(raw)
        assert payload["status"] == "partial"

    def test_invalid_status_normalized_to_error(self):
        raw = _tool_envelope("invalid", "Bad status.", {}, [], 1.0, "test_tool")
        payload = json.loads(raw)
        assert payload["status"] == "error"

    def test_confidence_clamped(self):
        raw = _tool_envelope("ok", "High.", {}, [], 5.0, "test_tool")
        payload = json.loads(raw)
        assert payload["confidence"] == 1.0

        raw2 = _tool_envelope("ok", "Low.", {}, [], -1.0, "test_tool")
        payload2 = json.loads(raw2)
        assert payload2["confidence"] == 0.0

    def test_empty_next_actions_omitted(self):
        raw = _tool_envelope("ok", "Done.", {}, [], 1.0, "test_tool")
        payload = json.loads(raw)
        assert "next_actions" not in payload

    def test_next_actions_truncated(self):
        long_action = "x" * 200
        raw = _tool_envelope("ok", "Done.", {}, [long_action], 1.0, "test_tool")
        payload = json.loads(raw)
        assert len(payload["next_actions"][0]) <= 120


# ---------------------------------------------------------------------------
# _no_project_envelope tests
# ---------------------------------------------------------------------------


class TestNoProjectEnvelope:
    """Verify _no_project_envelope returns correct structured error."""

    def test_basic_no_project(self):
        raw = _no_project_envelope("incident_overview")
        payload = json.loads(raw)
        assert payload["status"] == "error"
        assert "project" in payload["summary"].lower()
        assert payload["data"]["missing"] == "project_id"
        assert any("hikaflow init" in a for a in payload["next_actions"])

    def test_with_local_alternatives(self):
        raw = _no_project_envelope("incident_overview", ["codebase_scan", "run_tests"])
        payload = json.loads(raw)
        assert any("codebase_scan" in a for a in payload["next_actions"])

    def test_each_cloud_tool_has_envelope(self):
        """Every cloud tool name should produce a valid envelope."""
        cloud_tools = [
            "incident_overview",
            "failure_similarity_search",
            "generate_fix_candidates",
            "validate_fix_candidate",
            "create_fix_pr",
            "project_health_snapshot",
        ]
        for tool_name in cloud_tools:
            raw = _no_project_envelope(tool_name)
            payload = json.loads(raw)
            assert payload["status"] == "error", f"{tool_name} envelope status should be error"
            assert payload["confidence"] == 0.0, f"{tool_name} envelope confidence should be 0"


# ---------------------------------------------------------------------------
# _looks_like_error tests
# ---------------------------------------------------------------------------


class TestLooksLikeError:
    def test_error_prefix(self):
        assert _looks_like_error("Error: something broke") is True

    def test_confirmation_required(self):
        assert _looks_like_error("Confirmation required to create PR") is True

    def test_normal_text(self):
        assert _looks_like_error("All tests passed.") is False

    def test_empty(self):
        assert _looks_like_error("") is False

    def test_none(self):
        assert _looks_like_error(None) is False


# ---------------------------------------------------------------------------
# Compact profile membership
# ---------------------------------------------------------------------------


class TestCompactProfileMembership:
    """Ensure _COMPACT_PROFILE_TOOLS has the expected tools."""

    def test_expected_tool_count(self):
        from autodebug.agent.sdk_bridge import _COMPACT_PROFILE_TOOLS

        # 12 original + 3 split tools (memory_blocks, reflections, skills_manager)
        assert len(_COMPACT_PROFILE_TOOLS) == 15

    def test_expected_tool_names(self):
        from autodebug.agent.sdk_bridge import _COMPACT_PROFILE_TOOLS

        expected = {
            "incident_overview",
            "failure_similarity_search",
            "generate_fix_candidates",
            "validate_fix_candidate",
            "create_fix_pr",
            "quarantine_control",
            "codebase_scan",
            "review_files",
            "run_tests",
            "update_findings",
            "knowledge_memory",
            "memory_blocks",
            "reflections",
            "skills_manager",
            "project_health_snapshot",
        }
        assert _COMPACT_PROFILE_TOOLS == expected

    def test_split_tools_registered(self):
        """Verify the 3 new split tools are actually registered."""
        from autodebug.agent.tools import register_hikaflow_tools

        class FakeAgent:
            def __init__(self):
                self._function_tools = {}
            def tool(self, func):
                self._function_tools[func.__name__] = func
                return func

        fake = FakeAgent()
        register_hikaflow_tools(fake)
        for name in ("memory_blocks", "reflections", "skills_manager"):
            assert name in fake._function_tools, f"{name} not registered"


# ---------------------------------------------------------------------------
# Literal type schema validation
# ---------------------------------------------------------------------------


class TestLiteralTypeSchemas:
    """Verify that enum params use Literal types which propagate to JSON schema."""

    @pytest.fixture(scope="class")
    def tool_functions(self):
        """Get the compact tool functions from the registry."""
        import typing
        from autodebug.agent.tools import register_hikaflow_tools

        class FakeAgent:
            def __init__(self):
                self._function_tools = {}
            def tool(self, func):
                self._function_tools[func.__name__] = func
                return func

        fake = FakeAgent()
        register_hikaflow_tools(fake)
        return fake._function_tools

    def _get_literal_values(self, func, param_name):
        """Extract Literal values from a function's type annotations."""
        import typing
        hints = typing.get_type_hints(func)
        ann = hints.get(param_name)
        if ann is None:
            return None
        origin = getattr(ann, "__origin__", None)
        if origin is typing.Literal:
            return set(ann.__args__)
        # Check for Optional[Literal[...]]
        if origin is typing.Union:
            for arg in ann.__args__:
                if getattr(arg, "__origin__", None) is typing.Literal:
                    return set(arg.__args__)
        return None

    def test_incident_overview_environment(self, tool_functions):
        vals = self._get_literal_values(tool_functions["incident_overview"], "environment")
        assert vals == {"production", "staging", "canary"}

    def test_incident_overview_similarity_focus(self, tool_functions):
        vals = self._get_literal_values(tool_functions["incident_overview"], "similarity_focus")
        assert vals == {"stack", "transcript", "regression"}

    def test_failure_similarity_search_category(self, tool_functions):
        vals = self._get_literal_values(tool_functions["failure_similarity_search"], "category")
        assert vals == {"crash", "flake", "performance", "security"}

    def test_generate_fix_candidates_category(self, tool_functions):
        vals = self._get_literal_values(tool_functions["generate_fix_candidates"], "category")
        assert vals == {"logic", "config", "infra", "performance", "security"}

    def test_validate_fix_candidate_contract_scope(self, tool_functions):
        vals = self._get_literal_values(tool_functions["validate_fix_candidate"], "contract_scope")
        assert vals == {"unit", "integration", "full"}

    def test_validate_fix_candidate_perf_scope(self, tool_functions):
        vals = self._get_literal_values(tool_functions["validate_fix_candidate"], "perf_scope")
        assert vals == {"baseline", "regression"}

    def test_quarantine_control_scope(self, tool_functions):
        vals = self._get_literal_values(tool_functions["quarantine_control"], "scope")
        assert vals == {"local", "server"}

    def test_quarantine_control_action(self, tool_functions):
        vals = self._get_literal_values(tool_functions["quarantine_control"], "action")
        assert vals == {"list", "add", "remove", "deselect"}

    def test_codebase_scan_scope(self, tool_functions):
        vals = self._get_literal_values(tool_functions["codebase_scan"], "scope")
        assert vals == {"incremental", "full"}

    def test_codebase_scan_mode(self, tool_functions):
        vals = self._get_literal_values(tool_functions["codebase_scan"], "mode")
        assert vals == {"full", "semgrep-only", "security-only", "taint-only", "hygiene-only", "api-only"}

    def test_knowledge_memory_action(self, tool_functions):
        vals = self._get_literal_values(tool_functions["knowledge_memory"], "action")
        expected = {
            "list_blocks", "read_block", "edit_block",
            "list_findings", "upsert_finding", "close_finding",
            "search_reflections", "save_reflection",
            "skill_query", "skill_create", "skill_details", "skill_outcome",
        }
        assert vals == expected

    def test_project_health_snapshot_detail_level(self, tool_functions):
        vals = self._get_literal_values(tool_functions["project_health_snapshot"], "detail_level")
        assert vals == {"summary", "detailed"}

    # Split tools
    def test_memory_blocks_action(self, tool_functions):
        vals = self._get_literal_values(tool_functions["memory_blocks"], "action")
        assert vals == {"list", "read", "edit"}

    def test_memory_blocks_mode(self, tool_functions):
        vals = self._get_literal_values(tool_functions["memory_blocks"], "mode")
        assert vals == {"insert", "rethink", "replace"}

    def test_reflections_action(self, tool_functions):
        vals = self._get_literal_values(tool_functions["reflections"], "action")
        assert vals == {"search", "save"}

    def test_skills_manager_action(self, tool_functions):
        vals = self._get_literal_values(tool_functions["skills_manager"], "action")
        assert vals == {"query", "create", "details", "outcome"}


# ---------------------------------------------------------------------------
# api_base defaults to production
# ---------------------------------------------------------------------------


class TestApiBaseDefaults:
    """Verify api_base resolution prioritizes production over localhost."""

    def test_default_is_production(self, monkeypatch):
        monkeypatch.delenv("HIKAFLOW_ENDPOINT", raising=False)
        monkeypatch.delenv("AUTODEBUG_ENDPOINT", raising=False)
        import autodebug.config as cfg
        monkeypatch.setattr(cfg, "load_credentials", lambda: {})
        assert cfg.get_api_base() == "https://get.hikaflow.com"

    def test_env_override_takes_priority(self, monkeypatch):
        monkeypatch.setenv("HIKAFLOW_ENDPOINT", "https://custom.api.com")
        import autodebug.config as cfg
        monkeypatch.setattr(cfg, "load_credentials", lambda: {"api_base": "http://localhost:8000"})
        assert cfg.get_api_base() == "https://custom.api.com"

    def test_localhost_creds_fall_back_to_production(self, monkeypatch):
        monkeypatch.delenv("HIKAFLOW_ENDPOINT", raising=False)
        monkeypatch.delenv("AUTODEBUG_ENDPOINT", raising=False)
        import autodebug.config as cfg
        monkeypatch.setattr(cfg, "load_credentials", lambda: {"api_base": "http://localhost:8000"})
        assert cfg.get_api_base() == "https://get.hikaflow.com"

    def test_valid_stored_base_used(self, monkeypatch):
        monkeypatch.delenv("HIKAFLOW_ENDPOINT", raising=False)
        monkeypatch.delenv("AUTODEBUG_ENDPOINT", raising=False)
        import autodebug.config as cfg
        monkeypatch.setattr(cfg, "load_credentials", lambda: {"api_base": "https://staging.hikaflow.com"})
        assert cfg.get_api_base() == "https://staging.hikaflow.com"


# ---------------------------------------------------------------------------
# save_memory removed from built-in tools
# ---------------------------------------------------------------------------


class TestSaveMemoryRemoved:
    """Verify save_memory is no longer in LangGraph built-in tool names."""

    def test_save_memory_not_in_builtin_names(self):
        # Read the source to verify save_memory is not in _BUILTIN_NAMES
        import ast
        with open("autodebug/agent/langgraph_runner.py") as f:
            source = f.read()
        # Find _BUILTIN_NAMES frozenset
        assert '"save_memory"' not in source.split("_BUILTIN_NAMES")[1].split("}")[0]


# ---------------------------------------------------------------------------
# Tool path unification: run_tests uses compact version
# ---------------------------------------------------------------------------


class TestToolPathUnification:
    """Verify run_tests is not shadowed by a built-in duplicate."""

    def test_run_tests_not_in_builtin_names(self):
        """run_tests should not be in _BUILTIN_NAMES — compact profile version wins."""
        with open("autodebug/agent/langgraph_runner.py") as f:
            source = f.read()
        builtin_block = source.split("_BUILTIN_NAMES")[1].split("}")[0]
        assert '"run_tests"' not in builtin_block

    def test_no_builtin_run_tests_function(self):
        """_make_debugging_tools should not define a _run_tests function."""
        with open("autodebug/agent/langgraph_runner.py") as f:
            source = f.read()
        # The old built-in was "async def _run_tests(" inside _make_debugging_tools
        assert "async def _run_tests(" not in source

    def test_langgraph_run_tests_is_compact_version(self):
        """The run_tests in build_langgraph_tools should be the compact version."""
        from autodebug.agent.langgraph_runner import build_langgraph_tools
        from autodebug.agent.core import HikaflowDeps
        import os

        deps = HikaflowDeps(
            api_base="https://get.hikaflow.com",
            token="test",
            project_path=os.getcwd(),
        )
        deps.headless = True
        tools = build_langgraph_tools(deps)
        names = [t.name for t in tools]

        # run_tests should exist exactly once
        assert names.count("run_tests") == 1

        # Its description should match the compact version (mentions WARNING about tests/)
        for t in tools:
            if t.name == "run_tests":
                assert "pytest" in t.description.lower() or "run" in t.description.lower()
                break


# ---------------------------------------------------------------------------
# Circuit breaker for _agent_api_request
# ---------------------------------------------------------------------------


class TestCircuitBreaker:
    """Verify session-scoped circuit breaker in _agent_api_request."""

    def test_circuit_breaker_variables_exist(self):
        """Circuit breaker state variables should be defined in tools.py."""
        with open("autodebug/agent/tools.py") as f:
            source = f.read()
        assert "_circuit_failures" in source
        assert "_circuit_open" in source
        assert "_CIRCUIT_THRESHOLD" in source

    def test_circuit_host_extracts_netloc(self):
        """_circuit_host should extract the hostname from a URL."""
        from autodebug.agent.tools import register_hikaflow_tools

        class FakeAgent:
            def __init__(self):
                self._function_tools = {}
            def tool(self, func):
                self._function_tools[func.__name__] = func
                return func

        # We can't easily access closure vars, so verify via source inspection
        with open("autodebug/agent/tools.py") as f:
            source = f.read()
        assert "urlparse" in source
        assert ".netloc" in source

    def test_circuit_threshold_is_reasonable(self):
        """Threshold should be small but not 1 (transient failures)."""
        with open("autodebug/agent/tools.py") as f:
            source = f.read()
        # Find _CIRCUIT_THRESHOLD = N
        import re
        match = re.search(r"_CIRCUIT_THRESHOLD\s*=\s*(\d+)", source)
        assert match is not None
        threshold = int(match.group(1))
        assert 2 <= threshold <= 5, f"Threshold {threshold} should be 2-5"

    def test_circuit_breaker_in_api_request(self):
        """_agent_api_request should check circuit state before making requests."""
        with open("autodebug/agent/tools.py") as f:
            source = f.read()
        # Extract the full _agent_api_request function body
        start = source.find("async def _agent_api_request(")
        assert start != -1, "_agent_api_request not found in tools.py"
        rest = source[start + 1:]
        end_markers = ["\n    async def ", "\n    def ", "\n    @agent.tool"]
        end = len(rest)
        for marker in end_markers:
            idx = rest.find(marker)
            if idx != -1 and idx < end:
                end = idx
        api_req_section = rest[:end]
        assert "_circuit_open" in api_req_section
        assert "_circuit_failures" in api_req_section

    def test_circuit_opens_on_threshold(self):
        """Source should open circuit when failures reach threshold."""
        with open("autodebug/agent/tools.py") as f:
            source = f.read()
        assert "_circuit_open.add(" in source

    def test_circuit_resets_on_success(self):
        """Source should reset failure count on successful request."""
        with open("autodebug/agent/tools.py") as f:
            source = f.read()
        assert "_circuit_failures.pop(" in source


# ---------------------------------------------------------------------------
# Pipeline integration: end-to-end chain through 5 production compact tools
# ---------------------------------------------------------------------------


# Canned API responses for each endpoint the legacy tools hit
_FAKE_ERRORS_RESPONSE = {
    "errors": [
        {
            "error_id": "err-abc-123",
            "exception_type": "KeyError",
            "exception_message": "'user_id' missing from session payload",
            "breadcrumb_count": 42,
            "created_at": "2026-02-23T10:00:00Z",
        }
    ]
}

_FAKE_ERROR_DETAILS = {
    "exception": {"type": "KeyError", "message": "'user_id' missing from session payload"},
    "stack_trace": [
        {"filename": "api/sessions.py", "lineno": 88, "function": "get_session"},
    ],
    "io_summary": {"total": 5, "request_count": 2, "response_count": 3},
}

_FAKE_SIMILAR_FIXES = {
    "fixes": [
        {"fix_id": "fix-old-1", "similarity": 0.82, "patch": "add user_id guard"},
    ]
}

_FAKE_HYPOTHESIS = {
    "hypothesis": "The session payload is missing 'user_id' when auth middleware skips validation.",
    "confidence": 0.7,
}

_FAKE_VALIDATE = {
    "validation_status": "passed",
    "replay_passed": True,
    "replays_run": 10,
    "replays_passed": 10,
}

_FAKE_PR_CREATED = {
    "pr_url": "https://github.com/org/repo/pull/99",
    "pr_number": 99,
    "branch_name": "fix-err-abc-123",
    "deduped": False,
}


def _mock_httpx_response(status_code: int, json_data: dict):
    """Build a mock httpx.Response."""
    resp = MagicMock()
    resp.status_code = status_code
    resp.json.return_value = json_data
    resp.text = json.dumps(json_data)
    resp.headers = {}
    resp.raise_for_status = MagicMock()
    return resp


class _FakeHttpxClient:
    """Async context manager that returns canned responses based on URL path."""

    def __init__(self):
        self._routes = {
            "/v1/prod-errors": _FAKE_ERRORS_RESPONSE,
            "/v1/prod-errors/err-abc-123": _FAKE_ERROR_DETAILS,
            "/v1/search-similar": _FAKE_SIMILAR_FIXES,
            "/v1/hypothesis": _FAKE_HYPOTHESIS,
            "/v1/validate": _FAKE_VALIDATE,
            "/v1/contract-drift": {"drift": []},
            "/v1/perf-baselines": {"status": "ok"},
            "/v1/mutation": {"status": "ok"},
        }

    async def __aenter__(self):
        return self

    async def __aexit__(self, *args):
        pass

    async def request(self, method, url, **kwargs):
        for path_prefix, data in self._routes.items():
            if path_prefix in url:
                return _mock_httpx_response(200, data)
        # Default: return empty success for any other endpoint
        return _mock_httpx_response(200, {})

    async def get(self, url, **kwargs):
        return await self.request("GET", url, **kwargs)

    async def post(self, url, **kwargs):
        return await self.request("POST", url, **kwargs)


def _make_mock_ctx(tmp_path):
    """Create a mock RunContext with deps for testing compact tools."""
    deps = SimpleNamespace(
        api_base="https://test.hikaflow.com",
        token="test-token",
        project_path=str(tmp_path),
        environment="production",
        github_token="ghp_test",
        model_id="test",
        worker_model="test",
        orchestrator_model="test",
        _tool_cache={},
    )
    # Add cache methods that _agent_api_request uses
    deps.get_cached_tool_result = lambda tool, params: None
    deps.cache_tool_result = lambda tool, params, result: None
    ctx = SimpleNamespace(deps=deps)
    return ctx


class TestPipelineIntegration:
    """End-to-end test: chain outputs from one compact tool as inputs to the next.

    Validates:
    1. Each tool returns a valid envelope with status/summary/data/confidence.
    2. next_actions mentions the correct next tool in the pipeline.
    3. Output data contains fields that the next tool's next_actions reference.
    4. Parameter names in next_actions match the next tool's actual signature.
    """

    @pytest.fixture(scope="class")
    def tool_functions(self):
        """Register all compact tools and return the function dict."""
        from autodebug.agent.tools import register_hikaflow_tools

        class FakeAgent:
            def __init__(self):
                self._function_tools = {}
            def tool(self, func):
                self._function_tools[func.__name__] = func
                return func

        fake = FakeAgent()
        register_hikaflow_tools(fake)
        return fake._function_tools

    @pytest.fixture(scope="class")
    def tool_param_names(self, tool_functions):
        """Extract parameter names for each compact tool."""
        import inspect
        result = {}
        for name, func in tool_functions.items():
            sig = inspect.signature(func)
            # Skip 'ctx' parameter
            result[name] = [
                p for p in sig.parameters.keys() if p != "ctx"
            ]
        return result

    def _parse_envelope(self, raw: str) -> dict:
        """Parse and validate a tool envelope."""
        payload = json.loads(raw)
        assert "status" in payload, "Missing status"
        assert "summary" in payload, "Missing summary"
        assert "data" in payload, "Missing data"
        assert "confidence" in payload, "Missing confidence"
        assert payload["status"] in {"ok", "error", "partial"}, f"Bad status: {payload['status']}"
        return payload

    def _extract_next_tool(self, next_actions: list[str]) -> str | None:
        """Extract the tool name from next_actions like 'Call foo_bar(...)'."""
        for action in next_actions:
            match = re.search(r"Call (\w+)\(", action)
            if match:
                return match.group(1)
        return None

    def _extract_next_params(self, next_actions: list[str]) -> list[str]:
        """Extract parameter names from next_actions like 'Call foo(bar=..., baz=...)'."""
        params = []
        for action in next_actions:
            params.extend(re.findall(r"(\w+)=", action))
        return params

    @pytest.mark.asyncio
    async def test_step1_incident_overview(self, tool_functions, tool_param_names, tmp_path):
        """incident_overview returns envelope with error_id and chains to failure_similarity_search."""
        ctx = _make_mock_ctx(tmp_path)
        fn = tool_functions["incident_overview"]

        with patch("autodebug.config.load_credentials", return_value={"project_id": "proj-1"}), \
             patch("httpx.AsyncClient", return_value=_FakeHttpxClient()):
            raw = await fn(ctx, incident_id="err-abc-123", environment="production")

        env = self._parse_envelope(raw)
        assert env["status"] in {"ok", "partial"}
        assert env["data"]["incident_snapshot"]["incident_id"] == "err-abc-123"

        # next_actions should chain to failure_similarity_search
        next_actions = env.get("next_actions", [])
        next_tool = self._extract_next_tool(next_actions)
        assert next_tool == "failure_similarity_search", f"Expected chain to failure_similarity_search, got {next_tool}"

        # Parameter names in next_actions should match failure_similarity_search signature
        next_params = self._extract_next_params(next_actions)
        valid_params = set(tool_param_names["failure_similarity_search"])
        for p in next_params:
            assert p in valid_params, f"next_actions param '{p}' not in failure_similarity_search({valid_params})"

    @pytest.mark.asyncio
    async def test_step2_failure_similarity_search(self, tool_functions, tool_param_names, tmp_path):
        """failure_similarity_search returns valid envelope and chains correctly."""
        ctx = _make_mock_ctx(tmp_path)
        fn = tool_functions["failure_similarity_search"]

        with patch("autodebug.config.load_credentials", return_value={"project_id": "proj-1"}), \
             patch("httpx.AsyncClient", return_value=_FakeHttpxClient()):
            raw = await fn(ctx, error_id="err-abc-123", category="crash")

        env = self._parse_envelope(raw)
        # Legacy module may not be available — status can be error/partial/ok
        assert env["data"]["context"]["error_id"] == "err-abc-123"

        # next_actions should reference the correct next tools regardless of status
        next_actions = env.get("next_actions", [])
        all_text = " ".join(next_actions)
        # On success: chains to generate_fix_candidates; on error: chains to incident_overview
        assert "generate_fix_candidates" in all_text or "incident_overview" in all_text, \
            f"next_actions should chain to generate_fix_candidates or incident_overview: {next_actions}"

        # If chaining to generate_fix_candidates, verify param names
        next_tool = self._extract_next_tool(next_actions)
        if next_tool == "generate_fix_candidates":
            next_params = self._extract_next_params(next_actions)
            valid_params = set(tool_param_names["generate_fix_candidates"])
            for p in next_params:
                assert p in valid_params, f"next_actions param '{p}' not in generate_fix_candidates({valid_params})"

    @pytest.mark.asyncio
    async def test_step3_generate_fix_candidates(self, tool_functions, tool_param_names, tmp_path):
        """generate_fix_candidates returns valid envelope and chains correctly."""
        ctx = _make_mock_ctx(tmp_path)
        fn = tool_functions["generate_fix_candidates"]

        with patch("autodebug.config.load_credentials", return_value={"project_id": "proj-1"}), \
             patch("httpx.AsyncClient", return_value=_FakeHttpxClient()):
            raw = await fn(ctx, error_id="err-abc-123", category="logic")

        env = self._parse_envelope(raw)
        # Legacy modules may not be available — verify envelope structure
        assert env["data"]["context"]["error_id"] == "err-abc-123"

        next_actions = env.get("next_actions", [])
        all_text = " ".join(next_actions)
        # On success: chains to validate_fix_candidate; on error: chains to failure_similarity_search
        assert "validate_fix_candidate" in all_text or "failure_similarity_search" in all_text, \
            f"next_actions should chain to validate_fix_candidate or failure_similarity_search: {next_actions}"

        # If chaining to validate_fix_candidate, verify param names
        next_tool = self._extract_next_tool(next_actions)
        if next_tool == "validate_fix_candidate":
            next_params = self._extract_next_params(next_actions)
            valid_params = set(tool_param_names["validate_fix_candidate"])
            for p in next_params:
                assert p in valid_params, f"next_actions param '{p}' not in validate_fix_candidate({valid_params})"

    @pytest.mark.asyncio
    async def test_step4_validate_fix_candidate(self, tool_functions, tool_param_names, tmp_path):
        """validate_fix_candidate returns valid envelope and chains correctly."""
        ctx = _make_mock_ctx(tmp_path)
        fn = tool_functions["validate_fix_candidate"]

        with patch("autodebug.config.load_credentials", return_value={"project_id": "proj-1"}), \
             patch("httpx.AsyncClient", return_value=_FakeHttpxClient()):
            raw = await fn(ctx, error_id="err-abc-123", patch="fix: add user_id guard")

        env = self._parse_envelope(raw)
        # Legacy validation module may not be available

        next_actions = env.get("next_actions", [])
        all_text = " ".join(next_actions)
        # On success: chains to create_fix_pr; on error: chains to generate_fix_candidates
        assert "create_fix_pr" in all_text or "generate_fix_candidates" in all_text, \
            f"Expected create_fix_pr or generate_fix_candidates in next_actions: {next_actions}"

    @pytest.mark.asyncio
    async def test_step5_create_fix_pr_requires_confirm(self, tool_functions, tmp_path):
        """create_fix_pr without confirm=true returns error asking for confirmation."""
        ctx = _make_mock_ctx(tmp_path)
        fn = tool_functions["create_fix_pr"]

        with patch("autodebug.config.load_credentials", return_value={"project_id": "proj-1"}), \
             patch("httpx.AsyncClient", return_value=_FakeHttpxClient()):
            raw = await fn(ctx, fix_id="fix-old-1", confirm=False)

        env = self._parse_envelope(raw)
        assert env["status"] == "error"
        assert env["data"].get("requires_confirmation") is True

        next_actions = env.get("next_actions", [])
        all_text = " ".join(next_actions)
        assert "confirm=true" in all_text

    @pytest.mark.asyncio
    async def test_step5_create_fix_pr_with_confirm(self, tool_functions, tmp_path):
        """create_fix_pr with confirm=true creates the PR."""
        ctx = _make_mock_ctx(tmp_path)
        fn = tool_functions["create_fix_pr"]

        # For create_fix_pr, the POST to /v1/fixes/{id}/create-pr needs to return PR data
        class _PrHttpxClient(_FakeHttpxClient):
            async def request(self, method, url, **kwargs):
                if "create-pr" in url:
                    return _mock_httpx_response(200, _FAKE_PR_CREATED)
                return await super().request(method, url, **kwargs)

        with patch("autodebug.config.load_credentials", return_value={"project_id": "proj-1"}), \
             patch("httpx.AsyncClient", return_value=_PrHttpxClient()):
            raw = await fn(
                ctx,
                fix_id="fix-old-1",
                repo_owner="org",
                repo_name="repo",
                confirm=True,
            )

        env = self._parse_envelope(raw)
        assert env["status"] in {"ok", "partial"}
        assert env["data"]["pr_url"] == "https://github.com/org/repo/pull/99"
        assert env["data"]["pr_number"] == 99

    @pytest.mark.asyncio
    async def test_full_chain_error_id_propagates(self, tool_functions, tmp_path):
        """Verify error_id flows through output data at every pipeline step."""
        ctx = _make_mock_ctx(tmp_path)
        error_id = "err-abc-123"

        with patch("autodebug.config.load_credentials", return_value={"project_id": "proj-1"}), \
             patch("httpx.AsyncClient", return_value=_FakeHttpxClient()):

            # Step 1: incident_overview preserves error_id in data
            raw1 = await tool_functions["incident_overview"](ctx, incident_id=error_id)
            env1 = self._parse_envelope(raw1)
            assert env1["data"]["incident_snapshot"]["incident_id"] == error_id

            # next_actions should reference the error_id
            next1_text = " ".join(env1.get("next_actions", []))
            assert error_id in next1_text, "error_id should appear in incident_overview next_actions"

            # Step 2: failure_similarity_search preserves error_id in context
            raw2 = await tool_functions["failure_similarity_search"](ctx, error_id=error_id)
            env2 = self._parse_envelope(raw2)
            assert env2["data"]["context"]["error_id"] == error_id

            # Step 3: generate_fix_candidates preserves error_id in context
            raw3 = await tool_functions["generate_fix_candidates"](ctx, error_id=error_id)
            env3 = self._parse_envelope(raw3)
            assert env3["data"]["context"]["error_id"] == error_id

    @pytest.mark.asyncio
    async def test_no_project_breaks_chain_at_every_step(self, tool_functions, tmp_path):
        """Every production pipeline tool should return no-project envelope without a project."""
        ctx = _make_mock_ctx(tmp_path)
        pipeline_tools = [
            "incident_overview",
            "failure_similarity_search",
            "generate_fix_candidates",
            "validate_fix_candidate",
            "create_fix_pr",
        ]

        with patch("autodebug.config.load_credentials", return_value={}):
            for tool_name in pipeline_tools:
                fn = tool_functions[tool_name]
                # Call with minimal args
                if tool_name == "validate_fix_candidate":
                    raw = await fn(ctx, error_id="x", patch="y")
                elif tool_name == "create_fix_pr":
                    raw = await fn(ctx, fix_id="x")
                else:
                    raw = await fn(ctx)

                env = self._parse_envelope(raw)
                assert env["status"] == "error", f"{tool_name} should error without project"
                assert env["data"].get("missing") == "project_id", f"{tool_name} should report missing project_id"

    def test_next_actions_form_connected_graph(self, tool_functions, tool_param_names):
        """Verify the pipeline tools form a connected chain via next_actions tool references."""
        expected_chain = [
            ("incident_overview", "failure_similarity_search"),
            ("failure_similarity_search", "generate_fix_candidates"),
            ("generate_fix_candidates", "validate_fix_candidate"),
            ("validate_fix_candidate", "create_fix_pr"),
        ]
        # Each tool in the chain should exist and have compatible parameters
        for source, target in expected_chain:
            assert source in tool_functions, f"{source} not registered"
            assert target in tool_functions, f"{target} not registered"
            assert target in tool_param_names, f"{target} has no params"
