"""Configuration backup/restore API (admin only)."""

import asyncio
import hashlib
import json
import logging
import os
import signal
from datetime import datetime, timezone
from urllib.parse import urlparse

from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, UploadFile, File, Query
from fastapi.responses import JSONResponse
from sqlmodel.ext.asyncio.session import AsyncSession

from ..auth.dependencies import require_admin
from ..config import settings as app_config
from ..database.engine import get_session
from ..database.models import AppSetting, User
from ..services.backup_schema import (
    ALL_CATEGORIES, BackupCategory, BackupFile, ConflictMode, SecretsMode,
)
from ..services.backup_service import (
    cleanup_old_backups, export_backup, export_backup_to_file, import_backup,
    list_backup_files, validate_backup,
)

# Pending-restore marker keys (single source of truth consumed at startup)
PENDING_RESTORE_PATH_KEY = "pending_restore_path"
PENDING_RESTORE_CONFLICT_KEY = "pending_restore_conflict_mode"
PENDING_RESTORE_UPLOADED_AT_KEY = "pending_restore_uploaded_at"
PENDING_RESTORE_UPLOADED_BY_KEY = "pending_restore_uploaded_by"
# SHA256 of the staged pending_restore.json, written atomically with the file.
# The startup hook refuses to apply if the on-disk file's hash does not match —
# defends against anyone dropping a tampered file into data/ between prepare
# and restart.
PENDING_RESTORE_SHA256_KEY = "pending_restore_sha256"


def _pending_restore_path():
    return app_config.data_dir / "pending_restore.json"


def _applied_restores_dir():
    return app_config.data_dir / "applied_restores"

_LOGGER = logging.getLogger(__name__)

router = APIRouter(prefix="/api/backup", tags=["backup"])


# Hard caps on uploaded backup file shape — defend against parser DoS
# (deeply nested or pathologically wide JSON) before we hand the
# structure to the import pipeline.
_MAX_BACKUP_DEPTH = 10
_MAX_BACKUP_NODES = 100_000


def _validate_backup_shape(value: object, depth: int = 0, count: list[int] | None = None) -> None:
    """Walk ``value`` and reject anything beyond the depth/node caps.

    Raises HTTPException(400) on overflow. Cheap O(n) walk; runs once
    per upload.
    """
    if count is None:
        count = [0]
    if depth > _MAX_BACKUP_DEPTH:
        raise HTTPException(
            status_code=400,
            detail=f"Backup file too deeply nested (max depth {_MAX_BACKUP_DEPTH})",
        )
    count[0] += 1
    if count[0] > _MAX_BACKUP_NODES:
        raise HTTPException(
            status_code=400,
            detail=f"Backup file has too many nodes (max {_MAX_BACKUP_NODES})",
        )
    if isinstance(value, dict):
        for v in value.values():
            _validate_backup_shape(v, depth + 1, count)
    elif isinstance(value, list):
        for v in value:
            _validate_backup_shape(v, depth + 1, count)

MAX_UPLOAD_SIZE = 10 * 1024 * 1024  # 10 MB


async def _read_upload_bounded(file: UploadFile, max_bytes: int = MAX_UPLOAD_SIZE) -> bytes:
    """Read an UploadFile into memory, failing fast if it exceeds ``max_bytes``.

    Rejects on ``content_length`` header up-front when available; always
    stream-reads with a running byte counter so we never allocate more than
    the limit even when the header is missing or lies.
    """
    # Fast path: reject on header before we allocate anything.
    cl = file.headers.get("content-length") if hasattr(file, "headers") else None
    if cl:
        try:
            if int(cl) > max_bytes:
                raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
        except ValueError:
            pass

    chunks: list[bytes] = []
    total = 0
    while True:
        chunk = await file.read(64 * 1024)
        if not chunk:
            break
        total += len(chunk)
        if total > max_bytes:
            raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
        chunks.append(chunk)
    return b"".join(chunks)


def _check_same_origin(request: Request) -> None:
    """Reject cross-origin admin-write POSTs (CSRF defense).

    Bearer tokens in ``localStorage`` plus cookie-less CORS mean a malicious
    page cannot technically submit our Authorization header from a victim's
    session, BUT browser extensions and misconfigured CORS policies routinely
    break this assumption. For endpoints whose blast radius is restart/RCE-
    equivalent (restore apply), we additionally require the request to come
    from our own origin.
    """
    host = request.headers.get("host", "").lower()
    if not host:
        raise HTTPException(status_code=400, detail="Missing Host header")

    def _host_of(u: str | None) -> str:
        if not u:
            return ""
        try:
            return (urlparse(u).netloc or "").lower()
        except Exception:  # noqa: BLE001
            return ""

    origin_host = _host_of(request.headers.get("origin"))
    referer_host = _host_of(request.headers.get("referer"))
    # At least one of Origin/Referer must be present and match Host.
    # Legitimate browser requests to this endpoint always ship Origin.
    same = (origin_host and origin_host == host) or (referer_host and referer_host == host)
    if not same:
        raise HTTPException(
            status_code=403,
            detail="Cross-origin request rejected",
        )


def _backup_dir():
    return app_config.data_dir / "backups"


def _resolve_backup_file(filename: str):
    """Validate filename and resolve to a path strictly inside the backup dir."""
    if not filename.startswith("backup-") or not filename.endswith(".json"):
        raise HTTPException(status_code=404, detail="Backup file not found")
    if "/" in filename or "\\" in filename or ".." in filename or "\x00" in filename:
        raise HTTPException(status_code=404, detail="Backup file not found")
    base = _backup_dir().resolve()
    candidate = (base / filename).resolve()
    try:
        candidate.relative_to(base)
    except ValueError:
        raise HTTPException(status_code=404, detail="Backup file not found")
    if not candidate.is_file():
        raise HTTPException(status_code=404, detail="Backup file not found")
    return candidate


# ---------------------------------------------------------------------------
# Export
# ---------------------------------------------------------------------------

@router.get("/export")
async def export_config(
    secrets_mode: SecretsMode = Query(default=SecretsMode.EXCLUDE),
    categories: str = Query(default=""),
    user: User = Depends(require_admin),
    session: AsyncSession = Depends(get_session),
):
    """Export configuration as a downloadable JSON file."""
    cats = None
    if categories:
        try:
            cats = [BackupCategory(c.strip()) for c in categories.split(",") if c.strip()]
        except ValueError as e:
            raise HTTPException(status_code=400, detail=f"Invalid category: {e}")

    backup = await export_backup(session, user.id, cats, secrets_mode)
    ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S")
    filename = f"notify-bridge-backup-{ts}.json"

    return JSONResponse(
        content=backup.model_dump(),
        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
    )


# ---------------------------------------------------------------------------
# Validate
# ---------------------------------------------------------------------------

@router.post("/validate")
async def validate_config(
    file: UploadFile = File(...),
    user: User = Depends(require_admin),
):
    """Validate a backup file without importing."""
    content = await _read_upload_bounded(file)

    try:
        raw = json.loads(content)
    except json.JSONDecodeError as e:
        raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")

    _validate_backup_shape(raw)

    result = validate_backup(raw)
    return result.model_dump()


# ---------------------------------------------------------------------------
# Import
# ---------------------------------------------------------------------------

@router.post("/import")
async def import_config(
    file: UploadFile = File(...),
    conflict_mode: ConflictMode = Query(default=ConflictMode.SKIP),
    user: User = Depends(require_admin),
    session: AsyncSession = Depends(get_session),
):
    """Import configuration from a backup file."""
    content = await _read_upload_bounded(file)

    try:
        raw = json.loads(content)
    except json.JSONDecodeError as e:
        raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")

    _validate_backup_shape(raw)

    # Validate first
    validation = validate_backup(raw)
    if not validation.valid:
        raise HTTPException(status_code=400, detail=f"Invalid backup: {'; '.join(validation.errors)}")

    backup = BackupFile.model_validate(raw)
    result = await import_backup(session, user.id, backup, conflict_mode)
    return result.model_dump()


# ---------------------------------------------------------------------------
# Pending restore (prepare → apply on next restart)
# ---------------------------------------------------------------------------

async def _set_app_setting(session: AsyncSession, key: str, value: str) -> None:
    row = await session.get(AppSetting, key)
    if row:
        row.value = value
    else:
        row = AppSetting(key=key, value=value)
    session.add(row)


async def _clear_pending_restore_markers(session: AsyncSession) -> None:
    for key in (
        PENDING_RESTORE_PATH_KEY,
        PENDING_RESTORE_CONFLICT_KEY,
        PENDING_RESTORE_UPLOADED_AT_KEY,
        PENDING_RESTORE_UPLOADED_BY_KEY,
        PENDING_RESTORE_SHA256_KEY,
    ):
        row = await session.get(AppSetting, key)
        if row:
            await session.delete(row)


@router.post("/prepare-restore")
async def prepare_restore(
    file: UploadFile = File(...),
    conflict_mode: ConflictMode = Query(default=ConflictMode.SKIP),
    user: User = Depends(require_admin),
    session: AsyncSession = Depends(get_session),
):
    """Stage a backup for restore on next backend restart.

    Validates the uploaded file, writes it to ``data/pending_restore.json``,
    and persists marker settings so startup will apply it atomically.
    """
    content = await _read_upload_bounded(file)

    try:
        raw = json.loads(content)
    except json.JSONDecodeError as e:
        raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")

    _validate_backup_shape(raw)

    validation = validate_backup(raw)
    if not validation.valid:
        raise HTTPException(
            status_code=400,
            detail=f"Invalid backup: {'; '.join(validation.errors)}",
        )

    pending_path = _pending_restore_path()
    pending_path.parent.mkdir(parents=True, exist_ok=True)
    # Atomic write: write to tmp then rename, so a crash mid-write never
    # leaves a truncated pending_restore.json that would break startup apply.
    payload = json.dumps(raw).encode("utf-8")
    digest = hashlib.sha256(payload).hexdigest()
    tmp_path = pending_path.with_suffix(pending_path.suffix + ".tmp")
    tmp_path.write_bytes(payload)
    os.replace(tmp_path, pending_path)
    # Best-effort tighten perms so a non-root local user cannot swap the file
    # for one they control between prepare and restart. On Windows this is a
    # no-op; on POSIX we restrict to owner-only rw.
    try:
        os.chmod(pending_path, 0o600)
    except OSError:
        pass

    now_iso = datetime.now(timezone.utc).isoformat()
    await _set_app_setting(session, PENDING_RESTORE_PATH_KEY, str(pending_path))
    await _set_app_setting(session, PENDING_RESTORE_CONFLICT_KEY, conflict_mode.value)
    await _set_app_setting(session, PENDING_RESTORE_UPLOADED_AT_KEY, now_iso)
    await _set_app_setting(session, PENDING_RESTORE_UPLOADED_BY_KEY, user.username)
    await _set_app_setting(session, PENDING_RESTORE_SHA256_KEY, digest)
    await session.commit()

    return {
        "pending": True,
        "uploaded_at": now_iso,
        "uploaded_by": user.username,
        "conflict_mode": conflict_mode.value,
        "validation": validation.model_dump(),
        "supervised": _is_supervised(),
    }


@router.get("/pending-restore")
async def get_pending_restore(
    user: User = Depends(require_admin),
    session: AsyncSession = Depends(get_session),
):
    """Return current pending-restore state, or null if none."""
    path_row = await session.get(AppSetting, PENDING_RESTORE_PATH_KEY)
    if not path_row or not path_row.value:
        return {"pending": False, "supervised": _is_supervised()}

    conflict_row = await session.get(AppSetting, PENDING_RESTORE_CONFLICT_KEY)
    uploaded_at_row = await session.get(AppSetting, PENDING_RESTORE_UPLOADED_AT_KEY)
    uploaded_by_row = await session.get(AppSetting, PENDING_RESTORE_UPLOADED_BY_KEY)
    return {
        "pending": True,
        "uploaded_at": uploaded_at_row.value if uploaded_at_row else None,
        "uploaded_by": uploaded_by_row.value if uploaded_by_row else None,
        "conflict_mode": (conflict_row.value if conflict_row else ConflictMode.SKIP.value),
        "supervised": _is_supervised(),
    }


@router.delete("/pending-restore")
async def cancel_pending_restore(
    user: User = Depends(require_admin),
    session: AsyncSession = Depends(get_session),
):
    """Cancel a pending restore."""
    pending_path = _pending_restore_path()
    if pending_path.exists():
        pending_path.unlink()
    await _clear_pending_restore_markers(session)
    await session.commit()
    return {"cancelled": True}


def _is_supervised() -> bool:
    """Heuristic: is this process managed by something that will respawn it?

    Priority order:
    1. Explicit operator override: ``NOTIFY_BRIDGE_SUPERVISED`` env var or
       the ``supervised`` AppSetting (values: ``true``/``false``/``auto``).
       ``auto`` (or unset) falls through to the detection heuristic.
    2. Heuristic: look at common container/service-manager env vars.

    Used by the frontend to decide whether to offer "Restart now" — a bad
    guess here is a foot-gun (process exits, stays dead), so err on the side
    of false when unsure.
    """
    override = os.environ.get("NOTIFY_BRIDGE_SUPERVISED", "").strip().lower()
    if override in ("true", "1", "yes", "on"):
        return True
    if override in ("false", "0", "no", "off"):
        return False

    for var in ("CONTAINER", "DOCKER_CONTAINER", "KUBERNETES_SERVICE_HOST",
                "INVOCATION_ID", "PM2_HOME"):
        if os.environ.get(var):
            return True
    if os.path.exists("/.dockerenv"):
        return True
    return False


@router.post("/apply-restart")
async def apply_and_restart(
    request: Request,
    background_tasks: BackgroundTasks,
    user: User = Depends(require_admin),
    session: AsyncSession = Depends(get_session),
):
    """Trigger a graceful exit so the supervisor respawns and applies the pending restore.

    Only allowed when a pending restore is staged AND the process is supervised.
    Requires same-origin Origin/Referer — this endpoint's blast radius is a
    full config replace + restart, so an admin token alone (vulnerable to
    XSS-driven CSRF) is not enough.
    """
    _check_same_origin(request)
    path_row = await session.get(AppSetting, PENDING_RESTORE_PATH_KEY)
    if not path_row or not path_row.value:
        raise HTTPException(status_code=409, detail="No pending restore to apply")
    if not _is_supervised():
        raise HTTPException(
            status_code=409,
            detail=(
                "This process is not supervised. Restart the backend manually to apply "
                "the pending restore, or use the Cancel button."
            ),
        )

    async def _shutdown_soon() -> None:
        # Small delay so the HTTP response flushes before the signal fires.
        await asyncio.sleep(0.5)
        _LOGGER.warning("Admin triggered restart to apply pending restore")
        # SIGTERM lets uvicorn run its normal graceful shutdown:
        # drain in-flight requests, fire the lifespan shutdown hooks
        # (close_http_session, scheduler.shutdown), then exit. The
        # supervisor respawns, and startup applies the pending restore.
        try:
            os.kill(os.getpid(), signal.SIGTERM)
        except Exception:  # noqa: BLE001 — last-resort fallback on platforms that reject SIGTERM
            _LOGGER.exception("SIGTERM delivery failed; falling back to os._exit")
            os._exit(0)

    background_tasks.add_task(_shutdown_soon)
    return {"restart_requested": True}


# ---------------------------------------------------------------------------
# Scheduled backup settings
# ---------------------------------------------------------------------------

_BACKUP_SETTING_KEYS = {
    "backup_scheduled_enabled": "false",
    "backup_scheduled_interval_hours": "24",
    "backup_secrets_mode": "exclude",
    "backup_retention_count": "5",
}


@router.get("/scheduled")
async def get_scheduled_settings(
    user: User = Depends(require_admin),
    session: AsyncSession = Depends(get_session),
):
    """Get scheduled backup settings."""
    result = {}
    for key, default in _BACKUP_SETTING_KEYS.items():
        row = await session.get(AppSetting, key)
        result[key] = row.value if row and row.value else default
    return result


@router.put("/scheduled")
async def update_scheduled_settings(
    body: dict,
    user: User = Depends(require_admin),
    session: AsyncSession = Depends(get_session),
):
    """Update scheduled backup settings and reschedule."""
    for key, default in _BACKUP_SETTING_KEYS.items():
        value = body.get(key)
        if value is None:
            continue
        row = await session.get(AppSetting, key)
        if row:
            row.value = str(value)
        else:
            row = AppSetting(key=key, value=str(value))
        session.add(row)
    await session.commit()

    # Reschedule backup job
    from ..services.scheduler import schedule_backup
    enabled = body.get("backup_scheduled_enabled", "false") == "true"
    interval_hours = int(body.get("backup_scheduled_interval_hours", "24"))
    if enabled:
        await schedule_backup(interval_hours)
    else:
        from ..services.scheduler import unschedule_backup
        await unschedule_backup()

    # Return updated settings
    result = {}
    for key, default in _BACKUP_SETTING_KEYS.items():
        row = await session.get(AppSetting, key)
        result[key] = row.value if row and row.value else default
    return result


# ---------------------------------------------------------------------------
# Backup file management
# ---------------------------------------------------------------------------

@router.get("/files")
async def get_backup_files(
    user: User = Depends(require_admin),
):
    """List saved backup files."""
    return list_backup_files(_backup_dir())


@router.post("/files")
async def create_manual_backup(
    secrets_mode: SecretsMode = Query(default=SecretsMode.EXCLUDE),
    user: User = Depends(require_admin),
    session: AsyncSession = Depends(get_session),
):
    """Create a backup file in the backups directory (manual checkpoint).

    Produces the same JSON format as scheduled backups, saved under
    ``data/backups/backup-<timestamp>.json``. Retention is managed by the
    existing scheduled-backup settings (``backup_retention_count``).
    """
    backup_dir = _backup_dir()
    filepath = await export_backup_to_file(session, user.id, backup_dir, secrets_mode)
    # Apply the same retention as scheduled backups if configured.
    retention_row = await session.get(AppSetting, "backup_retention_count")
    if retention_row and retention_row.value:
        try:
            retention = int(retention_row.value)
            if retention > 0:
                cleanup_old_backups(backup_dir, keep=retention)
        except ValueError:
            pass
    stat = filepath.stat()
    return {
        "filename": filepath.name,
        "size": stat.st_size,
        "created_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
    }


@router.get("/files/{filename}")
async def download_backup_file(
    filename: str,
    user: User = Depends(require_admin),
):
    """Download a specific backup file."""
    filepath = _resolve_backup_file(filename)

    try:
        content = json.loads(filepath.read_text(encoding="utf-8"))
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Failed to read backup: {e}")

    return JSONResponse(
        content=content,
        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
    )


@router.delete("/files/{filename}")
async def delete_backup_file(
    filename: str,
    user: User = Depends(require_admin),
):
    """Delete a specific backup file."""
    filepath = _resolve_backup_file(filename)
    filepath.unlink()
    return {"deleted": filename}
