import pytest

from backend.app.models import Artist, Source

pytestmark = pytest.mark.integration


@pytest.fixture(autouse=True)
def _stub_preflight_verify(monkeypatch):
    """Backfill/recover arms run a pre-flight credential verify (plan #703 #2)
    which for Patreon would hit the network. Default it to 'valid' so the
    endpoint tests stay network-free; the dedicated pre-flight tests override
    this with a rejection/inconclusive verdict."""
    from backend.app.services import download_backends as db_mod

    async def _ok(**kwargs):
        return (True, "Credentials valid")

    monkeypatch.setattr(db_mod, "verify_source_credential", _ok)


@pytest.fixture
async def artist(db):
    a = Artist(name="Alice", slug="alice")
    db.add(a)
    await db.commit()
    return a


@pytest.mark.asyncio
async def test_list_failing_filter_returns_only_failing(client, artist, db):
    healthy = Source(
        artist_id=artist.id, platform="patreon", url="https://p/ok",
        enabled=True, consecutive_failures=0,
    )
    broken = Source(
        artist_id=artist.id, platform="pixiv", url="https://p/bad",
        enabled=True, consecutive_failures=3, last_error="boom",
    )
    db.add_all([healthy, broken])
    await db.commit()

    resp = await client.get("/api/sources?failing=true")
    assert resp.status_code == 200
    body = await resp.get_json()
    assert len(body) == 1
    assert body[0]["consecutive_failures"] == 3
    assert body[0]["last_error"] == "boom"


@pytest.mark.asyncio
async def test_schedule_status_shape(client):
    resp = await client.get("/api/sources/schedule-status")
    assert resp.status_code == 200
    body = await resp.get_json()
    assert set(body) == {
        "last_tick_at", "next_due_at", "due_now", "auto_sources",
        "platform_cooldowns",
    }
    assert isinstance(body["due_now"], int)
    assert isinstance(body["auto_sources"], int)


@pytest.mark.asyncio
async def test_create_list_get_delete(client, artist):
    create = await client.post("/api/sources", json={
        "artist_id": artist.id, "platform": "patreon",
        "url": "https://patreon.com/alice",
    })
    assert create.status_code == 201
    created = await create.get_json()
    assert created["artist_name"] == "Alice"
    sid = created["id"]

    listing = await client.get("/api/sources")
    assert listing.status_code == 200
    assert len(await listing.get_json()) == 1

    filtered = await client.get(f"/api/sources?artist_id={artist.id}")
    assert filtered.status_code == 200
    assert len(await filtered.get_json()) == 1

    one = await client.get(f"/api/sources/{sid}")
    assert one.status_code == 200
    assert (await one.get_json())["id"] == sid

    deleted = await client.delete(f"/api/sources/{sid}")
    assert deleted.status_code == 204
    assert (await client.get(f"/api/sources/{sid}")).status_code == 404


@pytest.mark.asyncio
async def test_create_kicks_off_backfill_for_enabled_source(client, artist, monkeypatch):
    """A new enabled source dispatches its first walk immediately (no waiting
    for the next scheduler tick), unless its platform is in cooldown."""
    delays: list = []
    monkeypatch.setattr(
        "backend.app.tasks.download.download_source.delay",
        lambda *a, **k: delays.append(a),
    )
    resp = await client.post("/api/sources", json={
        "artist_id": artist.id, "platform": "patreon",
        "url": "https://patreon.com/kickoff",
    })
    assert resp.status_code == 201
    sid = (await resp.get_json())["id"]
    assert delays == [(sid,)]


@pytest.mark.asyncio
async def test_create_disabled_source_does_not_kick_off(client, artist, monkeypatch):
    delays: list = []
    monkeypatch.setattr(
        "backend.app.tasks.download.download_source.delay",
        lambda *a, **k: delays.append(a),
    )
    resp = await client.post("/api/sources", json={
        "artist_id": artist.id, "platform": "patreon",
        "url": "https://patreon.com/disabled", "enabled": False,
    })
    assert resp.status_code == 201
    assert delays == []


@pytest.mark.asyncio
async def test_create_rejects_unknown_platform(client, artist):
    resp = await client.post("/api/sources", json={
        "artist_id": artist.id, "platform": "myspace", "url": "https://m/x",
    })
    assert resp.status_code == 400
    body = await resp.get_json()
    assert body["error"] == "unknown_platform"
    assert "known" in body


@pytest.mark.asyncio
async def test_create_rejects_bad_config(client, artist):
    resp = await client.post("/api/sources", json={
        "artist_id": artist.id, "platform": "patreon",
        "url": "https://p/a", "config_overrides": [1, 2, 3],
    })
    assert resp.status_code == 400
    assert (await resp.get_json())["error"] == "invalid_config"


@pytest.mark.asyncio
async def test_create_rejects_empty_url(client, artist):
    resp = await client.post("/api/sources", json={
        "artist_id": artist.id, "platform": "patreon", "url": "   ",
    })
    assert resp.status_code == 400
    assert (await resp.get_json())["error"] == "empty_url"


@pytest.mark.asyncio
async def test_create_rejects_unknown_artist(client):
    resp = await client.post("/api/sources", json={
        "artist_id": 99999, "platform": "patreon", "url": "https://p/a",
    })
    assert resp.status_code == 404


@pytest.mark.asyncio
async def test_create_duplicate_returns_409_with_existing_id(client, artist):
    a = await client.post("/api/sources", json={
        "artist_id": artist.id, "platform": "patreon", "url": "https://p/a",
    })
    first = (await a.get_json())["id"]
    b = await client.post("/api/sources", json={
        "artist_id": artist.id, "platform": "patreon", "url": "https://p/a",
    })
    assert b.status_code == 409
    body = await b.get_json()
    assert body["error"] == "duplicate"
    assert body["existing_id"] == first


@pytest.mark.asyncio
async def test_patch_updates_fields(client, artist):
    create = await client.post("/api/sources", json={
        "artist_id": artist.id, "platform": "patreon", "url": "https://p/a",
    })
    sid = (await create.get_json())["id"]
    patch = await client.patch(f"/api/sources/{sid}", json={"enabled": False})
    assert patch.status_code == 200
    assert (await patch.get_json())["enabled"] is False


@pytest.mark.asyncio
async def test_get_404(client):
    assert (await client.get("/api/sources/99999")).status_code == 404


@pytest.mark.asyncio
async def test_patch_404(client):
    assert (await client.patch("/api/sources/99999", json={"enabled": False})).status_code == 404


@pytest.mark.asyncio
async def test_delete_404(client):
    assert (await client.delete("/api/sources/99999")).status_code == 404


# --- FC-3d: SourceRecord surfaces consecutive_failures + next_check_at ----


@pytest.mark.asyncio
async def test_create_response_includes_fc3d_fields(client, artist):
    resp = await client.post("/api/sources", json={
        "artist_id": artist.id, "platform": "patreon",
        "url": "https://patreon.com/alice-fc3d-new",
    })
    assert resp.status_code == 201
    body = await resp.get_json()
    assert body["consecutive_failures"] == 0
    assert body["next_check_at"] is None  # never checked yet


@pytest.mark.asyncio
async def test_list_derives_next_check_at_when_last_checked_set(
    client, artist, db,
):
    from datetime import UTC, datetime

    from backend.app.models import Source

    src = Source(
        artist_id=artist.id, platform="patreon",
        url="https://patreon.com/alice-fc3d-checked", enabled=True,
        consecutive_failures=0,
        last_checked_at=datetime.now(UTC),
    )
    db.add(src)
    await db.commit()

    resp = await client.get("/api/sources")
    body = await resp.get_json()
    target = next(
        r for r in body if r["url"] == "https://patreon.com/alice-fc3d-checked"
    )
    assert target["next_check_at"] is not None
    assert "T" in target["next_check_at"]  # ISO 8601


# --- Plan #544: POST /api/sources/{id}/backfill ----------------------------


@pytest.mark.asyncio
async def test_backfill_endpoint_start_and_stop(client, artist, db):
    src = Source(
        artist_id=artist.id, platform="patreon",
        url="https://patreon.com/alice-backfill", enabled=True,
    )
    db.add(src)
    await db.commit()
    sid = src.id

    resp = await client.post(f"/api/sources/{sid}/backfill", json={"action": "start"})
    assert resp.status_code == 200
    body = await resp.get_json()
    assert body["backfill_state"] == "running"

    # GET reflects the running state.
    one = await client.get(f"/api/sources/{sid}")
    assert (await one.get_json())["backfill_state"] == "running"

    stopped = await client.post(f"/api/sources/{sid}/backfill", json={"action": "stop"})
    assert (await stopped.get_json())["backfill_state"] is None


@pytest.mark.asyncio
async def test_backfill_endpoint_defaults_to_start(client, artist, db):
    src = Source(
        artist_id=artist.id, platform="patreon",
        url="https://patreon.com/alice-backfill-default", enabled=True,
    )
    db.add(src)
    await db.commit()
    resp = await client.post(f"/api/sources/{src.id}/backfill", json={})
    body = await resp.get_json()
    assert body["backfill_state"] == "running"


@pytest.mark.asyncio
async def test_backfill_endpoint_rejects_bad_action(client, artist, db):
    src = Source(
        artist_id=artist.id, platform="patreon",
        url="https://patreon.com/alice-backfill-bad", enabled=True,
    )
    db.add(src)
    await db.commit()
    bad = await client.post(f"/api/sources/{src.id}/backfill", json={"action": "nope"})
    assert bad.status_code == 400


@pytest.mark.asyncio
async def test_backfill_endpoint_404_when_source_missing(client):
    resp = await client.post("/api/sources/999999/backfill", json={"action": "start"})
    assert resp.status_code == 404


@pytest.mark.asyncio
async def test_backfill_endpoint_recover_arms_bypass(client, artist, db):
    """Plan #697: action='recover' arms a backfill that bypasses the Patreon
    seen-ledger; the response exposes backfill_bypass_seen=True so the UI badge
    can label it 'Recovering'. Stop clears it."""
    src = Source(
        artist_id=artist.id, platform="patreon",
        url="https://patreon.com/alice-recover", enabled=True,
    )
    db.add(src)
    await db.commit()
    sid = src.id

    resp = await client.post(f"/api/sources/{sid}/backfill", json={"action": "recover"})
    assert resp.status_code == 200
    body = await resp.get_json()
    assert body["backfill_state"] == "running"
    assert body["backfill_bypass_seen"] is True

    stopped = await client.post(f"/api/sources/{sid}/backfill", json={"action": "stop"})
    stopped_body = await stopped.get_json()
    assert stopped_body["backfill_state"] is None
    assert stopped_body["backfill_bypass_seen"] is False


@pytest.mark.asyncio
async def test_backfill_endpoint_recapture_arms_flag(client, artist, db):
    """#830: action='recapture' arms a backfill that re-grabs post bodies/links +
    localizes on-disk inline images; the response exposes backfill_recapture=True
    (badge 'Recapturing') and NOT backfill_bypass_seen. Stop clears it."""
    src = Source(
        artist_id=artist.id, platform="patreon",
        url="https://patreon.com/alice-recapture", enabled=True,
    )
    db.add(src)
    await db.commit()
    sid = src.id

    resp = await client.post(f"/api/sources/{sid}/backfill", json={"action": "recapture"})
    assert resp.status_code == 200
    body = await resp.get_json()
    assert body["backfill_state"] == "running"
    assert body["backfill_recapture"] is True
    assert body["backfill_bypass_seen"] is False

    stopped = await client.post(f"/api/sources/{sid}/backfill", json={"action": "stop"})
    stopped_body = await stopped.get_json()
    assert stopped_body["backfill_state"] is None
    assert stopped_body["backfill_recapture"] is False


# --- Plan #703 #2: pre-flight credential verify on backfill/recover arm -----


@pytest.mark.asyncio
async def test_arm_blocked_when_credential_rejected(client, artist, db, monkeypatch):
    """A definitively-rejected credential refuses the arm (409 + reason) instead
    of starting a doomed walk; the source is NOT armed."""
    from backend.app.services import download_backends as db_mod

    async def _reject(**kwargs):
        return (False, "Patreon rejected the credential — cookies expired")
    monkeypatch.setattr(db_mod, "verify_source_credential", _reject)

    src = Source(
        artist_id=artist.id, platform="patreon",
        url="https://patreon.com/alice-preflight", enabled=True,
    )
    db.add(src)
    await db.commit()
    sid = src.id

    resp = await client.post(f"/api/sources/{sid}/backfill", json={"action": "recover"})
    assert resp.status_code == 409
    assert "expired" in ((await resp.get_json()).get("detail") or "")

    # Not armed.
    one = await (await client.get(f"/api/sources/{sid}")).get_json()
    assert one["backfill_state"] is None


@pytest.mark.asyncio
async def test_arm_proceeds_when_credential_inconclusive(client, artist, db, monkeypatch):
    """An inconclusive verify (network blip / drift) must NOT block the arm."""
    from backend.app.services import download_backends as db_mod

    async def _inconclusive(**kwargs):
        return (None, "couldn't verify (network)")
    monkeypatch.setattr(db_mod, "verify_source_credential", _inconclusive)

    src = Source(
        artist_id=artist.id, platform="patreon",
        url="https://patreon.com/alice-incon", enabled=True,
    )
    db.add(src)
    await db.commit()

    resp = await client.post(f"/api/sources/{src.id}/backfill", json={"action": "start"})
    assert resp.status_code == 200
    assert (await resp.get_json())["backfill_state"] == "running"


@pytest.mark.asyncio
async def test_stop_never_pre_flights(client, artist, db, monkeypatch):
    from backend.app.services import download_backends as db_mod

    async def _boom(**kwargs):
        raise AssertionError("stop must not pre-flight verify")
    monkeypatch.setattr(db_mod, "verify_source_credential", _boom)

    src = Source(
        artist_id=artist.id, platform="patreon",
        url="https://patreon.com/alice-stop", enabled=True,
    )
    db.add(src)
    await db.commit()
    resp = await client.post(f"/api/sources/{src.id}/backfill", json={"action": "stop"})
    assert resp.status_code == 200


@pytest.mark.asyncio
async def test_gallery_dl_platform_arm_skips_pre_flight(client, artist, db, monkeypatch):
    """Pre-flight is gated to native-ingester platforms (cheap verify); a
    gallery-dl source arms without the slow --simulate probe."""
    from backend.app.services import download_backends as db_mod

    async def _boom(**kwargs):
        raise AssertionError("gallery-dl arm must not pre-flight verify")
    monkeypatch.setattr(db_mod, "verify_source_credential", _boom)

    src = Source(
        artist_id=artist.id, platform="hentaifoundry",
        url="https://www.hentai-foundry.com/user/x", enabled=True,
    )
    db.add(src)
    await db.commit()
    resp = await client.post(f"/api/sources/{src.id}/backfill", json={"action": "start"})
    assert resp.status_code == 200
