# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""
Shared Utilities

Common functions used across core modules.

Provides crash-safe JSON I/O with automatic backup rotation.
All persistent data writes should go through atomic_write_json()
and all reads through safe_load_json() for consistency and safety.
"""

import json
import logging
import os
import secrets
import shutil
import tempfile
from pathlib import Path
from typing import Any, Optional

logger = logging.getLogger(__name__)

# Number of backup copies to retain per file
_BACKUP_RETENTION = 3


def atomic_write_json(
    filepath: Path,
    data: Any,
    indent: Optional[int] = 2,
    backup: bool = True,
    default: Any = None,
) -> None:
    """
    Atomically write JSON data to a file with automatic backup rotation.

    Uses write-to-temp-then-rename pattern for crash safety.
    This ensures that the file is never in a partially-written state,
    even if the process is killed mid-write or the system loses power.

    Before overwriting, the current file is rotated into a backup ring
    (filepath.bak.1, .bak.2, .bak.3) so a bad write can be recovered
    from the most recent good copy.

    Args:
        filepath: Path to the target file
        data: JSON-serializable data to write
        indent: JSON indentation (use None for compact output)
        backup: If True (default), rotate backups before writing
        default: Optional callable for json.dump default parameter
    """
    filepath = Path(filepath)
    filepath.parent.mkdir(parents=True, exist_ok=True)

    # Rotate backups before overwriting
    if backup and filepath.exists():
        _rotate_backups(filepath)

    # Create temp file in same directory (ensures same filesystem for atomic rename)
    fd, tmp_path = tempfile.mkstemp(suffix=".tmp", prefix=filepath.stem + "_", dir=filepath.parent)

    try:
        with os.fdopen(fd, "w") as f:
            json.dump(data, f, indent=indent, default=default)
            f.flush()
            os.fsync(f.fileno())  # Ensure data hits disk

        # Atomic rename (on POSIX systems)
        Path(tmp_path).replace(filepath)

    except Exception:
        # Clean up temp file on failure
        try:
            os.unlink(tmp_path)
        except OSError:
            pass
        raise


def safe_load_json(
    filepath: Path,
    default: Any = None,
    try_backups: bool = True,
) -> Any:
    """
    Load JSON from a file with automatic fallback to backups.

    If the primary file is missing or corrupt, tries each backup in
    reverse order (.bak.1 → .bak.2 → .bak.3). Returns ``default``
    only when no valid copy exists anywhere.

    Args:
        filepath: Path to the JSON file
        default: Value to return if no valid copy is found.
                 If callable, it is called to produce the default.
        try_backups: If True (default), attempt backup recovery

    Returns:
        Parsed JSON data, or ``default``
    """
    filepath = Path(filepath)

    # Try the primary file first
    data = _try_load(filepath)
    if data is not None:
        return data

    # Primary failed — try backups
    if try_backups:
        for i in range(1, _BACKUP_RETENTION + 1):
            bak = filepath.with_suffix(f"{filepath.suffix}.bak.{i}")
            data = _try_load(bak)
            if data is not None:
                logger.warning(
                    f"Primary file {filepath} corrupt/missing — recovered from {bak.name}"
                )
                # Restore the primary from this good backup
                try:
                    shutil.copy2(bak, filepath)
                except OSError:
                    pass
                return data

    # Nothing worked — return default
    if callable(default):
        return default()
    return default


def generate_id(nbytes: int = 4) -> str:
    """
    Generate a short, cryptographically random hex ID.

    Replaces the insecure ``random.choices()`` pattern.
    Default 4 bytes → 8 hex chars → 4 billion possible values.

    Args:
        nbytes: Number of random bytes (default 4 → 8 hex chars)
    """
    return secrets.token_hex(nbytes)


def format_http_error(
    service: str,
    *,
    status_code: int | None = None,
    exception: Exception | None = None,
    connect_cmd: str | None = None,
) -> str:
    """Contextual error message with troubleshooting hints."""
    hint = f" Try /connect {connect_cmd} to reconfigure." if connect_cmd else ""

    if status_code is not None:
        if status_code in (401, 403):
            return (
                f"{service} auth error (HTTP {status_code}): Invalid or expired credentials.{hint}"
            )
        if status_code == 404:
            return f"{service} error (HTTP 404): Endpoint not found. Check your URL."
        if status_code >= 500:
            return f"{service} server error (HTTP {status_code}): Check service logs."
        return f"{service} API error: HTTP {status_code}"

    if exception is not None:
        err = str(exception).lower()
        if "ssl" in err or "certificate" in err:
            return f"{service} SSL error: Check URL scheme (http vs https)."
        if "refused" in err:
            return f"{service}: Connection refused. Is the service running?"
        return f"{service} connection error: {exception}"

    return f"{service} error (unknown)"


# ── internal helpers ──────────────────────────────────────────


def _try_load(filepath: Path) -> Any:
    """Attempt to load and parse a JSON file. Returns None on any failure."""
    if not filepath.exists():
        return None
    try:
        with open(filepath, encoding="utf-8") as f:
            return json.load(f)
    except (json.JSONDecodeError, IOError, OSError, UnicodeDecodeError) as e:
        logger.warning(f"Failed to load {filepath}: {e}")
        return None


def _rotate_backups(filepath: Path) -> None:
    """
    Rotate backup copies: .bak.3 is deleted, .bak.2 → .bak.3,
    .bak.1 → .bak.2, current → .bak.1.
    """
    try:
        for i in range(_BACKUP_RETENTION, 1, -1):
            older = filepath.with_suffix(f"{filepath.suffix}.bak.{i}")
            newer = filepath.with_suffix(f"{filepath.suffix}.bak.{i - 1}")
            if newer.exists():
                shutil.copy2(newer, older)

        # Current file becomes .bak.1
        bak1 = filepath.with_suffix(f"{filepath.suffix}.bak.1")
        shutil.copy2(filepath, bak1)
    except OSError as e:
        # Backup failure should never block a write
        logger.debug(f"Backup rotation warning for {filepath}: {e}")
