"""
ShelfAI safety utilities.

Handles path traversal protection, file locking, backup/restore,
and logging. These are the production hardening pieces that prevent
the most common failure modes.
"""

import logging
import secrets
import shutil
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path

from filelock import FileLock, Timeout as FileLockTimeout

__all__ = [
    "validate_path",
    "shelf_lock",
    "backup_files",
    "restore_backup",
    "generate_session_id",
    "count_memory_entries",
    "setup_logging",
    "logger",
]

# ──────────────────────────────────────────────
# Logging
# ──────────────────────────────────────────────

logger = logging.getLogger("shelfai")


def setup_logging(shelf_root: Path, level: int = logging.INFO):
    """
    Configure ShelfAI logging. Writes to .shelfai/shelfai.log in the shelf root.

    Safe to call multiple times — clears existing handlers first to prevent
    duplicate log messages.

    Args:
        shelf_root: Path to the shelf directory
        level: Logging level
    """
    # Fix #1: Clear existing handlers to prevent duplicates on repeated calls
    logger.handlers.clear()

    log_dir = shelf_root / ".shelfai"
    log_dir.mkdir(exist_ok=True)
    log_file = log_dir / "shelfai.log"

    handler = logging.FileHandler(log_file, encoding="utf-8")
    handler.setFormatter(
        logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")
    )
    logger.addHandler(handler)
    logger.setLevel(level)

    console = logging.StreamHandler()
    console.setLevel(logging.WARNING)
    console.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
    logger.addHandler(console)


# ──────────────────────────────────────────────
# Path Traversal Protection
# ──────────────────────────────────────────────

# Windows reserved device names (block on all platforms for portability)
_RESERVED_NAMES = (
    {"CON", "PRN", "AUX", "NUL"}
    | {f"COM{i}" for i in range(1, 10)}
    | {f"LPT{i}" for i in range(1, 10)}
)


def validate_path(shelf_root: Path, relative_path: str) -> Path:
    """
    Validate that a relative path stays within the shelf root.

    Checks for:
        - Empty or whitespace-only paths
        - Null bytes (path injection vector)
        - Path traversal via .. components
        - Hidden files/directories (except .shelfai)
        - Windows reserved device names (for cross-platform safety)

    Args:
        shelf_root: Absolute path to the shelf root
        relative_path: The relative path to validate

    Returns:
        The resolved absolute path

    Raises:
        ValueError: If the path is invalid or escapes the shelf root
    """
    # Fix #3: Reject empty or whitespace-only paths
    if not relative_path or not relative_path.strip():
        raise ValueError(f"Empty or whitespace-only path: {relative_path!r}")

    # Fix #2: Reject null bytes
    if "\x00" in relative_path:
        raise ValueError(f"Null byte in path: {relative_path!r}")

    # Resolve both to absolute, canonical paths
    root = shelf_root.resolve()
    target = (root / relative_path).resolve()

    # Check the target is inside the root
    try:
        target.relative_to(root)
    except ValueError:
        raise ValueError(
            f"Path traversal blocked: '{relative_path}' resolves to "
            f"'{target}' which is outside shelf root '{root}'"
        )

    # Check each path component
    parts = Path(relative_path).parts
    for part in parts:
        # Block hidden files/dirs (except .shelfai)
        if part.startswith(".") and part != ".shelfai":
            raise ValueError(
                f"Hidden path component blocked: '{part}' in '{relative_path}'"
            )

        # Fix #2: Block Windows reserved names (cross-platform safety)
        stem = part.split(".")[0].upper()
        if stem in _RESERVED_NAMES:
            raise ValueError(
                f"Reserved name blocked: '{part}' in '{relative_path}'"
            )

    return target


# ──────────────────────────────────────────────
# File Locking
# ──────────────────────────────────────────────


@contextmanager
def shelf_lock(shelf_root: Path, timeout: float = 30.0):
    """
    Acquire an exclusive lock on the shelf for write operations.

    Uses a lockfile at .shelfai/shelf.lock via the `filelock` library.
    Only one process can hold the lock at a time.

    Args:
        shelf_root: Path to the shelf root
        timeout: Seconds to wait for the lock before raising

    Yields:
        None (context manager)

    Raises:
        TimeoutError: If the lock can't be acquired within timeout
    """
    lock_dir = shelf_root / ".shelfai"
    lock_dir.mkdir(exist_ok=True)
    lock_path = lock_dir / "shelf.lock"

    lock = FileLock(lock_path, timeout=timeout)

    # Fix #4: Separate acquire from yield so release() only runs if acquire succeeded
    try:
        lock.acquire()
    except FileLockTimeout:
        raise TimeoutError(
            f"Could not acquire shelf lock after {timeout}s. "
            f"Another session may be running. "
            f"Lock file: {lock_path}"
        )

    try:
        logger.debug("Shelf lock acquired")
        yield
    finally:
        lock.release()
        logger.debug("Shelf lock released")


# ──────────────────────────────────────────────
# Backup / Restore
# ──────────────────────────────────────────────


def backup_files(shelf_root: Path, file_paths: list[str], session_id: str) -> Path:
    """
    Snapshot files before modifying them.

    Creates copies in .shelfai/backups/{session_id}/ so changes
    can be rolled back if something goes wrong.

    Args:
        shelf_root: Path to the shelf root
        file_paths: List of relative paths to back up
        session_id: Unique identifier for this session

    Returns:
        Path to the backup directory
    """
    backup_dir = shelf_root / ".shelfai" / "backups" / session_id
    backup_dir.mkdir(parents=True, exist_ok=True)

    # Fix #5: Track actual backed-up count separately
    backed_up = 0
    for relative_path in file_paths:
        source = shelf_root / relative_path
        if source.exists():
            dest = backup_dir / relative_path
            dest.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(source, dest)
            logger.debug(f"Backed up: {relative_path}")
            backed_up += 1
        else:
            logger.debug(f"Skipped (not found): {relative_path}")

    logger.info(f"Backed up {backed_up}/{len(file_paths)} files to {backup_dir}")
    return backup_dir


def restore_backup(shelf_root: Path, session_id: str) -> list[str]:
    """
    Restore files from a backup.

    Validates each restored path to prevent writing outside the shelf root
    (e.g. if the backup directory was tampered with).

    Args:
        shelf_root: Path to the shelf root
        session_id: Session ID to restore from

    Returns:
        List of restored file paths

    Raises:
        FileNotFoundError: If the backup doesn't exist
    """
    backup_dir = shelf_root / ".shelfai" / "backups" / session_id
    if not backup_dir.exists():
        raise FileNotFoundError(f"No backup found for session: {session_id}")

    restored = []
    for backup_file in backup_dir.rglob("*"):
        if backup_file.is_file():
            relative = backup_file.relative_to(backup_dir)
            relative_str = str(relative)

            # Fix #6: Validate restored paths to prevent writing outside shelf root
            try:
                validate_path(shelf_root, relative_str)
            except ValueError as e:
                logger.warning(f"Skipping restore of suspicious path: {e}")
                continue

            dest = shelf_root / relative
            dest.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(backup_file, dest)
            restored.append(relative_str)

    logger.info(f"Restored {len(restored)} files from backup {session_id}")
    return restored


def generate_session_id() -> str:
    """
    Generate a unique session ID.

    Format: YYYYMMDD_HHMMSS_ffffff_xxxx where xxxx is random hex.
    The random suffix prevents collisions when two sessions start
    in the same microsecond (e.g. in tests or rapid CLI usage).
    """
    # Fix #7: Drop misleading truncation, add random suffix for uniqueness
    ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
    return f"{ts}_{secrets.token_hex(2)}"


# ──────────────────────────────────────────────
# Memory Entry Counting
# ──────────────────────────────────────────────


def count_memory_entries(file_path: Path) -> int:
    """
    Count the number of top-level memory entries in a memory file.

    Only counts lines starting with '- ' at column 0 (no indentation),
    and only after the frontmatter section. This prevents counting nested
    bullets, frontmatter content, or quoted examples.

    Args:
        file_path: Path to the memory file

    Returns:
        Number of top-level entries found
    """
    if not file_path.exists():
        return 0

    content = file_path.read_text(encoding="utf-8")

    # Skip frontmatter (between --- markers)
    lines = content.split("\n")
    start = 0
    if lines and lines[0].strip() == "---":
        found_close = False
        for i in range(1, len(lines)):
            if lines[i].strip() == "---":
                start = i + 1
                found_close = True
                break
        # Fix #8: Warn on unclosed frontmatter
        if not found_close:
            logger.debug(f"Unclosed frontmatter in {file_path}")

    return sum(
        1
        for line in lines[start:]
        if line.startswith("- ")  # Must start at column 0
    )
