"""SP17 — BackupScheduler unit tests.

Exercises the asyncio tick / start / stop loop without spinning up
the FastAPI app. The scheduler wraps a real BackupService against
a real on-disk sqlite DB.
"""
from __future__ import annotations

import asyncio
from datetime import datetime, timedelta, timezone
from pathlib import Path

import pytest

from cyclone import backup_service as svc_mod
from cyclone import backup_scheduler as sched_mod
from cyclone import db


@pytest.fixture
def fresh_db(tmp_path, monkeypatch):
    monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
    db._reset_for_tests()
    db.init_db()
    from cyclone.db import Batch
    import uuid
    with db.SessionLocal()() as s:
        s.add(Batch(
            id=str(uuid.uuid4()),
            kind="837P",
            input_filename="seed.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()
    yield
    db._reset_for_tests()


@pytest.fixture
def backup_svc(tmp_path):
    return svc_mod.BackupService(
        backup_dir=tmp_path / "backups",
        passphrase="test-pass",
        retention_days=7,
    )


# ---------------------------------------------------------------------------
# tick
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_tick_creates_backup_and_audits_it(fresh_db, backup_svc):
    sched = sched_mod.BackupScheduler(backup_svc, interval_hours=24.0)
    result = await sched.tick()
    assert result.ok
    assert result.created is not None
    assert result.error is None
    assert len(backup_svc.list_backups()) == 1


@pytest.mark.asyncio
async def test_tick_creates_audit_event(fresh_db, backup_svc):
    """db.backup_created audit event is written (SP11 hash chain)."""
    sched = sched_mod.BackupScheduler(backup_svc, interval_hours=24.0)
    await sched.tick()
    with db.SessionLocal()() as s:
        from cyclone.db import AuditLog
        rows = (
            s.query(AuditLog)
            .filter(AuditLog.event_type == "db.backup_created")
            .all()
        )
        assert len(rows) == 1
        assert "backup_id" in rows[0].payload_json


@pytest.mark.asyncio
async def test_tick_handles_create_failure_without_crashing(fresh_db, backup_svc, monkeypatch):
    """If create_now raises, tick records the error and continues."""
    def boom():
        raise RuntimeError("simulated failure")
    monkeypatch.setattr(backup_svc, "create_now", boom)
    sched = sched_mod.BackupScheduler(backup_svc, interval_hours=24.0)
    result = await sched.tick()
    assert result.error is not None
    assert "simulated failure" in result.error
    # Audit event written for the failure.
    with db.SessionLocal()() as s:
        from cyclone.db import AuditLog
        rows = (
            s.query(AuditLog)
            .filter(AuditLog.event_type == "db.backup_failed")
            .all()
        )
        assert len(rows) == 1


@pytest.mark.asyncio
async def test_tick_prunes_old_backups_and_audits(fresh_db, backup_svc):
    """A tick prunes backups past retention and writes a db.backup_pruned event."""
    from cyclone.db import DbBackup

    # Take an initial backup.
    initial = backup_svc.create_now()
    # Age it past retention.
    with db.SessionLocal()() as s:
        row = s.get(DbBackup, initial.backup.id)
        row.created_at = datetime.now(timezone.utc) - timedelta(days=30)
        s.commit()

    sched = sched_mod.BackupScheduler(backup_svc, interval_hours=24.0)
    result = await sched.tick()
    assert result.ok  # create_now succeeded even though prune removed old
    assert len(result.pruned_paths) == 2  # .bin + .meta.json

    with db.SessionLocal()() as s:
        from cyclone.db import AuditLog
        pruned_events = (
            s.query(AuditLog)
            .filter(AuditLog.event_type == "db.backup_pruned")
            .all()
        )
        assert len(pruned_events) == 1


# ---------------------------------------------------------------------------
# start / stop / is_running
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_start_then_stop(fresh_db, backup_svc):
    sched = sched_mod.BackupScheduler(backup_svc, interval_hours=24.0)
    assert not sched.is_running()
    await sched.start()
    assert sched.is_running()
    # Don't wait for the staggered first tick; just stop.
    await sched.stop()
    assert not sched.is_running()


@pytest.mark.asyncio
async def test_double_start_is_idempotent(fresh_db, backup_svc):
    sched = sched_mod.BackupScheduler(backup_svc, interval_hours=24.0)
    await sched.start()
    await sched.start()  # no-op
    assert sched.is_running()
    await sched.stop()


@pytest.mark.asyncio
async def test_concurrent_ticks_are_coalesced(fresh_db, backup_svc):
    """Two tick() calls in flight — second waits for first."""
    sched = sched_mod.BackupScheduler(backup_svc, interval_hours=24.0)
    r1, r2 = await asyncio.gather(sched.tick(), sched.tick())
    # Both should succeed and produce a single backup (the second
    # call returned the first call's result, or ran back-to-back
    # and produced a second backup — both are valid coalescings).
    assert r1 is not None
    assert r2 is not None
    # No matter the order, exactly 1 backup should exist OR 2 if they
    # ran sequentially. The point of coalescing is no-overlap, so
    # both should be ok=True.
    assert r1.ok
    assert r2.ok


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


def test_status_snapshot(fresh_db, backup_svc):
    sched = sched_mod.BackupScheduler(backup_svc, interval_hours=12.0)
    snap = sched.status()
    assert snap.running is False
    assert snap.interval_hours == 12.0
    assert snap.backup_dir == str(backup_svc.backup_dir)
    assert snap.retention_days == 7
    assert snap.tick_count == 0
    assert snap.last_tick is None


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


def test_module_singleton_round_trip(fresh_db, tmp_path):
    sched_mod.reset_backup_scheduler_for_tests()
    svc = svc_mod.BackupService(tmp_path / "b", passphrase="x", retention_days=1)
    sched = sched_mod.configure_backup_scheduler(svc, interval_hours=1)
    assert sched_mod.get_backup_scheduler() is sched
    # Second configure is a no-op.
    assert sched_mod.configure_backup_scheduler(svc) is sched
    sched_mod.reset_backup_scheduler_for_tests()


def test_module_singleton_get_raises_when_unset():
    sched_mod.reset_backup_scheduler_for_tests()
    with pytest.raises(RuntimeError):
        sched_mod.get_backup_scheduler()
