"""
FileVault 2.0.0 - Hash-based file storage with sync and async support

A Vault manages a hash directory tree of files on a filesystem.

Features:
- Content-addressable storage (store by MD5 hash)
- Seed-based deterministic paths (store by arbitrary key)
- Configurable directory depth with hex pairs (256 dirs/level)
- Thread-safe file locking (fcntl)
- Atomic write operations
- Optional in-memory existence cache
- Both sync (Vault) and async (AsyncVault) implementations

Usage:
    # Sync
    vault = Vault("vault", depth=9)
    path = vault.store(md5_hash, data, ".jpg")
    data = vault.get(md5_hash)

    # Async
    vault = AsyncVault("vault", depth=9)
    await vault.init()
    path = await vault.store(md5_hash, data, ".jpg")
    data = await vault.get(md5_hash)
"""

import asyncio
import contextlib
import fcntl
import json
import os
import tempfile
from hashlib import md5, sha256
from os import makedirs, path, rename
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
from uuid import uuid4

import aiofiles
import aiofiles.os

__version__ = "2.0.0"
__author__ = "Russell Ballestrini"


def _ensure_bytes(s: Union[str, bytes]) -> bytes:
    """Convert string to bytes if needed."""
    if isinstance(s, str):
        return s.encode("utf-8")
    return s


def _hash_to_path(h: str, depth: int, ext: str = "") -> Path:
    """
    Convert hash to directory path using hex pairs.

    Example (depth=9): abcdef123456... -> ab/cd/ef/12/34/56/78/9a/bc/abcdef123456...ext
    """
    parts = [h[i:i+2] for i in range(0, depth * 2, 2)]
    if ext and not ext.startswith('.'):
        ext = '.' + ext
    return Path(*parts) / f"{h}{ext}"


class Vault:
    """
    Synchronous hash-based file storage.

    Supports two modes:
    1. Content-addressable: store(hash, data) - use content's MD5 as key
    2. Seed-based: create_filename(seed) - deterministic path from arbitrary seed

    Directory structure uses hex pairs for 256 subdirs per level:
        vault/ab/cd/ef/.../hash.ext
    """

    def __init__(
        self,
        vaultpath: str = "vault",
        depth: int = 9,
        salt: str = "neopig",
        enable_memory_cache: bool = False,
    ):
        """
        Initialize vault.

        Args:
            vaultpath: Base directory for storage
            depth: Directory tree depth (default 9 = 18 hex chars used)
            salt: Salt for seed-based hashing
            enable_memory_cache: Cache file existence checks in memory
        """
        self.vaultpath = Path(vaultpath)
        self.depth = depth
        self.salt = _ensure_bytes(salt)
        self.enable_memory_cache = enable_memory_cache
        self._cache: Optional[Dict[str, bool]] = {} if enable_memory_cache else None

    def init(self) -> None:
        """Create vault base directory."""
        self.vaultpath.mkdir(parents=True, exist_ok=True)

    # Backwards compatibility
    init_vault = init

    # -------------------------------------------------------------------------
    # Content-Addressable Storage (by hash)
    # -------------------------------------------------------------------------

    def _get_hash_path(self, h: str, ext: str = "") -> Path:
        """Get full path for a content hash."""
        return self.vaultpath / _hash_to_path(h, self.depth, ext)

    def _find_by_hash(self, h: str) -> Optional[Path]:
        """Find file by hash prefix (any extension)."""
        subdir = self._get_hash_path(h).parent
        if not subdir.exists():
            return None
        for f in subdir.iterdir():
            if f.name.startswith(h):
                return f
        return None

    def store(self, content_hash: str, data: bytes, ext: str = "") -> Path:
        """
        Store data by content hash.

        Args:
            content_hash: MD5 hash of the data (32 hex chars)
            data: Raw bytes to store
            ext: File extension (e.g., ".jpg" or "jpg")

        Returns:
            Path to stored file
        """
        filepath = self._get_hash_path(content_hash, ext)
        filepath.parent.mkdir(parents=True, exist_ok=True)

        with open(filepath, 'wb') as f:
            fcntl.flock(f.fileno(), fcntl.LOCK_EX)
            f.write(data)
            f.flush()
            os.fsync(f.fileno())

        self._cache_set(str(filepath), True)
        return filepath

    def exists(self, content_hash: str) -> bool:
        """Check if content exists by hash."""
        return self._find_by_hash(content_hash) is not None

    def get(self, content_hash: str) -> Optional[bytes]:
        """Retrieve data by content hash."""
        filepath = self._find_by_hash(content_hash)
        if filepath is None:
            return None
        with open(filepath, 'rb') as f:
            fcntl.flock(f.fileno(), fcntl.LOCK_SH)
            return f.read()

    def get_path(self, content_hash: str) -> Optional[Path]:
        """Get filesystem path for stored content."""
        return self._find_by_hash(content_hash)

    def delete(self, content_hash: str) -> bool:
        """Delete content by hash. Returns True if deleted."""
        filepath = self._find_by_hash(content_hash)
        if filepath is None:
            return False
        filepath.unlink()
        self._cache_set(str(filepath), False)
        return True

    # -------------------------------------------------------------------------
    # Seed-Based Storage (deterministic paths from arbitrary keys)
    # -------------------------------------------------------------------------

    def create_filename(self, seed: str, ext: str = "", absolute: bool = True) -> str:
        """
        Create deterministic path from seed string.

        Args:
            seed: Arbitrary string to hash
            ext: File extension
            absolute: Include vault base path

        Returns:
            Path string
        """
        h = sha256(_ensure_bytes(seed) + self.salt).hexdigest()
        rel_path = _hash_to_path(h, self.depth, ext)
        if absolute:
            return str(self.vaultpath / rel_path)
        return str(rel_path)

    def create_random_filename(self, ext: str = "", absolute: bool = True) -> str:
        """Create random vault path."""
        return self.create_filename(str(uuid4()), ext, absolute)

    # -------------------------------------------------------------------------
    # File Operations with Locking
    # -------------------------------------------------------------------------

    @contextlib.contextmanager
    def file_lock(self, file_path: str, mode: str = "r"):
        """
        Context manager for locked file access.

        Args:
            file_path: Path to file
            mode: Open mode ('r', 'w', 'a', etc.)

        Yields:
            Locked file object
        """
        p = Path(file_path)
        if "w" in mode or "a" in mode:
            p.parent.mkdir(parents=True, exist_ok=True)

        lock_type = fcntl.LOCK_EX if ("w" in mode or "a" in mode) else fcntl.LOCK_SH

        with open(file_path, mode) as f:
            try:
                fcntl.flock(f.fileno(), lock_type)
                yield f
            finally:
                fcntl.flock(f.fileno(), fcntl.LOCK_UN)

    def write_bytes(self, file_path: str, data: bytes) -> None:
        """Write bytes with locking."""
        p = Path(file_path)
        p.parent.mkdir(parents=True, exist_ok=True)
        with open(file_path, 'wb') as f:
            fcntl.flock(f.fileno(), fcntl.LOCK_EX)
            f.write(data)
            f.flush()
            os.fsync(f.fileno())
        self._cache_set(file_path, True)

    def read_bytes(self, file_path: str) -> Optional[bytes]:
        """Read bytes with locking."""
        if not path.exists(file_path):
            return None
        with open(file_path, 'rb') as f:
            fcntl.flock(f.fileno(), fcntl.LOCK_SH)
            return f.read()

    def write_text(self, file_path: str, content: str) -> None:
        """Write text with locking."""
        p = Path(file_path)
        p.parent.mkdir(parents=True, exist_ok=True)
        with open(file_path, 'w') as f:
            fcntl.flock(f.fileno(), fcntl.LOCK_EX)
            f.write(content)
            f.flush()
            os.fsync(f.fileno())
        self._cache_set(file_path, True)

    def read_text(self, file_path: str, default: str = None) -> Optional[str]:
        """Read text with locking."""
        if not path.exists(file_path):
            return default
        with open(file_path, 'r') as f:
            fcntl.flock(f.fileno(), fcntl.LOCK_SH)
            return f.read()

    def write_json(self, file_path: str, data: Any, indent: int = 2) -> None:
        """Atomically write JSON with temp file + rename."""
        p = Path(file_path)
        p.parent.mkdir(parents=True, exist_ok=True)

        fd, temp_path = tempfile.mkstemp(
            dir=str(p.parent),
            prefix=f".tmp_{p.name}_",
            suffix=".json"
        )
        try:
            with open(temp_path, 'w') as f:
                fcntl.flock(f.fileno(), fcntl.LOCK_EX)
                json.dump(data, f, indent=indent)
                f.flush()
                os.fsync(f.fileno())
            rename(temp_path, file_path)
            self._cache_set(file_path, True)
        except Exception:
            try:
                os.unlink(temp_path)
            except OSError:
                pass
            raise
        finally:
            try:
                os.close(fd)
            except OSError:
                pass

    def read_json(self, file_path: str, default: Any = None) -> Any:
        """Read JSON with locking."""
        if not path.exists(file_path):
            return default
        try:
            with self.file_lock(file_path, 'r') as f:
                return json.load(f)
        except (json.JSONDecodeError, IOError):
            return default

    def remove(self, file_path: str) -> bool:
        """Remove file. Returns True if removed."""
        if not path.exists(file_path):
            self._cache_set(file_path, False)
            return False
        os.remove(file_path)
        self._cache_set(file_path, False)
        return True

    # -------------------------------------------------------------------------
    # Existence Cache
    # -------------------------------------------------------------------------

    def _cache_set(self, file_path: str, exists: bool) -> None:
        """Update existence cache."""
        if self._cache is not None:
            self._cache[file_path] = exists

    def _cache_get(self, file_path: str) -> Optional[bool]:
        """Check existence cache."""
        if self._cache is not None:
            return self._cache.get(file_path)
        return None

    def file_exists(self, file_path: str) -> bool:
        """Check if file exists (uses cache if enabled)."""
        cached = self._cache_get(file_path)
        if cached is not None:
            return cached
        exists = path.exists(file_path)
        self._cache_set(file_path, exists)
        return exists

    def clear_cache(self) -> None:
        """Clear existence cache."""
        if self._cache is not None:
            self._cache.clear()

    # -------------------------------------------------------------------------
    # Statistics
    # -------------------------------------------------------------------------

    def stats(self) -> Dict[str, Any]:
        """Get vault statistics."""
        count = 0
        total_size = 0
        for f in self.vaultpath.rglob('*'):
            if f.is_file():
                count += 1
                total_size += f.stat().st_size
        return {
            'path': str(self.vaultpath),
            'depth': self.depth,
            'count': count,
            'total_size': total_size,
            'cache_enabled': self.enable_memory_cache,
            'cache_entries': len(self._cache) if self._cache else 0,
        }

    # Backwards compatibility aliases
    atomic_write_json = write_json
    safe_read_json = read_json
    write_text_file = write_text
    read_text_file = read_text
    remove_file = remove
    mark_file_exists = _cache_set


class AsyncVault:
    """
    Asynchronous hash-based file storage.

    Same API as Vault but with async/await. Uses aiofiles for I/O
    and asyncio.to_thread for CPU-bound operations.
    """

    def __init__(
        self,
        vaultpath: str = "vault",
        depth: int = 9,
        salt: str = "neopig",
        enable_memory_cache: bool = False,
    ):
        """
        Initialize async vault.

        Args:
            vaultpath: Base directory for storage
            depth: Directory tree depth (default 9)
            salt: Salt for seed-based hashing
            enable_memory_cache: Cache file existence checks in memory
        """
        self.vaultpath = Path(vaultpath)
        self.depth = depth
        self.salt = _ensure_bytes(salt)
        self.enable_memory_cache = enable_memory_cache
        self._cache: Optional[Dict[str, bool]] = {} if enable_memory_cache else None
        self._initialized = False

    async def init(self) -> None:
        """Create vault base directory."""
        if self._initialized:
            return
        await aiofiles.os.makedirs(self.vaultpath, exist_ok=True)
        self._initialized = True

    # -------------------------------------------------------------------------
    # Content-Addressable Storage (by hash)
    # -------------------------------------------------------------------------

    def _get_hash_path(self, h: str, ext: str = "") -> Path:
        """Get full path for a content hash."""
        return self.vaultpath / _hash_to_path(h, self.depth, ext)

    async def _find_by_hash(self, h: str) -> Optional[Path]:
        """Find file by hash prefix (any extension)."""
        subdir = self._get_hash_path(h).parent

        def _find():
            if not subdir.exists():
                return None
            for f in subdir.iterdir():
                if f.name.startswith(h):
                    return f
            return None

        return await asyncio.to_thread(_find)

    async def store(self, content_hash: str, data: bytes, ext: str = "") -> Path:
        """
        Store data by content hash.

        Args:
            content_hash: MD5 hash of the data (32 hex chars)
            data: Raw bytes to store
            ext: File extension (e.g., ".jpg" or "jpg")

        Returns:
            Path to stored file
        """
        filepath = self._get_hash_path(content_hash, ext)
        await aiofiles.os.makedirs(filepath.parent, exist_ok=True)

        async with aiofiles.open(filepath, 'wb') as f:
            await f.write(data)

        self._cache_set(str(filepath), True)
        return filepath

    async def exists(self, content_hash: str) -> bool:
        """Check if content exists by hash."""
        return await self._find_by_hash(content_hash) is not None

    async def get(self, content_hash: str) -> Optional[bytes]:
        """Retrieve data by content hash."""
        filepath = await self._find_by_hash(content_hash)
        if filepath is None:
            return None
        async with aiofiles.open(filepath, 'rb') as f:
            return await f.read()

    async def get_path(self, content_hash: str) -> Optional[Path]:
        """Get filesystem path for stored content."""
        return await self._find_by_hash(content_hash)

    async def delete(self, content_hash: str) -> bool:
        """Delete content by hash. Returns True if deleted."""
        filepath = await self._find_by_hash(content_hash)
        if filepath is None:
            return False
        await aiofiles.os.remove(filepath)
        self._cache_set(str(filepath), False)
        return True

    # -------------------------------------------------------------------------
    # Seed-Based Storage
    # -------------------------------------------------------------------------

    def create_filename(self, seed: str, ext: str = "", absolute: bool = True) -> str:
        """Create deterministic path from seed string."""
        h = sha256(_ensure_bytes(seed) + self.salt).hexdigest()
        rel_path = _hash_to_path(h, self.depth, ext)
        if absolute:
            return str(self.vaultpath / rel_path)
        return str(rel_path)

    def create_random_filename(self, ext: str = "", absolute: bool = True) -> str:
        """Create random vault path."""
        return self.create_filename(str(uuid4()), ext, absolute)

    # -------------------------------------------------------------------------
    # File Operations
    # -------------------------------------------------------------------------

    async def write_bytes(self, file_path: str, data: bytes) -> None:
        """Write bytes."""
        p = Path(file_path)
        await aiofiles.os.makedirs(p.parent, exist_ok=True)
        async with aiofiles.open(file_path, 'wb') as f:
            await f.write(data)
        self._cache_set(file_path, True)

    async def read_bytes(self, file_path: str) -> Optional[bytes]:
        """Read bytes."""
        if not await aiofiles.os.path.exists(file_path):
            return None
        async with aiofiles.open(file_path, 'rb') as f:
            return await f.read()

    async def write_text(self, file_path: str, content: str) -> None:
        """Write text."""
        p = Path(file_path)
        await aiofiles.os.makedirs(p.parent, exist_ok=True)
        async with aiofiles.open(file_path, 'w') as f:
            await f.write(content)
        self._cache_set(file_path, True)

    async def read_text(self, file_path: str, default: str = None) -> Optional[str]:
        """Read text."""
        if not await aiofiles.os.path.exists(file_path):
            return default
        async with aiofiles.open(file_path, 'r') as f:
            return await f.read()

    async def write_json(self, file_path: str, data: Any, indent: int = 2) -> None:
        """Atomically write JSON."""
        p = Path(file_path)
        await aiofiles.os.makedirs(p.parent, exist_ok=True)

        # Create temp file
        fd, temp_path = await asyncio.to_thread(
            tempfile.mkstemp,
            dir=str(p.parent),
            prefix=f".tmp_{p.name}_",
            suffix=".json"
        )
        try:
            async with aiofiles.open(temp_path, 'w') as f:
                await f.write(json.dumps(data, indent=indent))

            await asyncio.to_thread(rename, temp_path, file_path)
            self._cache_set(file_path, True)
        except Exception:
            try:
                await aiofiles.os.remove(temp_path)
            except OSError:
                pass
            raise
        finally:
            try:
                os.close(fd)
            except OSError:
                pass

    async def read_json(self, file_path: str, default: Any = None) -> Any:
        """Read JSON."""
        if not await aiofiles.os.path.exists(file_path):
            return default
        try:
            async with aiofiles.open(file_path, 'r') as f:
                return json.loads(await f.read())
        except (json.JSONDecodeError, IOError):
            return default

    async def remove(self, file_path: str) -> bool:
        """Remove file. Returns True if removed."""
        if not await aiofiles.os.path.exists(file_path):
            self._cache_set(file_path, False)
            return False
        await aiofiles.os.remove(file_path)
        self._cache_set(file_path, False)
        return True

    # -------------------------------------------------------------------------
    # Existence Cache
    # -------------------------------------------------------------------------

    def _cache_set(self, file_path: str, exists: bool) -> None:
        """Update existence cache."""
        if self._cache is not None:
            self._cache[file_path] = exists

    def _cache_get(self, file_path: str) -> Optional[bool]:
        """Check existence cache."""
        if self._cache is not None:
            return self._cache.get(file_path)
        return None

    async def file_exists(self, file_path: str) -> bool:
        """Check if file exists (uses cache if enabled)."""
        cached = self._cache_get(file_path)
        if cached is not None:
            return cached
        exists = await aiofiles.os.path.exists(file_path)
        self._cache_set(file_path, exists)
        return exists

    def clear_cache(self) -> None:
        """Clear existence cache."""
        if self._cache is not None:
            self._cache.clear()

    # -------------------------------------------------------------------------
    # Statistics
    # -------------------------------------------------------------------------

    async def stats(self) -> Dict[str, Any]:
        """Get vault statistics."""
        def _count():
            count = 0
            total_size = 0
            for f in self.vaultpath.rglob('*'):
                if f.is_file():
                    count += 1
                    total_size += f.stat().st_size
            return count, total_size

        count, total_size = await asyncio.to_thread(_count)
        return {
            'path': str(self.vaultpath),
            'depth': self.depth,
            'count': count,
            'total_size': total_size,
            'cache_enabled': self.enable_memory_cache,
            'cache_entries': len(self._cache) if self._cache else 0,
        }


def content_hash(data: bytes) -> str:
    """Generate MD5 hash of bytes for content-addressable storage."""
    return md5(data).hexdigest()


def hash_to_path(h: str, depth: int = 9, ext: str = "") -> Path:
    """
    Convert hash to directory path using hex pairs.

    Example (depth=9): abcdef123456... -> ab/cd/ef/12/34/56/78/9a/bc/abcdef123456...ext
    """
    return _hash_to_path(h, depth, ext)


# Backward compatibility exports
ensure_bytes = _ensure_bytes


def create_vault(*args, **kwargs) -> Vault:
    """Factory function for Vault (backward compatibility)."""
    return Vault(*args, **kwargs)


def create_async_vault(*args, **kwargs) -> AsyncVault:
    """Factory function for AsyncVault (backward compatibility)."""
    return AsyncVault(*args, **kwargs)
