"""SP17 — BackupService integration tests.

Exercises the full create / list / verify / restore / prune flow
against a real on-disk SQLite file (no SQLCipher, no Keychain). We
inject the passphrase directly into the BackupService constructor.
"""
from __future__ import annotations

import os
from pathlib import Path

import pytest

from cyclone import backup as backup_mod
from cyclone import backup_service as svc_mod
from cyclone import db
from cyclone.backup import BackupError
from cyclone.backup_service import (
    BackupService,
    STATUS_ERROR,
    STATUS_OK,
    STATUS_PENDING,
    STATUS_PRUNED,
    configure_backup_service,
    get_backup_service,
    reset_backup_service_for_tests,
)


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def fresh_db(tmp_path, monkeypatch):
    """Fresh sqlite DB; init_db + create tables; yield the path."""
    monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
    db._reset_for_tests()
    db.init_db()
    yield tmp_path / "test.db"
    db._reset_for_tests()


@pytest.fixture
def backup_svc(fresh_db, tmp_path):
    """A BackupService rooted in a temp backup directory."""
    backup_dir = tmp_path / "backups"
    return BackupService(
        backup_dir=backup_dir,
        passphrase="test-passphrase-123",
        retention_days=7,
    )


def _make_a_row(s: "sa.orm.Session") -> None:
    """Insert one minimal Batch row so the DB has a real schema + content.

    Bypasses the Claim model (which has many NOT NULL columns tied to
    BatchRecord lifecycle) and just writes a Batch directly — the
    backup flow doesn't care which tables exist, only that there
    are some.
    """
    from cyclone.db import Batch
    import uuid
    from datetime import datetime, timezone
    from decimal import Decimal
    s.add(Batch(
        id=str(uuid.uuid4()),
        kind="837P",
        input_filename="test.x12",
        parsed_at=datetime.now(timezone.utc),
        totals_json=None,
        validation_json=None,
        raw_result_json={
            "envelope": {"control_number": "1"},
            "claims": [],
            "summary": {"passed": 0, "failed": 0, "failed_claim_ids": []},
        },
    ))
    s.commit()


# ---------------------------------------------------------------------------
# create_now
# ---------------------------------------------------------------------------


def test_create_now_writes_encrypted_blob_and_sidecar(fresh_db, backup_svc):
    from cyclone.db import DbBackup
    # Add a claim so the DB has content + table_count > 0.
    with db.SessionLocal()() as s:
        _make_a_row(s)

    result = backup_svc.create_now()
    record = result.backup
    sidecar = result.sidecar

    assert record.status == STATUS_OK
    assert record.size_bytes > 0
    assert record.db_fingerprint.startswith("sha256:")
    assert record.table_count >= 1
    assert record.completed_at is not None

    # The .bin file exists, is non-trivial size, and does NOT look
    # like a SQLite header (which is the whole point of encryption).
    bin_path = backup_svc.backup_dir / record.filename
    assert bin_path.exists()
    blob = bin_path.read_bytes()
    assert blob[:6] != b"SQLite"  # not a plaintext SQLite file

    # Sidecar exists and round-trips.
    meta_path = backup_svc.backup_dir / backup_mod.sidecar_filename(record.filename)
    assert meta_path.exists()
    parsed = backup_mod.Sidecar.from_json(meta_path.read_text())
    assert parsed.db_fingerprint == record.db_fingerprint
    assert parsed.table_count == record.table_count


def test_create_now_marks_error_on_db_failure(fresh_db, tmp_path, monkeypatch):
    """If SQLite .backup() raises, the row is marked error + files cleaned."""
    backup_dir = tmp_path / "backups"
    svc = BackupService(backup_dir=backup_dir, passphrase="x", retention_days=7)

    # Force the .backup() call to fail by patching sqlite3.connect to raise.
    import sqlite3 as _sqlite3
    real_connect = _sqlite3.connect
    def boom(path):
        raise RuntimeError("simulated disk failure")
    monkeypatch.setattr(_sqlite3, "connect", boom)
    # But we also need to make sure engine.raw_connection().driver_connection
    # is reachable — it's still using real_connect via the engine's
    # internals. So patch at the higher level: the BackupService's
    # _sqlite_backup_to.
    monkeypatch.setattr(svc, "_sqlite_backup_to",
                        lambda p: (_ for _ in ()).throw(RuntimeError("boom")))

    with pytest.raises(RuntimeError, match="boom"):
        svc.create_now()

    rows = svc.list_backups()
    assert len(rows) == 1
    assert rows[0].status == STATUS_ERROR
    assert "boom" in rows[0].error_message
    # No files left in the backup dir.
    assert list(backup_dir.iterdir()) == []


# ---------------------------------------------------------------------------
# list_backups
# ---------------------------------------------------------------------------


def test_list_backups_orders_newest_first(fresh_db, backup_svc):
    with db.SessionLocal()() as s:
        _make_a_row(s)
    r1 = backup_svc.create_now()
    r2 = backup_svc.create_now()
    rows = backup_svc.list_backups()
    assert [r.id for r in rows] == [r2.backup.id, r1.backup.id]


def test_list_backups_filter_by_status(fresh_db, backup_svc):
    with db.SessionLocal()() as s:
        _make_a_row(s)
    backup_svc.create_now()
    rows = backup_svc.list_backups(status=STATUS_OK)
    assert all(r.status == STATUS_OK for r in rows)
    rows = backup_svc.list_backups(status=STATUS_PENDING)
    assert rows == []


# ---------------------------------------------------------------------------
# verify
# ---------------------------------------------------------------------------


def test_verify_ok_after_create(fresh_db, backup_svc):
    with db.SessionLocal()() as s:
        _make_a_row(s)
    r = backup_svc.create_now()
    v = backup_svc.verify(r.backup.id)
    assert v.ok
    assert v.expected_fingerprint == v.actual_fingerprint
    assert v.table_count >= 1


def test_verify_detects_tampered_ciphertext(fresh_db, backup_svc):
    with db.SessionLocal()() as s:
        _make_a_row(s)
    r = backup_svc.create_now()
    bin_path = backup_svc.backup_dir / r.backup.filename
    # Flip a bit in the middle of the encrypted blob.
    data = bytearray(bin_path.read_bytes())
    idx = backup_mod.NONCE_LEN + 5
    data[idx] ^= 0x01
    bin_path.write_bytes(bytes(data))

    v = backup_svc.verify(r.backup.id)
    assert not v.ok
    assert "decryption failed" in (v.reason or "")


def test_verify_handles_missing_file(fresh_db, backup_svc):
    with db.SessionLocal()() as s:
        _make_a_row(s)
    r = backup_svc.create_now()
    (backup_svc.backup_dir / r.backup.filename).unlink()
    v = backup_svc.verify(r.backup.id)
    assert not v.ok
    assert "missing" in (v.reason or "")


# ---------------------------------------------------------------------------
# restore — two-step
# ---------------------------------------------------------------------------


def test_restore_two_step_round_trip(fresh_db, backup_svc, tmp_path):
    """Create a backup, mutate the live DB, restore, confirm mutation gone."""
    from cyclone.db import Batch
    import uuid
    from datetime import datetime, timezone

    # 1. Backup a DB with one Batch row.
    with db.SessionLocal()() as s:
        _make_a_row(s)
    snap = backup_svc.create_now()

    # 2. Mutate the live DB (add another Batch row).
    with db.SessionLocal()() as s:
        s.add(Batch(
            id=str(uuid.uuid4()),
            kind="837P",
            input_filename="mutated.x12",
            parsed_at=datetime.now(timezone.utc),
            totals_json=None,
            validation_json=None,
            raw_result_json={"envelope": {"control_number": "2"}, "claims": [], "summary": {"passed": 0, "failed": 0, "failed_claim_ids": []}},
        ))
        s.commit()
    with db.SessionLocal()() as s:
        assert s.query(Batch).count() == 2

    # 3. Initiate restore.
    init = backup_svc.restore_initiate(snap.backup.id)
    assert init.table_count >= 1
    assert init.current_db_fingerprint != init.db_fingerprint  # live != backup now
    assert init.restore_token and len(init.restore_token) == 64

    # 4. Confirm restore.
    result = backup_svc.restore_confirm(snap.backup.id, init.restore_token)
    assert result.new_db_fingerprint == init.db_fingerprint

    # 5. The live DB now reflects the snapshot (1 row, not 2).
    with db.SessionLocal()() as s:
        assert s.query(Batch).count() == 1


def test_restore_initiate_rejects_non_ok_backup(fresh_db, backup_svc, tmp_path, monkeypatch):
    """A backup row with status='error' cannot be restored."""
    with db.SessionLocal()() as s:
        _make_a_row(s)
    r = backup_svc.create_now()
    # Force the row to error.
    from cyclone.db import DbBackup
    with db.SessionLocal()() as session:
        row = session.get(DbBackup, r.backup.id)
        row.status = STATUS_ERROR
        row.error_message = "simulated"
        session.commit()
    with pytest.raises(BackupError, match="only 'ok' backups"):
        backup_svc.restore_initiate(r.backup.id)


def test_restore_confirm_rejects_wrong_token(fresh_db, backup_svc):
    with db.SessionLocal()() as s:
        _make_a_row(s)
    r = backup_svc.create_now()
    init = backup_svc.restore_initiate(r.backup.id)
    with pytest.raises(BackupError, match="not found"):
        backup_svc.restore_confirm(r.backup.id, "0" * 64)


def test_restore_confirm_rejects_expired_token(fresh_db, backup_svc, monkeypatch):
    """A token whose expires_at is in the past is rejected."""
    from datetime import datetime, timedelta, timezone
    with db.SessionLocal()() as s:
        _make_a_row(s)
    r = backup_svc.create_now()
    init = backup_svc.restore_initiate(r.backup.id)
    # Manually age the token past its expiry.
    with backup_svc._lock:
        backup_svc._pending_restores[init.restore_token] = (
            init.backup_id,
            datetime.now(timezone.utc) - timedelta(seconds=1),
        )
    with pytest.raises(BackupError, match="expired"):
        backup_svc.restore_confirm(r.backup.id, init.restore_token)


# ---------------------------------------------------------------------------
# prune
# ---------------------------------------------------------------------------


def test_prune_deletes_files_and_marks_status(fresh_db, backup_svc):
    with db.SessionLocal()() as s:
        _make_a_row(s)
    r1 = backup_svc.create_now()
    # The retention cutoff is 7 days from now. Move the row's created_at
    # back 30 days so it's definitely past retention.
    from datetime import datetime, timedelta, timezone
    from cyclone.db import DbBackup
    with db.SessionLocal()() as session:
        row = session.get(DbBackup, r1.backup.id)
        row.created_at = datetime.now(timezone.utc) - timedelta(days=30)
        session.commit()

    deleted = backup_svc.prune()
    assert len(deleted) == 2  # .bin + .meta.json
    rows = backup_svc.list_backups()
    assert rows[0].status == STATUS_PRUNED


def test_prune_keeps_recent_backups(fresh_db, backup_svc):
    with db.SessionLocal()() as s:
        _make_a_row(s)
    backup_svc.create_now()
    deleted = backup_svc.prune()
    assert deleted == []
    rows = backup_svc.list_backups()
    assert rows[0].status == STATUS_OK


# ---------------------------------------------------------------------------
# status
# ---------------------------------------------------------------------------


def test_status_reports_counts(fresh_db, backup_svc):
    with db.SessionLocal()() as s:
        _make_a_row(s)
    backup_svc.create_now()
    snap = backup_svc.status()
    assert snap["totals"]["ok"] == 1
    assert snap["totals"]["all"] == 1
    assert snap["backup_dir"] == str(backup_svc.backup_dir)
    assert snap["retention_days"] == 7
    assert snap["used_fallback_key"] is False
    assert snap["last_backup_at"] is not None
    assert snap["last_ok_backup_at"] is not None


# ---------------------------------------------------------------------------
# Fallback key
# ---------------------------------------------------------------------------


def test_fallback_key_used_when_no_passphrase(fresh_db, tmp_path):
    """If no passphrase AND no SQLCipher, refuse. Otherwise fallback + warn."""
    backup_dir = tmp_path / "backups"
    svc = BackupService(backup_dir=backup_dir, passphrase=None, retention_days=7)
    # No SQLCipher key either → BackupError.
    with pytest.raises(BackupError, match="no backup passphrase"):
        svc._ensure_key()


def test_key_fingerprint_changes_per_passphrase(fresh_db, tmp_path):
    """Two services with different passphrases have different key fingerprints."""
    s1 = BackupService(tmp_path / "b1", passphrase="alpha", retention_days=1)
    s2 = BackupService(tmp_path / "b2", passphrase="beta", retention_days=1)
    # Force key derivation.
    s1._ensure_key()
    s2._ensure_key()
    assert s1.key_fingerprint != s2.key_fingerprint


# ---------------------------------------------------------------------------
# Module-level singleton
# ---------------------------------------------------------------------------


def test_module_singleton_round_trip(fresh_db, tmp_path):
    reset_backup_service_for_tests()
    svc = configure_backup_service(
        tmp_path / "backups", passphrase="x", retention_days=1,
    )
    assert get_backup_service() is svc
    # Second configure is a no-op (returns existing).
    assert configure_backup_service(
        tmp_path / "backups2", passphrase="y", retention_days=2,
    ) is svc
    reset_backup_service_for_tests()


def test_module_singleton_get_raises_when_unset():
    reset_backup_service_for_tests()
    with pytest.raises(RuntimeError):
        get_backup_service()
