"""Distributed locking for multi-instance Oclawma deployments.

This module provides distributed locking mechanisms to prevent duplicate job execution
when running multiple Oclawma instances. It supports Redis for true distributed locking
and file-based locking for single-node deployments.

Features:
- Abstract LockManager interface
- Redis-based distributed locking (via redis-py)
- File-based locking fallback for single-node deployments
- Leader election for scheduler coordination
- Automatic lock renewal and expiration
"""

from __future__ import annotations

import fcntl
import logging
import os
import threading
import time
import uuid
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

# Optional redis import - only required for RedisLockManager
try:
    import redis
    from redis.exceptions import RedisError

    REDIS_AVAILABLE = True
except ImportError:
    redis = None  # type: ignore
    RedisError = Exception  # type: ignore
    REDIS_AVAILABLE = False

logger = logging.getLogger(__name__)

# Default configuration
DEFAULT_LOCK_TIMEOUT = 30  # seconds
DEFAULT_LOCK_RETRY_DELAY = 0.1  # seconds
DEFAULT_LEADER_LOCK_KEY = "oclawma:leader"
DEFAULT_LEADER_LOCK_TIMEOUT = 60  # seconds - longer for leader election


class LockError(Exception):
    """Raised when there's an error acquiring or releasing a lock."""

    pass


class LockTimeoutError(LockError):
    """Raised when a lock acquisition times out."""

    pass


@dataclass
class LockConfig:
    """Configuration for distributed locking.

    Attributes:
        backend: Lock backend type ("redis", "file", "auto")
        redis_url: Redis connection URL (for redis backend)
        redis_host: Redis hostname (for redis backend)
        redis_port: Redis port (for redis backend)
        redis_db: Redis database number (for redis backend)
        redis_password: Redis password (for redis backend)
        file_lock_dir: Directory for file-based locks
        lock_timeout: Default lock timeout in seconds
        leader_lock_timeout: Leader election lock timeout in seconds
        enable_leader_election: Whether to enable leader election for scheduler
    """

    backend: str = "auto"  # "redis", "file", "auto"
    redis_url: str | None = None
    redis_host: str = "localhost"
    redis_port: int = 6379
    redis_db: int = 0
    redis_password: str | None = None
    file_lock_dir: str | Path = field(default_factory=lambda: Path.home() / ".oclawma" / "locks")
    lock_timeout: int = DEFAULT_LOCK_TIMEOUT
    leader_lock_timeout: int = DEFAULT_LEADER_LOCK_TIMEOUT
    enable_leader_election: bool = True

    def to_dict(self) -> dict[str, Any]:
        """Convert configuration to dictionary."""
        return {
            "backend": self.backend,
            "redis_url": self.redis_url,
            "redis_host": self.redis_host,
            "redis_port": self.redis_port,
            "redis_db": self.redis_db,
            "redis_password": None,  # Never serialize passwords
            "file_lock_dir": str(self.file_lock_dir),
            "lock_timeout": self.lock_timeout,
            "leader_lock_timeout": self.leader_lock_timeout,
            "enable_leader_election": self.enable_leader_election,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> LockConfig:
        """Create configuration from dictionary."""
        return cls(
            backend=data.get("backend", "auto"),
            redis_url=data.get("redis_url"),
            redis_host=data.get("redis_host", "localhost"),
            redis_port=data.get("redis_port", 6379),
            redis_db=data.get("redis_db", 0),
            redis_password=data.get("redis_password"),
            file_lock_dir=Path(data.get("file_lock_dir", Path.home() / ".oclawma" / "locks")),
            lock_timeout=data.get("lock_timeout", DEFAULT_LOCK_TIMEOUT),
            leader_lock_timeout=data.get("leader_lock_timeout", DEFAULT_LEADER_LOCK_TIMEOUT),
            enable_leader_election=data.get("enable_leader_election", True),
        )


class LockManager(ABC):
    """Abstract base class for distributed lock managers.

    Implementations must provide thread-safe lock acquisition and release.
    """

    def __init__(self, config: LockConfig | None = None) -> None:
        """Initialize the lock manager.

        Args:
            config: Lock configuration
        """
        self.config = config or LockConfig()
        self._instance_id = str(uuid.uuid4())[:8]
        self._locks: dict[str, Any] = {}
        self._lock = threading.RLock()

    @abstractmethod
    def acquire_lock(
        self,
        lock_name: str,
        timeout: float | None = None,
        blocking: bool = True,
        lock_timeout: float | None = None,
    ) -> bool:
        """Acquire a distributed lock.

        Args:
            lock_name: Unique name for the lock
            timeout: Maximum time to wait for lock (None = forever)
            blocking: If False, return immediately if lock unavailable
            lock_timeout: How long the lock is valid (None = use default)

        Returns:
            True if lock was acquired, False otherwise

        Raises:
            LockTimeoutError: If timeout is reached while waiting for lock
            LockError: If there's an error acquiring the lock
        """
        pass

    @abstractmethod
    def release_lock(self, lock_name: str) -> bool:
        """Release a distributed lock.

        Args:
            lock_name: Name of the lock to release

        Returns:
            True if lock was released, False if not held

        Raises:
            LockError: If there's an error releasing the lock
        """
        pass

    @abstractmethod
    def is_locked(self, lock_name: str) -> bool:
        """Check if a lock is currently held.

        Args:
            lock_name: Name of the lock to check

        Returns:
            True if lock is held, False otherwise
        """
        pass

    @abstractmethod
    def extend_lock(self, lock_name: str, additional_time: float) -> bool:
        """Extend the expiration time of a held lock.

        Args:
            lock_name: Name of the lock to extend
            additional_time: Additional seconds to add to lock timeout

        Returns:
            True if lock was extended, False if not held
        """
        pass

    def close(self) -> None:
        """Clean up resources and release all held locks."""
        with self._lock:
            for lock_name in list(self._locks.keys()):
                try:
                    self.release_lock(lock_name)
                except Exception as e:
                    logger.warning(f"Error releasing lock {lock_name} during cleanup: {e}")
            self._locks.clear()

    def __enter__(self) -> LockManager:
        """Context manager entry."""
        return self

    def __exit__(self, *args: Any) -> None:
        """Context manager exit - release all locks."""
        self.close()


class RedisLockManager(LockManager):
    """Redis-based distributed lock manager.

    Uses Redis SET with NX (only if not exists) and EX (expiration) for atomic
    lock acquisition. Supports lock extension and automatic cleanup.
    """

    def __init__(self, config: LockConfig | None = None) -> None:
        """Initialize Redis lock manager.

        Args:
            config: Lock configuration with Redis settings

        Raises:
            LockError: If Redis is not available
        """
        if not REDIS_AVAILABLE:
            raise LockError("Redis is not installed. Install with: pip install redis")

        super().__init__(config)
        self._redis: redis.Redis | None = None
        self._connect()

    def _connect(self) -> None:
        """Establish connection to Redis."""
        try:
            if self.config.redis_url:
                self._redis = redis.from_url(
                    self.config.redis_url,
                    decode_responses=True,
                )
            else:
                self._redis = redis.Redis(
                    host=self.config.redis_host,
                    port=self.config.redis_port,
                    db=self.config.redis_db,
                    password=self.config.redis_password,
                    decode_responses=True,
                )
            # Test connection
            self._redis.ping()
            logger.debug("Connected to Redis for distributed locking")
        except RedisError as e:
            raise LockError(f"Failed to connect to Redis: {e}") from e

    def _get_lock_key(self, lock_name: str) -> str:
        """Generate Redis key for a lock."""
        return f"oclawma:lock:{lock_name}"

    def _get_lock_value(self) -> str:
        """Generate unique lock value (instance ID + timestamp)."""
        return f"{self._instance_id}:{time.time()}"

    def acquire_lock(
        self,
        lock_name: str,
        timeout: float | None = None,
        blocking: bool = True,
        lock_timeout: float | None = None,
    ) -> bool:
        """Acquire a Redis-based distributed lock.

        Uses SET NX EX for atomic lock acquisition.
        """
        if self._redis is None:
            raise LockError("Redis connection not established")

        lock_key = self._get_lock_key(lock_name)
        lock_value = self._get_lock_value()
        expiry = int(lock_timeout or self.config.lock_timeout)

        start_time = time.time()

        while True:
            try:
                # Try to acquire lock with SET NX EX
                acquired = self._redis.set(lock_key, lock_value, nx=True, ex=expiry)

                if acquired:
                    with self._lock:
                        self._locks[lock_name] = {
                            "value": lock_value,
                            "expires_at": time.time() + expiry,
                        }
                    logger.debug(f"Acquired lock '{lock_name}' with value {lock_value}")
                    return True

                if not blocking:
                    return False

                # Check timeout
                if timeout is not None:
                    elapsed = time.time() - start_time
                    if elapsed >= timeout:
                        raise LockTimeoutError(
                            f"Timeout waiting for lock '{lock_name}' after {timeout}s"
                        )

                # Wait before retry
                time.sleep(DEFAULT_LOCK_RETRY_DELAY)

            except RedisError as e:
                raise LockError(f"Redis error acquiring lock '{lock_name}': {e}") from e

    def release_lock(self, lock_name: str) -> bool:
        """Release a Redis-based distributed lock.

        Uses Lua script for atomic check-and-delete to ensure we only
        delete our own lock. Falls back to non-atomic get/delete for
        compatibility with testing libraries like fakeredis.
        """
        if self._redis is None:
            raise LockError("Redis connection not established")

        with self._lock:
            lock_info = self._locks.get(lock_name)
            if not lock_info:
                logger.debug(f"Lock '{lock_name}' not held by this instance")
                return False

            lock_key = self._get_lock_key(lock_name)
            lock_value = lock_info["value"]

            try:
                # Try Lua script first (atomic) - preferred for production
                lua_script = """
                if redis.call("get", KEYS[1]) == ARGV[1] then
                    return redis.call("del", KEYS[1])
                else
                    return 0
                end
                """
                result = self._redis.eval(lua_script, 1, lock_key, lock_value)

                if result:
                    del self._locks[lock_name]
                    logger.debug(f"Released lock '{lock_name}'")
                    return True
                else:
                    logger.warning(f"Lock '{lock_name}' was held by another instance")
                    del self._locks[lock_name]
                    return False

            except Exception as e:
                # Fallback for testing environments (e.g., fakeredis without Lua)
                if "unknown command" in str(e).lower() or "eval" in str(e).lower():
                    try:
                        # Non-atomic get/delete (acceptable for testing)
                        stored_value = self._redis.get(lock_key)
                        if stored_value == lock_value:
                            self._redis.delete(lock_key)
                            del self._locks[lock_name]
                            logger.debug(f"Released lock '{lock_name}' (fallback)")
                            return True
                        else:
                            logger.warning(f"Lock '{lock_name}' was held by another instance")
                            del self._locks[lock_name]
                            return False
                    except RedisError as e2:
                        raise LockError(f"Redis error releasing lock '{lock_name}': {e2}") from e2
                else:
                    raise LockError(f"Redis error releasing lock '{lock_name}': {e}") from e

    def is_locked(self, lock_name: str) -> bool:
        """Check if a lock is currently held (by any instance)."""
        if self._redis is None:
            raise LockError("Redis connection not established")

        lock_key = self._get_lock_key(lock_name)
        try:
            return bool(self._redis.exists(lock_key))
        except RedisError as e:
            raise LockError(f"Redis error checking lock '{lock_name}': {e}") from e

    def extend_lock(self, lock_name: str, additional_time: float) -> bool:
        """Extend the expiration time of a held lock.

        Uses Lua script to verify ownership before extending.
        Falls back to non-atomic get/expire for compatibility.
        """
        if self._redis is None:
            raise LockError("Redis connection not established")

        with self._lock:
            lock_info = self._locks.get(lock_name)
            if not lock_info:
                return False

            lock_key = self._get_lock_key(lock_name)
            lock_value = lock_info["value"]

            try:
                # Try Lua script first (atomic) - preferred for production
                lua_script = """
                if redis.call("get", KEYS[1]) == ARGV[1] then
                    return redis.call("expire", KEYS[1], ARGV[2])
                else
                    return 0
                end
                """
                result = self._redis.eval(lua_script, 1, lock_key, lock_value, int(additional_time))

                if result:
                    self._locks[lock_name]["expires_at"] = time.time() + additional_time
                    logger.debug(f"Extended lock '{lock_name}' by {additional_time}s")
                    return True
                return False

            except Exception as e:
                # Fallback for testing environments
                if "unknown command" in str(e).lower() or "eval" in str(e).lower():
                    try:
                        stored_value = self._redis.get(lock_key)
                        if stored_value == lock_value:
                            self._redis.expire(lock_key, int(additional_time))
                            self._locks[lock_name]["expires_at"] = time.time() + additional_time
                            logger.debug(
                                f"Extended lock '{lock_name}' by {additional_time}s (fallback)"
                            )
                            return True
                        return False
                    except RedisError as e2:
                        raise LockError(f"Redis error extending lock '{lock_name}': {e2}") from e2
                else:
                    raise LockError(f"Redis error extending lock '{lock_name}': {e}") from e

    def close(self) -> None:
        """Clean up Redis connection and release all locks."""
        super().close()
        if self._redis:
            try:
                self._redis.close()
            except Exception as e:
                logger.warning(f"Error closing Redis connection: {e}")
            finally:
                self._redis = None


class FileLockManager(LockManager):
    """File-based lock manager for single-node deployments.

    Uses POSIX file locking (fcntl) for inter-process synchronization.
    This is suitable for single-node deployments where Redis is not available.
    """

    def __init__(self, config: LockConfig | None = None) -> None:
        """Initialize file-based lock manager.

        Args:
            config: Lock configuration with file_lock_dir setting
        """
        super().__init__(config)
        self._lock_dir = Path(self.config.file_lock_dir)
        self._lock_dir.mkdir(parents=True, exist_ok=True)
        self._file_handles: dict[str, Any] = {}

    def _get_lock_file(self, lock_name: str) -> Path:
        """Get path to lock file for a given lock name."""
        # Sanitize lock name for filesystem
        safe_name = "".join(c if c.isalnum() or c in "_-" else "_" for c in lock_name)
        return self._lock_dir / f"{safe_name}.lock"

    def acquire_lock(
        self,
        lock_name: str,
        timeout: float | None = None,
        blocking: bool = True,
        lock_timeout: float | None = None,
    ) -> bool:
        """Acquire a file-based lock.

        Uses fcntl.flock for POSIX file locking.
        """
        import errno

        lock_file = self._get_lock_file(lock_name)

        with self._lock:
            # Check if we already hold this lock
            if lock_name in self._file_handles:
                logger.debug(f"Lock '{lock_name}' already held by this instance")
                return True

        # Open or create the lock file
        try:
            fd = os.open(str(lock_file), os.O_CREAT | os.O_RDWR)
            file_handle = os.fdopen(fd, "r+")
        except OSError as e:
            raise LockError(f"Failed to open lock file for '{lock_name}': {e}") from e

        start_time = time.time()

        while True:
            try:
                # Always use non-blocking to implement timeout in Python
                lock_flags = fcntl.LOCK_EX | fcntl.LOCK_NB

                fcntl.flock(file_handle.fileno(), lock_flags)

                # Write our instance ID to the file
                file_handle.seek(0)
                file_handle.truncate()
                file_handle.write(self._instance_id)
                file_handle.flush()

                with self._lock:
                    self._file_handles[lock_name] = file_handle
                    self._locks[lock_name] = {
                        "expires_at": time.time() + (lock_timeout or self.config.lock_timeout),
                    }

                logger.debug(f"Acquired file lock '{lock_name}'")
                return True

            except OSError as e:
                # Check if it's a lock contention error (EAGAIN or EACCES)
                if e.errno not in (errno.EAGAIN, errno.EACCES, errno.EWOULDBLOCK):
                    # Some other error - re-raise
                    file_handle.close()
                    raise

                if not blocking:
                    file_handle.close()
                    return False

                # Check timeout
                if timeout is not None:
                    elapsed = time.time() - start_time
                    if elapsed >= timeout:
                        file_handle.close()
                        raise LockTimeoutError(
                            f"Timeout waiting for lock '{lock_name}' after {timeout}s"
                        ) from None

                # Wait before retry
                time.sleep(DEFAULT_LOCK_RETRY_DELAY)

    def release_lock(self, lock_name: str) -> bool:
        """Release a file-based lock."""
        with self._lock:
            file_handle = self._file_handles.get(lock_name)
            if not file_handle:
                return False

            try:
                fcntl.flock(file_handle.fileno(), fcntl.LOCK_UN)
                file_handle.close()
                del self._file_handles[lock_name]
                del self._locks[lock_name]
                logger.debug(f"Released file lock '{lock_name}'")
                return True
            except OSError as e:
                raise LockError(f"Error releasing file lock '{lock_name}': {e}") from e

    def is_locked(self, lock_name: str) -> bool:
        """Check if a file-based lock is held.

        Note: This attempts to acquire the lock non-blocking and immediately
        releases it. This is not race-condition free but gives a good indication.
        """
        lock_file = self._get_lock_file(lock_name)

        if not lock_file.exists():
            return False

        # Check if we hold the lock
        with self._lock:
            if lock_name in self._file_handles:
                return True

        # Try to acquire non-blocking
        try:
            fd = os.open(str(lock_file), os.O_RDWR)
            try:
                fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
                # We got the lock, so it wasn't locked
                fcntl.flock(fd, fcntl.LOCK_UN)
                return False
            except OSError:
                return True
            finally:
                os.close(fd)
        except OSError:
            return False

    def extend_lock(self, lock_name: str, additional_time: float) -> bool:
        """Extend the expiration time of a held lock.

        For file-based locks, this just updates the internal tracking.
        The actual lock doesn't expire automatically.
        """
        with self._lock:
            if lock_name not in self._locks:
                return False
            self._locks[lock_name]["expires_at"] = time.time() + additional_time
            return True

    def close(self) -> None:
        """Clean up and release all file locks."""
        super().close()
        with self._lock:
            for file_handle in self._file_handles.values():
                try:
                    fcntl.flock(file_handle.fileno(), fcntl.LOCK_UN)
                    file_handle.close()
                except Exception as e:
                    logger.warning(f"Error closing file handle: {e}")
            self._file_handles.clear()


class LeaderElection:
    """Leader election for distributed scheduler coordination.

    Ensures only one instance runs scheduled cron jobs across a cluster.
    Uses a distributed lock with a longer timeout for leader election.
    """

    def __init__(self, lock_manager: LockManager, lock_key: str | None = None) -> None:
        """Initialize leader election.

        Args:
            lock_manager: The lock manager to use
            lock_key: Custom lock key for leader election (default: oclawma:leader)
        """
        self.lock_manager = lock_manager
        self.lock_key = lock_key or DEFAULT_LEADER_LOCK_KEY
        self._is_leader = False
        self._renewal_thread: threading.Thread | None = None
        self._stop_renewal_event = threading.Event()

    def acquire_leadership(self, blocking: bool = False, timeout: float | None = None) -> bool:
        """Attempt to become the leader.

        Args:
            blocking: If True, block until leadership is acquired
            timeout: Maximum time to wait for leadership

        Returns:
            True if this instance is now the leader
        """
        try:
            acquired = self.lock_manager.acquire_lock(
                lock_name=self.lock_key,
                timeout=timeout,
                blocking=blocking,
                lock_timeout=self.lock_manager.config.leader_lock_timeout,
            )
            self._is_leader = acquired

            if acquired:
                logger.info(f"Instance {self.lock_manager._instance_id} became leader")
                self._start_renewal()
            else:
                logger.debug(f"Instance {self.lock_manager._instance_id} failed to become leader")

            return acquired
        except LockTimeoutError:
            self._is_leader = False
            return False

    def release_leadership(self) -> bool:
        """Release leadership.

        Returns:
            True if leadership was released
        """
        self._stop_renewal()
        self._is_leader = False
        return self.lock_manager.release_lock(self.lock_key)

    def is_leader(self) -> bool:
        """Check if this instance is currently the leader.

        Note: This checks the local state. For a definitive check,
        the lock would need to be verified, but that would require
        holding the lock.
        """
        return self._is_leader

    def _start_renewal(self) -> None:
        """Start the background lock renewal thread."""
        if self._renewal_thread and self._renewal_thread.is_alive():
            return

        self._stop_renewal_event.clear()
        self._renewal_thread = threading.Thread(
            target=self._renewal_loop,
            daemon=True,
            name="leader-renewal",
        )
        self._renewal_thread.start()

    def _stop_renewal(self) -> None:
        """Stop the lock renewal thread."""
        if self._renewal_thread:
            self._stop_renewal_event.set()
            self._renewal_thread.join(timeout=5)
            self._renewal_thread = None

    def _renewal_loop(self) -> None:
        """Background thread that periodically renews the leader lock."""
        # Renew at 1/3 of the lock timeout to ensure we don't lose leadership
        renew_interval = self.lock_manager.config.leader_lock_timeout / 3

        while not self._stop_renewal_event.is_set():
            self._stop_renewal_event.wait(renew_interval)
            if self._stop_renewal_event.is_set():
                break

            try:
                success = self.lock_manager.extend_lock(
                    self.lock_key,
                    self.lock_manager.config.leader_lock_timeout,
                )
                if not success:
                    logger.warning("Failed to renew leader lock - may lose leadership")
                    self._is_leader = False
                    break
            except Exception as e:
                logger.error(f"Error renewing leader lock: {e}")
                self._is_leader = False
                break

    def __enter__(self) -> LeaderElection:
        """Context manager entry - acquire leadership."""
        self.acquire_leadership()
        return self

    def __exit__(self, *args: Any) -> None:
        """Context manager exit - release leadership."""
        self.release_leadership()


def create_lock_manager(config: LockConfig | None = None) -> LockManager:
    """Factory function to create the appropriate lock manager.

    Automatically selects Redis if available and configured, otherwise
    falls back to file-based locking.

    Args:
        config: Lock configuration (uses defaults if None)

    Returns:
        Configured LockManager instance
    """
    cfg = config or LockConfig()

    if cfg.backend == "redis":
        if not REDIS_AVAILABLE:
            raise LockError(
                "Redis backend requested but redis-py is not installed. "
                "Install with: pip install redis"
            )
        return RedisLockManager(cfg)

    elif cfg.backend == "file":
        return FileLockManager(cfg)

    elif cfg.backend == "auto":
        # Try Redis first, fall back to file
        if REDIS_AVAILABLE:
            try:
                manager = RedisLockManager(cfg)
                logger.info("Using Redis for distributed locking")
                return manager
            except LockError as e:
                logger.warning(f"Redis not available ({e}), falling back to file-based locking")
                return FileLockManager(cfg)
        else:
            logger.info("Redis not installed, using file-based locking")
            return FileLockManager(cfg)

    else:
        raise LockError(f"Unknown lock backend: {cfg.backend}")
