# -*- coding: utf-8 -*-
"""Integration tests for backup APIs (list/get/restore/create/delete/
export/import).
"""
from __future__ import annotations

import io
import json
import zipfile

import pytest
from helpers import default_http_timeout

_BACKUP_HTTP_TIMEOUT = default_http_timeout(30.0)


# --------------------------------------------------------------------------- #
# shared helpers for export / import cases
# --------------------------------------------------------------------------- #


_DEFAULT_BACKUP_PAYLOAD = {
    "scope": {
        "include_agents": False,
        "include_global_config": False,
        "include_secrets": False,
        "include_skill_pool": True,
    },
    "agents": [],
}


def _create_backup(app_server, *, name: str, description: str = "") -> str:
    """Create a minimal backup via stream and return its id."""
    payload = dict(_DEFAULT_BACKUP_PAYLOAD)
    payload["name"] = name
    payload["description"] = description or f"integration backup {name}"
    meta = _stream_create_backup(app_server, payload)
    backup_id = meta.get("id")
    assert isinstance(backup_id, str) and backup_id
    return backup_id


def _delete_backup(app_server, backup_id: str) -> None:
    """Best-effort backup delete used by finally blocks."""
    app_server.api_request(
        "POST",
        "/api/backups/delete",
        json={"ids": [backup_id]},
        timeout=_BACKUP_HTTP_TIMEOUT,
    )


def _export_backup_bytes(app_server, backup_id: str) -> bytes:
    """Hit the export endpoint and return the raw zip bytes."""
    resp = app_server.api_request(
        "GET",
        f"/api/backups/{backup_id}/export",
        timeout=_BACKUP_HTTP_TIMEOUT,
    )
    assert resp.status_code == 200, app_server.logs_tail()
    assert "zip" in resp.headers.get("content-type", "").lower()
    return resp.content


def _list_backup_ids(app_server) -> set[str]:
    resp = app_server.api_request(
        "GET",
        "/api/backups",
        timeout=_BACKUP_HTTP_TIMEOUT,
    )
    assert resp.status_code == 200, app_server.logs_tail()
    return {item.get("id") for item in resp.json()}


def _stream_create_backup(app_server, payload: dict) -> dict:
    """POST /api/backups/stream and return the 'meta' dict from the final
    ``done`` event.

    The endpoint emits ``data: {...}\\n\\n`` SSE frames; we walk them until
    a ``done`` event arrives, raising AssertionError on ``error`` events.
    """
    url = f"{app_server.base_url}/api/backups/stream"
    with app_server.client.stream(
        "POST",
        url,
        json=payload,
        timeout=_BACKUP_HTTP_TIMEOUT,
    ) as resp:
        assert resp.status_code == 200, (
            f"create stream returned {resp.status_code}; "
            f"logs: {app_server.logs_tail()}"
        )
        meta: dict | None = None
        for line in resp.iter_lines():
            if not line or not line.startswith("data:"):
                continue
            event = json.loads(line[len("data:") :].strip())
            if event.get("type") == "error":
                raise AssertionError(
                    f"backup create errored: {event} | "
                    f"logs: {app_server.logs_tail()}",
                )
            if event.get("type") == "done":
                meta = event["meta"]
                break
        assert meta is not None, (
            "backup stream ended without a 'done' event; "
            f"logs: {app_server.logs_tail()}"
        )
        return meta


@pytest.mark.integration
@pytest.mark.p0
def test_backup_get_detail_returns_404_for_missing(app_server) -> None:
    """Test purpose:
    - Verify GET /api/backups/{backup_id} returns 404 when the backup id
      is not present, so the console reliably distinguishes missing vs
      malformed backups.

    Test flow:
    1. GET /api/backups/{nonexistent_id}.
    2. Assert 404 status and ``detail`` == ``Backup not found``.

    API endpoints:
    - GET /api/backups/{backup_id}
    """
    resp = app_server.api_request(
        "GET",
        "/api/backups/qwenpaw-missing-integ-0001",
        timeout=_BACKUP_HTTP_TIMEOUT,
    )
    assert resp.status_code == 404, app_server.logs_tail()
    assert resp.json().get("detail") == "Backup not found"


@pytest.mark.integration
@pytest.mark.p0
def test_backup_restore_returns_404_for_missing(app_server) -> None:
    """Test purpose:
    - Verify POST /api/backups/{backup_id}/restore returns 404 when the
      backup id does not exist, preventing silent no-op restores.

    Test flow:
    1. POST /api/backups/{nonexistent_id}/restore with a minimal restore
       body (no agents, no secrets).
    2. Assert 404 status and ``detail`` == ``Backup not found``.

    API endpoints:
    - POST /api/backups/{backup_id}/restore
    """
    resp = app_server.api_request(
        "POST",
        "/api/backups/qwenpaw-missing-integ-0002/restore",
        json={
            "include_agents": False,
            "agent_ids": [],
            "include_global_config": False,
            "include_secrets": False,
            "include_skill_pool": False,
            "mode": "custom",
        },
        timeout=_BACKUP_HTTP_TIMEOUT,
    )
    assert resp.status_code == 404, app_server.logs_tail()
    assert resp.json().get("detail") == "Backup not found"


@pytest.mark.integration
@pytest.mark.p0
def test_backup_create_stream_and_restore_lifecycle(app_server) -> None:
    """Test purpose:
    - Verify the full backup CRUD lifecycle: create via SSE stream, list,
      detail, restore, delete. A regression on any step makes the
      backup/restore feature unusable, so the lifecycle is P0.

    Test flow:
    1. POST /api/backups/stream with a minimal scope (no agents/secrets,
       skill_pool only) and consume SSE events until ``done`` arrives.
    2. Capture ``backup_id`` from the final ``done`` event meta.
    3. GET /api/backups and assert the new id is present in the listing.
    4. GET /api/backups/{backup_id} and assert detail matches the id.
    5. POST /api/backups/{backup_id}/restore with a no-op restore body
       (no agents, no secrets, no global config); assert 200 and
       ``ok`` == True.
    6. POST /api/backups/delete with the id; assert it is reported in
       ``deleted``.
    7. GET /api/backups and assert the id is no longer present.

    API endpoints:
    - POST /api/backups/stream
    - GET /api/backups
    - GET /api/backups/{backup_id}
    - POST /api/backups/{backup_id}/restore
    - POST /api/backups/delete
    """
    create_payload = {
        "name": "integ-backup-lifecycle-01",
        "description": "integration backup lifecycle",
        "scope": {
            "include_agents": False,
            "include_global_config": False,
            "include_secrets": False,
            "include_skill_pool": True,
        },
        "agents": [],
    }
    meta = _stream_create_backup(app_server, create_payload)
    backup_id = meta.get("id")
    assert isinstance(backup_id, str) and backup_id
    assert meta.get("name") == "integ-backup-lifecycle-01"

    try:
        list_resp = app_server.api_request(
            "GET",
            "/api/backups",
            timeout=_BACKUP_HTTP_TIMEOUT,
        )
        assert list_resp.status_code == 200, app_server.logs_tail()
        listed_ids = {item.get("id") for item in list_resp.json()}
        assert backup_id in listed_ids

        detail_resp = app_server.api_request(
            "GET",
            f"/api/backups/{backup_id}",
            timeout=_BACKUP_HTTP_TIMEOUT,
        )
        assert detail_resp.status_code == 200, app_server.logs_tail()
        assert detail_resp.json().get("id") == backup_id

        restore_resp = app_server.api_request(
            "POST",
            f"/api/backups/{backup_id}/restore",
            json={
                "include_agents": False,
                "agent_ids": [],
                "include_global_config": False,
                "include_secrets": False,
                "include_skill_pool": False,
                "mode": "custom",
            },
            timeout=_BACKUP_HTTP_TIMEOUT,
        )
        assert restore_resp.status_code == 200, app_server.logs_tail()
        assert restore_resp.json().get("ok") is True
    finally:
        delete_resp = app_server.api_request(
            "POST",
            "/api/backups/delete",
            json={"ids": [backup_id]},
            timeout=_BACKUP_HTTP_TIMEOUT,
        )
        assert delete_resp.status_code == 200, app_server.logs_tail()
        deleted_payload = delete_resp.json()
        assert backup_id in deleted_payload.get("deleted", [])

        list_after = app_server.api_request(
            "GET",
            "/api/backups",
            timeout=_BACKUP_HTTP_TIMEOUT,
        )
        assert list_after.status_code == 200, app_server.logs_tail()
        remaining = {item.get("id") for item in list_after.json()}
        assert backup_id not in remaining


# --------------------------------------------------------------------------- #
# Sprint 1.3 — export + import coverage
# --------------------------------------------------------------------------- #


@pytest.mark.integration
@pytest.mark.p1
def test_backup_export_returns_404_for_missing(app_server) -> None:
    """Test purpose:
    - Verify GET /api/backups/{backup_id}/export returns 404 when the
      backup id does not exist, so Console can tell a real export error
      apart from a silent failure.

    Test flow:
    1. GET /api/backups/<synthetic-missing>/export.
    2. Assert 404 + ``detail`` == "Backup not found".

    API endpoints:
    - GET /api/backups/{backup_id}/export
    """
    resp = app_server.api_request(
        "GET",
        "/api/backups/qwenpaw-missing-export-integ-0001/export",
        timeout=_BACKUP_HTTP_TIMEOUT,
    )
    assert resp.status_code == 404, app_server.logs_tail()
    assert resp.json().get("detail") == "Backup not found"


@pytest.mark.integration
@pytest.mark.p1
def test_backup_export_returns_valid_zip_with_manifest(app_server) -> None:
    """Test purpose:
    - Verify export returns a real zip stream whose archive contains
      the backup manifest. Console relies on the manifest entry to
      validate the archive client-side before triggering a restore.

    Test flow:
    1. Create a backup via stream; capture ``backup_id``.
    2. GET export and assert the response Content-Type is zip.
    3. Open response bytes with ``zipfile.ZipFile``; assert it parses
       and includes a backup.json / manifest.json file with the same
       ``backup_id``.
    4. finally — delete the backup.

    API endpoints:
    - POST /api/backups/stream
    - GET /api/backups/{backup_id}/export
    - POST /api/backups/delete
    """
    backup_id = _create_backup(app_server, name="integ-export-zip-01")
    try:
        zip_bytes = _export_backup_bytes(app_server, backup_id)
        with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
            names = zf.namelist()
            # Manifest filename is implementation-defined; accept the
            # common shapes (backup.json / manifest.json) and verify the
            # backup_id appears somewhere in its contents.
            # Real archives store the manifest as ``meta.json`` at the
            # root; keep the alternatives accepted in case the layout
            # evolves (manifest.json / backup.json).
            manifest_name = next(
                (
                    n
                    for n in names
                    if n.endswith(
                        ("meta.json", "manifest.json", "backup.json"),
                    )
                ),
                None,
            )
            assert manifest_name is not None, f"no manifest in zip: {names}"
            manifest_text = zf.read(manifest_name).decode("utf-8")
            assert (
                backup_id in manifest_text
            ), f"backup_id missing from manifest: {manifest_text[:200]}"
    finally:
        _delete_backup(app_server, backup_id)


@pytest.mark.integration
@pytest.mark.p0
def test_backup_export_delete_import_restores_same_backup(app_server) -> None:
    """Test purpose:
    - Verify the disaster-recovery path: a user accidentally deletes a
      backup, then imports the previously-downloaded zip to recover it.
      The recovered backup keeps the same id (manifest-driven), so the
      restore flow afterwards behaves identically.

    Test flow:
    1. Create backup A; export it (zip bytes).
    2. Delete A; assert A is no longer in the list.
    3. POST /api/backups/import as multipart with the zip bytes.
    4. Assert 200 and the returned BackupMeta keeps id == A's id.
    5. GET /api/backups; assert A is back in the list.
    6. finally — delete A.

    API endpoints:
    - POST /api/backups/stream
    - GET /api/backups/{backup_id}/export
    - POST /api/backups/delete
    - POST /api/backups/import
    - GET /api/backups
    """
    backup_id = _create_backup(app_server, name="integ-recovery-01")
    try:
        zip_bytes = _export_backup_bytes(app_server, backup_id)

        _delete_backup(app_server, backup_id)
        assert backup_id not in _list_backup_ids(app_server)

        import_resp = app_server.api_request(
            "POST",
            "/api/backups/import",
            files={
                "file": (
                    f"{backup_id}.zip",
                    zip_bytes,
                    "application/zip",
                ),
            },
            timeout=_BACKUP_HTTP_TIMEOUT,
        )
        assert import_resp.status_code == 200, app_server.logs_tail()
        meta = import_resp.json()
        assert meta.get("id") == backup_id

        assert backup_id in _list_backup_ids(app_server)
    finally:
        _delete_backup(app_server, backup_id)


@pytest.mark.integration
@pytest.mark.p2
def test_backup_import_no_file_returns_400(app_server) -> None:
    """Test purpose:
    - Verify POST /api/backups/import with neither ``file`` nor
      ``pending_token`` returns 400, preventing silent no-op imports.

    Test flow:
    1. POST /api/backups/import with an empty multipart body
       (no file, no token).
    2. Assert 400 + ``detail`` == "file is required".

    API endpoints:
    - POST /api/backups/import
    """
    resp = app_server.api_request(
        "POST",
        "/api/backups/import",
        files={"_": ("placeholder", b"", "text/plain")},
        timeout=_BACKUP_HTTP_TIMEOUT,
    )
    # Some httpx builds collapse an entirely-empty multipart body, hence
    # the throwaway placeholder above. The server still sees no ``file``
    # form field and should reject.
    assert resp.status_code == 400, app_server.logs_tail()
    assert resp.json().get("detail") == "file is required"


@pytest.mark.integration
@pytest.mark.p2
def test_backup_import_non_zip_content_type_returns_400(app_server) -> None:
    """Test purpose:
    - Verify upload with a non-zip Content-Type is rejected with 400
      before the server tries to parse the archive, so a stray .txt
      upload doesn't waste cycles on extraction.

    Test flow:
    1. POST /api/backups/import multipart file with
       Content-Type=text/plain.
    2. Assert 400 + ``detail`` containing "Expected a zip file".

    API endpoints:
    - POST /api/backups/import
    """
    resp = app_server.api_request(
        "POST",
        "/api/backups/import",
        files={
            "file": (
                "not_a_backup.txt",
                b"this is plain text, not a zip",
                "text/plain",
            ),
        },
        timeout=_BACKUP_HTTP_TIMEOUT,
    )
    assert resp.status_code == 400, app_server.logs_tail()
    assert "Expected a zip file" in resp.json().get("detail", "")


@pytest.mark.integration
@pytest.mark.p2
def test_backup_import_corrupted_zip_returns_400(app_server) -> None:
    """Test purpose:
    - Verify a payload that *claims* to be a zip but is not parseable
      yields a 400 (via BackupValidationError) rather than a 500.

    Test flow:
    1. POST /api/backups/import with random bytes labelled
       ``application/zip`` and a .zip filename.
    2. Assert 400 (extraction / validation rejection).

    API endpoints:
    - POST /api/backups/import
    """
    resp = app_server.api_request(
        "POST",
        "/api/backups/import",
        files={
            "file": (
                "corrupted.zip",
                b"\x00\x01\x02 not a zip body \x03\x04\x05",
                "application/zip",
            ),
        },
        timeout=_BACKUP_HTTP_TIMEOUT,
    )
    assert resp.status_code == 400, app_server.logs_tail()


@pytest.mark.integration
@pytest.mark.p1
def test_backup_import_conflict_returns_409_with_pending_token(
    app_server,
) -> None:
    """Test purpose:
    - Verify importing a zip whose backup_id is already present returns
      409 with a ``pending_token`` and the existing meta, so the client
      can prompt the user "overwrite?" without re-uploading the file.

    Test flow:
    1. Create backup A; export it (zip bytes).
    2. POST /api/backups/import the exported zip (A is still on the
       server).
    3. Assert 409, the response JSON contains ``pending_token`` and
       ``existing`` whose id matches A.
    4. finally — delete A. The temp pending upload may stay behind; the
       app's _cleanup_stale_uploads will eventually reap it.

    API endpoints:
    - POST /api/backups/stream
    - GET /api/backups/{backup_id}/export
    - POST /api/backups/import
    - POST /api/backups/delete
    """
    backup_id = _create_backup(app_server, name="integ-conflict-01")
    try:
        zip_bytes = _export_backup_bytes(app_server, backup_id)

        resp = app_server.api_request(
            "POST",
            "/api/backups/import",
            files={
                "file": (
                    f"{backup_id}.zip",
                    zip_bytes,
                    "application/zip",
                ),
            },
            timeout=_BACKUP_HTTP_TIMEOUT,
        )
        assert resp.status_code == 409, app_server.logs_tail()
        payload = resp.json()
        assert isinstance(payload.get("pending_token"), str)
        assert payload["pending_token"]
        existing = payload.get("existing") or {}
        assert existing.get("id") == backup_id
    finally:
        _delete_backup(app_server, backup_id)


@pytest.mark.integration
@pytest.mark.p1
def test_backup_import_with_pending_token_completes_overwrite(
    app_server,
) -> None:
    """Test purpose:
    - Verify the two-step overwrite flow: after a 409 the client can
      POST again with only the ``pending_token`` (no re-upload) to
      commit the overwrite. Console's "import and overwrite" button
      exercises this exact sequence.

    Test flow:
    1. Create backup A; export it (zip bytes).
    2. POST import — expect 409 and capture pending_token.
    3. POST import again as form-encoded body containing only
       ``pending_token`` (no ``file`` field).
    4. Assert 200, returned BackupMeta has id == A.
    5. GET /api/backups; assert A still present (overwrite, not delete).
    6. finally — delete A.

    API endpoints:
    - POST /api/backups/stream
    - GET /api/backups/{backup_id}/export
    - POST /api/backups/import (×2)
    - GET /api/backups
    - POST /api/backups/delete
    """
    backup_id = _create_backup(app_server, name="integ-overwrite-01")
    try:
        zip_bytes = _export_backup_bytes(app_server, backup_id)

        conflict_resp = app_server.api_request(
            "POST",
            "/api/backups/import",
            files={
                "file": (
                    f"{backup_id}.zip",
                    zip_bytes,
                    "application/zip",
                ),
            },
            timeout=_BACKUP_HTTP_TIMEOUT,
        )
        assert conflict_resp.status_code == 409, app_server.logs_tail()
        pending_token = conflict_resp.json().get("pending_token")
        assert isinstance(pending_token, str) and pending_token

        commit_resp = app_server.api_request(
            "POST",
            "/api/backups/import",
            data={"pending_token": pending_token},
            timeout=_BACKUP_HTTP_TIMEOUT,
        )
        assert commit_resp.status_code == 200, app_server.logs_tail()
        meta = commit_resp.json()
        assert meta.get("id") == backup_id

        assert backup_id in _list_backup_ids(app_server)
    finally:
        _delete_backup(app_server, backup_id)
