"""Startup hook that applies a pending restore prepared via the backup API.

When an admin uploads a backup via /api/backup/prepare-restore, the file is
staged at data/pending_restore.json and marker rows are written to AppSetting.
This module is invoked during app startup (after migrations + seeds) to
atomically apply that pending restore — if present — before the server begins
serving requests.

If the apply fails, the pending file is kept so the operator can inspect it
and markers are updated to record the last error. On success, the staged file
is archived under data/applied_restores/<timestamp>.json and markers are
cleared.

Integrity checks on startup:
- The on-disk file's SHA256 must match ``PENDING_RESTORE_SHA256_KEY``
  (written atomically with the staged file). Protects against tampering
  between prepare and restart.
- The pending path must resolve *inside* ``app_config.data_dir``. Protects
  against a rogue AppSetting pointing at an arbitrary file.
"""

from __future__ import annotations

import asyncio
import hashlib
import json
import logging
import shutil
from datetime import datetime, timezone

from sqlmodel.ext.asyncio.session import AsyncSession

from ..api.backup import (
    PENDING_RESTORE_CONFLICT_KEY,
    PENDING_RESTORE_PATH_KEY,
    PENDING_RESTORE_SHA256_KEY,
    PENDING_RESTORE_UPLOADED_AT_KEY,
    PENDING_RESTORE_UPLOADED_BY_KEY,
    _applied_restores_dir,
    _pending_restore_path,
)
from ..config import settings as app_config
from ..database.engine import get_engine
from ..database.models import AppSetting
from .backup_schema import BackupFile, ConflictMode
from .backup_service import import_backup

_LOGGER = logging.getLogger(__name__)

PENDING_RESTORE_LAST_ERROR_KEY = "pending_restore_last_error"
PENDING_RESTORE_LAST_APPLIED_KEY = "pending_restore_last_applied"


async def apply_pending_restore_if_any() -> None:
    """Apply a staged restore if one exists. Idempotent and safe to call at startup."""
    engine = get_engine()
    async with AsyncSession(engine) as session:
        path_row = await session.get(AppSetting, PENDING_RESTORE_PATH_KEY)
        if not path_row or not path_row.value:
            return

        pending_path = _pending_restore_path()

        # Defensive: ensure the hard-coded path still lives inside data_dir.
        # If future refactors let this be read from AppSetting, this check
        # blocks arbitrary-file reads.
        try:
            resolved = pending_path.resolve()
            data_root = app_config.data_dir.resolve()
            resolved.relative_to(data_root)
        except (ValueError, OSError):
            _LOGGER.error(
                "Pending-restore path %s is outside data_dir %s — refusing to apply",
                pending_path, app_config.data_dir,
            )
            await _record_error(session, "Pending path outside data_dir")
            await session.commit()
            return

        if not pending_path.exists():
            _LOGGER.warning(
                "Pending-restore marker present but file missing at %s — clearing marker",
                pending_path,
            )
            await _clear_markers(session)
            await session.commit()
            return

        conflict_row = await session.get(AppSetting, PENDING_RESTORE_CONFLICT_KEY)
        conflict_mode = ConflictMode(conflict_row.value) if conflict_row and conflict_row.value else ConflictMode.SKIP
        uploaded_by_row = await session.get(AppSetting, PENDING_RESTORE_UPLOADED_BY_KEY)
        uploaded_by = uploaded_by_row.value if uploaded_by_row else "admin"
        sha_row = await session.get(AppSetting, PENDING_RESTORE_SHA256_KEY)
        expected_sha = (sha_row.value or "").strip().lower() if sha_row else ""

        try:
            raw_bytes = await asyncio.to_thread(pending_path.read_bytes)
        except OSError as err:
            _LOGGER.exception("Pending-restore file unreadable")
            await _record_error(session, f"Unreadable backup: {err}")
            await session.commit()
            return

        # Integrity: reject unless hash matches what prepare-restore stored.
        # An attacker with write access to data/ (swapped file, bind-mount
        # abuse) does not also have write access to the DB.
        if not expected_sha:
            _LOGGER.error("Pending-restore marker has no SHA256; refusing to apply")
            await _record_error(session, "Missing integrity marker")
            await session.commit()
            return
        actual_sha = hashlib.sha256(raw_bytes).hexdigest()
        if actual_sha != expected_sha:
            _LOGGER.error(
                "Pending-restore SHA256 mismatch (expected %s, got %s) — refusing to apply",
                expected_sha, actual_sha,
            )
            await _record_error(
                session,
                "Integrity check failed: on-disk backup SHA256 does not match the hash "
                "recorded at prepare time. File may have been tampered with; cancel and "
                "re-upload.",
            )
            await session.commit()
            return

        try:
            raw = json.loads(raw_bytes.decode("utf-8"))
            backup = BackupFile.model_validate(raw)
        except Exception as err:  # noqa: BLE001
            _LOGGER.exception("Pending-restore file unreadable")
            await _record_error(session, f"Unreadable backup: {err}")
            await session.commit()
            return

        # Resolve the target user: first admin (restore is cross-user).
        # The backup carries its own user_id per-record, so this is mostly
        # used for provenance.
        from sqlmodel import select
        from ..database.models import User
        admin_row = (await session.exec(select(User).where(User.role == "admin"))).first()
        if not admin_row:
            _LOGGER.error("No admin user found; refusing to apply pending restore")
            await _record_error(session, "No admin user available to own the restore")
            await session.commit()
            return

        try:
            result = await import_backup(session, admin_row.id, backup, conflict_mode)
        except Exception as err:  # noqa: BLE001
            _LOGGER.exception("Pending-restore apply failed")
            # Discard any partial inserts the importer made before raising —
            # committing partial state would let a crafted failing backup
            # selectively mutate entities. The error-record commit below
            # happens on a *fresh* session.
            await session.rollback()
            async with AsyncSession(engine) as fresh:
                await _record_error(fresh, str(err))
                await fresh.commit()
            return

        # Archive the file
        archive_dir = _applied_restores_dir()
        archive_dir.mkdir(parents=True, exist_ok=True)
        ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S")
        archived_name = f"applied-{ts}.json"
        try:
            shutil.move(str(pending_path), str(archive_dir / archived_name))
        except Exception as err:  # noqa: BLE001
            _LOGGER.warning("Could not archive applied restore file: %s", err)
            # Still consider the apply a success; just best-effort cleanup
            try:
                pending_path.unlink()
            except Exception:
                pass

        await _clear_markers(session)
        applied_summary = {
            "applied_at": datetime.now(timezone.utc).isoformat(),
            "uploaded_by": uploaded_by,
            "archived_file": archived_name,
            "stats": result.model_dump() if hasattr(result, "model_dump") else {},
        }
        await _set_setting(
            session,
            PENDING_RESTORE_LAST_APPLIED_KEY,
            json.dumps(applied_summary, default=str),
        )
        # Clear any prior error marker.
        err_row = await session.get(AppSetting, PENDING_RESTORE_LAST_ERROR_KEY)
        if err_row:
            await session.delete(err_row)
        await session.commit()
        _LOGGER.info(
            "Applied pending restore (uploaded by %s): %s",
            uploaded_by, applied_summary["stats"],
        )


async def _clear_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)


async def _record_error(session: AsyncSession, message: str) -> None:
    await _set_setting(
        session,
        PENDING_RESTORE_LAST_ERROR_KEY,
        json.dumps({
            "at": datetime.now(timezone.utc).isoformat(),
            "message": message[:2048],
        }),
    )


async def _set_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)
