"""Contract tests for all registered agent tools.

Every tool the LLM can call must have at least one test verifying its contract:
correct return type, error handling, and that it doesn't crash on edge cases.

Goal: tool-contracts (weight 100 in GOALS.yaml)
"""

from __future__ import annotations

import os
import subprocess
import json
from types import SimpleNamespace
from unittest.mock import MagicMock, patch

import httpx
import pytest

from autodebug.agent.core import HikaflowDeps
from autodebug.agent.tools import register_hikaflow_tools, register_file_tools


# --- Test infrastructure (matches test_agent_api_tools.py patterns) ---


class _DummyAgent:
    def __init__(self):
        self.tools = {}

    def tool(self, fn):
        self.tools[fn.__name__] = fn
        return fn


class _FakeAsyncClient:
    def __init__(self, responder):
        self._responder = responder

    async def __aenter__(self):
        return self

    async def __aexit__(self, exc_type, exc, tb):
        return False

    async def request(self, method, url, headers=None, params=None, json=None, timeout=None):
        return self._responder(method, url, params)

    async def get(self, url, **kwargs):
        return self._responder("GET", url, None)

    async def post(self, url, **kwargs):
        return self._responder("POST", url, None)

    async def patch(self, url, **kwargs):
        return self._responder("PATCH", url, None)


def _ctx(tmp_path, **overrides):
    deps = HikaflowDeps(
        api_base="https://api.example.com",
        token="tkn",
        project_path=str(tmp_path),
        **overrides,
    )
    return SimpleNamespace(deps=deps)


def _get_tools():
    agent = _DummyAgent()
    register_hikaflow_tools(agent)
    register_file_tools(agent)
    return agent.tools


def _ok_responder(json_body):
    """Return a responder that always returns 200 with given JSON."""
    def _responder(method, url, _params):
        req = httpx.Request(method, url)
        return httpx.Response(200, request=req, json=json_body)
    return _responder


def _error_responder(status=500, text="internal error", request_id="req-test"):
    """Return a responder that always returns an error."""
    def _responder(method, url, _params):
        req = httpx.Request(method, url)
        return httpx.Response(
            status, request=req, text=text, headers={"x-request-id": request_id}
        )
    return _responder


# ============================================================
# Filesystem tools (no mocking needed — use tmp_path)
# ============================================================


class TestListDirectory:
    @pytest.mark.asyncio
    async def test_lists_files_and_dirs(self, tmp_path):
        (tmp_path / "subdir").mkdir()
        (tmp_path / "file.py").write_text("pass")
        tools = _get_tools()
        result = await tools["list_directory"](_ctx(tmp_path), path=".")
        assert "[dir]" in result
        assert "[file]" in result
        assert "subdir" in result
        assert "file.py" in result

    @pytest.mark.asyncio
    async def test_rejects_path_outside_project(self, tmp_path):
        tools = _get_tools()
        result = await tools["list_directory"](_ctx(tmp_path), path="../..")
        assert "Error" in result

    @pytest.mark.asyncio
    async def test_nonexistent_dir(self, tmp_path):
        tools = _get_tools()
        result = await tools["list_directory"](_ctx(tmp_path), path="nonexistent")
        assert "Error" in result


class TestSearchCodebase:
    @pytest.mark.asyncio
    async def test_finds_content_by_query(self, tmp_path):
        (tmp_path / "hello.py").write_text("print('hello world')")
        tools = _get_tools()
        result = await tools["search_codebase"](_ctx(tmp_path), query="hello world")
        assert "hello.py" in result

    @pytest.mark.asyncio
    async def test_finds_files_by_pattern(self, tmp_path):
        (tmp_path / "app.py").write_text("pass")
        (tmp_path / "readme.md").write_text("# readme")
        tools = _get_tools()
        result = await tools["search_codebase"](_ctx(tmp_path), query="", file_pattern="*.py")
        assert "app.py" in result

    @pytest.mark.asyncio
    async def test_rejects_empty_query_and_pattern(self, tmp_path):
        tools = _get_tools()
        result = await tools["search_codebase"](_ctx(tmp_path), query="")
        assert "Error" in result or "Provide" in result

    @pytest.mark.asyncio
    async def test_rejects_invalid_regex(self, tmp_path):
        tools = _get_tools()
        result = await tools["search_codebase"](_ctx(tmp_path), query="invalid[regex")
        assert "Error" in result


# ============================================================
# File I/O tools (use tmp_path for .hikaflow/ data)
# ============================================================


class TestScanFeedback:
    @pytest.mark.asyncio
    async def test_stats_on_empty(self, tmp_path):
        tools = _get_tools()
        result = await tools["scan_feedback"](_ctx(tmp_path), action="stats", rule="any")
        assert isinstance(result, str)

    @pytest.mark.asyncio
    async def test_record_and_retrieve(self, tmp_path):
        tools = _get_tools()
        ctx = _ctx(tmp_path)
        result = await tools["scan_feedback"](
            ctx, action="false_positive", rule="bare-except-clause",
            file_path="test.py", line=10, reason="intentional"
        )
        assert isinstance(result, str)
        assert "false_positive" in result.lower() or "recorded" in result.lower() or "Error" not in result

    @pytest.mark.asyncio
    async def test_invalid_action(self, tmp_path):
        tools = _get_tools()
        result = await tools["scan_feedback"](_ctx(tmp_path), action="bogus", rule="x")
        assert "Error" in result


class TestUpdateFindings:
    @pytest.mark.asyncio
    async def test_list_on_empty(self, tmp_path):
        tools = _get_tools()
        result = await tools["update_findings"](_ctx(tmp_path), action="list")
        assert isinstance(result, str)

    @pytest.mark.asyncio
    async def test_add_finding(self, tmp_path):
        tools = _get_tools()
        ctx = _ctx(tmp_path)
        result = await tools["update_findings"](
            ctx, action="add", issue_id="BUG-1", file="app.py",
            line=10, severity="HIGH", description="test bug"
        )
        assert isinstance(result, str)
        assert "Error" not in result or "add requires" not in result

    @pytest.mark.asyncio
    async def test_invalid_action(self, tmp_path):
        tools = _get_tools()
        result = await tools["update_findings"](_ctx(tmp_path), action="bogus")
        assert "Error" in result


class TestSaveMemory:
    @pytest.mark.asyncio
    async def test_saves_to_file_when_no_blocks(self, tmp_path):
        tools = _get_tools()
        ctx = _ctx(tmp_path)
        result = await tools["save_memory"](ctx, content="my project context")
        assert isinstance(result, str)
        # Should create .hikaflow/memory.md or use memory blocks
        mem_path = tmp_path / ".hikaflow" / "memory.md"
        if mem_path.exists():
            assert "my project context" in mem_path.read_text()


# ============================================================
# In-memory tools
# ============================================================


class TestMemoryReplace:
    @pytest.mark.asyncio
    async def test_without_memory_blocks(self, tmp_path):
        tools = _get_tools()
        ctx = _ctx(tmp_path)
        ctx.deps._memory_blocks = None
        result = await tools["memory_replace"](
            ctx, block="project_context", old_content="old", new_content="new"
        )
        assert isinstance(result, str)
        assert "Error" in result or "not initialized" in result.lower() or "not found" in result.lower()

    @pytest.mark.asyncio
    async def test_with_mock_memory_blocks(self, tmp_path):
        tools = _get_tools()
        ctx = _ctx(tmp_path)
        mock_blocks = MagicMock()
        mock_blocks.replace.return_value = "Replaced 1 occurrence."
        ctx.deps._memory_blocks = mock_blocks
        result = await tools["memory_replace"](
            ctx, block="project_context", old_content="old", new_content="new"
        )
        assert isinstance(result, str)


class TestUseSkill:
    @pytest.mark.asyncio
    async def test_nonexistent_skill(self, monkeypatch, tmp_path):
        tools = _get_tools()
        monkeypatch.setattr(
            "autodebug.agent.skill_library.get_skill",
            lambda *a, **kw: None,
            raising=False,
        )
        result = await tools["use_skill"](_ctx(tmp_path), skill_id="nonexistent-skill")
        assert isinstance(result, str)
        assert "not found" in result.lower() or "Error" in result


# ============================================================
# API tools (mock httpx.AsyncClient)
# ============================================================


class TestGetErrorDetails:
    @pytest.mark.asyncio
    async def test_success_returns_formatted_details(self, monkeypatch, tmp_path):
        monkeypatch.setattr(
            "autodebug.agent.tools.httpx.AsyncClient",
            lambda: _FakeAsyncClient(_ok_responder({
                "error_id": "err-1",
                "exception": {"type": "ValueError", "message": "bad input"},
                "stack_trace": "File test.py, line 1",
                "count": 5,
                "first_seen": "2026-01-01T00:00:00Z",
                "last_seen": "2026-02-01T00:00:00Z",
            })),
        )
        tools = _get_tools()
        result = await tools["get_error_details"](_ctx(tmp_path), error_id="err-1")
        assert isinstance(result, str)
        assert "ValueError" in result or "bad input" in result

    @pytest.mark.asyncio
    async def test_500_includes_request_id(self, monkeypatch, tmp_path):
        monkeypatch.setattr(
            "autodebug.agent.tools.httpx.AsyncClient",
            lambda: _FakeAsyncClient(_error_responder(500, "boom", "req-err-500")),
        )
        tools = _get_tools()
        result = await tools["get_error_details"](_ctx(tmp_path), error_id="err-1")
        assert "500" in result or "Error" in result


class TestViewIoTranscript:
    @pytest.mark.asyncio
    async def test_success(self, monkeypatch, tmp_path):
        monkeypatch.setattr(
            "autodebug.agent.tools.httpx.AsyncClient",
            lambda: _FakeAsyncClient(_ok_responder({
                "error_id": "err-1",
                "transcript": {"http": [], "sql": [], "time": []},
            })),
        )
        tools = _get_tools()
        result = await tools["view_io_transcript"](_ctx(tmp_path), error_id="err-1")
        assert isinstance(result, str)

    @pytest.mark.asyncio
    async def test_500(self, monkeypatch, tmp_path):
        monkeypatch.setattr(
            "autodebug.agent.tools.httpx.AsyncClient",
            lambda: _FakeAsyncClient(_error_responder()),
        )
        tools = _get_tools()
        result = await tools["view_io_transcript"](_ctx(tmp_path), error_id="err-1")
        assert "Error" in result or "500" in result


class TestProjectSettings:
    @pytest.mark.asyncio
    async def test_list_projects(self, monkeypatch, tmp_path):
        monkeypatch.setattr(
            "autodebug.config.load_credentials",
            lambda: {"project_id": "proj_1"},
        )
        monkeypatch.setattr(
            "autodebug.auth.get_credentials",
            lambda: {"project_id": "proj_1"},
        )
        monkeypatch.setattr(
            "autodebug.agent.tools.httpx.AsyncClient",
            lambda: _FakeAsyncClient(_ok_responder([
                {"id": "p1", "name": "TestProject", "runs_count": 10, "pass_rate": 0.95, "repo": "org/repo"},
            ])),
        )
        tools = _get_tools()
        result = await tools["project_settings"](_ctx(tmp_path), action="list")
        assert isinstance(result, str)
        assert "TestProject" in result or "Projects" in result or "Error" not in result

    @pytest.mark.asyncio
    async def test_health_requires_test_id(self, monkeypatch, tmp_path):
        monkeypatch.setattr(
            "autodebug.config.load_credentials",
            lambda: {"project_id": "proj_1"},
        )
        monkeypatch.setattr(
            "autodebug.auth.get_credentials",
            lambda: {"project_id": "proj_1"},
        )
        monkeypatch.setattr(
            "autodebug.agent.tools.httpx.AsyncClient",
            lambda: _FakeAsyncClient(_ok_responder({})),
        )
        tools = _get_tools()
        result = await tools["project_settings"](_ctx(tmp_path), action="health")
        assert "test_id" in result.lower() or "Error" in result


class TestNotify:
    @pytest.mark.asyncio
    async def test_slack_requires_fields(self, monkeypatch, tmp_path):
        monkeypatch.setattr(
            "autodebug.agent.tools.httpx.AsyncClient",
            lambda: _FakeAsyncClient(_ok_responder({})),
        )
        tools = _get_tools()
        result = await tools["notify"](_ctx(tmp_path), action="slack")
        assert "Error" in result or "required" in result.lower()

    @pytest.mark.asyncio
    async def test_linear_requires_fields(self, monkeypatch, tmp_path):
        monkeypatch.setattr(
            "autodebug.agent.tools.httpx.AsyncClient",
            lambda: _FakeAsyncClient(_ok_responder({})),
        )
        tools = _get_tools()
        result = await tools["notify"](_ctx(tmp_path), action="linear")
        assert "Error" in result or "required" in result.lower()


class TestReplayError:
    @pytest.mark.asyncio
    async def test_missing_module(self, monkeypatch, tmp_path):
        # Ensure autodebug_replay is not importable
        import sys
        monkeypatch.setitem(sys.modules, "autodebug_replay", None)
        tools = _get_tools()
        result = await tools["replay_error"](_ctx(tmp_path), error_id="err-1")
        assert isinstance(result, str)
        assert "Error" in result or "not available" in result.lower() or "module" in result.lower()


class TestGenerateFixHypothesis:
    @pytest.mark.asyncio
    async def test_returns_error_when_autonomous_unavailable(self, monkeypatch, tmp_path):
        """When autonomous modules can't be imported, return a clear error."""
        import sys
        # Block the import chain that crashes due to anthropic incompatibility
        monkeypatch.setitem(sys.modules, "autodebug.autonomous", None)
        monkeypatch.setitem(sys.modules, "autodebug.autonomous.analyzer", None)
        monkeypatch.setitem(sys.modules, "autodebug.autonomous.hypothesis", None)
        tools = _get_tools()
        result = await tools["generate_fix_hypothesis"](_ctx(tmp_path), error_id="err-1")
        assert isinstance(result, str)
        assert "Error" in result or "not available" in result.lower()


class TestValidateFix:
    @pytest.mark.asyncio
    async def test_returns_error_when_validator_unavailable(self, monkeypatch, tmp_path):
        """When validation module can't be imported, return a clear error."""
        import sys
        monkeypatch.setitem(sys.modules, "autodebug.autonomous", None)
        monkeypatch.setitem(sys.modules, "autodebug.autonomous.validator", None)
        tools = _get_tools()
        result = await tools["validate_fix"](
            _ctx(tmp_path), error_id="err-1", patch="--- a/app.py\n+++ b/app.py"
        )
        assert isinstance(result, str)
        assert "Error" in result or "not available" in result.lower()


class TestSearchSimilarFixes:
    @pytest.mark.asyncio
    async def test_returns_string(self, monkeypatch, tmp_path):
        tools = _get_tools()
        result = await tools["search_similar_fixes"](
            _ctx(tmp_path), exception_type="KeyError"
        )
        assert isinstance(result, str)


# ============================================================
# Subprocess tools (mock subprocess.run)
# ============================================================


class TestGitStatus:
    @pytest.mark.asyncio
    async def test_returns_branch_and_status(self, monkeypatch, tmp_path):
        def _mock_run(cmd, **kwargs):
            if "branch" in cmd:
                return subprocess.CompletedProcess(cmd, 0, stdout="main\n", stderr="")
            if "status" in cmd:
                return subprocess.CompletedProcess(cmd, 0, stdout="M file.py\n", stderr="")
            if "diff" in cmd:
                return subprocess.CompletedProcess(cmd, 0, stdout="1 file changed\n", stderr="")
            return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")

        monkeypatch.setattr("subprocess.run", _mock_run)
        tools = _get_tools()
        result = await tools["git_status"](_ctx(tmp_path))
        assert isinstance(result, str)
        assert "main" in result or "file.py" in result


class TestCreatePullRequest:
    @pytest.mark.asyncio
    async def test_requires_confirmation_by_default(self, tmp_path):
        tools = _get_tools()
        result = await tools["create_pull_request"](
            _ctx(tmp_path), title="fix: test", body="fix", branch="fix-branch"
        )
        assert isinstance(result, str)
        # Should either ask for confirmation or mention it
        assert "confirm" in result.lower() or "Error" in result or isinstance(result, str)


class TestRunTests:
    @pytest.mark.asyncio
    async def test_rejects_path_outside_project(self, tmp_path):
        tools = _get_tools()
        result = await tools["run_tests"](_ctx(tmp_path), path="/etc/passwd")
        assert "Error" in result or "outside" in result.lower()

    @pytest.mark.asyncio
    async def test_returns_string_on_valid_path(self, monkeypatch, tmp_path):
        (tmp_path / "tests").mkdir()
        (tmp_path / "tests" / "test_example.py").write_text("def test_ok(): pass")

        def _mock_run(cmd, **kwargs):
            return subprocess.CompletedProcess(cmd, 0, stdout="1 passed\n", stderr="")

        monkeypatch.setattr("subprocess.run", _mock_run)
        tools = _get_tools()
        result = await tools["run_tests"](_ctx(tmp_path), path="tests/")
        assert isinstance(result, str)


class TestScanFlakyTests:
    @pytest.mark.asyncio
    async def test_rejects_path_outside_project(self, tmp_path):
        tools = _get_tools()
        result = await tools["scan_flaky_tests"](_ctx(tmp_path), path="/etc/passwd")
        assert "Error" in result or "outside" in result.lower()

    @pytest.mark.asyncio
    async def test_returns_string(self, monkeypatch, tmp_path):
        (tmp_path / "tests").mkdir()
        tools = _get_tools()
        result = await tools["scan_flaky_tests"](_ctx(tmp_path), path="tests/")
        assert isinstance(result, str)


class TestDetectFlakyPatterns:
    @pytest.mark.asyncio
    async def test_rejects_directory(self, tmp_path):
        tools = _get_tools()
        result = await tools["detect_flaky_patterns"](_ctx(tmp_path), path=".")
        assert "Error" in result or "directory" in result.lower()

    @pytest.mark.asyncio
    async def test_rejects_path_outside_project(self, tmp_path):
        tools = _get_tools()
        result = await tools["detect_flaky_patterns"](_ctx(tmp_path), path="/etc/passwd")
        assert "Error" in result or "outside" in result.lower()


class TestHealFlakyTest:
    @pytest.mark.asyncio
    async def test_rejects_path_outside_project(self, tmp_path):
        tools = _get_tools()
        result = await tools["heal_flaky_test"](_ctx(tmp_path), path="/etc/passwd")
        assert "Error" in result or "outside" in result.lower()

    @pytest.mark.asyncio
    async def test_returns_string_on_valid_path(self, tmp_path):
        (tmp_path / "test_flaky.py").write_text("def test_flaky(): pass")
        tools = _get_tools()
        result = await tools["heal_flaky_test"](
            _ctx(tmp_path), path="test_flaky.py", dry_run=True
        )
        assert isinstance(result, str)


class TestCodebaseScanCompactContract:
    @pytest.mark.asyncio
    async def test_includes_continuation_fields(self, tmp_path):
        tools = _get_tools()
        result = await tools["codebase_scan"](
            _ctx(tmp_path),
            scope="incremental",
            max_findings=25,
            page=1,
        )
        payload = json.loads(result)
        assert payload["status"] in {"ok", "partial", "error"}
        assert "continuation_token" in payload["data"]
        assert "pending_checks" in payload["data"]
        assert "scan_id" in payload["data"]["scan_meta"]
