"""Core backup service for encrypted database backups.

Uses sqlcipher_export() for safe atomic backups that preserve encryption
and work correctly with WAL mode.
"""

import os
import shutil
import threading
import time
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Optional

from loguru import logger

from ...utilities.resource_utils import safe_close

from ...config.paths import (
    get_encrypted_database_path,
    get_user_backup_directory,
    get_user_database_filename,
)
from ..sqlcipher_utils import (
    apply_sqlcipher_pragmas,
    create_sqlcipher_connection,
    get_key_from_password,
    get_sqlcipher_settings,
    set_sqlcipher_key,
    verify_sqlcipher_connection,
)

# Module-level per-user locks to prevent concurrent backup operations
# for the same user across different BackupService instances
_user_locks: dict[str, threading.Lock] = {}
_user_locks_lock = threading.Lock()

# Characters that must never appear in a backup path. ATTACH DATABASE takes a
# string literal and cannot be parameterized in SQLite/SQLCipher, so the path is
# interpolated into SQL. A single quote IS allowed — it is escaped by doubling
# (per the SQLite literal grammar) so an apostrophe in the data-dir path (a
# home dir named O'Brien, say) doesn't break backups. Backslash, double-quote,
# NUL and control chars are still rejected (never valid in a real backup path).
_UNSAFE_BACKUP_PATH_CHARS = frozenset('"\\\0\n\r\t')


def _get_user_lock(username: str) -> threading.Lock:
    """Get or create a lock for a specific user.

    Thread-safe lazy initialization of per-user locks.

    Args:
        username: The username to get lock for

    Returns:
        A threading.Lock for the specified user
    """
    with _user_locks_lock:
        if username not in _user_locks:
            _user_locks[username] = threading.Lock()
        return _user_locks[username]


def is_safe_glob_result(path: Path, base_dir: Path) -> bool:
    """Validate that a glob result is safe to use.

    Rejects symlinks and paths that resolve outside the expected
    base directory, preventing path-traversal via crafted filenames
    or symlink attacks.

    This is a best-effort pre-check, not atomic with the subsequent
    stat()/unlink()/open() on the path (a classic TOCTOU window). It is
    acceptable here because the backup directory is per-user and
    server-owned with 0o600 backups — an attacker who could swap a file
    for a symlink already has write access to that directory.

    Args:
        path: The path returned by glob to validate.
        base_dir: The directory the path must reside within.

    Returns:
        True if the path is a regular (non-symlink) file that resolves
        to a location within base_dir.
    """
    return not path.is_symlink() and path.resolve().is_relative_to(
        base_dir.resolve()
    )


def pop_user_lock(username: str) -> None:
    """Remove the per-user backup lock for ``username`` from the registry.

    Called from the user-close path so the module-level dict doesn't
    accumulate one entry per username across the process lifetime. The
    next backup operation lazily re-creates the lock if needed — the
    lock has no state that needs to persist across login/logout.
    """
    with _user_locks_lock:
        _user_locks.pop(username, None)


@dataclass
class BackupResult:
    """Result of a backup operation."""

    success: bool
    backup_path: Optional[Path] = None
    error: Optional[str] = None
    size_bytes: int = 0


class BackupService:
    """Service for creating and managing encrypted database backups.

    Uses sqlcipher_export() for safe backups that:
    - Work correctly with WAL mode
    - Preserve encryption with the same key
    - Create atomic copies via ATTACH + export + DETACH
    - Never corrupt the source database
    """

    def __init__(
        self,
        username: str,
        password: str,
        max_backups: int = 1,
        max_age_days: int = 7,
    ):
        """Initialize backup service.

        Args:
            username: User's username
            password: User's password (for encryption)
            max_backups: Maximum number of backup files to keep
            max_age_days: Delete backups older than this many days
        """
        self.username = username
        self.password = password
        self.max_backups = max_backups
        self.max_age_days = max_age_days

        # Get paths
        self.db_filename = get_user_database_filename(username)
        self.db_path = get_encrypted_database_path() / self.db_filename
        self.backup_dir = get_user_backup_directory(username)

    def create_backup(self, force: bool = False) -> BackupResult:
        """Create an encrypted backup of the user's database.

        Uses sqlcipher_export() to create a safe, atomic backup that inherits
        the encryption key from the source database. The backup is created
        with a .tmp suffix and atomically renamed to prevent race conditions
        with cleanup operations.

        By default, only one backup per calendar day is created to prevent
        a corrupted database from rapidly overwriting all good backups.
        Use force=True to bypass this check (used by pre-migration backups).

        This method is protected by a per-user lock to prevent concurrent
        backup operations for the same user.

        Args:
            force: If True, skip the daily limit check.

        Returns:
            BackupResult with success status and backup path
        """
        # Acquire per-user lock to prevent concurrent backup operations
        with _get_user_lock(self.username):
            # Skip if a backup already exists for today (unless forced)
            if not force:
                today = datetime.now(UTC).strftime("%Y%m%d")
                # After globbing, verify each result is a direct child of backup_dir
                existing_today = [
                    f
                    for f in self.backup_dir.glob(f"ldr_backup_{today}_*.db")
                    if is_safe_glob_result(f, self.backup_dir)
                ]
                if existing_today:
                    latest = max(existing_today, key=lambda p: p.name)
                    logger.debug(
                        f"Backup already exists for today ({latest.name}), "
                        "skipping"
                    )
                    return BackupResult(
                        success=True,
                        backup_path=latest,
                        size_bytes=latest.stat().st_size
                        if latest.exists()
                        else 0,
                    )

            start = time.perf_counter()
            result = self._create_backup_impl()
            elapsed_ms = (time.perf_counter() - start) * 1000
            size_info = (
                f"{result.size_bytes / (1024 * 1024):.1f}MB"
                if result.size_bytes
                else "unknown size"
            )
            if elapsed_ms > 1000:
                logger.info(
                    f"Backup for user {self.username} "
                    f"({size_info}) took {elapsed_ms:.0f}ms"
                )
            else:
                logger.debug(
                    f"Backup for user {self.username} "
                    f"({size_info}) took {elapsed_ms:.0f}ms"
                )
            return result

    def _create_backup_impl(self) -> BackupResult:
        """Internal implementation of backup creation (must be called with lock held)."""
        if not self.db_path.exists():
            return BackupResult(
                success=False,
                error=f"Database not found: {self.db_path}",
            )

        # Check available disk space
        try:
            db_size = self.db_path.stat().st_size
            free_space = shutil.disk_usage(self.backup_dir).free
            # Require at least 2x the database size as free space
            if free_space < db_size * 2:
                return BackupResult(
                    success=False,
                    error=f"Insufficient disk space. Need {db_size * 2} bytes, have {free_space}",
                )
        except OSError as e:
            # Fail closed - don't proceed with backup if we can't verify disk space
            logger.warning("Could not check disk space, skipping backup")
            return BackupResult(
                success=False,
                error=f"Could not verify disk space: {e}",
            )

        # Generate backup filename with timestamp
        # Use .tmp suffix during creation to prevent cleanup race conditions
        timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
        backup_filename = f"ldr_backup_{timestamp}.db"
        backup_path = self.backup_dir / backup_filename
        temp_path = self.backup_dir / f"ldr_backup_{timestamp}.db.tmp"

        try:
            # Create connection to source database
            conn = create_sqlcipher_connection(str(self.db_path), self.password)
            cursor = conn.cursor()

            # Set busy timeout so concurrent writers don't cause instant failure
            cursor.execute("PRAGMA busy_timeout = 10000")

            try:
                # Use sqlcipher_export() to create an encrypted backup
                # VACUUM INTO doesn't preserve encryption in SQLCipher
                # Security: the ATTACH path below is interpolated (not
                # parameterizable). Single quotes are escaped (see below); any
                # other unsafe char is rejected. Don't log the full path — it can
                # contain a username — only the offending characters.
                temp_path_str = str(temp_path)
                bad_chars = _UNSAFE_BACKUP_PATH_CHARS & set(temp_path_str)
                if bad_chars:
                    raise ValueError(
                        "Backup path contains characters not allowed in a "
                        "SQLCipher ATTACH statement: "
                        + ", ".join(sorted(repr(c) for c in bad_chars))
                    )

                # Get the hex key for ATTACH (same key derivation as source)
                hex_key = get_key_from_password(
                    self.password, db_path=self.db_path
                ).hex()

                # Defensive: ensure hex_key is strictly hexadecimal
                if not hex_key or not all(
                    c in "0123456789abcdef" for c in hex_key
                ):
                    raise ValueError("Derived key is not valid hex")

                # Attach backup database with encryption (using temp path).
                # ATTACH DATABASE can't be parameterized in SQLite/SQLCipher, so
                # the path is interpolated as a string literal — escape any
                # single quote by doubling it per the SQLite literal grammar
                # (all other unsafe chars were rejected above).
                attach_path = temp_path_str.replace("'", "''")
                cursor.execute(
                    f"ATTACH DATABASE '{attach_path}' AS backup KEY \"x'{hex_key}'\""
                )

                try:
                    # Apply cipher settings to the backup database (must match source)
                    # Note: PRAGMA statements do not support parameter binding
                    # in SQLite — f-string is required. Values are validated
                    # upstream by get_sqlcipher_settings() against allow-lists.
                    settings = get_sqlcipher_settings()
                    page_size = int(settings["page_size"])
                    kdf_iter = int(settings["kdf_iterations"])
                    hmac_alg = str(settings["hmac_algorithm"])
                    cursor.execute(
                        f"PRAGMA backup.cipher_page_size = {page_size}"
                    )
                    cursor.execute(
                        f"PRAGMA backup.cipher_hmac_algorithm = {hmac_alg}"
                    )
                    cursor.execute(f"PRAGMA backup.kdf_iter = {kdf_iter}")

                    # Export all data to the backup database
                    cursor.execute("SELECT sqlcipher_export('backup')")
                finally:
                    # Always detach to release the backup file handle
                    try:
                        cursor.execute("DETACH DATABASE backup")
                    except Exception:
                        logger.warning(
                            "DETACH failed (connection will release on close)"
                        )
            finally:
                safe_close(cursor, "backup cursor")
                safe_close(conn, "backup connection")

            # Verify the backup is valid (still using temp path)
            if not self._verify_backup(temp_path):
                # Delete corrupted backup
                if temp_path.exists():
                    temp_path.unlink()
                return BackupResult(
                    success=False,
                    error="Backup verification failed - backup was corrupted",
                )

            # Set restrictive permissions (owner read/write only)
            # SECURITY: Backup files contain sensitive user data
            os.chmod(temp_path, 0o600)

            # Get backup size before rename
            backup_size = temp_path.stat().st_size

            # Atomic rename from .tmp to final .db
            # This ensures cleanup won't see/delete partially created backups
            temp_path.rename(backup_path)

            logger.info(
                f"Created backup for user: {backup_path.name} ({backup_size} bytes)"
            )

            # Cleanup old backups (safe now - new backup is finalized)
            self._cleanup_old_backups()

            return BackupResult(
                success=True,
                backup_path=backup_path,
                size_bytes=backup_size,
            )

        except Exception as e:
            logger.exception("Backup creation failed")
            # Clean up any partial backup (temp file)
            if temp_path.exists():
                try:
                    temp_path.unlink()
                except OSError:
                    pass
            # Also clean up final path in case rename partially succeeded
            if backup_path.exists():
                try:
                    backup_path.unlink()
                except OSError:
                    pass
            return BackupResult(
                success=False,
                error=str(e),
            )

    def _verify_backup(self, backup_path: Path) -> bool:
        """Verify that a backup file is valid and readable.

        Args:
            backup_path: Path to the backup file

        Returns:
            True if backup is valid, False otherwise
        """
        if not backup_path.exists():
            return False

        if backup_path.stat().st_size == 0:
            logger.warning("Backup file is empty (0 bytes)")
            return False

        try:
            # Import SQLCipher module
            from ..sqlcipher_compat import get_sqlcipher_module

            sqlcipher3 = get_sqlcipher_module()

            # Open the backup with the same password
            conn = sqlcipher3.connect(str(backup_path))
            cursor = conn.cursor()

            try:
                # Set encryption key using the SOURCE database's salt
                # (backup was encrypted with the source DB's per-database salt)
                set_sqlcipher_key(cursor, self.password, db_path=self.db_path)
                apply_sqlcipher_pragmas(cursor, creation_mode=False)

                # Run quick integrity check
                cursor.execute("PRAGMA quick_check")
                result = cursor.fetchone()

                if result and result[0] == "ok":
                    # Additional verification: try to read a table
                    if verify_sqlcipher_connection(cursor):
                        return True

                logger.warning(f"Backup integrity check failed: {result}")
                return False

            finally:
                safe_close(cursor, "backup cursor")
                safe_close(conn, "backup connection")

        except Exception:
            logger.warning("Backup verification failed")
            return False

    def _cleanup_old_backups(self) -> int:
        """Remove old backups based on age and count limits.

        Also cleans up stale .tmp files from interrupted backups.

        Returns:
            Number of backups deleted
        """
        deleted_count = 0
        cutoff_time = datetime.now(UTC) - timedelta(days=self.max_age_days)
        stale_tmp_cutoff = datetime.now(UTC) - timedelta(hours=1)

        try:
            # Clean up stale .tmp files from interrupted/crashed backups
            for tmp_file in [
                f
                for f in self.backup_dir.glob("ldr_backup_*.db.tmp")
                if is_safe_glob_result(f, self.backup_dir)
            ]:
                try:
                    mtime = datetime.fromtimestamp(
                        tmp_file.stat().st_mtime, tz=UTC
                    )
                    if mtime < stale_tmp_cutoff:
                        tmp_file.unlink()
                        logger.info(
                            f"Cleaned up stale temp file: {tmp_file.name}"
                        )
                except (OSError, FileNotFoundError):
                    pass

            # Get all backup files sorted by modification time (newest first)
            def _safe_mtime(p: Path) -> float:
                try:
                    return p.stat().st_mtime
                except FileNotFoundError:
                    return 0.0

            # After globbing, verify each result is a direct child of backup_dir
            backups = [
                p
                for p in sorted(
                    self.backup_dir.glob("ldr_backup_*.db"),
                    key=_safe_mtime,
                    reverse=True,
                )
                if p.exists() and is_safe_glob_result(p, self.backup_dir)
            ]

            for i, backup in enumerate(backups):
                should_delete = False

                # Delete if beyond max count
                if i >= self.max_backups:
                    should_delete = True
                    reason = f"exceeds max count ({self.max_backups})"

                # Delete if too old
                else:
                    try:
                        mtime = datetime.fromtimestamp(
                            backup.stat().st_mtime, tz=UTC
                        )
                        if mtime < cutoff_time:
                            should_delete = True
                            reason = f"older than {self.max_age_days} days"
                    except FileNotFoundError:
                        continue

                if should_delete:
                    try:
                        backup.unlink()
                        deleted_count += 1
                        logger.debug(
                            f"Deleted old backup {backup.name}: {reason}"
                        )
                    except OSError:
                        logger.warning(f"Could not delete backup {backup.name}")

        except Exception:
            logger.exception("Error during backup cleanup")

        if deleted_count > 0:
            logger.info(f"Cleaned up {deleted_count} old backups")

        return deleted_count

    def list_backups(self) -> list[dict]:
        """List all backups for this user.

        Returns:
            List of backup info dictionaries with path, size, and timestamp
        """
        backups = []

        try:

            def _safe_mtime_list(p: Path) -> float:
                try:
                    return p.stat().st_mtime
                except FileNotFoundError:
                    return 0.0

            # After globbing, verify each result is a direct child of backup_dir
            for backup_file in sorted(
                [
                    f
                    for f in self.backup_dir.glob("ldr_backup_*.db")
                    if is_safe_glob_result(f, self.backup_dir)
                ],
                key=_safe_mtime_list,
                reverse=True,
            ):
                try:
                    stat = backup_file.stat()
                except FileNotFoundError:
                    continue
                backups.append(
                    {
                        "filename": backup_file.name,
                        "path": backup_file.name,  # Only expose filename, not full server path
                        "size_bytes": stat.st_size,
                        "created_at": datetime.fromtimestamp(
                            stat.st_mtime, tz=UTC
                        ).isoformat(),
                    }
                )
        except Exception:
            logger.exception("Error listing backups")

        return backups

    def purge_and_refresh(self) -> "BackupResult":
        """Delete all existing backups and create a fresh one.

        Used after a password change to replace old-key backups with a
        new backup encrypted under the current password.  Old backups
        encrypted with a previous password are a security risk (NIST
        SP 800-57, OWASP A02) because they remain decryptable with the
        old (potentially compromised) password.

        Returns:
            BackupResult from the fresh backup creation
        """
        # Hold per-user lock for the entire purge+create operation to
        # prevent a concurrent backup from writing an old-key backup
        # between the purge and the fresh backup creation.
        with _get_user_lock(self.username):
            # Delete all existing backup files
            for info in self.list_backups():
                try:
                    (self.backup_dir / info["path"]).unlink()
                    logger.debug(f"Purged old-key backup: {info['filename']}")
                except OSError:
                    logger.warning(
                        f"Could not delete backup {info['filename']}"
                    )

            # Also clean up any stale .tmp files
            for tmp_file in [
                f
                for f in self.backup_dir.glob("ldr_backup_*.db.tmp")
                if is_safe_glob_result(f, self.backup_dir)
            ]:
                try:
                    tmp_file.unlink()
                except OSError:
                    logger.warning(
                        f"Could not delete stale tmp file {tmp_file.name}"
                    )

            # Create fresh backup with current password (lock already held)
            return self._create_backup_impl()

    def get_latest_backup(self) -> Optional[Path]:
        """Get the path to the most recent backup.

        Returns:
            Path to latest backup, or None if no backups exist
        """
        try:

            def _safe_mtime_latest(p: Path) -> float:
                try:
                    return p.stat().st_mtime
                except FileNotFoundError:
                    return 0.0

            # After globbing, verify each result is a direct child of backup_dir
            backups = [
                p
                for p in sorted(
                    self.backup_dir.glob("ldr_backup_*.db"),
                    key=_safe_mtime_latest,
                    reverse=True,
                )
                if p.exists() and is_safe_glob_result(p, self.backup_dir)
            ]
            return backups[0] if backups else None
        except Exception:
            logger.exception("Error finding latest backup")
            return None
