"""DownloadService orchestrator tests.

GalleryDLService is mocked to return a canned DownloadResult; tests
provide on-disk files for the importer to attach_in_place.
"""

from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock

import pytest
from sqlalchemy import select

from backend.app.models import Artist, DownloadEvent, ImportSettings, Source
from backend.app.services.credential_crypto import CredentialCrypto
from backend.app.services.credential_service import CredentialService
from backend.app.services.thumbnailer import Thumbnailer

pytestmark = pytest.mark.integration


@pytest.fixture
async def seed_artist_and_source(db, db_sync):
    artist = Artist(name="Alice", slug="alice")
    db.add(artist)
    await db.flush()
    source = Source(
        artist_id=artist.id, platform="patreon",
        url="https://patreon.com/alice", enabled=True,
        config_overrides={},
    )
    db.add(source)
    await db.commit()
    return artist, source


def _make_jpg(path: Path, split: str = "h"):
    """Write a structured JPEG so phash isn't degenerate.

    Solid-color images all phash-collapse (DCT of a flat image is zero).
    Use `split='h'` (top/bottom halves) or `split='v'` (left/right) to
    give each fixture file a distinct phash.
    """
    from PIL import Image
    path.parent.mkdir(parents=True, exist_ok=True)
    im = Image.new("RGB", (64, 64), (255, 255, 255))
    px = im.load()
    if split == "h":
        for y in range(32):
            for x in range(64):
                px[x, y] = (0, 0, 0)
    else:  # 'v'
        for y in range(64):
            for x in range(32):
                px[x, y] = (0, 0, 0)
    im.save(path, "JPEG")


def _make_fake_dl_result(
    *, success=True, written_paths=None, quarantined_paths=None,
    files_downloaded=0, error_type=None, error_message=None,
    stdout="", stderr="", cursor=None, run_stats=None, posts_processed=0,
    relink_source_paths=None,
):
    return SimpleNamespace(
        relink_source_paths=relink_source_paths or [],
        success=success,
        url="https://patreon.com/alice",
        artist_slug="alice",
        platform="patreon",
        files_downloaded=files_downloaded,
        files_quarantined=len(quarantined_paths or []),
        quarantined_paths=quarantined_paths or [],
        written_paths=written_paths or [],
        stdout=stdout,
        stderr=stderr,
        return_code=0 if success else 1,
        error_type=error_type,
        error_message=error_message,
        duration_seconds=1.23,
        started_at="2026-05-20T14:00:00+00:00",
        completed_at="2026-05-20T14:01:00+00:00",
        # plan #704: native ingester now returns the cursor + run_stats
        # structurally (no more stderr `Cursor:` scraping).
        cursor=cursor,
        run_stats=run_stats,
        posts_processed=posts_processed,
    )


def _fake_gdl_with_result(result):
    fake = MagicMock()
    fake.download = AsyncMock(return_value=result)
    # Real values for the attrs run_download's native branch reads off the gdl
    # (the native pacing config, plan #703) — a MagicMock would break the
    # arithmetic (max(0.5, rate_limit/4)).
    fake._rate_limit = 3.0
    fake._validate_files = True
    fake._compute_run_stats = lambda *a, **k: {
        "exit_code": 0, "downloaded_count": len(result.written_paths),
        "skipped_count": 0, "per_item_failures": 0,
        "warning_count": 0, "tier_gated_count": 0,
    }
    fake._extract_errors_warnings = lambda *a, **k: ""
    fake._truncate_log = lambda x, **k: x
    return fake


def _stub_patreon_ingester(svc, result, resolved_campaign_id=None):
    """Route the phase-2 dispatch (download_backends.run_download via
    DownloadService._run_download, plan #707 A5) to a canned DownloadResult so
    these tests exercise download_service's phase-2-result→phase-3 handling
    (status mapping, backfill lifecycle, health) without the real ingester's
    network/DB. The ingester itself is covered by test_patreon_ingester.py; the
    native construction/resolution by the run_download tests below. Returns a list
    capturing (ctx, source_config, skip_value, mode) per call so a test can assert
    mode / resume_cursor threading."""
    calls = []

    async def fake(*, ctx, source_config, skip_value, mode):
        calls.append({
            "ctx": ctx, "source_config": source_config,
            "skip_value": skip_value, "mode": mode,
        })
        return result, resolved_campaign_id

    svc._run_download = fake
    return calls


@pytest.mark.asyncio
async def test_download_source_attaches_written_files(
    db, db_sync, tmp_path, seed_artist_and_source,
):
    from backend.app.services.download_service import DownloadService
    from backend.app.services.importer import Importer

    _artist, source = seed_artist_and_source

    images_root = tmp_path / "images"
    f1 = images_root / "alice" / "patreon" / "post" / "a.jpg"
    f2 = images_root / "alice" / "patreon" / "post" / "b.jpg"
    # Structured h/v split → distinct sha256 AND distinct phash;
    # also pin phash_threshold=0 so even tiny perceptual similarity
    # between the two doesn't trigger duplicate_phash.
    _make_jpg(f1, split="h")
    _make_jpg(f2, split="v")

    result = _make_fake_dl_result(
        success=True,
        written_paths=[str(f1), str(f2)],
        files_downloaded=2,
        stdout=f"{f1}\n{f2}\n",
    )
    fake_gdl = _fake_gdl_with_result(result)

    sync_settings = db_sync.execute(
        select(ImportSettings).where(ImportSettings.id == 1)
    ).scalar_one()
    sync_settings.phash_threshold = 0  # only exact phash collisions dedup

    importer = Importer(
        session=db_sync, images_root=images_root,
        import_root=images_root,
        thumbnailer=Thumbnailer(images_root=images_root),
        settings=sync_settings,
    )
    crypto = CredentialCrypto(tmp_path / "key.b64", bootstrap_ok=True)
    cred_service = CredentialService(db, crypto)

    svc = DownloadService(
        async_session=db, sync_session=db_sync,
        gdl=fake_gdl, importer=importer, cred_service=cred_service,
    )
    _stub_patreon_ingester(svc, result)
    event_id = await svc.download_source(source.id)

    ev = (await db.execute(
        select(DownloadEvent).where(DownloadEvent.id == event_id)
    )).scalar_one()
    assert ev.status == "ok"
    assert ev.files_count == 2
    assert ev.metadata_["import_summary"]["attached"] == 2
    assert ev.metadata_["run_stats"]["downloaded_count"] == 2


@pytest.mark.asyncio
async def test_download_source_skipped_when_disabled(
    db, db_sync, tmp_path, seed_artist_and_source,
):
    from backend.app.services.download_service import DownloadService
    from backend.app.services.importer import Importer

    _artist, source = seed_artist_and_source
    source.enabled = False
    await db.commit()

    fake_gdl = MagicMock()
    fake_gdl.download = AsyncMock()

    sync_settings = db_sync.execute(
        select(ImportSettings).where(ImportSettings.id == 1)
    ).scalar_one()
    importer = Importer(
        session=db_sync, images_root=tmp_path,
        import_root=tmp_path,
        thumbnailer=Thumbnailer(images_root=tmp_path),
        settings=sync_settings,
    )
    cred_service = CredentialService(db, MagicMock())

    svc = DownloadService(
        async_session=db, sync_session=db_sync,
        gdl=fake_gdl, importer=importer, cred_service=cred_service,
    )
    event_id = await svc.download_source(source.id)

    ev = (await db.execute(
        select(DownloadEvent).where(DownloadEvent.id == event_id)
    )).scalar_one()
    assert ev.status == "skipped"
    assert ev.error == "source disabled"
    fake_gdl.download.assert_not_called()


@pytest.mark.asyncio
async def test_in_flight_idempotency_returns_existing_event(
    db, db_sync, tmp_path, seed_artist_and_source,
):
    from backend.app.services.download_service import DownloadService

    _artist, source = seed_artist_and_source
    running = DownloadEvent(source_id=source.id, status="running")
    db.add(running)
    await db.commit()
    await db.refresh(running)

    fake_gdl = MagicMock()
    fake_gdl.download = AsyncMock()
    importer_stub = MagicMock()
    cred_service = CredentialService(db, MagicMock())

    svc = DownloadService(
        async_session=db, sync_session=db_sync,
        gdl=fake_gdl, importer=importer_stub, cred_service=cred_service,
    )
    event_id = await svc.download_source(source.id)
    assert event_id == running.id
    fake_gdl.download.assert_not_called()


@pytest.mark.asyncio
async def test_patreon_resolved_campaign_id_is_cached(
    db, db_sync, tmp_path, seed_artist_and_source,
):
    """plan #697: the native ingester resolves a Patreon vanity → campaign id
    on the fly (replacing gallery-dl's reactive campaign-id retry). When phase 2
    reports a freshly-resolved id, phase 3 caches it on the source so later runs
    skip the lookup. Stub the ingester to report a resolved id; assert it lands
    in config_overrides."""
    from backend.app.services.download_service import DownloadService
    from backend.app.services.importer import Importer

    _artist, source = seed_artist_and_source

    sync_settings = db_sync.execute(
        select(ImportSettings).where(ImportSettings.id == 1)
    ).scalar_one()
    importer = Importer(
        session=db_sync, images_root=tmp_path,
        import_root=tmp_path,
        thumbnailer=Thumbnailer(images_root=tmp_path),
        settings=sync_settings,
    )
    cred_service = CredentialService(db, MagicMock())

    svc = DownloadService(
        async_session=db, sync_session=db_sync,
        gdl=_fake_gdl_with_result(_make_fake_dl_result(success=True)),
        importer=importer, cred_service=cred_service,
    )
    _stub_patreon_ingester(
        svc, _make_fake_dl_result(success=True, written_paths=[]),
        resolved_campaign_id="99",
    )
    await svc.download_source(source.id)

    overrides = db_sync.execute(
        select(Source.config_overrides).where(Source.id == source.id)
    ).scalar_one()
    assert overrides.get("patreon_campaign_id") == "99"


@pytest.mark.asyncio
async def test_run_download_native_resolves_vanity_and_runs(
    db, db_sync, tmp_path, seed_artist_and_source, monkeypatch,
):
    """run_download (native branch, plan #707 A5): with no cached campaign id,
    resolve the vanity and pass the resolved id straight to the ingester; report
    it back so phase 3 can cache it."""
    from backend.app.services import download_backends as db_mod
    from backend.app.services.gallery_dl import SourceConfig

    _artist, source = seed_artist_and_source

    monkeypatch.setattr(
        db_mod, "resolve_campaign_id_for_source",
        AsyncMock(return_value=("4242", "4242")),
    )
    run_kwargs = {}

    class _FakeIngester:
        def __init__(self, **kw):
            pass

        def run(self, **kw):
            run_kwargs.update(kw)
            return _make_fake_dl_result(success=True, written_paths=[])

    monkeypatch.setattr(db_mod, "PatreonIngester", _FakeIngester)

    ctx = {
        "platform": "patreon",
        "source_id": source.id, "url": "https://patreon.com/alice",
        "artist_slug": "alice", "cookies_path": None, "auth_token": None,
        "config_overrides": {},
    }
    result, resolved = await db_mod.run_download(
        ctx=ctx, source_config=SourceConfig.from_dict({}), skip_value=True,
        mode="tick",
        gdl=_fake_gdl_with_result(_make_fake_dl_result(success=True)),
        sync_session_factory=MagicMock(),
    )
    assert result.success is True
    assert resolved == "4242"
    assert run_kwargs["campaign_id"] == "4242"
    assert run_kwargs["mode"] == "tick"


@pytest.mark.asyncio
async def test_run_download_native_unresolvable_fails_loud(
    db, db_sync, tmp_path, seed_artist_and_source, monkeypatch,
):
    """A campaign id we can't resolve is a loud NOT_FOUND failure, never a
    silent empty success."""
    from backend.app.services import download_backends as db_mod
    from backend.app.services.gallery_dl import ErrorType, SourceConfig

    _artist, source = seed_artist_and_source

    monkeypatch.setattr(
        db_mod, "resolve_campaign_id_for_source",
        AsyncMock(return_value=(None, None)),
    )
    ctx = {
        "platform": "patreon",
        "source_id": source.id, "url": "https://patreon.com/alice",
        "artist_slug": "alice", "cookies_path": None, "auth_token": None,
        "config_overrides": {},
    }
    result, resolved = await db_mod.run_download(
        ctx=ctx, source_config=SourceConfig.from_dict({}), skip_value=True,
        mode="tick", gdl=MagicMock(), sync_session_factory=MagicMock(),
    )
    assert result.success is False
    assert result.error_type == ErrorType.NOT_FOUND
    assert resolved is None


# --- FC-3d: finalize hook updates Source health columns -------------------


async def _seed_source_with_health(db, *, failures=0, last_error=None, suffix="fz"):
    artist = Artist(name=f"alice-{suffix}", slug=f"alice-{suffix}")
    db.add(artist)
    await db.flush()
    source = Source(
        artist_id=artist.id, platform="patreon",
        url=f"https://patreon.com/alice-{suffix}", enabled=True,
        consecutive_failures=failures, last_error=last_error,
    )
    db.add(source)
    await db.flush()
    event = DownloadEvent(source_id=source.id, status="running")
    db.add(event)
    await db.commit()
    return source.id, event.id


@pytest.mark.asyncio
async def test_finalize_ok_resets_failures_and_clears_error(db):
    from backend.app.services.download_service import DownloadService

    source_id, event_id = await _seed_source_with_health(
        db, failures=3, last_error="prev", suffix="ok",
    )
    svc = DownloadService(
        async_session=db, sync_session=None,
        gdl=None, importer=None, cred_service=None,
    )
    await svc._finalize_event_and_source(
        event_id=event_id, source_id=source_id, status="ok",
        error_message=None,
    )

    row = (await db.execute(
        select(
            Source.consecutive_failures, Source.last_error, Source.last_checked_at,
        ).where(Source.id == source_id)
    )).one()
    assert row.consecutive_failures == 0
    assert row.last_error is None
    assert row.last_checked_at is not None


@pytest.mark.asyncio
async def test_finalize_error_increments_failures_and_sets_error(db):
    from backend.app.services.download_service import DownloadService

    source_id, event_id = await _seed_source_with_health(
        db, failures=2, last_error=None, suffix="err",
    )
    svc = DownloadService(
        async_session=db, sync_session=None,
        gdl=None, importer=None, cred_service=None,
    )
    await svc._finalize_event_and_source(
        event_id=event_id, source_id=source_id, status="error",
        error_message="boom",
    )

    row = (await db.execute(
        select(
            Source.consecutive_failures, Source.last_error, Source.last_checked_at,
        ).where(Source.id == source_id)
    )).one()
    assert row.consecutive_failures == 3
    assert row.last_error == "boom"
    assert row.last_checked_at is not None


@pytest.mark.asyncio
async def test_rate_limited_cooldown_honors_retry_after(db, monkeypatch):
    """plan #708 B1: a RATE_LIMITED result with a Retry-After stamps the platform
    cooldown at that duration, clamped to [60, 3600]; no hint → the flat default."""
    from unittest.mock import AsyncMock, MagicMock

    from backend.app.services import download_service as dl_mod
    from backend.app.services.download_service import DownloadService

    source_id, _event_id = await _seed_source_with_health(db, suffix="rl")
    cooldown = AsyncMock()
    monkeypatch.setattr(dl_mod, "set_platform_cooldown", cooldown)
    svc = DownloadService(
        async_session=db, sync_session=None,
        gdl=MagicMock(), importer=MagicMock(), cred_service=MagicMock(),
    )

    async def _health(retry):
        cooldown.reset_mock()
        await svc._update_source_health(
            source_id=source_id, status="error", error_message="rate limited",
            error_type="rate_limited", retry_after_seconds=retry,
        )

    await _health(120.0)
    assert cooldown.await_args.kwargs["seconds"] == 120  # honored as-is
    await _health(5.0)
    assert cooldown.await_args.kwargs["seconds"] == 60  # floored
    await _health(99999.0)
    assert cooldown.await_args.kwargs["seconds"] == 3600  # capped
    await _health(None)
    assert "seconds" not in cooldown.await_args.kwargs  # default cooldown


@pytest.mark.asyncio
async def test_finalize_skipped_preserves_failures_clears_error(db):
    from backend.app.services.download_service import DownloadService

    source_id, event_id = await _seed_source_with_health(
        db, failures=4, last_error="stale", suffix="skip",
    )
    svc = DownloadService(
        async_session=db, sync_session=None,
        gdl=None, importer=None, cred_service=None,
    )
    await svc._finalize_event_and_source(
        event_id=event_id, source_id=source_id, status="skipped",
        error_message=None,
    )

    row = (await db.execute(
        select(
            Source.consecutive_failures, Source.last_error, Source.last_checked_at,
        ).where(Source.id == source_id)
    )).one()
    assert row.consecutive_failures == 4
    assert row.last_error is None
    assert row.last_checked_at is not None


# --- Plan #693: backfill state machine (time-boxed chunks, run-until-done) ---


def _backfill_svc(db, db_sync, tmp_path, result):
    """DownloadService wired with a real importer (so empty written_paths just
    attaches nothing) whose Patreon phase-2 branch is stubbed to return `result`.

    The seeded source is Patreon, so phase 2 routes to the native ingester
    (plan #697); the stub returns the canned result + captures the per-call
    (ctx, source_config, mode). Returns (svc, ingester_calls)."""
    from backend.app.services.download_service import DownloadService
    from backend.app.services.importer import Importer

    images_root = tmp_path / "images"
    sync_settings = db_sync.execute(
        select(ImportSettings).where(ImportSettings.id == 1)
    ).scalar_one()
    importer = Importer(
        session=db_sync, images_root=images_root, import_root=images_root,
        thumbnailer=Thumbnailer(images_root=images_root), settings=sync_settings,
    )
    cred_service = CredentialService(db, CredentialCrypto(tmp_path / "key.b64", bootstrap_ok=True))
    fake_gdl = _fake_gdl_with_result(result)
    svc = DownloadService(
        async_session=db, sync_session=db_sync,
        gdl=fake_gdl, importer=importer, cred_service=cred_service,
    )
    calls = _stub_patreon_ingester(svc, result)
    return svc, calls


@pytest.mark.asyncio
async def test_backfill_chunk_progress_advances_cursor(
    db, db_sync, tmp_path, seed_artist_and_source,
):
    """A running backfill chunk that didn't finish but advanced (new cursor)
    stays 'running', checkpoints the cursor, bumps the chunk counter, and
    spends one safety-cap chunk."""
    _artist, source = seed_artist_and_source
    source.config_overrides = {"_backfill_state": "running"}
    source.backfill_runs_remaining = 5
    await db.commit()

    svc, _ = _backfill_svc(db, db_sync, tmp_path, _make_fake_dl_result(
        success=False, written_paths=[], files_downloaded=0,
        cursor="03:PAGE2:xyz",
    ))
    await svc.download_source(source.id)

    remaining, co = (await db.execute(
        select(Source.backfill_runs_remaining, Source.config_overrides)
        .where(Source.id == source.id)
    )).one()
    assert co.get("_backfill_state") == "running"
    assert co.get("_backfill_cursor") == "03:PAGE2:xyz"
    assert co.get("_backfill_chunks") == 1
    assert remaining == 4


@pytest.mark.asyncio
async def test_backfill_state_running_selects_backfill_mode_and_resumes(
    db, db_sync, tmp_path, seed_artist_and_source,
):
    """state=='running' drives backfill mode (chunk budget) and threads the
    stored cursor to the native ingester as the resume point."""
    from backend.app.services.gallery_dl import BACKFILL_CHUNK_SECONDS

    _artist, source = seed_artist_and_source
    source.config_overrides = {"_backfill_state": "running", "_backfill_cursor": "03:RESUME:here"}
    source.backfill_runs_remaining = 5
    await db.commit()

    svc, calls = _backfill_svc(db, db_sync, tmp_path, _make_fake_dl_result(
        success=False, written_paths=[],
        cursor="03:RESUME2:next",
    ))
    await svc.download_source(source.id)

    assert len(calls) == 1
    assert calls[0]["mode"] == "backfill"
    assert calls[0]["source_config"].resume_cursor == "03:RESUME:here"
    assert calls[0]["source_config"].timeout == BACKFILL_CHUNK_SECONDS

    co = (await db.execute(
        select(Source.config_overrides).where(Source.id == source.id)
    )).scalar_one()
    assert co.get("_backfill_cursor") == "03:RESUME2:next"


@pytest.mark.asyncio
async def test_backfill_clean_exit_marks_complete(
    db, db_sync, tmp_path, seed_artist_and_source,
):
    """A clean rc=0 chunk = gallery-dl reached the bottom → state 'complete',
    cursor cleared, returns to tick mode."""
    _artist, source = seed_artist_and_source
    source.config_overrides = {"_backfill_state": "running", "_backfill_cursor": "03:NEAR:bottom"}
    source.backfill_runs_remaining = 5
    await db.commit()

    svc, _ = _backfill_svc(db, db_sync, tmp_path, _make_fake_dl_result(
        success=True, written_paths=[], files_downloaded=0,
    ))
    await svc.download_source(source.id)

    remaining, co = (await db.execute(
        select(Source.backfill_runs_remaining, Source.config_overrides)
        .where(Source.id == source.id)
    )).one()
    assert co.get("_backfill_state") == "complete"
    assert "_backfill_cursor" not in co
    assert remaining == 0


@pytest.mark.asyncio
async def test_backfill_cap_exhaustion_stalls(
    db, db_sync, tmp_path, seed_artist_and_source,
):
    """A progressing chunk that spends the last safety-cap chunk without
    reaching the bottom pauses as 'stalled' (doesn't loop forever)."""
    _artist, source = seed_artist_and_source
    source.config_overrides = {"_backfill_state": "running"}
    source.backfill_runs_remaining = 1
    await db.commit()

    svc, _ = _backfill_svc(db, db_sync, tmp_path, _make_fake_dl_result(
        success=False, written_paths=[],
        cursor="03:MORE:left",
    ))
    await svc.download_source(source.id)

    remaining, co = (await db.execute(
        select(Source.backfill_runs_remaining, Source.config_overrides)
        .where(Source.id == source.id)
    )).one()
    assert co.get("_backfill_state") == "stalled"
    assert remaining == 0


@pytest.mark.asyncio
async def test_backfill_stuck_guard_stalls_after_two_no_advance(
    db, db_sync, tmp_path, seed_artist_and_source,
):
    """Two consecutive chunks that fail to advance the cursor → 'stalled',
    cursor cleared, so a wedged walk can't re-strand forever."""
    from unittest.mock import MagicMock

    from backend.app.services.download_service import DownloadService

    _artist, source = seed_artist_and_source
    source.config_overrides = {"_backfill_state": "running", "_backfill_cursor": "stuck"}
    source.backfill_runs_remaining = 5
    await db.commit()

    svc = DownloadService(
        async_session=db, sync_session=db_sync,
        gdl=MagicMock(), importer=MagicMock(), cred_service=MagicMock(),
    )
    ctx = {
        "source_id": source.id, "platform": "patreon",
        "config_overrides": {"_backfill_state": "running", "_backfill_cursor": "stuck"},
        "backfill_runs_remaining": 5,
    }
    stuck = _make_fake_dl_result(success=False, cursor="stuck")

    await svc._apply_backfill_lifecycle(ctx, stuck)
    await db.commit()
    co = (await db.execute(
        select(Source.config_overrides).where(Source.id == source.id)
    )).scalar_one()
    assert co.get("_backfill_state") == "running"
    assert co.get("_backfill_cursor_stalls") == 1

    ctx["config_overrides"] = dict(co)
    await svc._apply_backfill_lifecycle(ctx, stuck)
    await db.commit()
    co2 = (await db.execute(
        select(Source.config_overrides).where(Source.id == source.id)
    )).scalar_one()
    assert co2.get("_backfill_state") == "stalled"
    assert "_backfill_cursor" not in co2


@pytest.mark.asyncio
async def test_backfill_lifecycle_leaves_posts_to_ingester(
    db, db_sync, tmp_path, seed_artist_and_source,
):
    """plan #704 (#5), revised: _backfill_posts is OWNED by the ingester now (it
    writes a monotonic absolute live mid-walk), so the post-chunk lifecycle must
    NOT touch it — it carries the ingester's committed value forward unchanged."""
    from unittest.mock import MagicMock

    from backend.app.services.download_service import DownloadService

    _artist, source = seed_artist_and_source
    source.config_overrides = {"_backfill_state": "running", "_backfill_posts": 12}
    source.backfill_runs_remaining = 5
    await db.commit()

    svc = DownloadService(
        async_session=db, sync_session=db_sync,
        gdl=MagicMock(), importer=MagicMock(), cred_service=MagicMock(),
    )
    ctx = {
        "source_id": source.id, "platform": "patreon",
        "config_overrides": {"_backfill_state": "running", "_backfill_posts": 12},
        "backfill_runs_remaining": 5,
    }
    await svc._apply_backfill_lifecycle(
        ctx, _make_fake_dl_result(success=False, cursor="C1", posts_processed=10),
    )
    await db.commit()
    co = (await db.execute(
        select(Source.config_overrides).where(Source.id == source.id)
    )).scalar_one()
    assert co.get("_backfill_posts") == 12  # untouched — the ingester owns it


@pytest.mark.asyncio
async def test_backfill_timeout_chunk_reclassified_to_ok(
    db, db_sync, tmp_path, seed_artist_and_source,
):
    """A backfill chunk that hit its time-box (TIMEOUT) but advanced is
    reclassified to PARTIAL → event status 'ok' (progress, not error), and
    consecutive_failures is not bumped."""
    from backend.app.services.gallery_dl import ErrorType

    _artist, source = seed_artist_and_source
    source.config_overrides = {"_backfill_state": "running"}
    source.backfill_runs_remaining = 5
    await db.commit()

    svc, _ = _backfill_svc(db, db_sync, tmp_path, _make_fake_dl_result(
        success=False, files_downloaded=3,
        error_type=ErrorType.TIMEOUT,
        error_message="Download timed out",
        cursor="03:NEXT:page",
    ))
    event_id = await svc.download_source(source.id)

    ev = (await db.execute(
        select(DownloadEvent).where(DownloadEvent.id == event_id)
    )).scalar_one()
    assert ev.status == "ok"
    failures = (await db.execute(
        select(Source.consecutive_failures).where(Source.id == source.id)
    )).scalar_one()
    assert failures == 0


@pytest.mark.asyncio
async def test_tick_mode_when_not_running_leaves_state_untouched(
    db, db_sync, tmp_path, seed_artist_and_source,
):
    """No _backfill_state → not backfilling; the lifecycle is a no-op and the
    counter/state aren't mutated."""
    _artist, source = seed_artist_and_source
    assert (source.config_overrides or {}).get("_backfill_state") is None

    svc, _ = _backfill_svc(db, db_sync, tmp_path, _make_fake_dl_result(
        success=True, written_paths=[], files_downloaded=0,
    ))
    await svc.download_source(source.id)

    co = (await db.execute(
        select(Source.config_overrides).where(Source.id == source.id)
    )).scalar_one()
    assert (co or {}).get("_backfill_state") is None


@pytest.mark.asyncio
async def test_recovery_mode_selected_and_flag_cleared_on_complete(
    db, db_sync, tmp_path, seed_artist_and_source,
):
    """plan #697: `_backfill_bypass_seen` alongside a running backfill selects
    recovery mode (ingester bypasses the seen-ledger). A clean rc=0 walk
    completes the shared lifecycle AND clears the bypass flag so the next tick
    honors the ledger again."""
    _artist, source = seed_artist_and_source
    source.config_overrides = {"_backfill_state": "running", "_backfill_bypass_seen": True}
    source.backfill_runs_remaining = 5
    await db.commit()

    svc, calls = _backfill_svc(db, db_sync, tmp_path, _make_fake_dl_result(
        success=True, written_paths=[], files_downloaded=0,
    ))
    await svc.download_source(source.id)

    assert calls[0]["mode"] == "recovery"
    co = (await db.execute(
        select(Source.config_overrides).where(Source.id == source.id)
    )).scalar_one()
    assert co.get("_backfill_state") == "complete"
    assert "_backfill_bypass_seen" not in co


@pytest.mark.asyncio
async def test_recapture_mode_selected_and_flag_cleared_on_complete(
    db, db_sync, tmp_path, seed_artist_and_source,
):
    """#830: `_backfill_recapture` alongside a running backfill selects recapture
    mode. A clean rc=0 walk completes the shared lifecycle AND clears the
    recapture flag so the next tick is a plain tick again."""
    _artist, source = seed_artist_and_source
    source.config_overrides = {"_backfill_state": "running", "_backfill_recapture": True}
    source.backfill_runs_remaining = 5
    await db.commit()

    svc, calls = _backfill_svc(db, db_sync, tmp_path, _make_fake_dl_result(
        success=True, written_paths=[], files_downloaded=0,
    ))
    await svc.download_source(source.id)

    assert calls[0]["mode"] == "recapture"
    co = (await db.execute(
        select(Source.config_overrides).where(Source.id == source.id)
    )).scalar_one()
    assert co.get("_backfill_state") == "complete"
    assert "_backfill_recapture" not in co


@pytest.mark.asyncio
async def test_partial_error_type_maps_to_ok_status(
    db, db_sync, tmp_path, seed_artist_and_source,
):
    """A non-zero gallery-dl exit accompanied by PARTIAL error_type
    (real files were downloaded before the run was cut short) is treated
    as status=ok by the orchestrator — next tick continues."""
    from backend.app.services.download_service import DownloadService
    from backend.app.services.gallery_dl import ErrorType
    from backend.app.services.importer import Importer

    _artist, source = seed_artist_and_source

    images_root = tmp_path / "images"
    f1 = images_root / "alice" / "patreon" / "post" / "a.jpg"
    _make_jpg(f1, split="h")

    fake_result = _make_fake_dl_result(
        success=False, written_paths=[str(f1)], files_downloaded=1,
        error_type=ErrorType.PARTIAL,
        error_message="Downloaded 1 file; run did not complete in budget",
        stdout=f"{f1}\n",
    )
    fake_gdl = _fake_gdl_with_result(fake_result)

    sync_settings = db_sync.execute(
        select(ImportSettings).where(ImportSettings.id == 1)
    ).scalar_one()
    importer = Importer(
        session=db_sync, images_root=images_root, import_root=images_root,
        thumbnailer=Thumbnailer(images_root=images_root), settings=sync_settings,
    )
    cred_service = CredentialService(db, CredentialCrypto(tmp_path / "key.b64", bootstrap_ok=True))
    svc = DownloadService(
        async_session=db, sync_session=db_sync,
        gdl=fake_gdl, importer=importer, cred_service=cred_service,
    )
    _stub_patreon_ingester(svc, fake_result)
    event_id = await svc.download_source(source.id)

    ev = (await db.execute(
        select(DownloadEvent).where(DownloadEvent.id == event_id)
    )).scalar_one()
    assert ev.status == "ok"
    assert ev.error is None
    # And the source's failure counter isn't bumped — PARTIAL isn't a failure.
    src_after = (await db.execute(
        select(Source.consecutive_failures).where(Source.id == source.id)
    )).scalar_one()
    assert src_after == 0


@pytest.mark.asyncio
async def test_download_enqueues_thumbnail_and_ml_per_attached_image(
    db, db_sync, tmp_path, seed_artist_and_source, monkeypatch,
):
    """Operator-flagged 2026-06-01: downloaded images stayed at
    thumbnail_path=NULL until periodic backfill swept them up, surfacing
    as broken-thumbnail tiles in the gallery. Importer.attach_in_place
    deliberately skips inline generation; the calling code MUST enqueue
    the thumbnail + ML tasks per attached image (matching the pattern in
    tasks/import_file.py)."""
    from backend.app.services.download_service import DownloadService
    from backend.app.services.importer import Importer

    _artist, source = seed_artist_and_source

    images_root = tmp_path / "images"
    f1 = images_root / "alice" / "patreon" / "post" / "a.jpg"
    f2 = images_root / "alice" / "patreon" / "post" / "b.jpg"
    _make_jpg(f1, split="h")
    _make_jpg(f2, split="v")

    result = _make_fake_dl_result(
        success=True, written_paths=[str(f1), str(f2)],
        files_downloaded=2, stdout=f"{f1}\n{f2}\n",
    )
    fake_gdl = _fake_gdl_with_result(result)

    sync_settings = db_sync.execute(
        select(ImportSettings).where(ImportSettings.id == 1)
    ).scalar_one()
    sync_settings.phash_threshold = 0
    importer = Importer(
        session=db_sync, images_root=images_root, import_root=images_root,
        thumbnailer=Thumbnailer(images_root=images_root), settings=sync_settings,
    )
    cred_service = CredentialService(db, CredentialCrypto(tmp_path / "key.b64", bootstrap_ok=True))

    # Capture the IDs that the orchestrator hands off to each Celery task.
    # The .delay() shim runs inside DownloadService._phase3_persist (lazy
    # imports under ..tasks.thumbnail / ..tasks.ml), so monkeypatch the
    # symbols on those modules.
    thumb_calls: list[int] = []
    ml_calls: list[int] = []
    from backend.app.tasks import ml as ml_mod
    from backend.app.tasks import thumbnail as thumb_mod
    monkeypatch.setattr(
        thumb_mod.generate_thumbnail, "delay",
        lambda image_id: thumb_calls.append(image_id),
    )
    monkeypatch.setattr(
        ml_mod.embed_image, "delay",
        lambda image_id: ml_calls.append(image_id),
    )

    svc = DownloadService(
        async_session=db, sync_session=db_sync,
        gdl=fake_gdl, importer=importer, cred_service=cred_service,
    )
    _stub_patreon_ingester(svc, result)
    await svc.download_source(source.id)

    # Two files attached → two thumbnail enqueues + two ML enqueues, IDs
    # match the actually-imported records (not ad-hoc — drawn from the
    # importer's returned image_id so future supersede paths stay covered).
    assert len(thumb_calls) == 2
    assert len(ml_calls) == 2
    assert sorted(thumb_calls) == sorted(ml_calls)


@pytest.mark.asyncio
async def test_releases_db_connections_before_subprocess(
    db, db_sync, tmp_path, seed_artist_and_source, monkeypatch,
):
    """Phase 1's DB connections must be released BEFORE the (multi-minute)
    phase-2 fetch, so they don't idle-die and strand phase 3 with
    ConnectionDoesNotExistError (Anduo #40014). Spy on the async session close
    and assert it happened before the phase-2 work (here the native Patreon
    ingester) runs; phase 3 must still finalize the event."""
    from backend.app.services.download_service import DownloadService
    from backend.app.services.importer import Importer

    _artist, source = seed_artist_and_source

    closed = {"async": False}
    orig_close = db.close

    async def spy_close():
        closed["async"] = True
        await orig_close()

    monkeypatch.setattr(db, "close", spy_close)

    seen = {}
    fake_gdl = _fake_gdl_with_result(
        _make_fake_dl_result(success=True, written_paths=[], stdout="")
    )

    sync_settings = db_sync.execute(
        select(ImportSettings).where(ImportSettings.id == 1)
    ).scalar_one()
    importer = Importer(
        session=db_sync, images_root=tmp_path, import_root=tmp_path,
        thumbnailer=Thumbnailer(images_root=tmp_path), settings=sync_settings,
    )
    cred_service = CredentialService(
        db, CredentialCrypto(tmp_path / "key.b64", bootstrap_ok=True)
    )

    svc = DownloadService(
        async_session=db, sync_session=db_sync,
        gdl=fake_gdl, importer=importer, cred_service=cred_service,
    )

    async def fake_ingest(*, ctx, source_config, skip_value, mode):
        seen["closed_before_phase2"] = closed["async"]
        return _make_fake_dl_result(success=True, written_paths=[], stdout=""), None

    svc._run_download = fake_ingest
    event_id = await svc.download_source(source.id)

    assert seen["closed_before_phase2"] is True
    ev = (await db.execute(
        select(DownloadEvent).where(DownloadEvent.id == event_id)
    )).scalar_one()
    assert ev.status == "ok"


@pytest.mark.asyncio
async def test_async_task_engine_uses_nullpool():
    """The per-task async engine must use NullPool so phase 3 re-acquires a
    fresh connection instead of a pooled one the server reaped during the
    long subprocess (Anduo #40014)."""
    from sqlalchemy.pool import NullPool

    from backend.app.tasks._async_session import async_session_factory

    _factory, engine = async_session_factory()
    try:
        assert isinstance(engine.sync_engine.pool, NullPool)
    finally:
        await engine.dispose()
