"""
Tests for PatchAgent (WO-ECHO-PATCH-AGENT-01)

Tests covering:
- File path validation against allowlist
- Pre-test gate enforcement
- Post-test gate enforcement
- Rollback on post-test failure
- Receipt generation with chain-hash
- Diff size limits
"""

import asyncio
import hashlib
import json
import os
import pytest
import tempfile
import shutil
from pathlib import Path
from typing import Dict, Any
from unittest.mock import patch, MagicMock, AsyncMock

# Configure pytest-anyio for async tests
pytest_plugins = ('anyio',)


@pytest.fixture
def anyio_backend():
    return 'asyncio'


# Import the patch agent and related modules
from helix_cdc.echo.agents.patch_agent import PatchAgent
from helix_cdc.echo.agents import AgentResult, PROJECT_ROOT
from helix_cdc.echo.ops.helpers import (
    validate_patch_path,
    detect_patch_intent,
    ALLOWED_PATCH_PATHS,
)


class TestValidatePatchPath:
    """Tests for the validate_patch_path function."""

    def test_valid_helix_cdc_path(self):
        """Test that helix_cdc/ paths are allowed."""
        is_valid, reason = validate_patch_path("helix_cdc/echo/ops/helpers.py")
        assert is_valid is True
        assert reason == "OK"

    def test_valid_api_path(self):
        """Test that api/ paths are allowed."""
        is_valid, reason = validate_patch_path("api/basin_server.py")
        assert is_valid is True

    def test_valid_tools_path(self):
        """Test that tools/ paths are allowed."""
        is_valid, reason = validate_patch_path("tools/echo_probe.py")
        assert is_valid is True

    def test_valid_tests_path(self):
        """Test that tests/ paths are allowed."""
        is_valid, reason = validate_patch_path("tests/test_patch_agent.py")
        assert is_valid is True

    def test_reject_root_file(self):
        """Test that root-level files are rejected."""
        is_valid, reason = validate_patch_path("setup.py")
        assert is_valid is False
        assert "must start with" in reason

    def test_reject_seeds_path(self):
        """Test that seeds/ paths are rejected (not in allowlist)."""
        is_valid, reason = validate_patch_path("seeds/dna_seed.json")
        assert is_valid is False

    def test_reject_git_path(self):
        """Test that .git/ paths are rejected (excluded)."""
        is_valid, reason = validate_patch_path("helix_cdc/.git/config")
        assert is_valid is False
        assert "excluded" in reason

    def test_reject_pycache_path(self):
        """Test that __pycache__/ paths are rejected (excluded)."""
        is_valid, reason = validate_patch_path("helix_cdc/__pycache__/helpers.cpython-39.pyc")
        assert is_valid is False
        assert "excluded" in reason

    def test_reject_venv_path(self):
        """Test that venv/ paths are rejected (excluded)."""
        is_valid, reason = validate_patch_path("venv/lib/python3.9/site-packages/foo.py")
        assert is_valid is False
        assert "excluded" in reason

    def test_reject_invalid_extension(self):
        """Test that invalid extensions are rejected."""
        is_valid, reason = validate_patch_path("helix_cdc/foo.exe")
        assert is_valid is False
        assert "extension" in reason.lower()

    def test_allowed_extensions(self):
        """Test all allowed extensions."""
        for ext in ALLOWED_PATCH_PATHS["extensions"]:
            path = f"helix_cdc/test_file{ext}"
            is_valid, reason = validate_patch_path(path)
            assert is_valid is True, f"Extension {ext} should be allowed"

    def test_absolute_path_within_project(self):
        """Test that absolute paths within PROJECT_ROOT are normalized."""
        abs_path = str(PROJECT_ROOT / "helix_cdc/echo/agents/patch_agent.py")
        is_valid, reason = validate_patch_path(abs_path)
        assert is_valid is True

    def test_absolute_path_outside_project(self):
        """Test that absolute paths outside PROJECT_ROOT are rejected."""
        is_valid, reason = validate_patch_path("/etc/passwd")
        assert is_valid is False
        assert "PROJECT_ROOT" in reason


class TestDetectPatchIntent:
    """Tests for the detect_patch_intent function."""

    def test_basic_edit_detection(self):
        """Test detection of basic edit request."""
        result = detect_patch_intent("edit the file helix_cdc/foo.py")
        assert result["is_patch"] is True
        assert result["confidence"] > 0.3

    def test_fix_bug_detection(self):
        """Test detection of fix bug request."""
        result = detect_patch_intent("fix the bug in helix_cdc/echo/ops/helpers.py")
        assert result["is_patch"] is True
        assert len(result["detected_files"]) > 0

    def test_add_comment_detection(self):
        """Test detection of add comment request."""
        result = detect_patch_intent("add a comment to api/basin_server.py")
        assert result["is_patch"] is True

    def test_file_path_extraction(self):
        """Test that file paths are extracted from prompt."""
        result = detect_patch_intent("update the tests/test_patch_agent.py file")
        assert "tests/test_patch_agent.py" in result["detected_files"]

    def test_non_patch_query(self):
        """Test that non-patch queries are not detected."""
        result = detect_patch_intent("what is the weather today")
        assert result["is_patch"] is False
        assert result["detected_files"] == []

    def test_multiple_files_detected(self):
        """Test detection of multiple file paths."""
        result = detect_patch_intent(
            "update helix_cdc/foo.py and api/bar.py with the new logic"
        )
        assert len(result["detected_files"]) == 2

    def test_partial_match_requires_file(self):
        """Test that keywords alone without file don't trigger patch."""
        result = detect_patch_intent("edit my understanding of this")
        assert result["is_patch"] is False


class TestPatchAgent:
    """Tests for the PatchAgent class."""

    @pytest.fixture
    def agent(self):
        """Create a PatchAgent instance with temp directories."""
        return PatchAgent(
            receipt_dir="receipts/patch_agent_test",
            backup_dir="receipts/patch_agent_test/.backups",
            timeout_seconds=30,
        )

    def test_agent_type(self, agent):
        """Test that agent type is 'patch'."""
        assert agent.agent_type == "patch"

    def test_available_check(self, agent):
        """Test availability check."""
        result = agent.available()
        assert isinstance(result, bool)

    def test_validate_file_path_method(self, agent):
        """Test the _validate_file_path method."""
        # Valid path
        is_valid, reason = agent._validate_file_path("helix_cdc/foo.py")
        assert is_valid is True

        # Invalid path
        is_valid, reason = agent._validate_file_path("outside/foo.py")
        assert is_valid is False

    def test_parse_patch_intent_from_context(self, agent):
        """Test parsing patch intent from context."""
        context = {
            "patch_spec": {
                "file": "helix_cdc/test.py",
                "operation": "add_comment",
                "content": "Test comment",
            }
        }
        result = agent._parse_patch_intent("add comment", context)
        assert result["files"] == ["helix_cdc/test.py"]
        assert result["operation"] == "add_comment"

    def test_parse_patch_intent_from_query(self, agent):
        """Test parsing patch intent from query when no context."""
        result = agent._parse_patch_intent(
            "edit helix_cdc/echo/ops/helpers.py", {}
        )
        assert "helix_cdc/echo/ops/helpers.py" in result.get("files", [])

    def test_compute_chain_hash(self, agent):
        """Test chain hash computation."""
        hash_result = agent._compute_chain_hash(
            prompt_sha="abc123",
            diff_sha="def456",
            pre_hash="ghi789",
            post_hash="jkl012",
            status="APPLIED",
        )
        # Should be a hex string of 64 characters (sha256)
        assert len(hash_result) == 64
        assert all(c in "0123456789abcdef" for c in hash_result)

    def test_chain_hash_deterministic(self, agent):
        """Test that chain hash is deterministic."""
        hash1 = agent._compute_chain_hash("a", "b", "c", "d", "APPLIED")
        hash2 = agent._compute_chain_hash("a", "b", "c", "d", "APPLIED")
        assert hash1 == hash2

    def test_chain_hash_changes_with_status(self, agent):
        """Test that chain hash changes when status changes."""
        hash1 = agent._compute_chain_hash("a", "b", "c", "d", "APPLIED")
        hash2 = agent._compute_chain_hash("a", "b", "c", "d", "ROLLBACK")
        assert hash1 != hash2

    def test_max_diff_lines_limit(self, agent):
        """Test that MAX_DIFF_LINES is set."""
        assert agent.MAX_DIFF_LINES == 500


class TestPatchAgentBackup:
    """Tests for PatchAgent backup/restore functionality."""

    @pytest.fixture
    def agent(self):
        return PatchAgent(
            receipt_dir="receipts/patch_agent_test",
            backup_dir="receipts/patch_agent_test/.backups",
        )

    @pytest.fixture
    def temp_file(self):
        """Create a temporary test file in helix_cdc/."""
        # Create in PROJECT_ROOT to match allowlist
        test_dir = PROJECT_ROOT / "helix_cdc" / "_test_temp"
        test_dir.mkdir(parents=True, exist_ok=True)
        test_file = test_dir / "backup_test.py"
        test_file.write_text("# Original content\nprint('hello')\n")
        yield test_file
        # Cleanup
        if test_file.exists():
            test_file.unlink()
        if test_dir.exists():
            shutil.rmtree(test_dir)

    def test_create_backups(self, agent, temp_file):
        """Test that backups are created correctly."""
        rel_path = str(temp_file.relative_to(PROJECT_ROOT))
        backups = agent._create_backups([rel_path])

        assert rel_path in backups
        backup_path = PROJECT_ROOT / backups[rel_path]
        assert backup_path.exists()

        # Verify content matches
        original = temp_file.read_text()
        backup_content = backup_path.read_text()
        assert original == backup_content

        # Cleanup
        backup_path.unlink()

    def test_restore_backups(self, agent, temp_file):
        """Test that backups can be restored."""
        rel_path = str(temp_file.relative_to(PROJECT_ROOT))
        original_content = temp_file.read_text()

        # Create backup
        backups = agent._create_backups([rel_path])

        # Modify original
        temp_file.write_text("# Modified content\n")
        assert temp_file.read_text() != original_content

        # Restore
        restored = agent._restore_backups(backups)

        assert rel_path in restored
        assert temp_file.read_text() == original_content

        # Cleanup
        backup_path = PROJECT_ROOT / backups[rel_path]
        if backup_path.exists():
            backup_path.unlink()


class TestPatchAgentApply:
    """Tests for PatchAgent patch application."""

    @pytest.fixture
    def agent(self):
        return PatchAgent(
            receipt_dir="receipts/patch_agent_test",
            backup_dir="receipts/patch_agent_test/.backups",
        )

    @pytest.fixture
    def temp_file(self):
        """Create a temporary test file in helix_cdc/."""
        test_dir = PROJECT_ROOT / "helix_cdc" / "_test_temp"
        test_dir.mkdir(parents=True, exist_ok=True)
        test_file = test_dir / "apply_test.py"
        test_file.write_text("print('hello')\n")
        yield test_file
        # Cleanup
        if test_file.exists():
            test_file.unlink()
        if test_dir.exists():
            shutil.rmtree(test_dir)

    def test_apply_add_comment(self, agent, temp_file):
        """Test adding a comment to file."""
        rel_path = str(temp_file.relative_to(PROJECT_ROOT))
        patch_spec = {
            "files": [rel_path],
            "operation": "add_comment",
            "content": "Test comment",
        }

        result = agent._apply_patch(patch_spec)

        assert rel_path in result["files_modified"]
        assert result["lines_added"] > 0

        content = temp_file.read_text()
        assert "# Test comment" in content

    def test_apply_append(self, agent, temp_file):
        """Test appending content to file."""
        rel_path = str(temp_file.relative_to(PROJECT_ROOT))
        original = temp_file.read_text()
        patch_spec = {
            "files": [rel_path],
            "operation": "append",
            "content": "# Appended line",
        }

        result = agent._apply_patch(patch_spec)

        content = temp_file.read_text()
        assert content.endswith("# Appended line\n") or "# Appended line" in content

    def test_apply_replace(self, agent, temp_file):
        """Test replacing content in file."""
        rel_path = str(temp_file.relative_to(PROJECT_ROOT))
        patch_spec = {
            "files": [rel_path],
            "operation": "replace",
            "old_text": "hello",
            "content": "world",
        }

        result = agent._apply_patch(patch_spec)

        content = temp_file.read_text()
        assert "world" in content
        assert "hello" not in content

    def test_diff_size_limit(self, agent, temp_file):
        """Test that large diffs are rejected."""
        rel_path = str(temp_file.relative_to(PROJECT_ROOT))

        # Create a very large patch
        large_content = "\n".join([f"# Line {i}" for i in range(1000)])
        patch_spec = {
            "files": [rel_path],
            "operation": "append",
            "content": large_content,
        }

        with pytest.raises(ValueError) as exc_info:
            agent._apply_patch(patch_spec)

        assert "too large" in str(exc_info.value).lower()


class TestPatchAgentReceipts:
    """Tests for PatchAgent receipt generation."""

    @pytest.fixture
    def agent(self):
        return PatchAgent(
            receipt_dir="receipts/patch_agent_test",
            backup_dir="receipts/patch_agent_test/.backups",
        )

    def test_build_receipt_structure(self, agent):
        """Test that receipt has required fields."""
        receipt = agent._build_receipt(
            query="fix bug in helpers.py",
            patch_spec={
                "files": ["helix_cdc/echo/ops/helpers.py"],
                "operation": "replace",
            },
            pre_receipt="receipts/test_agent/pass_20260203_abc.json",
            post_receipt="receipts/test_agent/pass_20260203_def.json",
            file_diffs={"helix_cdc/echo/ops/helpers.py": "--- a\n+++ b\n"},
            file_hashes_before={"helix_cdc/echo/ops/helpers.py": "abc123"},
            file_hashes_after={"helix_cdc/echo/ops/helpers.py": "def456"},
            backup_manifest={"helix_cdc/echo/ops/helpers.py": "receipts/patch_agent/.backups/helpers.py.bak"},
            lines_added=5,
            lines_removed=2,
            status="APPLIED",
        )

        # Check required fields
        assert "receipt_id" in receipt
        assert "receipt_type" in receipt
        assert receipt["receipt_type"] == "patch_execution"
        assert "timestamp_utc" in receipt
        assert "work_order" in receipt
        assert receipt["work_order"] == "WO-ECHO-PATCH-AGENT-01"
        assert "prompt" in receipt
        assert "prompt_sha256" in receipt
        assert "action" in receipt
        assert "files_modified" in receipt
        assert "file_hashes_before" in receipt
        assert "file_hashes_after" in receipt
        assert "diff_sha256" in receipt
        assert "diff_preview" in receipt
        assert "lines_added" in receipt
        assert "lines_removed" in receipt
        assert "pre_test_receipt" in receipt
        assert "post_test_receipt" in receipt
        assert "backup_manifest" in receipt
        assert "status" in receipt
        assert "chain_hash" in receipt

    def test_receipt_chain_hash_verifiable(self, agent):
        """Test that receipt chain_hash is verifiable."""
        query = "fix bug"
        diff_content = "--- a\n+++ b\n+new line"

        receipt = agent._build_receipt(
            query=query,
            patch_spec={"files": [], "operation": "replace"},
            pre_receipt=None,
            post_receipt=None,
            file_diffs={"test.py": diff_content},
            file_hashes_before={},
            file_hashes_after={},
            backup_manifest={},
            lines_added=1,
            lines_removed=0,
            status="APPLIED",
        )

        # Recompute chain hash manually
        prompt_sha = hashlib.sha256(query.encode()).hexdigest()
        diff_sha = hashlib.sha256(diff_content.encode()).hexdigest()

        expected_chain_hash = agent._compute_chain_hash(
            prompt_sha, diff_sha, "", "", "APPLIED"
        )

        assert receipt["chain_hash"] == expected_chain_hash


class TestPatchAgentExecution:
    """Integration tests for PatchAgent execution."""

    @pytest.fixture
    def agent(self):
        return PatchAgent(
            receipt_dir="receipts/patch_agent_test",
            backup_dir="receipts/patch_agent_test/.backups",
            timeout_seconds=30,
        )

    @pytest.mark.anyio
    async def test_execute_no_files_detected(self, agent):
        """Test execution with no files detected."""
        result = await agent.execute("fix the issue", {})

        assert isinstance(result, AgentResult)
        assert result.agent_type == "patch"
        assert result.result["status"] == "REJECTED"
        assert "no files" in result.error.lower()

    @pytest.mark.anyio
    async def test_execute_invalid_path(self, agent):
        """Test execution with invalid file path."""
        context = {
            "patch_spec": {
                "files": ["outside/dangerous.py"],
                "operation": "replace",
                "content": "bad",
            }
        }
        result = await agent.execute("edit file", context)

        assert isinstance(result, AgentResult)
        assert result.result["status"] == "REJECTED"
        assert "rejected" in result.error.lower()

    @pytest.mark.anyio
    async def test_execute_rejected_pre_test(self, agent):
        """Test that pre-test failure causes rejection."""
        # Mock TestAgent to return FAIL
        mock_test_result = AgentResult(
            agent_id="test123",
            agent_type="test",
            query="test",
            result={"status": "FAIL", "receipt_path": "test/receipt.json"},
            sources=[],
            confidence=0.8,
            elapsed_ms=100,
        )

        with patch.object(agent, '_get_test_agent') as mock_get:
            mock_agent = MagicMock()
            mock_agent.execute = AsyncMock(return_value=mock_test_result)
            mock_get.return_value = mock_agent

            context = {
                "patch_spec": {
                    "files": ["helix_cdc/test.py"],
                    "operation": "add_comment",
                    "content": "test",
                }
            }
            result = await agent.execute("add comment", context)

        assert result.result["status"] == "REJECTED"
        assert "pre-test failed" in result.error.lower()

    @pytest.mark.anyio
    async def test_execute_rollback_on_post_test_failure(self, agent):
        """Test that post-test failure triggers rollback."""
        # Create a temp file
        test_dir = PROJECT_ROOT / "helix_cdc" / "_test_temp"
        test_dir.mkdir(parents=True, exist_ok=True)
        test_file = test_dir / "rollback_test.py"
        original_content = "# Original\nprint('hello')\n"
        test_file.write_text(original_content)

        try:
            rel_path = str(test_file.relative_to(PROJECT_ROOT))

            # Mock TestAgent: PASS for pre, FAIL for post
            call_count = [0]

            async def mock_execute(query, context):
                call_count[0] += 1
                if call_count[0] == 1:  # pre-test
                    return AgentResult(
                        agent_id="test1",
                        agent_type="test",
                        query=query,
                        result={"status": "PASS", "receipt_path": "test/pre.json"},
                        sources=[],
                        confidence=1.0,
                        elapsed_ms=100,
                    )
                else:  # post-test
                    return AgentResult(
                        agent_id="test2",
                        agent_type="test",
                        query=query,
                        result={"status": "FAIL", "receipt_path": "test/post.json"},
                        sources=[],
                        confidence=0.8,
                        elapsed_ms=100,
                    )

            with patch.object(agent, '_get_test_agent') as mock_get:
                mock_agent = MagicMock()
                mock_agent.execute = mock_execute
                mock_get.return_value = mock_agent

                context = {
                    "patch_spec": {
                        "files": [rel_path],
                        "operation": "add_comment",
                        "content": "This should be rolled back",
                    }
                }
                result = await agent.execute("add comment", context)

            assert result.result["status"] == "ROLLBACK"

            # Verify file was restored
            restored_content = test_file.read_text()
            assert restored_content == original_content
            assert "This should be rolled back" not in restored_content

        finally:
            # Cleanup
            if test_file.exists():
                test_file.unlink()
            if test_dir.exists():
                shutil.rmtree(test_dir)

    @pytest.mark.anyio
    async def test_execute_success_creates_receipt(self, agent):
        """Test that successful execution creates receipt file."""
        # Create a temp file
        test_dir = PROJECT_ROOT / "helix_cdc" / "_test_temp"
        test_dir.mkdir(parents=True, exist_ok=True)
        test_file = test_dir / "success_test.py"
        test_file.write_text("# Original\nprint('hello')\n")

        try:
            rel_path = str(test_file.relative_to(PROJECT_ROOT))

            # Mock TestAgent: PASS for both
            async def mock_execute(query, context):
                return AgentResult(
                    agent_id="test1",
                    agent_type="test",
                    query=query,
                    result={"status": "PASS", "receipt_path": "test/pass.json"},
                    sources=[],
                    confidence=1.0,
                    elapsed_ms=100,
                )

            with patch.object(agent, '_get_test_agent') as mock_get:
                mock_agent = MagicMock()
                mock_agent.execute = mock_execute
                mock_get.return_value = mock_agent

                context = {
                    "patch_spec": {
                        "files": [rel_path],
                        "operation": "add_comment",
                        "content": "Success comment",
                    }
                }
                result = await agent.execute("add comment", context)

            assert result.result["status"] == "APPLIED"
            assert "receipt_path" in result.result
            assert "chain_hash" in result.result

            # Verify receipt file exists
            receipt_path = PROJECT_ROOT / result.result["receipt_path"]
            if receipt_path.exists():
                with open(receipt_path) as f:
                    receipt = json.load(f)

                assert receipt["receipt_type"] == "patch_execution"
                assert receipt["work_order"] == "WO-ECHO-PATCH-AGENT-01"
                assert "chain_hash" in receipt

                # Cleanup receipt
                receipt_path.unlink()

        finally:
            # Cleanup
            if test_file.exists():
                test_file.unlink()
            if test_dir.exists():
                shutil.rmtree(test_dir)


class TestAgentResultIntegration:
    """Tests for AgentResult format compliance."""

    @pytest.fixture
    def agent(self):
        return PatchAgent(timeout_seconds=10)

    @pytest.mark.anyio
    async def test_agent_result_to_dict(self, agent):
        """Test that AgentResult.to_dict() works correctly."""
        result = await agent.execute("fix bug", {})

        result_dict = result.to_dict()

        assert "agent_id" in result_dict
        assert "agent_type" in result_dict
        assert result_dict["agent_type"] == "patch"
        assert "query" in result_dict
        assert "result" in result_dict
        assert "sources" in result_dict
        assert "confidence" in result_dict
        assert "elapsed_ms" in result_dict


if __name__ == "__main__":
    pytest.main([__file__, "-v"])
