"""Adversarial E2E tests for Lattice.

Tests error handling, edge cases, and security boundaries:
1. Malformed inputs - Invalid paths, corrupt data
2. Secret injection - Secrets should be sanitized during ingest
3. SQL injection attempts - Search queries with SQL syntax
4. Path traversal prevention - Cannot access files outside project
5. Resource limits - Large content handling
6. Concurrent access - Multiple clients
7. Corruption recovery - Missing/corrupted files

Reference: RFC-002 §7 Security Considerations
"""

import sqlite3
import threading
from pathlib import Path

import pytest
from returns.result import Failure, Success

from lattice import Client
from lattice.core.sanitizer import sanitize
from lattice.core.types.enums import Role
from lattice.shell.schema import create_store
from lattice.shell.store import generate_session_id, insert_log


class TestMalformedInputs:
    """Test handling of malformed inputs."""

    def test_client_with_nonexistent_project(self, tmp_path: Path) -> None:
        """Client should handle nonexistent project path gracefully."""
        nonexistent = tmp_path / "does_not_exist"
        client = Client(nonexistent)

        # Search should still work (returns empty for no store)
        result = client.search("anything")
        assert isinstance(result, Failure)
        failure_msg = result.failure().lower()
        assert (
            "not found" in failure_msg
            or "error" in failure_msg
            or "not initialized" in failure_msg
        )

    def test_client_with_file_as_project(self, tmp_path: Path) -> None:
        """Client should handle file path as project path."""
        file_path = tmp_path / "not_a_directory.txt"
        file_path.write_text("I am a file, not a directory")

        client = Client(file_path)
        # Should not crash, may fail gracefully
        result = client.search("test")
        assert isinstance(result, (Success, Failure))

    def test_search_with_empty_query(self, tmp_path: Path) -> None:
        """Search with empty query should be handled."""
        project = tmp_path / "project"
        project.mkdir()
        lattice_dir = project / ".lattice"
        lattice_dir.mkdir()
        (lattice_dir / "rules").mkdir()
        (lattice_dir / "config.toml").write_text('[compiler]\nmodel = "test"\n')

        result = create_store(lattice_dir / "store.db")
        assert isinstance(result, Success)
        result.unwrap().close()

        client = Client(project)
        # Empty query - FTS5 may reject or return empty
        search_result = client.search("")
        assert isinstance(search_result, (Success, Failure))

    def test_search_with_special_characters(self, tmp_path: Path) -> None:
        """Search with special FTS5 characters should not crash."""
        project = tmp_path / "project"
        project.mkdir()
        lattice_dir = project / ".lattice"
        lattice_dir.mkdir()
        (lattice_dir / "rules").mkdir()
        (lattice_dir / "config.toml").write_text('[compiler]\nmodel = "test"\n')

        result = create_store(lattice_dir / "store.db")
        assert isinstance(result, Success)
        conn = result.unwrap()

        try:
            # Ingest some content
            session_id = generate_session_id()
            insert_log(conn, session_id, Role.USER, "Normal content for testing", None)
        finally:
            conn.close()

        client = Client(project)

        # FTS5 special characters that could cause issues
        special_queries = [
            "test OR injection",
            "test AND bypass",
            "test NOT filtered",
            "test*",
            '"quoted string"',
            "test -exclude",
            "test^2",
        ]

        for query in special_queries:
            # Should not crash, may return empty or filtered results
            result = client.search(query)
            assert isinstance(result, (Success, Failure))


class TestSecretSanitization:
    """Test secret detection and sanitization during ingest."""

    @pytest.fixture
    def project_setup(self, tmp_path: Path) -> Path:
        """Create a basic project setup."""
        project = tmp_path / "project"
        project.mkdir()
        lattice_dir = project / ".lattice"
        lattice_dir.mkdir()
        (lattice_dir / "rules").mkdir()
        (lattice_dir / "config.toml").write_text(
            '[compiler]\nmodel = "test"\n'
            'secret_patterns = ["sk-[a-zA-Z0-9]+", "Bearer\\\\s+\\\\S+"]\n'
        )

        result = create_store(lattice_dir / "store.db")
        assert isinstance(result, Success)
        result.unwrap().close()

        return project

    def test_sanitize_removes_api_keys(self) -> None:
        """Sanitizer removes API key patterns."""
        content = "api_key=sk-proj-abc123def456 and more text"
        patterns = [r"sk-[a-zA-Z0-9\-]+"]
        result = sanitize(content, patterns)
        assert "sk-proj-abc123def456" not in result
        assert "[REDACTED]" in result

    def test_sanitize_removes_bearer_tokens(self) -> None:
        """Sanitizer removes Bearer token patterns."""
        content = "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.abc"
        patterns = [r"Bearer\s+[a-zA-Z0-9_\-\.]+"]
        result = sanitize(content, patterns)
        assert "eyJhbGciOiJIUzI1NiJ9" not in result

    def test_sanitize_preserves_safe_content(self) -> None:
        """Sanitizer preserves non-secret content."""
        content = "The user alice made a request to /api/users endpoint"
        patterns = [r"sk-[a-zA-Z0-9]+"]
        result = sanitize(content, patterns)
        assert "alice" in result
        assert "/api/users" in result

    def test_sanitize_multiple_patterns(self) -> None:
        """Sanitizer handles multiple patterns."""
        content = "key=sk-abc password=secret123 token=bearer-xyz"
        patterns = [r"sk-[a-z]+", r"password=[a-zA-Z0-9]+"]
        result = sanitize(content, patterns)
        assert "sk-abc" not in result
        assert "secret123" not in result

    def test_overlapping_secrets_merged(self) -> None:
        """Overlapping secret spans are merged, not double-redacted."""
        content = "key=sk-abc123xyz"
        # Two patterns that would match overlapping spans
        patterns = [r"sk-[a-z]+", r"sk-[a-z0-9]+"]
        result = sanitize(content, patterns)
        # Should have one clean redaction, not nested
        assert "[REDACTED][REDACTED]" not in result
        assert result.count("[REDACTED]") == 1


class TestSQLInjectionPrevention:
    """Test that SQL injection attempts are handled safely."""

    @pytest.fixture
    def project_with_data(self, tmp_path: Path) -> Path:
        """Create project with some data."""
        project = tmp_path / "project"
        project.mkdir()
        lattice_dir = project / ".lattice"
        lattice_dir.mkdir()
        (lattice_dir / "rules").mkdir()
        (lattice_dir / "config.toml").write_text('[compiler]\nmodel = "test"\n')

        result = create_store(lattice_dir / "store.db")
        assert isinstance(result, Success)
        conn = result.unwrap()

        try:
            session_id = generate_session_id()
            insert_log(conn, session_id, Role.USER, "Normal content here", None)
        finally:
            conn.close()

        return project

    def test_search_sql_injection_attempt(self, project_with_data: Path) -> None:
        """SQL injection in search query should not expose data."""
        client = Client(project_with_data)

        # Common SQL injection patterns
        injection_queries = [
            "'; DROP TABLE logs; --",
            "' OR '1'='1",
            "1; SELECT * FROM logs",
            "admin'--",
            "' UNION SELECT * FROM logs --",
        ]

        for query in injection_queries:
            result = client.search(query)
            # Should not crash, should not return all data
            assert isinstance(result, (Success, Failure))
            if isinstance(result, Success):
                # If it returned results, they should be empty or filtered
                results = result.unwrap()
                assert len(results) == 0 or all(
                    "DROP" not in r.content and "SELECT" not in r.content
                    for r in results
                )


class TestPathTraversalPrevention:
    """Test that path traversal attacks are prevented."""

    def test_cannot_access_parent_directory(self, tmp_path: Path) -> None:
        """Client cannot read files outside project directory."""
        # Create a secret file outside project
        secret_file = tmp_path / "secret.txt"
        secret_file.write_text("SECRET_CONTENT")

        # Create project
        project = tmp_path / "project"
        project.mkdir()
        lattice_dir = project / ".lattice"
        lattice_dir.mkdir()
        (lattice_dir / "rules").mkdir()
        (lattice_dir / "config.toml").write_text('[compiler]\nmodel = "test"\n')

        result = create_store(lattice_dir / "store.db")
        assert isinstance(result, Success)
        result.unwrap().close()

        client = Client(project)

        # Secrets from outside should not appear in instincts
        instincts = client.get_instincts()
        assert "SECRET_CONTENT" not in instincts

    def test_rules_directory_enforcement(self, tmp_path: Path) -> None:
        """Rules can only be read from .lattice/rules directory."""
        project = tmp_path / "project"
        project.mkdir()
        lattice_dir = project / ".lattice"
        lattice_dir.mkdir()
        (lattice_dir / "rules").mkdir()

        # Write a legitimate rule
        rules_dir = lattice_dir / "rules"
        (rules_dir / "test.md").write_text("# Test Rule\n\nContent.\n")

        # Try to create a file outside rules with .md extension
        outside_file = lattice_dir / "outside.md"
        outside_file.write_text("# Outside Rule\n\nShould not be read.\n")

        client = Client(project)
        instincts = client.get_instincts()

        # Should include rules directory content
        assert "Test Rule" in instincts
        # Should NOT include files outside rules directory
        assert "Outside Rule" not in instincts


class TestCorruptionRecovery:
    """Test handling of corrupted or missing files."""

    def test_missing_store_db_returns_failure(self, tmp_path: Path) -> None:
        """Missing store.db returns Failure, not crash."""
        project = tmp_path / "project"
        project.mkdir()
        lattice_dir = project / ".lattice"
        lattice_dir.mkdir()
        (lattice_dir / "rules").mkdir()
        # Don't create store.db

        client = Client(project)
        result = client.search("test")

        assert isinstance(result, Failure)

    def test_corrupted_store_db_handled(self, tmp_path: Path) -> None:
        """Corrupted store.db is handled gracefully."""
        project = tmp_path / "project"
        project.mkdir()
        lattice_dir = project / ".lattice"
        lattice_dir.mkdir()
        (lattice_dir / "rules").mkdir()
        (lattice_dir / "config.toml").write_text('[compiler]\nmodel = "test"\n')

        # Create corrupted store.db
        (lattice_dir / "store.db").write_text("NOT A DATABASE")

        client = Client(project)
        result = client.search("test")

        # Should fail gracefully, not crash
        assert isinstance(result, Failure)

    def test_empty_rules_directory(self, tmp_path: Path) -> None:
        """Empty rules directory returns empty instincts."""
        project = tmp_path / "project"
        project.mkdir()
        lattice_dir = project / ".lattice"
        lattice_dir.mkdir()
        (lattice_dir / "rules").mkdir()  # Empty
        (lattice_dir / "config.toml").write_text('[compiler]\nmodel = "test"\n')

        client = Client(project)
        instincts = client.get_instincts()

        # Should be empty string or minimal content
        assert isinstance(instincts, str)


class TestConcurrentAccess:
    """Test handling of concurrent access."""

    @pytest.fixture
    def project_for_concurrency(self, tmp_path: Path) -> Path:
        """Create project for concurrency testing."""
        project = tmp_path / "project"
        project.mkdir()
        lattice_dir = project / ".lattice"
        lattice_dir.mkdir()
        (lattice_dir / "rules").mkdir()
        (lattice_dir / "config.toml").write_text('[compiler]\nmodel = "test"\n')

        result = create_store(lattice_dir / "store.db")
        assert isinstance(result, Success)
        result.unwrap().close()

        return project

    def test_concurrent_searches(self, project_for_concurrency: Path) -> None:
        """Multiple concurrent searches should not crash."""
        results = []
        errors = []

        def search_task(query: str) -> None:
            try:
                client = Client(project_for_concurrency)
                result = client.search(query)
                results.append((query, result))
            except Exception as e:
                errors.append((query, e))

        threads = [
            threading.Thread(target=search_task, args=(f"query{i}",)) for i in range(5)
        ]

        for t in threads:
            t.start()
        for t in threads:
            t.join()

        # No errors should occur
        assert len(errors) == 0, f"Errors during concurrent search: {errors}"
        assert len(results) == 5

    def test_concurrent_reads_and_writes(self, project_for_concurrency: Path) -> None:
        """Concurrent reads and writes should not corrupt data."""
        write_errors = []
        read_errors = []

        def write_task() -> None:
            try:
                lattice_dir = project_for_concurrency / ".lattice"
                for i in range(3):
                    result = create_store(lattice_dir / "store.db")
                    if isinstance(result, Success):
                        conn = result.unwrap()
                        try:
                            session_id = generate_session_id()
                            insert_log(
                                conn,
                                session_id,
                                Role.USER,
                                f"Write task message {i}",
                                None,
                            )
                        finally:
                            conn.close()
            except Exception as e:
                write_errors.append(e)

        def read_task() -> None:
            try:
                client = Client(project_for_concurrency)
                for i in range(3):
                    result = client.search("message")
                    assert isinstance(result, (Success, Failure))
            except Exception as e:
                read_errors.append(e)

        threads = [threading.Thread(target=write_task) for _ in range(2)] + [
            threading.Thread(target=read_task) for _ in range(3)
        ]

        for t in threads:
            t.start()
        for t in threads:
            t.join()

        # Should not crash - SQLite handles concurrent access
        assert len(write_errors) == 0, f"Write errors: {write_errors}"
        assert len(read_errors) == 0, f"Read errors: {read_errors}"


class TestResourceLimits:
    """Test handling of large inputs and resource limits."""

    @pytest.fixture
    def project_for_limits(self, tmp_path: Path) -> Path:
        """Create project for limit testing."""
        project = tmp_path / "project"
        project.mkdir()
        lattice_dir = project / ".lattice"
        lattice_dir.mkdir()
        (lattice_dir / "rules").mkdir()
        (lattice_dir / "config.toml").write_text('[compiler]\nmodel = "test"\n')

        result = create_store(lattice_dir / "store.db")
        assert isinstance(result, Success)
        result.unwrap().close()

        return project

    def test_large_content_insertion(self, project_for_limits: Path) -> None:
        """Large content can be inserted without crash."""
        client = Client(project_for_limits)
        lattice_dir = project_for_limits / ".lattice"

        # Create large content (100KB)
        large_content = "x" * 100000

        result = create_store(lattice_dir / "store.db")
        assert isinstance(result, Success)
        conn = result.unwrap()

        try:
            session_id = generate_session_id()
            insert_result = insert_log(conn, session_id, Role.USER, large_content, None)
            assert isinstance(insert_result, Success)
        finally:
            conn.close()

    def test_very_long_search_query(self, project_for_limits: Path) -> None:
        """Very long search query should be handled."""
        client = Client(project_for_limits)

        # Very long query (10KB)
        long_query = "test " * 2000

        result = client.search(long_query)
        # Should not crash
        assert isinstance(result, (Success, Failure))

    def test_many_sessions_searchable(self, project_for_limits: Path) -> None:
        """Can ingest and search across many sessions."""
        lattice_dir = project_for_limits / ".lattice"

        result = create_store(lattice_dir / "store.db")
        assert isinstance(result, Success)
        conn = result.unwrap()

        try:
            # Ingest 100 sessions
            for i in range(100):
                session_id = generate_session_id()
                insert_log(
                    conn,
                    session_id,
                    Role.USER,
                    f"Session {i} with unique keyword UNIQUE{i}",
                    None,
                )
        finally:
            conn.close()

        client = Client(project_for_limits)

        # Search for a specific session
        result = client.search("UNIQUE50")
        assert isinstance(result, Success)
        results = result.unwrap()
        assert any("UNIQUE50" in r.content for r in results)


class TestProposalAdversarial:
    """Test adversarial scenarios in proposal handling."""

    @pytest.fixture
    def proposal_setup(self, tmp_path: Path) -> Path:
        """Create project for proposal testing."""
        project = tmp_path / "project"
        project.mkdir()
        lattice_dir = project / ".lattice"
        lattice_dir.mkdir()
        (lattice_dir / "rules").mkdir()
        (lattice_dir / "drift" / "proposals").mkdir(parents=True)
        (lattice_dir / "config.toml").write_text('[compiler]\nmodel = "test"\n')

        result = create_store(lattice_dir / "store.db")
        assert isinstance(result, Success)
        result.unwrap().close()

        return project

    def test_proposal_with_markdown_injection(self, proposal_setup: Path) -> None:
        """Proposal with markdown injection should be stored safely."""
        from lattice.core.types.enums import PatternType
        from lattice.core.types.proposal import Proposal

        client = Client(proposal_setup)

        # Try to inject markdown that could break the proposal format
        malicious_proposal = Proposal(
            proposal_id="test-injection",
            pattern=PatternType.CONVENTION,
            action="ADD",
            title="""Normal Title
---
# Overridden Section
malicious content""",
            content="Normal content with --- separator",
            evidence_session_ids=[],
        )

        result = client.propose_rule(
            pattern=PatternType.CONVENTION,
            observation="Test observation",
            evidence=["session-1"],
            suggested_action="test",
        )

        # Should succeed without crashing
        assert isinstance(result, Success)

    def test_proposal_with_sql_in_title(self, proposal_setup: Path) -> None:
        """Proposal with SQL-like content in title is stored safely."""
        from lattice.core.types.enums import PatternType

        client = Client(proposal_setup)

        result = client.propose_rule(
            pattern=PatternType.CONVENTION,
            observation="Test with SQL: SELECT * FROM logs",
            evidence=["session-1"],
            suggested_action="test",
        )

        assert isinstance(result, Success)
