"""Main RedisFs class providing unified file-system-like storage API."""

from __future__ import annotations

import asyncio
import logging
from dataclasses import dataclass, field
from functools import cached_property
from pathlib import Path
from typing import TYPE_CHECKING, Any

from redis_fs.adapter import wrap_if_upstash
from redis_fs.exceptions import ConfigurationError, ConnectionError, NotFoundError
from redis_fs import lua_scripts

if TYPE_CHECKING:
    from redis.asyncio import Redis

logger = logging.getLogger("redis_fs")
logger.addHandler(logging.NullHandler())


@dataclass
class RedisFs:
    """Unified file-system-like storage API with Redis and local filesystem backends.

    Configuration determines the operating mode:
    - Redis + FS: Dual backend mode with Redis as source of truth
    - Redis only: Redis-only mode
    - FS only: Local filesystem-only mode

    At least one of `redis` or `fs_root` must be provided.

    Attributes:
        redis: Redis connection (URL string, redis-py client, or upstash-redis client).
        redis_prefix: Prefix for all Redis keys.
        fs_root: Root path for local filesystem operations.
    """

    redis: str | Any | None = None
    redis_prefix: str = ""
    fs_root: Path | str | None = None

    # Internal state (not constructor params)
    _sync_task: asyncio.Task[None] | None = field(default=None, init=False, repr=False)
    _sync_running: bool = field(default=False, init=False, repr=False)
    _pubsub: Any = field(default=None, init=False, repr=False)

    def __post_init__(self) -> None:
        """Validate configuration after initialization."""
        if self.redis is None and self.fs_root is None:
            raise ConfigurationError(
                "At least one of 'redis' or 'fs_root' must be provided"
            )

        # Normalize fs_root to Path
        if self.fs_root is not None:
            self.fs_root = Path(self.fs_root)

    @cached_property
    def _redis_client(self) -> "Redis":
        """Lazily create and return the Redis client.

        Handles URL strings, redis-py clients, and upstash-redis clients.
        """
        if self.redis is None:
            raise ConfigurationError("Redis not configured")

        if isinstance(self.redis, str):
            # URL string - create redis-py client
            from redis.asyncio import Redis

            return Redis.from_url(self.redis, decode_responses=False)
        else:
            # Client instance - wrap if upstash
            return wrap_if_upstash(self.redis)

    @cached_property
    def _scripts(self) -> dict[str, Any]:
        """Register and cache Lua scripts."""
        client = self._redis_client
        return {
            "write_file": client.register_script(lua_scripts.WRITE_FILE),
            "append_text": client.register_script(lua_scripts.APPEND_TEXT),
            "append_bytes": client.register_script(lua_scripts.APPEND_BYTES),
            "delete": client.register_script(lua_scripts.DELETE),
            "bulk_cleanup": client.register_script(lua_scripts.BULK_CLEANUP),
            "read_file": client.register_script(lua_scripts.READ_FILE),
            "read_version": client.register_script(lua_scripts.READ_VERSION),
            "get_version": client.register_script(lua_scripts.GET_VERSION),
        }

    @property
    def _version_key(self) -> str:
        """Get the Redis key for the version counter."""
        return f"{self.redis_prefix}:version_counter"

    def _redis_key(self, path: str) -> str:
        """Convert a relative path to a Redis key.

        Args:
            path: Relative path (e.g., "path/to/file.txt").

        Returns:
            Full Redis key (e.g., "prefix:/path/to/file.txt").
        """
        # Normalize: strip leading slashes, ensure no double slashes
        normalized = path.lstrip("/")
        return f"{self.redis_prefix}:/{normalized}"

    def _fs_path(self, path: str) -> Path:
        """Convert a relative path to a filesystem Path.

        Args:
            path: Relative path (e.g., "path/to/file.txt").

        Returns:
            Full filesystem Path.
        """
        if self.fs_root is None:
            raise ConfigurationError("Filesystem not configured")
        normalized = path.lstrip("/")
        return self.fs_root / normalized

    @property
    def _has_redis(self) -> bool:
        """Check if Redis is configured."""
        return self.redis is not None

    @property
    def _has_fs(self) -> bool:
        """Check if filesystem is configured."""
        return self.fs_root is not None

    # =========================================================================
    # File Operations
    # =========================================================================

    async def read_bytes(self, path: str) -> bytes:
        """Read file content as bytes.

        Args:
            path: Relative path to the file.

        Returns:
            File content as bytes.

        Raises:
            NotFoundError: If file does not exist.
            ConnectionError: If Redis connection fails.
        """
        logger.debug(f"read_bytes: {path}")

        # Simple rule: if Redis is configured, read from Redis (source of truth)
        if self._has_redis:
            return await self._read_bytes_redis(path)
        else:
            return await self._read_bytes_fs(path)

    async def read_text(self, path: str, encoding: str = "utf-8") -> str:
        """Read file content as text.

        Args:
            path: Relative path to the file.
            encoding: Text encoding (default: utf-8).

        Returns:
            File content as string.

        Raises:
            NotFoundError: If file does not exist.
            ConnectionError: If Redis connection fails.
        """
        content = await self.read_bytes(path)
        return content.decode(encoding)

    async def write_bytes(self, path: str, content: bytes) -> None:
        """Write bytes to a file.

        Creates missing directories automatically. Overwrites existing content.

        Args:
            path: Relative path to the file.
            content: Content to write.

        Raises:
            ConnectionError: If Redis connection fails.
        """
        logger.debug(f"write_bytes: {path}")

        if self._has_redis and self._has_fs:
            if self._sync_running:
                # Sync active: write to Redis only, subscription mirrors to FS
                await self._write_bytes_redis(path, content)
            else:
                # Sync not active: write to both
                await self._write_bytes_redis(path, content)
                await self._write_bytes_fs(path, content)
        elif self._has_redis:
            await self._write_bytes_redis(path, content)
        else:
            await self._write_bytes_fs(path, content)

    async def write_text(
        self, path: str, content: str, encoding: str = "utf-8"
    ) -> None:
        """Write text to a file.

        Creates missing directories automatically. Overwrites existing content.

        Args:
            path: Relative path to the file.
            content: Text content to write.
            encoding: Text encoding (default: utf-8).

        Raises:
            ConnectionError: If Redis connection fails.
        """
        await self.write_bytes(path, content.encode(encoding))

    async def append_bytes(self, path: str, content: bytes) -> None:
        """Append bytes to a file.

        Creates missing directories and file if they don't exist.
        No separator or newline handling for binary append.

        Args:
            path: Relative path to the file.
            content: Content to append.

        Raises:
            ConnectionError: If Redis connection fails.
        """
        logger.debug(f"append_bytes: {path}")

        if self._has_redis and self._has_fs:
            if self._sync_running:
                await self._append_bytes_redis(path, content)
            else:
                await self._append_bytes_redis(path, content)
                await self._append_bytes_fs(path, content)
        elif self._has_redis:
            await self._append_bytes_redis(path, content)
        else:
            await self._append_bytes_fs(path, content)

    async def append_text(
        self, path: str, content: str, separator: str, encoding: str = "utf-8"
    ) -> None:
        """Append text to a file with separator.

        Creates missing directories and file if they don't exist.
        Ensures existing content ends with newline, then appends separator + content.

        Args:
            path: Relative path to the file.
            content: Text content to append.
            separator: Separator to insert before content (e.g., "---\\n").
            encoding: Text encoding (default: utf-8).

        Raises:
            ConnectionError: If Redis connection fails.
        """
        logger.debug(f"append_text: {path}")

        if self._has_redis and self._has_fs:
            if self._sync_running:
                await self._append_text_redis(path, content, separator, encoding)
            else:
                await self._append_text_redis(path, content, separator, encoding)
                await self._append_text_fs(path, content, separator, encoding)
        elif self._has_redis:
            await self._append_text_redis(path, content, separator, encoding)
        else:
            await self._append_text_fs(path, content, separator, encoding)

    async def delete(self, path: str) -> None:
        """Delete a file or directory recursively.

        Like `rm -rf`: deletes the path and all sub-paths.

        Args:
            path: Relative path to delete.

        Raises:
            ConnectionError: If Redis connection fails.
        """
        logger.debug(f"delete: {path}")

        if self._has_redis and self._has_fs:
            if self._sync_running:
                await self._delete_redis(path)
            else:
                await self._delete_redis(path)
                await self._delete_fs(path)
        elif self._has_redis:
            await self._delete_redis(path)
        else:
            await self._delete_fs(path)

    async def list_dir(self, path: str = "", depth: int = 1) -> set[str]:
        """List directory contents.

        Args:
            path: Relative path to directory (empty string for root).
            depth: How deep to recurse (1 = immediate children, -1 = unlimited).

        Returns:
            Set of relative paths. Directories have trailing slash.

        Raises:
            ConnectionError: If Redis connection fails.
        """
        logger.debug(f"list_dir: {path} (depth={depth})")

        # Simple rule: if Redis is configured, list from Redis (source of truth)
        if self._has_redis:
            return await self._list_dir_redis(path, depth)
        else:
            return await self._list_dir_fs(path, depth)

    # =========================================================================
    # Sync Operations
    # =========================================================================

    async def start_sync(self) -> None:
        """Start sync: subscribe to Redis changes and mirror to filesystem.

        This operation:
        1. Subscribes to Redis keyspace notifications
        2. Performs bulk download of current Redis state
        3. Starts background task to process change notifications

        Requires both Redis and filesystem to be configured.

        Raises:
            ConfigurationError: If not in dual backend mode.
            ConnectionError: If Redis connection fails.
        """
        if not self._has_redis or not self._has_fs:
            raise ConfigurationError(
                "start_sync requires both Redis and filesystem configured"
            )

        if self._sync_running:
            logger.warning("Sync already running")
            return

        logger.info("Starting sync")

        # Subscribe first (before bulk download) per consistency model
        await self._setup_subscription()

        # Bulk download current state
        await self.bulk_download()

        # Start processing notifications
        self._sync_running = True
        self._sync_task = asyncio.create_task(self._sync_loop())
        logger.info("Sync started")

    async def stop_sync(self) -> None:
        """Stop sync: unsubscribe and stop mirroring.

        Raises:
            ConfigurationError: If not in dual backend mode.
        """
        if not self._sync_running:
            logger.warning("Sync not running")
            return

        logger.info("Stopping sync")
        self._sync_running = False

        if self._sync_task:
            self._sync_task.cancel()
            try:
                await self._sync_task
            except asyncio.CancelledError:
                pass
            self._sync_task = None

        if self._pubsub:
            await self._pubsub.unsubscribe()
            await self._pubsub.close()
            self._pubsub = None

        logger.info("Sync stopped")

    # =========================================================================
    # Bulk Operations
    # =========================================================================

    async def bulk_upload(self) -> int:
        """Upload local filesystem content to Redis.

        Replaces all Redis content under the prefix with local filesystem content.
        Uses version counter for cleanup of stale files.

        Returns:
            Number of files uploaded.

        Raises:
            ConfigurationError: If not in dual backend mode.
            ConnectionError: If operation fails.
        """
        if not self._has_redis or not self._has_fs:
            raise ConfigurationError(
                "bulk_upload requires both Redis and filesystem configured"
            )

        logger.info("Starting bulk upload")

        # Get the version for this upload
        upload_version = await self._get_new_version()
        logger.debug(f"Bulk upload version: {upload_version}")

        # Upload all files with this version
        fs_root = self.fs_root
        if fs_root is None or not fs_root.exists():
            logger.warning("Filesystem root does not exist")
            return 0

        count = 0
        for file_path in fs_root.rglob("*"):
            if file_path.is_file():
                relative_path = str(file_path.relative_to(fs_root))
                content = file_path.read_bytes()
                await self._write_bytes_redis_with_version(
                    relative_path, content, upload_version
                )
                count += 1

        # Cleanup old files
        deleted = await self._bulk_cleanup(upload_version)
        logger.info(f"Bulk upload complete: {count} uploaded, {deleted} cleaned up")

        return count

    async def bulk_download(self) -> int:
        """Download Redis content to local filesystem.

        Replaces local filesystem content with Redis content.
        Note: No consistency guarantee if overlaps with bulk_upload.

        Returns:
            Number of files downloaded.

        Raises:
            ConfigurationError: If not in dual backend mode.
            ConnectionError: If operation fails.
        """
        if not self._has_redis or not self._has_fs:
            raise ConfigurationError(
                "bulk_download requires both Redis and filesystem configured"
            )

        logger.info("Starting bulk download")

        # Get all file keys from Redis
        pattern = f"{self.redis_prefix}:/*"
        keys = await self._scan_keys(pattern)

        count = 0
        for key in keys:
            # Extract relative path from key
            prefix_len = len(self.redis_prefix) + 2  # +2 for ":/"
            relative_path = key[prefix_len:]

            try:
                content = await self._read_bytes_redis(relative_path)
                await self._write_bytes_fs(relative_path, content)
                count += 1
            except NotFoundError:
                # Key may have been deleted between scan and read
                pass

        logger.info(f"Bulk download complete: {count} files")
        return count

    # =========================================================================
    # Redis Backend Implementation
    # =========================================================================

    async def _read_bytes_redis(self, path: str) -> bytes:
        """Read file content from Redis."""
        try:
            key = self._redis_key(path)
            result = await self._redis_client.hget(key, "data")
            if result is None:
                raise NotFoundError(path)
            return result if isinstance(result, bytes) else result.encode()
        except NotFoundError:
            raise
        except Exception as e:
            raise ConnectionError(f"Redis read failed: {e}") from e

    async def _write_bytes_redis(self, path: str, content: bytes) -> int:
        """Write file content to Redis, returns version."""
        try:
            scripts = self._scripts
            key = self._redis_key(path)
            version = await scripts["write_file"](
                keys=[self._version_key, key], args=[content]
            )
            return int(version)
        except Exception as e:
            raise ConnectionError(f"Redis write failed: {e}") from e

    async def _write_bytes_redis_with_version(
        self, path: str, content: bytes, version: int
    ) -> None:
        """Write file with specific version (for bulk upload)."""
        try:
            key = self._redis_key(path)
            await self._redis_client.hset(key, mapping={"data": content, "version": version})
        except Exception as e:
            raise ConnectionError(f"Redis write failed: {e}") from e

    async def _append_bytes_redis(self, path: str, content: bytes) -> int:
        """Append bytes to file in Redis, returns version."""
        try:
            scripts = self._scripts
            key = self._redis_key(path)
            version = await scripts["append_bytes"](
                keys=[self._version_key, key], args=[content]
            )
            return int(version)
        except Exception as e:
            raise ConnectionError(f"Redis append failed: {e}") from e

    async def _append_text_redis(
        self, path: str, content: str, separator: str, encoding: str
    ) -> int:
        """Append text to file in Redis with separator, returns version."""
        try:
            scripts = self._scripts
            key = self._redis_key(path)
            content_bytes = content.encode(encoding)
            separator_bytes = separator.encode(encoding)
            version = await scripts["append_text"](
                keys=[self._version_key, key], args=[content_bytes, separator_bytes]
            )
            return int(version)
        except Exception as e:
            raise ConnectionError(f"Redis append failed: {e}") from e

    async def _delete_redis(self, path: str) -> int:
        """Delete path and sub-paths from Redis, returns version."""
        try:
            scripts = self._scripts
            # Pattern for rm -rf: matches exact path and all children
            normalized = path.lstrip("/")
            if normalized:
                pattern = f"{self.redis_prefix}:/{normalized}*"
            else:
                # Root deletion - delete all files
                pattern = f"{self.redis_prefix}:/*"
            version = await scripts["delete"](
                keys=[self._version_key], args=[pattern]
            )
            return int(version)
        except Exception as e:
            raise ConnectionError(f"Redis delete failed: {e}") from e

    async def _list_dir_redis(self, path: str, depth: int) -> set[str]:
        """List directory contents from Redis."""
        try:
            normalized = path.lstrip("/").rstrip("/")
            if normalized:
                pattern = f"{self.redis_prefix}:/{normalized}/*"
                base_prefix = f"{self.redis_prefix}:/{normalized}/"
            else:
                pattern = f"{self.redis_prefix}:/*"
                base_prefix = f"{self.redis_prefix}:/"

            keys = await self._scan_keys(pattern)

            result: set[str] = set()
            seen_dirs: set[str] = set()

            for key in keys:
                # Extract relative path from key
                if not key.startswith(base_prefix):
                    continue
                relative = key[len(base_prefix):]

                # Handle depth limiting
                parts = relative.split("/")
                if depth > 0 and len(parts) > depth:
                    # Only include up to depth levels
                    for i in range(1, depth + 1):
                        if i < len(parts):
                            dir_path = "/".join(parts[:i]) + "/"
                            if dir_path not in seen_dirs:
                                seen_dirs.add(dir_path)
                                result.add(dir_path)
                        else:
                            result.add(relative)
                else:
                    # Within depth limit
                    result.add(relative)
                    # Add intermediate directories
                    for i in range(1, len(parts)):
                        dir_path = "/".join(parts[:i]) + "/"
                        if dir_path not in seen_dirs:
                            seen_dirs.add(dir_path)
                            result.add(dir_path)

            return result
        except Exception as e:
            raise ConnectionError(f"Redis list failed: {e}") from e

    async def _scan_keys(self, pattern: str) -> list[str]:
        """Scan for keys matching pattern."""
        keys: list[str] = []
        cursor = 0
        while True:
            cursor, batch = await self._redis_client.scan(
                cursor, match=pattern, count=100
            )
            for key in batch:
                if isinstance(key, bytes):
                    keys.append(key.decode())
                else:
                    keys.append(key)
            if cursor == 0:
                break
        return keys

    async def _get_new_version(self) -> int:
        """Increment and return new version number."""
        return await self._redis_client.incr(self._version_key)

    async def _bulk_cleanup(self, min_version: int) -> int:
        """Delete files with version < min_version."""
        try:
            scripts = self._scripts
            pattern = f"{self.redis_prefix}:/*"
            deleted = await scripts["bulk_cleanup"](
                keys=[self._version_key], args=[pattern, min_version]
            )
            return int(deleted)
        except Exception as e:
            raise ConnectionError(f"Redis cleanup failed: {e}") from e

    # =========================================================================
    # Filesystem Backend Implementation
    # =========================================================================

    async def _read_bytes_fs(self, path: str) -> bytes:
        """Read file content from filesystem."""
        fs_path = self._fs_path(path)
        if not fs_path.exists():
            raise NotFoundError(path)
        return await asyncio.to_thread(fs_path.read_bytes)

    async def _write_bytes_fs(self, path: str, content: bytes) -> None:
        """Write file content to filesystem."""
        fs_path = self._fs_path(path)
        # Create parent directories
        await asyncio.to_thread(fs_path.parent.mkdir, parents=True, exist_ok=True)
        await asyncio.to_thread(fs_path.write_bytes, content)

    async def _append_bytes_fs(self, path: str, content: bytes) -> None:
        """Append bytes to file in filesystem."""
        fs_path = self._fs_path(path)
        await asyncio.to_thread(fs_path.parent.mkdir, parents=True, exist_ok=True)

        def do_append() -> None:
            with open(fs_path, "ab") as f:
                f.write(content)

        await asyncio.to_thread(do_append)

    async def _append_text_fs(
        self, path: str, content: str, separator: str, encoding: str
    ) -> None:
        """Append text to file in filesystem with separator."""
        fs_path = self._fs_path(path)
        await asyncio.to_thread(fs_path.parent.mkdir, parents=True, exist_ok=True)

        def do_append() -> None:
            # Read existing content
            existing = ""
            if fs_path.exists():
                existing = fs_path.read_text(encoding=encoding)

            # Ensure ends with newline
            if existing and not existing.endswith("\n"):
                existing += "\n"

            # Write back with separator and new content
            with open(fs_path, "w", encoding=encoding) as f:
                f.write(existing + separator + content)

        await asyncio.to_thread(do_append)

    async def _delete_fs(self, path: str) -> None:
        """Delete path and sub-paths from filesystem."""
        import shutil

        fs_path = self._fs_path(path)
        if not fs_path.exists():
            return

        def do_delete() -> None:
            if fs_path.is_dir():
                shutil.rmtree(fs_path)
            else:
                fs_path.unlink()

        await asyncio.to_thread(do_delete)

    async def _list_dir_fs(self, path: str, depth: int) -> set[str]:
        """List directory contents from filesystem."""
        if self.fs_root is None:
            return set()

        normalized = path.lstrip("/").rstrip("/")
        if normalized:
            base_path = self.fs_root / normalized
        else:
            base_path = self.fs_root

        if not base_path.exists() or not base_path.is_dir():
            return set()

        result: set[str] = set()

        def collect_entries(current: Path, current_depth: int) -> None:
            if depth > 0 and current_depth > depth:
                return
            for item in current.iterdir():
                relative = str(item.relative_to(base_path))
                if item.is_dir():
                    result.add(relative + "/")
                    if depth < 0 or current_depth < depth:
                        collect_entries(item, current_depth + 1)
                else:
                    result.add(relative)

        await asyncio.to_thread(collect_entries, base_path, 1)
        return result

    # =========================================================================
    # Subscription / Sync Loop
    # =========================================================================

    async def _setup_subscription(self) -> None:
        """Subscribe to Redis keyspace notifications."""
        # Enable keyspace notifications if not already enabled
        # KEA = Keyspace events + Expiry + All commands (except miscellaneous)
        await self._redis_client.config_set("notify-keyspace-events", "KEA")

        # Keyspace notifications for hash commands
        pubsub = self._redis_client.pubsub()
        pattern = f"__keyspace@0__:{self.redis_prefix}:/*"
        await pubsub.psubscribe(pattern)
        self._pubsub = pubsub
        logger.debug(f"Subscribed to pattern: {pattern}")

    async def _sync_loop(self) -> None:
        """Process Redis keyspace notifications and mirror to filesystem."""
        logger.debug("Sync loop started")
        try:
            while self._sync_running:
                message = await self._pubsub.get_message(
                    ignore_subscribe_messages=True, timeout=1.0
                )
                if message is None:
                    continue

                await self._handle_notification(message)
        except asyncio.CancelledError:
            logger.debug("Sync loop cancelled")
            raise
        except Exception as e:
            logger.error(f"Sync loop error: {e}")
            raise

    async def _handle_notification(self, message: dict[str, Any]) -> None:
        """Handle a single keyspace notification."""
        if message.get("type") != "pmessage":
            return

        channel = message.get("channel", b"")
        if isinstance(channel, bytes):
            channel = channel.decode()

        data = message.get("data", b"")
        if isinstance(data, bytes):
            data = data.decode()

        # Extract key from channel: __keyspace@0__:prefix:/path -> prefix:/path
        key_start = channel.find(f"{self.redis_prefix}:/")
        if key_start < 0:
            return
        key = channel[key_start:]

        # Extract relative path
        prefix_len = len(self.redis_prefix) + 2  # +2 for ":/"
        relative_path = key[prefix_len:]

        logger.debug(f"Notification: {data} on {relative_path}")

        if data in ("hset", "set"):
            # File created or modified
            try:
                content = await self._read_bytes_redis(relative_path)
                await self._write_bytes_fs(relative_path, content)
            except NotFoundError:
                pass  # File was deleted before we could read it
        elif data == "del":
            # File deleted
            await self._delete_fs(relative_path)

    # =========================================================================
    # Context Manager
    # =========================================================================

    async def close(self) -> None:
        """Close connections and stop sync if running."""
        if self._sync_running:
            await self.stop_sync()

        # Close Redis client if we created it from URL
        if isinstance(self.redis, str) and "_redis_client" in self.__dict__:
            await self._redis_client.close()

    async def __aenter__(self) -> "RedisFs":
        return self

    async def __aexit__(self, *args: Any) -> None:
        await self.close()
