"""Tests for distributed locking module.

Uses fakeredis for Redis testing without requiring a real Redis server.
"""

from __future__ import annotations

import threading
import time
from pathlib import Path
from unittest.mock import patch

import pytest

# Import after path setup
try:
    import fakeredis

    FAKEREDIS_AVAILABLE = True
except ImportError:
    FAKEREDIS_AVAILABLE = False

from oclawma.distributed import (
    DEFAULT_LEADER_LOCK_TIMEOUT,
    DEFAULT_LOCK_TIMEOUT,
    FileLockManager,
    LeaderElection,
    LockConfig,
    LockError,
    LockTimeoutError,
    RedisLockManager,
    create_lock_manager,
)

# =============================================================================
# Fixtures
# =============================================================================


@pytest.fixture
def temp_lock_dir(tmp_path: Path) -> Path:
    """Create a temporary directory for file locks."""
    lock_dir = tmp_path / "locks"
    lock_dir.mkdir()
    return lock_dir


@pytest.fixture
def file_config(temp_lock_dir: Path) -> LockConfig:
    """Create a file-based lock configuration."""
    return LockConfig(
        backend="file",
        file_lock_dir=temp_lock_dir,
        lock_timeout=5,
        leader_lock_timeout=10,
    )


@pytest.fixture
def file_lock_manager(file_config: LockConfig) -> FileLockManager:
    """Create a file-based lock manager."""
    manager = FileLockManager(file_config)
    yield manager
    manager.close()


@pytest.fixture
def redis_config() -> LockConfig:
    """Create a Redis-based lock configuration."""
    return LockConfig(
        backend="redis",
        redis_host="localhost",
        redis_port=6379,
        redis_db=0,
        lock_timeout=5,
        leader_lock_timeout=10,
    )


@pytest.fixture
def mock_redis():
    """Create a mock Redis connection using fakeredis."""
    if not FAKEREDIS_AVAILABLE:
        pytest.skip("fakeredis not installed")
    return fakeredis.FakeRedis(decode_responses=True)


@pytest.fixture
def redis_lock_manager(redis_config: LockConfig, mock_redis) -> RedisLockManager:
    """Create a Redis-based lock manager with fakeredis."""
    # Create manager without connecting, then inject mock
    manager = object.__new__(RedisLockManager)
    manager.config = redis_config
    manager._instance_id = "test-instance"
    manager._locks = {}
    manager._lock = threading.RLock()
    manager._redis = mock_redis
    yield manager
    manager.close()


@pytest.fixture
def lock_configs(temp_lock_dir: Path) -> dict[str, LockConfig]:
    """Provide different lock configurations."""
    return {
        "file": LockConfig(backend="file", file_lock_dir=temp_lock_dir),
        "redis": LockConfig(backend="redis"),
    }


# =============================================================================
# LockConfig Tests
# =============================================================================


class TestLockConfig:
    """Test LockConfig dataclass."""

    def test_default_creation(self):
        """Test creating config with defaults."""
        config = LockConfig()

        assert config.backend == "auto"
        assert config.redis_host == "localhost"
        assert config.redis_port == 6379
        assert config.redis_db == 0
        assert config.lock_timeout == DEFAULT_LOCK_TIMEOUT
        assert config.leader_lock_timeout == DEFAULT_LEADER_LOCK_TIMEOUT
        assert config.enable_leader_election is True

    def test_custom_creation(self):
        """Test creating config with custom values."""
        config = LockConfig(
            backend="redis",
            redis_url="redis://localhost:6379/1",
            redis_host="redis.example.com",
            redis_port=6380,
            redis_db=2,
            redis_password="secret",
            lock_timeout=60,
            leader_lock_timeout=120,
            enable_leader_election=False,
        )

        assert config.backend == "redis"
        assert config.redis_url == "redis://localhost:6379/1"
        assert config.redis_host == "redis.example.com"
        assert config.redis_port == 6380
        assert config.redis_db == 2
        assert config.redis_password == "secret"
        assert config.lock_timeout == 60
        assert config.leader_lock_timeout == 120
        assert config.enable_leader_election is False

    def test_to_dict(self):
        """Test converting config to dictionary."""
        config = LockConfig(
            backend="redis",
            redis_password="secret",
        )
        data = config.to_dict()

        assert data["backend"] == "redis"
        assert data["redis_password"] is None  # Password should not be serialized
        assert data["lock_timeout"] == DEFAULT_LOCK_TIMEOUT

    def test_from_dict(self):
        """Test creating config from dictionary."""
        data = {
            "backend": "file",
            "file_lock_dir": "/tmp/locks",
            "lock_timeout": 30,
        }
        config = LockConfig.from_dict(data)

        assert config.backend == "file"
        assert config.file_lock_dir == Path("/tmp/locks")
        assert config.lock_timeout == 30


# =============================================================================
# FileLockManager Tests
# =============================================================================


class TestFileLockManager:
    """Test FileLockManager implementation."""

    def test_acquire_and_release(self, file_lock_manager: FileLockManager):
        """Test basic lock acquisition and release."""
        lock_name = "test_lock"

        # Acquire lock
        acquired = file_lock_manager.acquire_lock(lock_name)
        assert acquired is True

        # Check it's locked
        assert file_lock_manager.is_locked(lock_name) is True

        # Release lock
        released = file_lock_manager.release_lock(lock_name)
        assert released is True

        # Check it's unlocked
        assert file_lock_manager.is_locked(lock_name) is False

    def test_acquire_already_held(self, file_lock_manager: FileLockManager):
        """Test acquiring a lock already held by same instance."""
        lock_name = "test_lock"

        # Acquire lock
        assert file_lock_manager.acquire_lock(lock_name) is True

        # Try to acquire again (should succeed since we hold it)
        assert file_lock_manager.acquire_lock(lock_name) is True

    def test_release_not_held(self, file_lock_manager: FileLockManager):
        """Test releasing a lock not held."""
        lock_name = "test_lock"

        # Try to release lock we don't hold
        released = file_lock_manager.release_lock(lock_name)
        assert released is False

    def test_non_blocking_acquire(self, file_lock_manager: FileLockManager):
        """Test non-blocking lock acquisition."""
        lock_name = "test_lock"

        # Acquire lock in blocking mode
        assert file_lock_manager.acquire_lock(lock_name, blocking=True) is True

        # Try non-blocking acquire (should fail since we already hold it)
        # Note: FileLockManager tracks held locks internally, so this will succeed
        # because we already hold it
        acquired = file_lock_manager.acquire_lock(lock_name, blocking=False)
        assert acquired is True  # We already hold it

    def test_is_locked_unheld(self, file_lock_manager: FileLockManager):
        """Test is_locked for unheld lock."""
        lock_name = "test_lock"

        assert file_lock_manager.is_locked(lock_name) is False

    def test_extend_lock(self, file_lock_manager: FileLockManager):
        """Test extending lock expiration."""
        lock_name = "test_lock"

        # Acquire lock
        assert file_lock_manager.acquire_lock(lock_name) is True

        # Extend lock
        extended = file_lock_manager.extend_lock(lock_name, 60)
        assert extended is True

    def test_extend_lock_not_held(self, file_lock_manager: FileLockManager):
        """Test extending a lock not held."""
        lock_name = "test_lock"

        extended = file_lock_manager.extend_lock(lock_name, 60)
        assert extended is False

    def test_context_manager(self, file_config: LockConfig):
        """Test using lock manager as context manager."""
        lock_name = "test_lock"

        with FileLockManager(file_config) as manager:
            manager.acquire_lock(lock_name)
            assert manager.is_locked(lock_name) is True

        # After exiting context, lock should be released
        # (Though we can't easily verify without a new manager instance)

    def test_concurrent_locks(self, temp_lock_dir: Path):
        """Test concurrent lock acquisition from different managers."""
        config = LockConfig(backend="file", file_lock_dir=temp_lock_dir)
        lock_name = "concurrent_lock"
        results = []

        def acquire_lock():
            manager = FileLockManager(config)
            try:
                acquired = manager.acquire_lock(lock_name, blocking=False)
                results.append(acquired)
                if acquired:
                    time.sleep(0.1)  # Hold lock briefly
                    manager.release_lock(lock_name)
            finally:
                manager.close()

        # Start two threads trying to acquire the same lock
        threads = [threading.Thread(target=acquire_lock) for _ in range(2)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        # One should succeed, one should fail (non-blocking)
        assert results.count(True) == 1
        assert results.count(False) == 1

    def test_cross_process_locking(self, temp_lock_dir: Path):
        """Test that file locks work across processes."""
        import multiprocessing

        config = LockConfig(backend="file", file_lock_dir=temp_lock_dir)
        lock_name = "cross_process_lock"

        def try_acquire(result_queue):
            manager = FileLockManager(config)
            try:
                acquired = manager.acquire_lock(lock_name, blocking=False)
                result_queue.put(acquired)
                if acquired:
                    time.sleep(0.2)  # Hold lock
                    manager.release_lock(lock_name)
            finally:
                manager.close()

        result_queue = multiprocessing.Queue()

        # Start two processes
        p1 = multiprocessing.Process(target=try_acquire, args=(result_queue,))
        p2 = multiprocessing.Process(target=try_acquire, args=(result_queue,))

        p1.start()
        time.sleep(0.05)  # Let first process acquire lock
        p2.start()

        p1.join()
        p2.join()

        results = [result_queue.get(), result_queue.get()]
        assert results.count(True) == 1
        assert results.count(False) == 1


# =============================================================================
# RedisLockManager Tests
# =============================================================================


@pytest.mark.skipif(not FAKEREDIS_AVAILABLE, reason="fakeredis not installed")
class TestRedisLockManager:
    """Test RedisLockManager implementation with fakeredis."""

    def test_acquire_and_release(self, redis_lock_manager: RedisLockManager):
        """Test basic lock acquisition and release."""
        lock_name = "test_lock"

        # Acquire lock
        acquired = redis_lock_manager.acquire_lock(lock_name)
        assert acquired is True

        # Check it's locked
        assert redis_lock_manager.is_locked(lock_name) is True

        # Release lock
        released = redis_lock_manager.release_lock(lock_name)
        assert released is True

        # Check it's unlocked
        assert redis_lock_manager.is_locked(lock_name) is False

    def test_acquire_already_held_same_instance(self, redis_lock_manager: RedisLockManager):
        """Test acquiring lock already held by same instance updates value."""
        lock_name = "test_lock"

        # Acquire lock
        assert redis_lock_manager.acquire_lock(lock_name) is True

        # Try to acquire again (will succeed with new value since Redis lock is released)
        # Actually, with Redis, the lock will be held, so another acquire from different
        # instance would fail, but from same instance we track it locally
        acquired = redis_lock_manager.acquire_lock(lock_name, blocking=False)
        # This should be False because Redis still has the lock
        assert acquired is False

    def test_release_not_held(self, redis_lock_manager: RedisLockManager):
        """Test releasing a lock not held."""
        lock_name = "test_lock"

        released = redis_lock_manager.release_lock(lock_name)
        assert released is False

    def test_non_blocking_acquire(self, redis_lock_manager: RedisLockManager):
        """Test non-blocking lock acquisition."""
        lock_name = "test_lock"

        # First acquire should succeed
        assert redis_lock_manager.acquire_lock(lock_name, blocking=False) is True

        # Create a second manager with different instance ID
        config = LockConfig(backend="redis")
        manager2 = object.__new__(RedisLockManager)
        manager2.config = config
        manager2._instance_id = "test-instance-2"
        manager2._locks = {}
        manager2._lock = threading.RLock()
        manager2._redis = redis_lock_manager._redis

        # Second acquire should fail (lock held by first manager)
        acquired = manager2.acquire_lock(lock_name, blocking=False)
        assert acquired is False

        manager2.close()

    def test_lock_timeout(self, redis_config: LockConfig, mock_redis):
        """Test that locks expire after timeout."""
        # Create config with very short timeout
        config = LockConfig(
            backend="redis",
            lock_timeout=1,  # 1 second timeout
        )

        # Create manager without connecting
        manager = object.__new__(RedisLockManager)
        manager.config = config
        manager._instance_id = "test-instance-1"
        manager._locks = {}
        manager._lock = threading.RLock()
        manager._redis = mock_redis

        lock_name = "timeout_lock"

        # Acquire lock
        assert manager.acquire_lock(lock_name) is True

        # Wait for lock to expire
        time.sleep(1.5)

        # Lock should be expired now - we can acquire it again
        # Create new manager to simulate different instance
        manager2 = object.__new__(RedisLockManager)
        manager2.config = config
        manager2._instance_id = "test-instance-2"
        manager2._locks = {}
        manager2._lock = threading.RLock()
        manager2._redis = mock_redis

        acquired = manager2.acquire_lock(lock_name, blocking=False)
        assert acquired is True

        manager.close()
        manager2.close()

    def test_extend_lock(self, redis_lock_manager: RedisLockManager):
        """Test extending lock expiration."""
        lock_name = "test_lock"

        # Acquire lock
        assert redis_lock_manager.acquire_lock(lock_name) is True

        # Extend lock
        extended = redis_lock_manager.extend_lock(lock_name, 60)
        assert extended is True

        # Verify TTL was extended
        ttl = redis_lock_manager._redis.ttl(redis_lock_manager._get_lock_key(lock_name))
        assert ttl > 55  # Should be close to 60

    def test_extend_lock_not_held(self, redis_lock_manager: RedisLockManager):
        """Test extending a lock not held."""
        lock_name = "test_lock"

        extended = redis_lock_manager.extend_lock(lock_name, 60)
        assert extended is False

    def test_extend_lock_wrong_owner(self, redis_lock_manager: RedisLockManager, mock_redis):
        """Test extending a lock held by another instance."""
        lock_name = "test_lock"

        # Acquire lock with first manager
        assert redis_lock_manager.acquire_lock(lock_name) is True

        # Create second manager with different instance ID
        config = LockConfig(backend="redis")
        manager2 = object.__new__(RedisLockManager)
        manager2.config = config
        manager2._instance_id = "test-instance-2"
        manager2._locks = {}
        manager2._lock = threading.RLock()
        manager2._redis = mock_redis

        # Try to extend with second manager (should fail - not the owner)
        extended = manager2.extend_lock(lock_name, 60)
        assert extended is False

        manager2.close()

    def test_context_manager(self, redis_config: LockConfig, mock_redis):
        """Test using Redis lock manager as context manager."""
        lock_name = "test_lock"

        # Create manager without connecting
        manager = object.__new__(RedisLockManager)
        manager.config = redis_config
        manager._instance_id = "test-instance"
        manager._locks = {}
        manager._lock = threading.RLock()
        manager._redis = mock_redis

        with manager:
            manager.acquire_lock(lock_name)
            assert manager.is_locked(lock_name) is True

        # After exiting context, lock should be released
        # Note: is_locked() won't work after close() sets _redis to None
        # So we check by trying to acquire with a new manager
        manager2 = object.__new__(RedisLockManager)
        manager2.config = redis_config
        manager2._instance_id = "test-instance-2"
        manager2._locks = {}
        manager2._lock = threading.RLock()
        manager2._redis = mock_redis

        # Should be able to acquire since lock was released
        acquired = manager2.acquire_lock(lock_name, blocking=False)
        assert acquired is True

        manager2.close()

    def test_lock_value_uniqueness(self, redis_lock_manager: RedisLockManager):
        """Test that lock values are unique per instance."""
        lock_name = "test_lock"

        # Acquire lock
        redis_lock_manager.acquire_lock(lock_name)

        # Check the stored value matches our instance
        lock_key = redis_lock_manager._get_lock_key(lock_name)
        stored_value = redis_lock_manager._redis.get(lock_key)
        assert stored_value.startswith(redis_lock_manager._instance_id)


# =============================================================================
# LeaderElection Tests
# =============================================================================


class TestLeaderElection:
    """Test LeaderElection functionality."""

    def test_acquire_leadership_file(self, file_lock_manager: FileLockManager):
        """Test acquiring leadership with file-based locks."""
        election = LeaderElection(file_lock_manager)

        # Acquire leadership
        acquired = election.acquire_leadership()
        assert acquired is True
        assert election.is_leader() is True

        # Clean up
        election.release_leadership()

    def test_release_leadership(self, file_lock_manager: FileLockManager):
        """Test releasing leadership."""
        election = LeaderElection(file_lock_manager)

        # Acquire and then release
        election.acquire_leadership()
        assert election.is_leader() is True

        election.release_leadership()
        assert election.is_leader() is False

    def test_only_one_leader_file(self, temp_lock_dir: Path):
        """Test that only one instance can be leader with file locks."""
        config = LockConfig(backend="file", file_lock_dir=temp_lock_dir)
        manager1 = FileLockManager(config)
        manager2 = FileLockManager(config)

        election1 = LeaderElection(manager1)
        election2 = LeaderElection(manager2)

        try:
            # First instance becomes leader
            assert election1.acquire_leadership(blocking=False) is True
            assert election1.is_leader() is True

            # Second instance should fail to become leader
            assert election2.acquire_leadership(blocking=False) is False
            assert election2.is_leader() is False

        finally:
            election1.release_leadership()
            election2.release_leadership()
            manager1.close()
            manager2.close()

    @pytest.mark.skipif(not FAKEREDIS_AVAILABLE, reason="fakeredis not installed")
    def test_acquire_leadership_redis(self, redis_lock_manager: RedisLockManager):
        """Test acquiring leadership with Redis locks."""
        election = LeaderElection(redis_lock_manager)

        acquired = election.acquire_leadership()
        assert acquired is True
        assert election.is_leader() is True

        election.release_leadership()

    @pytest.mark.skipif(not FAKEREDIS_AVAILABLE, reason="fakeredis not installed")
    def test_only_one_leader_redis(self, redis_config: LockConfig, mock_redis):
        """Test that only one instance can be leader with Redis locks."""
        manager1 = object.__new__(RedisLockManager)
        manager1.config = redis_config
        manager1._instance_id = "test-instance-1"
        manager1._locks = {}
        manager1._lock = threading.RLock()
        manager1._redis = mock_redis

        manager2 = object.__new__(RedisLockManager)
        manager2.config = redis_config
        manager2._instance_id = "test-instance-2"
        manager2._locks = {}
        manager2._lock = threading.RLock()
        manager2._redis = mock_redis

        election1 = LeaderElection(manager1)
        election2 = LeaderElection(manager2)

        try:
            # First instance becomes leader
            assert election1.acquire_leadership(blocking=False) is True

            # Second instance should fail
            assert election2.acquire_leadership(blocking=False) is False

        finally:
            election1.release_leadership()
            election2.release_leadership()
            manager1.close()
            manager2.close()

    def test_context_manager(self, file_lock_manager: FileLockManager):
        """Test leader election as context manager."""
        election = LeaderElection(file_lock_manager)

        with election:
            assert election.is_leader() is True

        # After exiting context
        assert election.is_leader() is False

    def test_custom_lock_key(self, file_lock_manager: FileLockManager):
        """Test using a custom lock key."""
        custom_key = "custom:leader:key"
        election = LeaderElection(file_lock_manager, lock_key=custom_key)

        election.acquire_leadership()
        assert election.is_leader() is True

        # Verify the lock was created with custom key
        assert file_lock_manager.is_locked(custom_key) is True

        election.release_leadership()


# =============================================================================
# Factory Function Tests
# =============================================================================


class TestCreateLockManager:
    """Test create_lock_manager factory function."""

    def test_create_file_backend(self, temp_lock_dir: Path):
        """Test creating file-based lock manager explicitly."""
        config = LockConfig(backend="file", file_lock_dir=temp_lock_dir)
        manager = create_lock_manager(config)

        assert isinstance(manager, FileLockManager)
        manager.close()

    @pytest.mark.skipif(not FAKEREDIS_AVAILABLE, reason="fakeredis not installed")
    def test_create_redis_backend(self):
        """Test creating Redis lock manager explicitly."""
        config = LockConfig(backend="redis")

        # Create manager without connecting
        manager = object.__new__(RedisLockManager)
        manager.config = config
        manager._instance_id = "test-instance"
        manager._locks = {}
        manager._lock = threading.RLock()
        manager._redis = fakeredis.FakeRedis(decode_responses=True)

        assert isinstance(manager, RedisLockManager)
        manager.close()

    def test_create_auto_fallback_to_file(self, temp_lock_dir: Path):
        """Test auto backend falls back to file when Redis not available."""
        config = LockConfig(backend="auto", file_lock_dir=temp_lock_dir)

        # Mock REDIS_AVAILABLE to False to force fallback
        with patch("oclawma.distributed.REDIS_AVAILABLE", False):
            manager = create_lock_manager(config)
            assert isinstance(manager, FileLockManager)
            manager.close()

    def test_create_unknown_backend(self):
        """Test creating with unknown backend raises error."""
        config = LockConfig(backend="unknown")

        with pytest.raises(LockError, match="Unknown lock backend"):
            create_lock_manager(config)


# =============================================================================
# Integration Tests
# =============================================================================


class TestDistributedLockingIntegration:
    """Integration tests for distributed locking."""

    def test_file_lock_persistence(self, temp_lock_dir: Path):
        """Test that file locks persist across manager instances."""
        config = LockConfig(backend="file", file_lock_dir=temp_lock_dir)
        lock_name = "persistent_lock"

        # Create manager and acquire lock
        manager1 = FileLockManager(config)
        manager1.acquire_lock(lock_name)

        # Create new manager instance
        manager2 = FileLockManager(config)

        # Second manager should see the lock as held
        assert manager2.is_locked(lock_name) is True

        # But shouldn't be able to acquire it non-blocking
        acquired = manager2.acquire_lock(lock_name, blocking=False)
        assert acquired is False

        manager1.close()
        manager2.close()

    def test_multiple_locks_same_manager(self, file_lock_manager: FileLockManager):
        """Test holding multiple locks with same manager."""
        lock_names = ["lock_a", "lock_b", "lock_c"]

        # Acquire all locks
        for name in lock_names:
            assert file_lock_manager.acquire_lock(name) is True

        # Verify all are locked
        for name in lock_names:
            assert file_lock_manager.is_locked(name) is True

        # Release all locks
        for name in lock_names:
            assert file_lock_manager.release_lock(name) is True

        # Verify all are unlocked
        for name in lock_names:
            assert file_lock_manager.is_locked(name) is False

    def test_lock_cleanup_on_close(self, file_config: LockConfig):
        """Test that locks are cleaned up when manager closes."""
        lock_name = "cleanup_lock"

        manager = FileLockManager(file_config)
        manager.acquire_lock(lock_name)
        assert manager.is_locked(lock_name) is True

        manager.close()

        # After close, we should be able to acquire with a new manager
        manager2 = FileLockManager(file_config)
        acquired = manager2.acquire_lock(lock_name, blocking=False)
        assert acquired is True  # Lock was released on close

        manager2.close()

    @pytest.mark.slow
    def test_lock_timeout_blocking(self, temp_lock_dir: Path):
        """Test blocking lock acquisition with timeout."""
        config = LockConfig(backend="file", file_lock_dir=temp_lock_dir)
        lock_name = "timeout_lock"

        # Create two separate manager instances
        manager1 = FileLockManager(config)
        manager2 = FileLockManager(config)

        try:
            # Acquire lock with first manager
            manager1.acquire_lock(lock_name)

            # Try to acquire with second manager with timeout (should timeout)
            start = time.time()
            with pytest.raises(LockTimeoutError):
                manager2.acquire_lock(lock_name, timeout=0.5, blocking=True)
            elapsed = time.time() - start

            assert elapsed >= 0.5
        finally:
            manager1.close()
            manager2.close()


# =============================================================================
# Error Handling Tests
# =============================================================================


class TestErrorHandling:
    """Test error handling in distributed locking."""

    def test_file_lock_directory_creation(self, tmp_path: Path):
        """Test that lock directory is created if it doesn't exist."""
        lock_dir = tmp_path / "nonexistent" / "locks"
        config = LockConfig(backend="file", file_lock_dir=lock_dir)

        manager = FileLockManager(config)
        assert lock_dir.exists()
        manager.close()

    def test_redis_not_installed(self):
        """Test error when Redis backend requested but not installed."""
        config = LockConfig(backend="redis")

        with (
            patch("oclawma.distributed.REDIS_AVAILABLE", False),
            pytest.raises(LockError, match="Redis backend requested"),
        ):
            create_lock_manager(config)
