"""
Sandbox - Path Resolution & Traversal Prevention

The Iron Vault's first line of defense. This module RIGIDLY enforces that
ALL paths resolve within FSAL_ROOT. No exceptions. No bypasses.

Constitution B3: "All file operations MUST be sandboxed to prevent
directory traversal attacks."

SECURITY INVARIANTS:
1. All user-supplied paths are normalized to relative paths.
2. Absolute paths have their root stripped.
3. ".." components are resolved BEFORE path construction.
4. Symlinks are NOT followed outside the sandbox.
5. Null bytes in paths are REJECTED.
6. FSAL_ROOT MUST be set in production mode.
"""

from __future__ import annotations

import logging
import os
import tempfile
from pathlib import Path

log = logging.getLogger(__name__)

# Environment variable priority (first non-empty wins)
_ENV_CANDIDATES = (
    "FSAL_ROOT",
    "APERION_FSAL_SANDBOX",
    "TEST_SANDBOX",
    "APERION_SANDBOX",
    "FSAL_SANDBOX",
)


class SandboxViolation(PermissionError):
    """Raised when a path operation would escape the sandbox."""

    pass


class SandboxNotConfigured(RuntimeError):
    """Raised when FSAL_ROOT is not set in production mode."""

    pass


def _expanduser_safe(value: str) -> Path | None:
    """Expand user paths safely; return None on failure."""
    try:
        return Path(value).expanduser()
    except Exception as err:
        log.debug("sandbox: expanduser failed for %r: %s", value, err)
        return None


def get_sandbox_root() -> Path:
    """
    Return the sandbox root directory.

    Priority:
    1. FSAL_ROOT environment variable
    2. APERION_FSAL_SANDBOX (legacy compatibility)
    3. TEST_SANDBOX / APERION_SANDBOX / FSAL_SANDBOX (test compat)
    4. CWD (only if APERION_ENV=dev or testing)
    5. System temp directory (absolute fallback, logs warning)

    Raises:
        SandboxNotConfigured: If no sandbox root found in production mode.
    """
    for name in _ENV_CANDIDATES:
        v = os.environ.get(name)
        if not v:
            continue
        p = _expanduser_safe(v)
        if p:
            # Do NOT require existence; tests may set root before creating dir
            return p.resolve(strict=False)

    # No explicit sandbox - check environment
    env_mode = os.environ.get("APERION_ENV", "").lower()
    is_dev_mode = env_mode in ("dev", "development", "test", "testing")

    if is_dev_mode:
        log.warning(
            "FSAL_ROOT not set - defaulting to CWD in dev mode. Set FSAL_ROOT for production!"
        )
        try:
            return Path.cwd().resolve()
        except Exception as err:
            log.warning("CWD resolution failed: %s - using temp dir", err)
            return Path(tempfile.gettempdir()).resolve()

    # Production mode: REFUSE to start without explicit sandbox
    raise SandboxNotConfigured(
        "FSAL_ROOT environment variable is required in production mode. "
        "Set APERION_ENV=dev to allow CWD fallback during development."
    )


def _reject_null_bytes(path: str | Path) -> None:
    """
    Reject paths containing null bytes.

    Null byte injection is a classic attack vector that can truncate paths
    in C-based filesystem APIs. We reject them outright.
    """
    path_str = str(path)
    if "\x00" in path_str:
        raise SandboxViolation("Path contains null byte - this is a security violation")


def _normalize_user_path(user_path: str | Path) -> Path:
    """
    Convert user input into a RELATIVE path (strip any leading root).

    Examples:
        "/foo/bar" -> "foo/bar"
        "foo/bar"  -> "foo/bar"
        "C:\\foo"  -> "foo" (Windows)
    """
    _reject_null_bytes(user_path)

    p = Path(user_path)
    if p.is_absolute():
        # Strip the leading '/' so we always anchor below the sandbox
        p = Path(*p.parts[1:]) if len(p.parts) > 1 else Path(".")
    return p


def resolve_safe_path(
    user_path: str | Path,
    root: Path | None = None,
    *,
    must_exist: bool = False,
    allow_symlinks: bool = False,
) -> Path:
    """
    Resolve a user-supplied path SAFELY against the sandbox.

    This is THE authoritative path resolution function. All other modules
    MUST use this for any user-provided path.

    Args:
        user_path: User-supplied path (absolute or relative).
        root: Sandbox root directory (defaults to get_sandbox_root()).
        must_exist: If True, raises FileNotFoundError if path doesn't exist.
        allow_symlinks: If True, allows symlinks that resolve within sandbox.
                       Default False - symlinks are rejected for security.

    Returns:
        Absolute path guaranteed to be within the sandbox.

    Raises:
        SandboxViolation: If path would escape sandbox.
        FileNotFoundError: If must_exist=True and path doesn't exist.
    """
    _reject_null_bytes(user_path)

    base = Path(root).resolve(strict=False) if root else get_sandbox_root()
    rel = _normalize_user_path(user_path)

    # Build the candidate path WITHOUT resolving symlinks yet
    # This prevents symlink-following from leaking information about external paths
    candidate_unresolved = base / rel

    # Security check 1: Check for symlinks BEFORE resolving
    # Walk each component to detect symlinks early
    if not allow_symlinks:
        check_path = base
        for part in rel.parts:
            check_path = check_path / part
            # Use lstat to check the path itself, not what it points to
            try:
                if check_path.is_symlink():
                    raise SandboxViolation(
                        f"Symlinks not allowed: {user_path!r} (symlink at {part!r})"
                    )
            except SandboxViolation:
                # Re-raise our own exception (SandboxViolation inherits from OSError)
                raise
            except OSError:
                # Path doesn't exist yet, which is fine for creates
                break

    # Now resolve to get the canonical path
    candidate = candidate_unresolved.resolve(strict=False)

    # Security check 2: Path must be within sandbox
    try:
        candidate.relative_to(base)
    except ValueError:
        raise SandboxViolation(
            f"Path escapes sandbox: {user_path!r} resolved to {candidate}"
        ) from None

    # Security check 3: If symlinks allowed, verify target is in sandbox
    if allow_symlinks and candidate.exists() and candidate.is_symlink():
        real_path = candidate.resolve(strict=True)
        try:
            real_path.relative_to(base)
        except ValueError:
            raise SandboxViolation(
                f"Symlink target escapes sandbox: {user_path!r} -> {real_path}"
            ) from None

    # Security check 4: Existence validation
    if must_exist and not candidate.exists():
        raise FileNotFoundError(f"Path does not exist: {user_path!r}")

    return candidate


def _safe(path: str | Path, base: str | Path | None = None) -> Path:
    """
    Back-compat shim for tests expecting _safe() function.

    This is an alias for resolve_safe_path with explicit base.
    """
    base_p = (
        Path(base).expanduser().resolve(strict=False) if base is not None else get_sandbox_root()
    )
    return resolve_safe_path(path, root=base_p)


def _coerce_helper_path(path: str | Path, *, root: Path | None = None) -> Path:
    """
    For low-level helpers used in tests (atomic_write/read_bytes/etc).

    - If explicit sandbox root provided, resolve within it.
    - If caller passes absolute Path and no root, respect directly
      (helpers act as thin filesystem utilities in tests).
    - Otherwise, resolve relative to the sandbox.
    """
    if root is not None:
        return resolve_safe_path(path, root=root)
    if isinstance(path, Path) and path.is_absolute():
        return path
    return resolve_safe_path(path, root=None)


def _rel_to_root(p: Path, root: Path | None = None) -> str:
    """
    Return POSIX-style string of 'p' relative to 'root'.

    Used for API responses to return sandbox-relative paths.
    """
    base = Path(root).resolve(strict=False) if root else get_sandbox_root()
    try:
        rel = p.relative_to(base)
    except ValueError:
        # If not under base (shouldn't happen with proper validation),
        # fall back to name to avoid leaking host paths
        log.warning("Path %s not under sandbox root - returning basename only", p)
        rel = Path(p.name)
    return rel.as_posix()


def validate_sandbox_config() -> dict[str, str | bool]:
    """
    Validate current sandbox configuration.

    Returns status dict for health checks and diagnostics.
    """
    result: dict[str, str | bool] = {
        "configured": False,
        "root": "",
        "exists": False,
        "writable": False,
        "production_safe": False,
    }

    try:
        root = get_sandbox_root()
        result["configured"] = True
        result["root"] = str(root)
        result["exists"] = root.exists()
        result["writable"] = os.access(root, os.W_OK) if root.exists() else False
        # Production safe = explicitly configured (not CWD fallback)
        result["production_safe"] = bool(os.environ.get("FSAL_ROOT"))
    except SandboxNotConfigured:
        result["error"] = "FSAL_ROOT not configured (required in production)"

    return result
