"""
Chaos tests for concurrent evolution scenarios.

Tests use real SQLite databases and real file locks (no mocking at boundaries).
Per R8 (Real Wiring): Integration tests use real implementations at boundaries.

These tests exercise:
1. Concurrent Evolve Commands - Multiple threads calling evolve simultaneously
2. SQLite Write Conflicts - Database locking under concurrent writes
3. Lock Acquisition - Lock acquisition and release behavior
4. Proposal Conflicts - Multiple proposals to the same rule file
"""

import fcntl
import sqlite3
import sys
import tempfile
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import TYPE_CHECKING

import pytest
from returns.result import Failure, Success

from lattice.core.types.enums import Role
from lattice.shell.schema import create_store, create_global_store
from lattice.shell.store import (
    insert_log,
    set_metadata,
    get_metadata,
    insert_event,
)

if TYPE_CHECKING:
    pass


class TestLockAcquisition:
    """Tests for file lock acquisition and release behavior."""

    def test_lock_file_creation_and_release(self, tmp_path: Path):
        """Should create lock file and properly release it."""
        lock_path = tmp_path / "evolve.lock"

        # Acquire lock
        lock_fd = open(lock_path, "w")
        fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
        lock_fd.write(f"{sys.argv[0]}\n")
        lock_fd.flush()

        assert lock_path.exists()

        # Release lock
        fcntl.flock(lock_fd.fileno(), fcntl.LOCK_UN)
        lock_fd.close()

        # Should be able to acquire again
        lock_fd2 = open(lock_path, "w")
        fcntl.flock(lock_fd2.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
        fcntl.flock(lock_fd2.fileno(), fcntl.LOCK_UN)
        lock_fd2.close()

    def test_concurrent_lock_blocks(self, tmp_path: Path):
        """Second lock attempt should raise BlockingIOError."""
        lock_path = tmp_path / "evolve.lock"

        # First lock acquisition
        lock_fd1 = open(lock_path, "w")
        fcntl.flock(lock_fd1.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)

        # Second lock should fail with BlockingIOError
        lock_fd2 = open(lock_path, "w")
        try:
            fcntl.flock(lock_fd2.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
            assert False, "Should have raised BlockingIOError"
        except BlockingIOError:
            pass  # Expected
        finally:
            lock_fd2.close()
            fcntl.flock(lock_fd1.fileno(), fcntl.LOCK_UN)
            lock_fd1.close()

    def test_lock_release_allows_reacquisition(self, tmp_path: Path):
        """After lock is released, another process can acquire it."""
        lock_path = tmp_path / "evolve.lock"

        # First acquisition
        lock_fd1 = open(lock_path, "w")
        fcntl.flock(lock_fd1.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
        fcntl.flock(lock_fd1.fileno(), fcntl.LOCK_UN)
        lock_fd1.close()

        # Second acquisition should succeed immediately
        lock_fd2 = open(lock_path, "w")
        fcntl.flock(lock_fd2.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
        fcntl.flock(lock_fd2.fileno(), fcntl.LOCK_UN)
        lock_fd2.close()

    def test_multiple_threads_sequential_lock_access(self, tmp_path: Path):
        """Multiple threads can access lock sequentially."""
        lock_path = tmp_path / "evolve.lock"
        results = []
        lock = threading.Lock()

        def acquire_and_record(thread_id: int):
            try:
                fd = open(lock_path, "w")
                fcntl.flock(fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
                results.append((thread_id, "acquired"))
                time.sleep(0.01)  # Hold lock briefly
                fcntl.flock(fd.fileno(), fcntl.LOCK_UN)
                fd.close()
                return (thread_id, "success")
            except BlockingIOError:
                with lock:
                    results.append((thread_id, "blocked"))
                return (thread_id, "blocked")

        # Run threads sequentially (with small delay between)
        threads = []
        for i in range(3):
            t = threading.Thread(target=acquire_and_record, args=(i,))
            threads.append(t)
            t.start()
            time.sleep(0.05)  # Give time for lock release
            t.join()  # Wait for thread to complete

        # All should succeed with sequential access
        assert all(r[1] == "acquired" for r in results)


class TestConcurrentSQLite:
    """Tests for SQLite database behavior under concurrent access."""

    def test_concurrent_writes_to_same_database(self, tmp_path: Path):
        """Multiple threads writing to the same database should handle conflicts."""
        db_path = tmp_path / "store.db"
        result = create_store(db_path)
        assert isinstance(result, Success)
        conn = result.unwrap()

        # Enable WAL mode for better concurrent access
        conn.execute("PRAGMA journal_mode=WAL")

        errors = []
        success_count = [0]
        lock = threading.Lock()

        def write_log(thread_id: int):
            try:
                # Each thread gets its own connection
                thread_conn = sqlite3.connect(str(db_path))
                thread_conn.execute("PRAGMA journal_mode=WAL")
                for i in range(10):
                    insert_result = insert_log(
                        thread_conn,
                        f"session-{thread_id}",
                        Role.USER,
                        f"Message {i} from thread {thread_id}",
                    )
                    if isinstance(insert_result, Failure):
                        with lock:
                            errors.append((thread_id, insert_result.failure()))
                    else:
                        with lock:
                            success_count[0] += 1
                thread_conn.close()
            except Exception as e:
                with lock:
                    errors.append((thread_id, str(e)))

        # Run concurrent writes
        threads = [threading.Thread(target=write_log, args=(i,)) for i in range(5)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        conn.close()

        # With WAL mode, all writes should succeed
        assert len(errors) == 0, f"Errors: {errors}"
        assert success_count[0] == 50  # 5 threads * 10 messages

    def test_concurrent_writes_with_check_constraint(self, tmp_path: Path):
        """Check constraints should still work under concurrent writes."""
        db_path = tmp_path / "store.db"
        result = create_store(db_path)
        assert isinstance(result, Success)
        conn = result.unwrap()
        conn.execute("PRAGMA journal_mode=WAL")

        errors = []
        lock = threading.Lock()

        def write_with_metadata(thread_id: int):
            try:
                thread_conn = sqlite3.connect(str(db_path))
                thread_conn.execute("PRAGMA journal_mode=WAL")
                insert_result = insert_log(
                    thread_conn,
                    f"session-{thread_id}",
                    Role.USER,
                    f"Message from thread {thread_id}",
                    metadata={"thread": thread_id},
                )
                thread_conn.close()
                return isinstance(insert_result, Success)
            except Exception as e:
                with lock:
                    errors.append(str(e))
                return False

        threads = [
            threading.Thread(target=write_with_metadata, args=(i,)) for i in range(10)
        ]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        conn.close()

        assert len(errors) == 0, f"Unexpected errors: {errors}"

    def test_concurrent_metadata_updates(self, tmp_path: Path):
        """Concurrent metadata updates should not corrupt data."""
        db_path = tmp_path / "store.db"
        result = create_store(db_path)
        assert isinstance(result, Success)
        conn = result.unwrap()
        conn.execute("PRAGMA journal_mode=WAL")

        errors = []
        lock = threading.Lock()

        def update_counter(thread_id: int):
            try:
                thread_conn = sqlite3.connect(str(db_path))
                thread_conn.execute("PRAGMA journal_mode=WAL")

                # Read-modify-write cycle
                current = get_metadata(thread_conn, "counter")
                current_val = int(current.unwrap() or "0")
                time.sleep(0.001)  # Simulate race condition window
                set_result = set_metadata(thread_conn, "counter", str(current_val + 1))

                thread_conn.close()
                return isinstance(set_result, Success)
            except Exception as e:
                with lock:
                    errors.append(str(e))
                return False

        threads = [
            threading.Thread(target=update_counter, args=(i,)) for i in range(10)
        ]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        # Note: Without serialization, the counter may not be exactly 10
        # due to the read-modify-write race condition
        final_counter = get_metadata(conn, "counter")
        conn.close()

        # At minimum, we shouldn't have errors
        assert len(errors) == 0, f"Unexpected errors: {errors}"
        # Counter should be at least 1 (some updates succeeded)
        assert int(final_counter.unwrap() or "0") >= 1


class TestConcurrentEvolution:
    """Tests for concurrent evolve command behavior."""

    def test_evolution_lock_prevents_concurrent_runs(self, tmp_path: Path):
        """Only one evolution process should run at a time per project."""
        lattice_dir = tmp_path / ".lattice"
        lattice_dir.mkdir(parents=True)
        lock_path = lattice_dir / "evolve.lock"

        acquired_count = [0]
        blocked_count = [0]
        lock = threading.Lock()

        def try_evolution(thread_id: int):
            """Simulate evolution process with lock."""
            try:
                fd = open(lock_path, "w")
                fcntl.flock(fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
                with lock:
                    acquired_count[0] += 1
                time.sleep(0.1)  # Simulate evolution work
                fcntl.flock(fd.fileno(), fcntl.LOCK_UN)
                fd.close()
                return "acquired"
            except BlockingIOError:
                with lock:
                    blocked_count[0] += 1
                return "blocked"

        # Simulate concurrent evolution attempts
        with ThreadPoolExecutor(max_workers=5) as executor:
            futures = [executor.submit(try_evolution, i) for i in range(5)]
            results = [f.result() for f in as_completed(futures)]

        # Only one should have acquired, rest should be blocked
        assert acquired_count[0] == 1
        assert blocked_count[0] == 4

    def test_global_evolution_lock_coordination(self, tmp_path: Path):
        """Global evolution should use its own lock file."""
        global_dir = tmp_path / ".lattice"
        global_dir.mkdir(parents=True)
        global_lock_path = global_dir / "evolve.lock"

        # Test global lock behavior
        lock_fd = open(global_lock_path, "w")
        fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)

        # Second attempt should block
        lock_fd2 = open(global_lock_path, "w")
        try:
            fcntl.flock(lock_fd2.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
            assert False, "Should have raised BlockingIOError"
        except BlockingIOError:
            pass  # Expected
        finally:
            lock_fd2.close()

        fcntl.flock(lock_fd.fileno(), fcntl.LOCK_UN)
        lock_fd.close()

    def test_project_and_global_locks_independent(self, tmp_path: Path):
        """Project and global locks should be independent."""
        project_dir = tmp_path / "project"
        project_dir.mkdir(parents=True)
        project_lattice = project_dir / ".lattice"
        project_lattice.mkdir(parents=True)

        global_dir = tmp_path / ".lattice"
        global_dir.mkdir(parents=True)

        project_lock_path = project_lattice / "evolve.lock"
        global_lock_path = global_dir / "evolve.lock"

        # Acquire project lock
        project_fd = open(project_lock_path, "w")
        fcntl.flock(project_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)

        # Should still be able to acquire global lock (different file)
        global_fd = open(global_lock_path, "w")
        fcntl.flock(global_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)

        # Both held simultaneously - should be allowed
        # (represents project evolve running while global evolve on other projects)

        # Release both
        fcntl.flock(project_fd.fileno(), fcntl.LOCK_UN)
        project_fd.close()
        fcntl.flock(global_fd.fileno(), fcntl.LOCK_UN)
        global_fd.close()


class TestProposalConflicts:
    """Tests for proposal file conflicts under concurrent access."""

    def test_concurrent_proposal_writing_different_files(self, tmp_path: Path):
        """Concurrent proposals to different files should succeed."""
        proposals_dir = tmp_path / "proposals"
        proposals_dir.mkdir(parents=True)

        errors = []
        lock = threading.Lock()

        def write_proposal(proposal_id: int):
            try:
                proposal_path = proposals_dir / f"proposal-{proposal_id}.md"
                content = (
                    f"# Proposal {proposal_id}\n\nContent for proposal {proposal_id}."
                )
                proposal_path.write_text(content)
                return True
            except Exception as e:
                with lock:
                    errors.append(str(e))
                return False

        threads = [threading.Thread(target=write_proposal, args=(i,)) for i in range(5)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert len(errors) == 0
        # All proposals should exist
        for i in range(5):
            assert (proposals_dir / f"proposal-{i}.md").exists()

    def test_concurrent_proposal_writing_same_file(self, tmp_path: Path):
        """Concurrent proposals to the same file - last writer wins."""
        proposals_dir = tmp_path / "proposals"
        proposals_dir.mkdir(parents=True)
        proposal_path = proposals_dir / "proposal.md"

        errors = []
        written_contents = []
        lock = threading.Lock()

        def write_proposal(thread_id: int):
            try:
                content = f"# Proposal from thread {thread_id}\n\nContent {thread_id}."
                proposal_path.write_text(content)
                with lock:
                    written_contents.append((thread_id, content))
                return True
            except Exception as e:
                with lock:
                    errors.append(str(e))
                return False

        threads = [threading.Thread(target=write_proposal, args=(i,)) for i in range(5)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert len(errors) == 0
        # File should exist and have content from one of the threads
        assert proposal_path.exists()
        content = proposal_path.read_text()
        assert "Proposal from thread" in content

    def test_concurrent_proposal_list_reading(self, tmp_path: Path):
        """Concurrent reading of proposal list should not interfere."""
        proposals_dir = tmp_path / "proposals"
        proposals_dir.mkdir(parents=True)

        # Pre-create proposals
        for i in range(10):
            (proposals_dir / f"proposal-{i:03d}.md").write_text(f"Content {i}")

        errors = []
        read_counts = []
        lock = threading.Lock()

        def list_proposals(thread_id: int):
            try:
                proposals = list(proposals_dir.glob("proposal-*.md"))
                with lock:
                    read_counts.append((thread_id, len(proposals)))
                return True
            except Exception as e:
                with lock:
                    errors.append(str(e))
                return False

        threads = [
            threading.Thread(target=list_proposals, args=(i,)) for i in range(10)
        ]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert len(errors) == 0
        # All threads should see all 10 proposals
        for thread_id, count in read_counts:
            assert count == 10


class TestEventInsertion:
    """Tests for concurrent event insertion."""

    def test_concurrent_event_insertion_same_session(self, tmp_path: Path):
        """Concurrent events to the same session should all be recorded."""
        db_path = tmp_path / "store.db"
        result = create_store(db_path)
        assert isinstance(result, Success)
        conn = result.unwrap()
        conn.execute("PRAGMA journal_mode=WAL")

        errors = []
        success_count = [0]
        lock = threading.Lock()
        session_id = "concurrent-session"

        def insert_event_thread(thread_id: int):
            try:
                thread_conn = sqlite3.connect(str(db_path))
                thread_conn.execute("PRAGMA journal_mode=WAL")
                result = insert_event(
                    thread_conn,
                    session_id,
                    "user",
                    f"Event from thread {thread_id}",
                    None,
                    None,
                    None,
                )
                if isinstance(result, Success):
                    with lock:
                        success_count[0] += 1
                thread_conn.close()
            except Exception as e:
                with lock:
                    errors.append(str(e))

        threads = [
            threading.Thread(target=insert_event_thread, args=(i,)) for i in range(20)
        ]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        conn.close()

        assert len(errors) == 0, f"Errors: {errors}"
        assert success_count[0] == 20

    def test_concurrent_event_insertion_different_sessions(self, tmp_path: Path):
        """Concurrent events to different sessions should all succeed."""
        db_path = tmp_path / "store.db"
        result = create_store(db_path)
        assert isinstance(result, Success)
        conn = result.unwrap()
        conn.execute("PRAGMA journal_mode=WAL")

        errors = []
        success_count = [0]
        lock = threading.Lock()

        def insert_event_thread(thread_id: int):
            try:
                thread_conn = sqlite3.connect(str(db_path))
                thread_conn.execute("PRAGMA journal_mode=WAL")
                result = insert_event(
                    thread_conn,
                    f"session-{thread_id}",
                    "assistant",
                    f"Response from thread {thread_id}",
                    tool_input=f"tool-{thread_id}",
                    tool_status="success",
                    tool_error=None,
                )
                if isinstance(result, Success):
                    with lock:
                        success_count[0] += 1
                thread_conn.close()
            except Exception as e:
                with lock:
                    errors.append(str(e))

        threads = [
            threading.Thread(target=insert_event_thread, args=(i,)) for i in range(50)
        ]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        conn.close()

        assert len(errors) == 0, f"Errors: {errors}"
        assert success_count[0] == 50


class TestGlobalStoreConcurrency:
    """Tests for global.db concurrent access."""

    def test_concurrent_global_store_writes(self, tmp_path: Path):
        """Concurrent writes to global.db should not cause corruption."""
        db_path = tmp_path / "global.db"
        result = create_global_store(db_path)
        assert isinstance(result, Success)
        conn = result.unwrap()
        conn.execute("PRAGMA journal_mode=WAL")
        conn.execute("PRAGMA busy_timeout=5000")  # 5 second timeout

        errors = []
        success_count = [0]
        lock = threading.Lock()

        def write_evidence(thread_id: int):
            try:
                thread_conn = sqlite3.connect(str(db_path))
                thread_conn.execute("PRAGMA journal_mode=WAL")
                thread_conn.execute("PRAGMA busy_timeout=5000")  # 5 second timeout
                # Insert into evidence table (extracted_at is required)
                thread_conn.execute(
                    """
                    INSERT INTO evidence
                    (source_project, source_session_id, source_rule_file, pattern,
                     summary, original_snippet, extracted_at, confidence)
                    VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                    """,
                    (
                        f"project-{thread_id}",
                        f"session-{thread_id}",
                        f"rule-{thread_id}.md",
                        f"pattern-{thread_id}",
                        f"summary-{thread_id}",
                        f"snippet-{thread_id}",
                        "2026-02-20T00:00:00Z",
                        0.9,
                    ),
                )
                thread_conn.commit()
                thread_conn.close()
                with lock:
                    success_count[0] += 1
            except Exception as e:
                with lock:
                    errors.append(str(e))

        threads = [
            threading.Thread(target=write_evidence, args=(i,)) for i in range(20)
        ]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        # Verify all records were inserted
        count = conn.execute("SELECT COUNT(*) FROM evidence").fetchone()[0]
        conn.close()

        assert len(errors) == 0, f"Errors: {errors}"
        assert count == 20


class TestLockTimeoutScenarios:
    """Tests for lock timeout and recovery scenarios."""

    def test_lock_remains_after_process_exit_simulation(self, tmp_path: Path):
        """Simulate what happens when a process exits without releasing lock."""
        lock_path = tmp_path / "evolve.lock"

        # Create and acquire lock
        lock_fd = open(lock_path, "w")
        fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)

        # Close without explicit unlock (simulating process crash)
        # Note: On Unix, locks are released when file descriptor is closed
        lock_fd.close()

        # New process should be able to acquire lock
        lock_fd2 = open(lock_path, "w")
        fcntl.flock(lock_fd2.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
        fcntl.flock(lock_fd2.fileno(), fcntl.LOCK_UN)
        lock_fd2.close()

    def test_stale_lock_file_content(self, tmp_path: Path):
        """Lock file contains process info for debugging."""
        lock_path = tmp_path / "evolve.lock"

        lock_fd = open(lock_path, "w")
        fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
        lock_fd.write("lattice evolve\n")
        lock_fd.flush()

        # Lock file should contain process info
        content = lock_path.read_text()
        assert "lattice" in content

        fcntl.flock(lock_fd.fileno(), fcntl.LOCK_UN)
        lock_fd.close()
