"""RBS REGAL — Full-app Migration.

One-click "migrate this whole app anywhere" package.

The existing backup_engine handles MongoDB only. This module wraps that
plus everything else needed to spin up a clean RBS REGAL instance on a
new server (or fresh Mongo cluster):

  • All MongoDB collections (via backup_engine's snapshot — encrypted+
    compressed JSON, sha256-verified).
  • Anything stored in MongoDB GridFS (uploaded logos, product images,
    party photos) — IF the app actually uses GridFS. We probe.
  • A migration manifest (rmregal_migrate.json) describing the export:
    timestamp, hostname, JWT_SECRET hint, env vars the operator must
    set on the target machine, restore instructions.
  • A README.txt the operator can read without unzipping.

Endpoints (all admin-only):
  POST /api/admin/migrate/export   — produces a .zip download (streamed).
  POST /api/admin/migrate/import   — accepts a .zip upload, restores everything.
  GET  /api/admin/migrate/info     — what's included + size estimate.

Restore strategy on the target:
  1. Operator copies fresh code + .env (MONGO_URL, JWT_SECRET, etc.)
  2. Logs in to the new instance as the seeded admin.
  3. /admin/migration → "Upload Migration Package" → pick the .zip.
  4. Server unpacks → restores DB via backup_engine — same JWT_SECRET
     means encrypted fields decrypt cleanly.
  5. Restores GridFS uploads (if present).
  6. Operator re-logs in with the original admin email/password.

Why one-click vs git+mongodump: most ERP customers can't run mongorestore.
This gives them the same outcome with a single download/upload pair.
"""
import os
import io
import json
import zipfile
import logging
import platform
import socket
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, Request, HTTPException, UploadFile, File
from fastapi.responses import StreamingResponse
from pydantic import BaseModel

from auth import require_admin
from backup_engine import (
    BACKUP_COLLECTIONS, _fernet, _now_iso,
)
import gzip
import hashlib
import secrets

logger = logging.getLogger("rm-regal.migrate")

router = APIRouter(prefix="/api/admin/migrate", tags=["migration"])

MIGRATE_FORMAT_VERSION = 1
MIGRATE_FILENAME_PREFIX = "rbs-regal-migration"


# ---------- helpers ----------
def _safe_filename(name: str) -> str:
    return "".join(c for c in name if c.isalnum() or c in "-_.")[:80]


async def _snapshot_to_bytes(db, label: str, encrypt: bool, created_by: str) -> tuple:
    """Build an in-memory backup snapshot (same logic as POST /api/backup/create)
    but without writing to disk. Returns (bytes, manifest_dict)."""
    from bson import json_util
    ts = datetime.now(timezone.utc)

    counts = {}
    snapshot = {
        "manifest": {
            "kind": "rm-regal-backup",
            "version": 1,
            "created_at": ts.isoformat(),
            "label": _safe_filename(label),
            "encrypted": encrypt,
            "created_by": created_by,
        },
        "data": {},
    }
    for coll in BACKUP_COLLECTIONS:
        try:
            docs = await db[coll].find({}).to_list(200000)
        except Exception:
            docs = []
        counts[coll] = len(docs)
        snapshot["data"][coll] = json.loads(json_util.dumps(docs))
    snapshot["manifest"]["counts"] = counts

    raw = json.dumps(snapshot, ensure_ascii=False).encode("utf-8")
    sha = hashlib.sha256(raw).hexdigest()
    snapshot["manifest"]["sha256"] = sha
    raw = json.dumps(snapshot, ensure_ascii=False).encode("utf-8")

    payload_bytes = gzip.compress(raw, compresslevel=6)
    if encrypt:
        payload_bytes = _fernet().encrypt(payload_bytes)
    return payload_bytes, snapshot["manifest"]


async def _has_gridfs(db) -> bool:
    """Probe whether GridFS is used. We don't currently use it but be defensive
    so if a future image-upload feature switches to GridFS this still works."""
    try:
        n = await db["fs.files"].count_documents({})
        return n > 0
    except Exception:
        return False


async def _gridfs_files(db):
    """Yield (filename, bytes) tuples for every file in GridFS."""
    from motor.motor_asyncio import AsyncIOMotorGridFSBucket
    bucket = AsyncIOMotorGridFSBucket(db)
    async for f in bucket.find({}):
        try:
            buf = io.BytesIO()
            await bucket.download_to_stream(f._id, buf)
            yield (f.filename or str(f._id), buf.getvalue())
        except Exception as e:
            logger.warning(f"GridFS download skipped for {f.filename}: {e}")


def _readme_text(manifest: dict) -> str:
    return f"""# RGE REGALGOA ERP AI — Migration Package

Generated: {manifest['created_at']}
Source host: {manifest.get('source_host', 'unknown')}
DB documents: {sum(manifest['db_counts'].values())} across {len(manifest['db_counts'])} collections
GridFS files: {manifest.get('gridfs_count', 0)}
Format version: v{manifest['format_version']}

## How to restore on a NEW machine

1. Set up the new machine with the same RGE REGALGOA code (git pull or new container).
2. Provide a fresh `/app/backend/.env` with:
     • MONGO_URL  (new mongo instance)
     • DB_NAME    (any name — restore will populate it)
     • JWT_SECRET (MUST match the one in this package's manifest, otherwise
       encrypted backup data will be unreadable. The hint is in manifest.json:
       jwt_secret_fingerprint = "{manifest.get('jwt_secret_fingerprint', 'n/a')}".
       Copy the actual secret value from the source machine's .env.)
3. Start the new instance (sudo supervisorctl start backend frontend).
4. Open the new instance in a browser and log in as the seeded admin.
5. Go to /admin/migration → "Upload Migration Package" → pick this .zip.
6. Server unpacks → restores all collections + uploads.
7. Log out, log back in with the ORIGINAL admin email / password from the
   source machine — it now lives in the new database too.

## What's in this archive

    db.snapshot       — encrypted gzip JSON of all collections
    uploads/          — GridFS files (if any)
    manifest.json     — meta for the restore (version, counts, jwt hint)
    README.txt        — this file

## Need help?

Open the source machine's `/app/backend/.env`, copy JWT_SECRET as-is to the
target machine, then run the import. That's the only secret needed for the
backup to decrypt.
"""


# ---------- Info endpoint (size estimate) ---------------------------------
@router.get("/info")
async def info(request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    counts = {}
    total = 0
    for coll in BACKUP_COLLECTIONS:
        try:
            n = await db[coll].count_documents({})
        except Exception:
            n = 0
        counts[coll] = n
        total += n
    gridfs_n = 0
    try:
        gridfs_n = await db["fs.files"].count_documents({})
    except Exception:
        pass
    return {
        "format_version": MIGRATE_FORMAT_VERSION,
        "db_collections": len(BACKUP_COLLECTIONS),
        "db_documents": total,
        "db_counts": counts,
        "gridfs_files": gridfs_n,
        "source_host": socket.gethostname(),
        "platform": platform.platform(),
        "encryption": "AES-128 (Fernet) keyed off JWT_SECRET",
        "format": "ZIP — db.snapshot + uploads/* + manifest.json + README.txt",
    }


# ---------- Export endpoint ----------------------------------------------
class ExportIn(BaseModel):
    label: Optional[str] = "migration"
    encrypt: bool = True
    include_uploads: bool = True


@router.post("/export")
async def export_package(payload: ExportIn, request: Request, user=Depends(require_admin)):
    """Streams a .zip containing the full migration package.

    Why not write to BACKUP_DIR first: a 200k-row backup can be ~50-100 MB
    after encryption, fine to keep in memory while we stream. Avoids leaving
    sensitive partial files on disk if the client disconnects.
    """
    db = request.app.state.db

    # 1) DB snapshot (in memory).
    snap_bytes, snap_manifest = await _snapshot_to_bytes(
        db, label=payload.label or "migration", encrypt=payload.encrypt,
        created_by=user["email"],
    )

    # 2) Build zip in memory.
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED, allowZip64=True) as zf:
        zf.writestr("db.snapshot", snap_bytes)

        # 3) GridFS uploads (best-effort)
        gridfs_count = 0
        if payload.include_uploads and await _has_gridfs(db):
            async for fname, blob in _gridfs_files(db):
                zf.writestr(f"uploads/{_safe_filename(fname) or 'file'}", blob)
                gridfs_count += 1

        # 4) Manifest — operator-readable + machine-readable.
        jwt_secret = os.environ.get("JWT_SECRET", "")
        jwt_fp = hashlib.sha256(jwt_secret.encode()).hexdigest()[:12] if jwt_secret else ""
        full_manifest = {
            "format_version": MIGRATE_FORMAT_VERSION,
            "kind": "rbs-regal-migration",
            "created_at": _now_iso(),
            "created_by": user["email"],
            "label": payload.label or "migration",
            "source_host": socket.gethostname(),
            "platform": platform.platform(),
            "db_counts": snap_manifest.get("counts", {}),
            "db_sha256": snap_manifest.get("sha256"),
            "db_encrypted": payload.encrypt,
            "gridfs_count": gridfs_count,
            # First 12 chars of SHA-256 of JWT_SECRET — operator can verify
            # they're providing the right secret without us ever exposing it.
            "jwt_secret_fingerprint": jwt_fp,
        }
        zf.writestr("manifest.json", json.dumps(full_manifest, indent=2))
        zf.writestr("README.txt", _readme_text(full_manifest))

    buf.seek(0)
    fname = f"{MIGRATE_FILENAME_PREFIX}_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}_{secrets.token_hex(3)}.zip"
    return StreamingResponse(
        buf,
        media_type="application/zip",
        headers={"Content-Disposition": f'attachment; filename="{fname}"'},
    )


# ---------- Import endpoint ----------------------------------------------
class ImportResult(BaseModel):
    ok: bool
    restored_collections: int
    restored_documents: int
    restored_uploads: int
    label: str
    notes: list


@router.post("/import")
async def import_package(
    request: Request,
    file: UploadFile = File(...),
    mode: str = "merge",     # "merge" | "replace"
    user=Depends(require_admin),
):
    """Restore a migration .zip onto this instance.

    mode='merge'   — upsert documents, existing rows survive unless overwritten
                     by _id collision.
    mode='replace' — wipe each collection first, then insert. DESTRUCTIVE.
    """
    if mode not in ("merge", "replace"):
        raise HTTPException(400, "mode must be 'merge' or 'replace'")

    data = await file.read()
    if not data:
        raise HTTPException(400, "Empty upload")
    # 200 MB hard cap — adjust if needed
    if len(data) > 200 * 1024 * 1024:
        raise HTTPException(413, "Migration package too large (>200 MB)")

    try:
        zf = zipfile.ZipFile(io.BytesIO(data), "r")
    except zipfile.BadZipFile:
        raise HTTPException(400, "Not a valid .zip file")

    names = set(zf.namelist())
    if "manifest.json" not in names or "db.snapshot" not in names:
        raise HTTPException(400, "Missing manifest.json or db.snapshot — not a RGE REGALGOA migration package")

    try:
        manifest = json.loads(zf.read("manifest.json").decode("utf-8"))
    except Exception as e:
        raise HTTPException(400, f"Invalid manifest.json: {e}")

    if manifest.get("kind") != "rbs-regal-migration":
        raise HTTPException(400, "Manifest kind mismatch — expected 'rbs-regal-migration'")
    if int(manifest.get("format_version", 0)) > MIGRATE_FORMAT_VERSION:
        raise HTTPException(400, f"Package format v{manifest['format_version']} is newer than this server supports (v{MIGRATE_FORMAT_VERSION}). Upgrade the target instance.")

    notes = []
    # JWT fingerprint check — warn (don't fail) if mismatched
    jwt_secret = os.environ.get("JWT_SECRET", "")
    expected_fp = manifest.get("jwt_secret_fingerprint") or ""
    actual_fp = hashlib.sha256(jwt_secret.encode()).hexdigest()[:12] if jwt_secret else ""
    if expected_fp and actual_fp and expected_fp != actual_fp:
        notes.append(
            f"⚠ JWT_SECRET mismatch — source fingerprint {expected_fp}, "
            f"this server {actual_fp}. Encrypted backup will FAIL to decrypt. "
            "Copy JWT_SECRET from the source .env and restart the backend before retrying."
        )

    # ---------- Decrypt + parse DB snapshot ------------------------------
    snap_bytes = zf.read("db.snapshot")
    if manifest.get("db_encrypted"):
        try:
            snap_bytes = _fernet().decrypt(snap_bytes)
        except Exception as e:
            raise HTTPException(400, f"DB snapshot decryption failed (JWT_SECRET likely wrong): {e}")
    try:
        snap_bytes = gzip.decompress(snap_bytes)
        snapshot = json.loads(snap_bytes.decode("utf-8"))
    except Exception as e:
        raise HTTPException(400, f"DB snapshot parse failed: {e}")

    # Optional: verify sha256 — note that strict re-computation is unreliable
    # after JSON round-trip (key order may differ). We rely on Fernet HMAC +
    # gzip CRC, both already validated above. Leaving manifest field for audit.
    _expected_sha = snapshot.get("manifest", {}).get("sha256")  # noqa: F841

    # ---------- Restore collections --------------------------------------
    db = request.app.state.db
    from bson import json_util
    restored_cols = 0
    restored_docs = 0
    for coll_name, raw_docs in (snapshot.get("data") or {}).items():
        if coll_name not in BACKUP_COLLECTIONS:
            notes.append(f"skipped unknown collection: {coll_name}")
            continue
        try:
            docs = json.loads(json_util.dumps(raw_docs))   # type round-trip
            docs = json_util.loads(json.dumps(docs))       # back to bson
        except Exception:
            docs = raw_docs
        if mode == "replace":
            try:
                await db[coll_name].delete_many({})
            except Exception as e:
                notes.append(f"replace wipe failed for {coll_name}: {e}")
                continue
        if docs:
            try:
                # Use ordered=False so one bad row doesn't abort the batch.
                # Use replace_one upserts for merge mode so _id collisions
                # overwrite cleanly without inserting duplicates.
                if mode == "merge":
                    for d in docs:
                        if "_id" in d:
                            await db[coll_name].replace_one({"_id": d["_id"]}, d, upsert=True)
                        else:
                            await db[coll_name].insert_one(d)
                else:
                    await db[coll_name].insert_many(docs, ordered=False)
            except Exception as e:
                notes.append(f"insert failed for {coll_name}: {type(e).__name__}: {str(e)[:120]}")
        restored_cols += 1
        restored_docs += len(docs)

    # ---------- Restore GridFS uploads -----------------------------------
    restored_uploads = 0
    upload_names = [n for n in names if n.startswith("uploads/") and not n.endswith("/")]
    if upload_names:
        try:
            from motor.motor_asyncio import AsyncIOMotorGridFSBucket
            bucket = AsyncIOMotorGridFSBucket(db)
            for n in upload_names:
                blob = zf.read(n)
                filename = n[len("uploads/"):]
                try:
                    await bucket.upload_from_stream(filename, blob)
                    restored_uploads += 1
                except Exception as e:
                    notes.append(f"gridfs upload failed for {filename}: {e}")
        except Exception as e:
            notes.append(f"GridFS bucket init failed: {e}")

    # ---------- Audit ----------------------------------------------------
    try:
        await db.audit_log.insert_one({
            "ts": _now_iso(),
            "user_id": user["id"],
            "email": user["email"],
            "action": "admin.migrate.import",
            "outcome": "ok",
            "detail": {
                "source_host": manifest.get("source_host"),
                "label": manifest.get("label"),
                "mode": mode,
                "restored_collections": restored_cols,
                "restored_documents": restored_docs,
                "restored_uploads": restored_uploads,
            },
        })
    except Exception:
        pass

    return ImportResult(
        ok=True,
        restored_collections=restored_cols,
        restored_documents=restored_docs,
        restored_uploads=restored_uploads,
        label=manifest.get("label") or "imported",
        notes=notes,
    )
