"""
Safe I/O - The Atomic Operations Kernel

The Iron Vault's core I/O primitives. Every read/write operation in
this module enforces sandbox boundaries and provides atomicity guarantees.

ATOMICITY GUARANTEE:
atomic_write() uses the "write-temp-then-rename" pattern with fsync,
guaranteeing zero corruption on power loss.

SECURITY INVARIANTS:
1. ALL paths are resolved through sandbox.resolve_safe_path().
2. Temp files are created in the SAME directory as the target.
3. fsync() is called BEFORE rename to ensure data hits disk.
4. Cleanup happens in finally blocks to prevent temp file leaks.
5. Disk space is checked before writes to prevent filling disk.
6. File size limits are enforced to prevent memory exhaustion.
"""

from __future__ import annotations

import base64
import fnmatch
import hashlib
import logging
import os
import shutil
import tempfile
from collections.abc import Iterator
from contextlib import contextmanager, suppress
from pathlib import Path
from typing import Any, BinaryIO, cast

from .sandbox import (
    _coerce_helper_path,
    _rel_to_root,
    resolve_safe_path,
)

log = logging.getLogger(__name__)

# ----------------------------- Configuration --------------------------------

# Maximum file size for writes (default 100MB, configurable via env)
MAX_FILE_SIZE = int(os.environ.get("FSAL_MAX_FILE_SIZE", 100 * 1024 * 1024))

# Minimum free disk space required before writes (default 100MB)
MIN_FREE_DISK_SPACE = int(os.environ.get("FSAL_MIN_FREE_SPACE", 100 * 1024 * 1024))


class DiskSpaceError(OSError):
    """Raised when there isn't enough disk space for an operation."""

    pass


class FileSizeLimitError(ValueError):
    """Raised when file size exceeds the configured limit."""

    pass


def check_disk_space(path: Path, required_bytes: int) -> None:
    """
    Check if there's enough disk space for an operation.

    Args:
        path: Path on the target filesystem.
        required_bytes: Number of bytes needed.

    Raises:
        DiskSpaceError: If insufficient space available.
    """
    try:
        usage = shutil.disk_usage(path if path.is_dir() else path.parent)
        # Require space + 10% buffer
        needed = int(required_bytes * 1.1)
        if usage.free < needed + MIN_FREE_DISK_SPACE:
            raise DiskSpaceError(
                f"Insufficient disk space: {usage.free} bytes free, "
                f"need {needed + MIN_FREE_DISK_SPACE} bytes"
            )
    except DiskSpaceError:
        # Re-raise our own errors
        raise
    except OSError as e:
        # If we can't check (e.g., permission error), log warning but proceed
        log.warning("disk_space_check_failed: path=%s error=%s", path, e)


def check_file_size(size: int) -> None:
    """
    Check if file size is within limits.

    Args:
        size: File size in bytes.

    Raises:
        FileSizeLimitError: If size exceeds limit.
    """
    if size > MAX_FILE_SIZE:
        raise FileSizeLimitError(f"File size {size} bytes exceeds limit of {MAX_FILE_SIZE} bytes")


# ----------------------------- Listing ----------------------------------


def listdir(
    path: str | Path = ".",
    glob: str | None = None,
    files_only: bool = False,
    dirs_only: bool = False,
    max_entries: int | None = None,
    *,
    root: Path | None = None,
) -> list[dict[str, Any]]:
    """
    List a directory inside the sandbox.

    Returns dicts with structure:
        {"path": "<root-relative>", "is_dir": bool, "size": int, "mtime": float}

    Args:
        path: Directory path relative to sandbox root.
        glob: Glob pattern to filter entries (e.g., "*.txt").
        files_only: Return only files, not directories.
        dirs_only: Return only directories, not files.
        max_entries: Maximum number of entries to return.
        root: Override sandbox root (for testing).

    Returns:
        List of entry dicts sorted: directories first, then files.

    Raises:
        FileNotFoundError: If directory doesn't exist.
        NotADirectoryError: If path is not a directory.
        SandboxViolation: If path escapes sandbox.
    """
    base = resolve_safe_path(path, root=root)
    if not base.exists():
        raise FileNotFoundError(str(base))
    if not base.is_dir():
        raise NotADirectoryError(str(base))

    children: list[Path] = []
    for child in base.iterdir():
        if files_only and not child.is_file():
            continue
        if dirs_only and not child.is_dir():
            continue
        if glob and not fnmatch.fnmatch(child.name, glob):
            continue
        children.append(child)

    # Deterministic order: dirs first, then files; case-insensitive name
    children.sort(key=lambda p: (p.is_file(), p.name.lower()))
    if max_entries is not None:
        children = children[:max_entries]

    out: list[dict[str, Any]] = []
    for c in children:
        try:
            st = c.stat()
            out.append(
                {
                    # Return path relative to sandbox root, not listed dir
                    "path": _rel_to_root(c, root=root),
                    "is_dir": c.is_dir(),
                    "size": 0 if c.is_dir() else int(st.st_size),
                    "mtime": float(st.st_mtime),
                }
            )
        except Exception as exc:
            log.debug("listdir: skipping %s due to stat error: %s", c, exc)
            continue
    return out


def listdir_recursive(
    path: str | Path = ".",
    glob: str | None = None,
    files_only: bool = False,
    dirs_only: bool = False,
    max_entries: int | None = None,
    max_depth: int | None = None,
    *,
    root: Path | None = None,
) -> list[dict[str, Any]]:
    """
    Recursively list a directory and all subdirectories inside the sandbox.

    Returns dicts with structure:
        {"path": "<root-relative>", "is_dir": bool, "size": int, "mtime": float}

    Args:
        path: Directory path relative to sandbox root.
        glob: Glob pattern to filter entries (e.g., "*.txt").
        files_only: Return only files, not directories.
        dirs_only: Return only directories, not files.
        max_entries: Maximum total entries to return.
        max_depth: Maximum recursion depth (None = unlimited).
        root: Override sandbox root (for testing).

    Returns:
        List of all entries sorted by path.

    Raises:
        FileNotFoundError: If directory doesn't exist.
        NotADirectoryError: If path is not a directory.
        SandboxViolation: If path escapes sandbox.
    """
    base = resolve_safe_path(path, root=root)
    if not base.exists():
        raise FileNotFoundError(str(base))
    if not base.is_dir():
        raise NotADirectoryError(str(base))

    out: list[dict[str, Any]] = []
    count = 0
    max_count = max_entries or float("inf")

    def _walk(dir_path: Path, current_depth: int) -> None:
        nonlocal count
        if max_depth is not None and current_depth > max_depth:
            return
        if count >= max_count:
            return

        try:
            children = sorted(dir_path.iterdir(), key=lambda p: p.name.lower())
        except PermissionError:
            return

        for child in children:
            if count >= max_count:
                return

            is_dir = child.is_dir()
            is_file = child.is_file()

            # Apply filters
            if files_only and not is_file:
                if is_dir:
                    # Still recurse into directories to find files
                    _walk(child, current_depth + 1)
                continue
            if dirs_only and not is_dir:
                continue
            if glob and not fnmatch.fnmatch(child.name, glob):
                if is_dir and not dirs_only:
                    # Still recurse to find matching files
                    _walk(child, current_depth + 1)
                continue

            try:
                st = child.stat()
                out.append(
                    {
                        "path": _rel_to_root(child, root=root),
                        "is_dir": is_dir,
                        "size": 0 if is_dir else int(st.st_size),
                        "mtime": float(st.st_mtime),
                    }
                )
                count += 1
            except Exception as exc:
                log.debug("listdir_recursive: skipping %s: %s", child, exc)
                continue

            # Recurse into subdirectories
            if is_dir:
                _walk(child, current_depth + 1)

    _walk(base, 0)
    return out


def stat(path: str | Path, *, root: Path | None = None) -> dict[str, Any]:
    """
    Get detailed file/directory statistics.

    Args:
        path: Path relative to sandbox root.
        root: Override sandbox root (for testing).

    Returns:
        Dict with path, exists, is_dir, is_file, size, mtime, ctime, mode.
    """
    try:
        p = resolve_safe_path(path, root=root)
    except Exception:
        return {
            "path": str(path),
            "exists": False,
            "is_dir": False,
            "is_file": False,
            "size": 0,
            "mtime": None,
            "ctime": None,
            "mode": None,
        }

    if not p.exists():
        return {
            "path": _rel_to_root(p, root=root) if p.exists() else str(path),
            "exists": False,
            "is_dir": False,
            "is_file": False,
            "size": 0,
            "mtime": None,
            "ctime": None,
            "mode": None,
        }

    st = p.stat()
    return {
        "path": _rel_to_root(p, root=root),
        "exists": True,
        "is_dir": p.is_dir(),
        "is_file": p.is_file(),
        "size": int(st.st_size),
        "mtime": float(st.st_mtime),
        "ctime": float(st.st_ctime),
        "mode": st.st_mode,
    }


def exists(path: str | Path, *, root: Path | None = None) -> bool:
    """
    Check if a path exists inside the sandbox.

    Args:
        path: Path relative to sandbox root.
        root: Override sandbox root (for testing).

    Returns:
        True if path exists, False otherwise.
    """
    try:
        p = resolve_safe_path(path, root=root)
        return p.exists()
    except Exception:
        return False


# ----------------------------- Reading ----------------------------------


def read(
    path: str | Path,
    offset: int | None = None,
    length: int | None = None,
    *,
    root: Path | None = None,
) -> bytes:
    """
    Read file bytes with optional (offset, length) window.

    Args:
        path: File path relative to sandbox root.
        offset: Start reading from this byte offset.
        length: Read at most this many bytes.
        root: Override sandbox root (for testing).

    Returns:
        File content as bytes.

    Raises:
        FileNotFoundError: If file doesn't exist.
        IsADirectoryError: If path is a directory.
        SandboxViolation: If path escapes sandbox.
    """
    p = resolve_safe_path(path, root=root)
    if not p.exists():
        raise FileNotFoundError(str(p))
    if not p.is_file():
        raise IsADirectoryError(str(p))
    with p.open("rb") as f:
        if offset is not None:
            f.seek(max(0, offset))
        data = f.read(length) if length is not None else f.read()
    return data


def read_bytes(path: str | Path, *, root: Path | None = None) -> bytes:
    """
    Read entire file as bytes.

    Helper function for tests and simple use cases.
    """
    p = _coerce_helper_path(path, root=root)
    with p.open("rb") as f:
        return f.read()


@contextmanager
def read_chunks(
    path: str | Path, chunk_size: int = 64 * 1024, *, root: Path | None = None
) -> Iterator[Iterator[bytes]]:
    """
    Context manager yielding an iterator of chunks.

    Ensures file handle is properly closed to prevent resource leaks.

    Args:
        path: File path relative to sandbox root.
        chunk_size: Size of each chunk in bytes (default 64KB).
        root: Override sandbox root (for testing).

    Yields:
        Iterator of byte chunks.
    """
    p = _coerce_helper_path(path, root=root)
    f = p.open("rb")

    def _gen() -> Iterator[bytes]:
        try:
            while True:
                chunk = f.read(chunk_size)
                if not chunk:
                    break
                yield chunk
        finally:
            with suppress(OSError):
                f.close()

    try:
        yield _gen()
    finally:
        with suppress(OSError):
            f.close()


def open_binary(path: str | Path, mode: str = "rb", *, root: Path | None = None) -> BinaryIO:
    """
    Open a file inside the sandbox in binary mode.

    Returns a file object that must be closed by the caller.
    Prefer using context managers.
    """
    p = _coerce_helper_path(path, root=root)
    f = p.open(mode)
    return cast(BinaryIO, f)


# ----------------------------- Writing ----------------------------------


def atomic_write(
    path: str | Path,
    content: bytes,
    *,
    perms: int | None = None,
    make_dirs: bool = False,
    root: Path | None = None,
) -> None:
    """
    Atomically write content to path inside the sandbox.

    ATOMICITY GUARANTEE:
    1. Write to temp file in SAME directory as target.
    2. fsync() to ensure data hits disk.
    3. Atomic rename() to move temp to target.
    4. Cleanup temp file in finally block.

    This guarantees that on power loss:
    - Either the OLD content is preserved, OR
    - The NEW content is fully written.
    - Never a partial/corrupt file.

    Args:
        path: Target file path relative to sandbox root.
        content: Bytes to write.
        perms: Optional file permissions (e.g., 0o644).
        make_dirs: Create parent directories if missing.
        root: Override sandbox root (for testing).

    Raises:
        SandboxViolation: If path escapes sandbox.
        PermissionError: If parent directory is not writable.
        FileSizeLimitError: If content exceeds size limit.
        DiskSpaceError: If insufficient disk space.
    """
    p = _coerce_helper_path(path, root=root)

    # Check file size limit
    check_file_size(len(content))

    # Ensure parent directory exists
    if make_dirs or not p.parent.exists():
        p.parent.mkdir(parents=True, exist_ok=True)

    # Check disk space before writing
    check_disk_space(p.parent, len(content))

    tmp_path: Path | None = None
    try:
        # Create temp file in SAME directory (required for atomic rename)
        with tempfile.NamedTemporaryFile(dir=p.parent, delete=False) as tmp:
            tmp.write(content)
            tmp.flush()
            # CRITICAL: fsync ensures data hits disk before rename
            os.fsync(tmp.fileno())
            tmp_path = Path(tmp.name)

        # Atomic rename (POSIX guarantees this is atomic within same filesystem)
        os.replace(tmp_path, p)

        # CRITICAL: fsync the parent directory to ensure the rename is persisted
        # Without this, a power failure could leave the directory entry missing
        # even though the file content was fsynced
        dir_fd = os.open(p.parent, os.O_RDONLY | os.O_DIRECTORY)
        try:
            os.fsync(dir_fd)
        finally:
            os.close(dir_fd)

        # Best-effort chmod if requested
        if perms is not None:
            try:
                os.chmod(p, int(perms))
            except Exception as exc:
                log.debug("atomic_write: chmod failed for %s: %s", p, exc)
    finally:
        # ALWAYS clean up temp file on any error
        if tmp_path is not None:
            with suppress(OSError):
                tmp_path.unlink(missing_ok=True)


def write(
    path: str | Path,
    content_b64: str,
    mode: int | None = None,
    make_dirs: bool = False,
    *,
    root: Path | None = None,
) -> None:
    """
    Decode base64 and atomically write to sandbox.

    API-layer helper that accepts base64-encoded content.

    Args:
        path: Target file path.
        content_b64: Base64-encoded content.
        mode: Optional file permissions.
        make_dirs: Create parent directories if missing.
        root: Override sandbox root.

    Raises:
        ValueError: If content_b64 is not valid base64.
        SandboxViolation: If path escapes sandbox.
    """
    try:
        data = base64.b64decode(content_b64, validate=True)
    except Exception as exc:
        raise ValueError("invalid base64") from exc
    atomic_write(path, data, perms=mode, make_dirs=make_dirs, root=root)


def write_bytes(
    path: str | Path,
    data: bytes,
    *,
    make_dirs: bool = True,
    root: Path | None = None,
) -> None:
    """
    Write raw bytes atomically.

    Helper for tests and direct binary writes.
    """
    atomic_write(path, data, perms=None, make_dirs=make_dirs, root=root)


# ----------------------------- Directory ops ----------------------------


def mkdir(
    path: str | Path,
    mode: int | None = None,
    exist_ok: bool = False,
    *,
    root: Path | None = None,
    **kwargs: Any,
) -> None:
    """
    Create directory inside sandbox.

    Args:
        path: Directory path relative to sandbox root.
        mode: Directory permissions (e.g., 0o755).
        exist_ok: Don't raise if directory already exists.
        root: Override sandbox root.
        **kwargs: Back-compat for parents= kwarg.

    Raises:
        FileExistsError: If directory exists and exist_ok=False.
        SandboxViolation: If path escapes sandbox.
    """
    p = resolve_safe_path(path, root=root)
    parents = bool(kwargs.get("parents"))
    p.mkdir(parents=parents, exist_ok=exist_ok)
    if mode is not None:
        try:
            os.chmod(p, int(mode))
        except Exception as exc:
            log.debug("mkdir: chmod failed for %s: %s", p, exc)


def delete(
    path: str | Path,
    recursive: bool = False,
    *,
    root: Path | None = None,
    missing_ok: bool = False,
) -> None:
    """
    Delete file or directory inside sandbox.

    Args:
        path: Path to delete relative to sandbox root.
        recursive: Delete directories recursively.
        root: Override sandbox root.
        missing_ok: Don't raise if path doesn't exist.

    Raises:
        FileNotFoundError: If path doesn't exist and missing_ok=False.
        OSError: If directory is not empty and recursive=False.
        SandboxViolation: If path escapes sandbox.
    """
    p = resolve_safe_path(path, root=root)
    if not p.exists():
        if missing_ok:
            return
        raise FileNotFoundError(str(p))
    if p.is_dir():
        if recursive:
            shutil.rmtree(p)
        else:
            p.rmdir()
    else:
        p.unlink()


# ----------------------------- Move/Copy --------------------------------


def move(
    src: str | Path,
    dst: str | Path,
    *,
    overwrite: bool = False,
    root: Path | None = None,
) -> None:
    """
    Move/rename file or directory within sandbox.

    Args:
        src: Source path.
        dst: Destination path.
        overwrite: Overwrite destination if exists.
        root: Override sandbox root.

    Raises:
        FileNotFoundError: If source doesn't exist.
        FileExistsError: If destination exists and overwrite=False.
        SandboxViolation: If either path escapes sandbox.
    """
    s = resolve_safe_path(src, root=root)
    d = resolve_safe_path(dst, root=root)
    if d.exists() and not overwrite:
        raise FileExistsError(str(d))
    d.parent.mkdir(parents=True, exist_ok=True)
    shutil.move(str(s), str(d))


def copy(
    src: str | Path,
    dst: str | Path,
    *,
    overwrite: bool = False,
    root: Path | None = None,
) -> None:
    """
    Copy file or directory within sandbox.

    Args:
        src: Source path.
        dst: Destination path.
        overwrite: Overwrite destination if exists.
        root: Override sandbox root.

    Raises:
        FileNotFoundError: If source doesn't exist.
        FileExistsError: If destination exists and overwrite=False.
        SandboxViolation: If either path escapes sandbox.
    """
    s = resolve_safe_path(src, root=root)
    d = resolve_safe_path(dst, root=root)
    if d.exists():
        if not overwrite:
            raise FileExistsError(str(d))
        if d.is_dir():
            shutil.rmtree(d)
        else:
            d.unlink()
    d.parent.mkdir(parents=True, exist_ok=True)
    if s.is_dir():
        shutil.copytree(s, d)
    else:
        shutil.copy2(s, d)


# ----------------------------- Hashing ----------------------------------


def digest(
    path: str | Path,
    algo: str = "sha256",
    *,
    root: Path | None = None,
) -> str:
    """
    Calculate file hash digest.

    Args:
        path: File path relative to sandbox root.
        algo: Hash algorithm (default sha256).
        root: Override sandbox root.

    Returns:
        Hex digest string.

    Raises:
        FileNotFoundError: If file doesn't exist.
        ValueError: If algorithm is not available.
        SandboxViolation: If path escapes sandbox.
    """
    p = resolve_safe_path(path, root=root)
    h = hashlib.new(algo)
    with p.open("rb") as f:
        while True:
            b = f.read(64 * 1024)
            if not b:
                break
            h.update(b)
    return h.hexdigest()
