"""Tests for the DownloadEngine skeleton (Phase B).

Pinning the engine's state-storage contract: add/update/remove,
per-source iteration, find-by-id, plugin registration, lock-held
mutations vs lock-released reads. Future phases (C/D/E/F) bolt
behavior on top of this surface — these tests stay green and act
as the regression net while behavior moves in.
"""

from __future__ import annotations

import threading

import pytest

from core.download_engine import DownloadEngine


# ---------------------------------------------------------------------------
# Plugin registration
# ---------------------------------------------------------------------------


def test_register_plugin_stores_under_source_name():
    engine = DownloadEngine()
    plugin = object()
    engine.register_plugin('soulseek', plugin)
    assert engine.get_plugin('soulseek') is plugin
    assert 'soulseek' in engine.registered_sources()


def test_get_plugin_returns_none_for_unknown_source():
    engine = DownloadEngine()
    assert engine.get_plugin('made_up') is None


def test_register_plugin_swallows_set_engine_failure(caplog):
    """Per JohnBaumb: if a plugin's ``set_engine`` callback raises,
    registration shouldn't take down the engine — the failure gets
    logged + the plugin stays registered. The plugin's own download()
    method is responsible for surfacing the missing-engine state to
    the user (see ``test_download_raises_when_engine_not_wired`` per
    source). Pinning this so a future refactor can't accidentally
    propagate the set_engine exception and crash boot.
    """
    class _BrokenSetEngine:
        def set_engine(self, engine):
            raise RuntimeError("plugin's set_engine blew up")

    engine = DownloadEngine()
    plugin = _BrokenSetEngine()
    # Should not raise.
    engine.register_plugin('flaky', plugin)

    # Plugin still registered + lookupable.
    assert engine.get_plugin('flaky') is plugin
    assert 'flaky' in engine.registered_sources()


def test_register_plugin_overwrites_on_duplicate(caplog):
    """Re-registering under the same name overwrites and warns. Not a
    common path but useful so test fixtures that build a fresh engine
    can swap a mock plugin in without setup gymnastics."""
    engine = DownloadEngine()
    first = object()
    second = object()
    engine.register_plugin('soulseek', first)
    engine.register_plugin('soulseek', second)
    assert engine.get_plugin('soulseek') is second


# ---------------------------------------------------------------------------
# Active-download state — add / get / update / remove
# ---------------------------------------------------------------------------


def test_add_record_inserts_under_composite_key():
    engine = DownloadEngine()
    engine.add_record('youtube', 'dl-1', {'state': 'Initializing', 'progress': 0.0})

    rec = engine.get_record('youtube', 'dl-1')
    assert rec is not None
    assert rec['state'] == 'Initializing'
    assert rec['progress'] == 0.0


def test_get_record_returns_shallow_copy():
    """Mutating the returned dict must NOT affect engine state.
    Engine reads should be safe to hold / iterate without locks."""
    engine = DownloadEngine()
    engine.add_record('youtube', 'dl-1', {'state': 'Initializing'})

    rec = engine.get_record('youtube', 'dl-1')
    rec['state'] = 'TamperedByCaller'

    # Engine state still has the original.
    fresh = engine.get_record('youtube', 'dl-1')
    assert fresh['state'] == 'Initializing'


def test_update_record_applies_partial_patch():
    engine = DownloadEngine()
    engine.add_record('tidal', 'dl-2', {'state': 'Initializing', 'progress': 0.0,
                                         'file_path': None})

    engine.update_record('tidal', 'dl-2', {'state': 'Completed, Succeeded',
                                            'progress': 100.0,
                                            'file_path': '/tmp/song.flac'})

    rec = engine.get_record('tidal', 'dl-2')
    assert rec['state'] == 'Completed, Succeeded'
    assert rec['progress'] == 100.0
    assert rec['file_path'] == '/tmp/song.flac'


def test_update_record_is_noop_when_record_removed():
    """If a record was removed (e.g. user cancelled mid-download),
    the worker thread's late update is silently dropped — never
    raises. Mirrors the per-client `if download_id in active_downloads`
    guard pattern that's all over the existing clients."""
    engine = DownloadEngine()
    engine.add_record('tidal', 'dl-2', {'state': 'Initializing'})
    engine.remove_record('tidal', 'dl-2')

    # Should not raise.
    engine.update_record('tidal', 'dl-2', {'state': 'Completed, Succeeded'})

    assert engine.get_record('tidal', 'dl-2') is None


def test_remove_record_returns_removed_record():
    engine = DownloadEngine()
    engine.add_record('qobuz', 'dl-3', {'state': 'InProgress'})

    removed = engine.remove_record('qobuz', 'dl-3')
    assert removed is not None
    assert removed['state'] == 'InProgress'
    assert engine.get_record('qobuz', 'dl-3') is None


def test_remove_record_returns_none_when_missing():
    engine = DownloadEngine()
    assert engine.remove_record('qobuz', 'never-existed') is None


def test_per_source_locks_dont_block_each_other():
    """Per JohnBaumb: each source must have its own lock so a long-held
    write on one source doesn't block writes on another. Pre-refactor
    each client owned its own download lock; the engine has to match
    that semantic.

    Hold source-A's lock from one thread, then mutate source-B from
    another thread. Source-B's mutation must complete promptly even
    while source-A is locked.
    """
    engine = DownloadEngine()
    engine.add_record('youtube', 'yt-1', {'state': 'InProgress'})
    engine.add_record('tidal', 'td-1', {'state': 'InProgress'})

    held = threading.Event()
    release = threading.Event()
    other_done = threading.Event()

    def hold_youtube_lock():
        with engine._source_lock('youtube'):
            held.set()
            release.wait(timeout=2.0)

    def update_tidal_while_youtube_held():
        held.wait(timeout=1.0)
        engine.update_record('tidal', 'td-1', {'state': 'Completed, Succeeded'})
        other_done.set()

    holder = threading.Thread(target=hold_youtube_lock)
    other = threading.Thread(target=update_tidal_while_youtube_held)
    holder.start()
    other.start()

    # Tidal write must complete even though YouTube's lock is held.
    assert other_done.wait(timeout=1.0), (
        "Tidal mutation blocked by YouTube's lock — sources are not "
        "independently shardable"
    )

    release.set()
    holder.join()
    other.join()
    assert engine.get_record('tidal', 'td-1')['state'] == 'Completed, Succeeded'


def test_remove_record_drops_empty_source_bucket():
    """Per JohnBaumb: nested layout makes per-source iteration
    O(source_records). Removing the last record for a source must
    also drop the empty source bucket so future iter_records_for_source
    calls don't see a stale source key."""
    engine = DownloadEngine()
    engine.add_record('youtube', 'yt-1', {'title': 'A'})
    engine.remove_record('youtube', 'yt-1')

    # Source bucket gone — internal state matches the contract.
    assert 'youtube' not in engine._records
    # Public surface still answers correctly.
    assert list(engine.iter_records_for_source('youtube')) == []
    assert engine.get_record('youtube', 'yt-1') is None


# ---------------------------------------------------------------------------
# Iteration
# ---------------------------------------------------------------------------


def test_iter_records_for_source_filters_correctly():
    engine = DownloadEngine()
    engine.add_record('youtube', 'yt-1', {'title': 'A'})
    engine.add_record('youtube', 'yt-2', {'title': 'B'})
    engine.add_record('tidal', 'td-1', {'title': 'C'})

    yt_records = list(engine.iter_records_for_source('youtube'))
    assert len(yt_records) == 2
    assert {r['title'] for r in yt_records} == {'A', 'B'}

    td_records = list(engine.iter_records_for_source('tidal'))
    assert len(td_records) == 1
    assert td_records[0]['title'] == 'C'


def test_iter_yields_shallow_copies():
    """Iteration returns COPIES — caller can hold the list and mutate
    each record without affecting engine state. Important: future
    Phase B3's `get_all_downloads` will iterate then build
    DownloadStatus objects from the snapshots."""
    engine = DownloadEngine()
    engine.add_record('youtube', 'yt-1', {'title': 'A'})

    snapshot = list(engine.iter_records_for_source('youtube'))
    snapshot[0]['title'] = 'TAMPERED'

    fresh = engine.get_record('youtube', 'yt-1')
    assert fresh['title'] == 'A'


# ---------------------------------------------------------------------------
# Thread safety — basic concurrent-mutation smoke
# ---------------------------------------------------------------------------


def test_concurrent_adds_dont_lose_records():
    """Hammer the engine with concurrent add_record from multiple
    threads. With proper locking, every record lands in state.
    Future Phase C BackgroundDownloadWorker spawns N threads doing
    exactly this kind of mutation."""
    engine = DownloadEngine()

    def add_records(source, base):
        for i in range(50):
            engine.add_record(source, f'{base}-{i}', {'i': i})

    threads = [
        threading.Thread(target=add_records, args=(f'src-{n}', f'dl-{n}'))
        for n in range(4)
    ]
    for t in threads:
        t.start()
    for t in threads:
        t.join()

    total = sum(
        1
        for n in range(4)
        for _ in engine.iter_records_for_source(f'src-{n}')
    )
    assert total == 4 * 50  # 200 records, none lost


# ---------------------------------------------------------------------------
# Cross-source query dispatch (Phase B2)
# ---------------------------------------------------------------------------


def _run_async(coro):
    import asyncio
    loop = asyncio.new_event_loop()
    try:
        return loop.run_until_complete(coro)
    finally:
        loop.close()


class _FakePlugin:
    """Minimal plugin double for engine query tests. Exposes the
    methods engine.get_all_downloads / get_download_status /
    cancel_download / clear_all_completed_downloads call."""

    def __init__(self, name, configured=True, downloads=None,
                 cancel_result=True, clear_result=True):
        self.name = name
        self._configured = configured
        self._downloads = downloads or []
        self._cancel_result = cancel_result
        self._clear_result = clear_result
        self.cancel_calls = []
        self.clear_calls = 0

    def is_configured(self):
        return self._configured

    async def get_all_downloads(self):
        return list(self._downloads)

    async def get_download_status(self, download_id):
        for d in self._downloads:
            if getattr(d, 'id', None) == download_id:
                return d
        return None

    async def cancel_download(self, download_id, source_hint, remove):
        self.cancel_calls.append((download_id, source_hint, remove))
        return self._cancel_result

    async def clear_all_completed_downloads(self):
        self.clear_calls += 1
        return self._clear_result


class _FakeStatus:
    def __init__(self, id, source):
        self.id = id
        self.source = source


def test_engine_get_all_downloads_aggregates_across_plugins():
    """Engine concatenates every plugin's get_all_downloads output —
    same behavior as the legacy orchestrator."""
    engine = DownloadEngine()
    yt_plugin = _FakePlugin('youtube', downloads=[_FakeStatus('yt-1', 'youtube')])
    td_plugin = _FakePlugin('tidal', downloads=[_FakeStatus('td-1', 'tidal'),
                                                  _FakeStatus('td-2', 'tidal')])
    engine.register_plugin('youtube', yt_plugin)
    engine.register_plugin('tidal', td_plugin)

    result = _run_async(engine.get_all_downloads())
    assert len(result) == 3
    assert {r.id for r in result} == {'yt-1', 'td-1', 'td-2'}


def test_engine_get_all_downloads_excludes_dont_invoke_plugin():
    """Per JohnBaumb: the monitor calls engine.get_all_downloads(
    exclude=('soulseek',)) AFTER already pulling slskd transfers via
    the transfers/downloads endpoint. The whole point of the exclude
    is to NOT touch soulseek's plugin a second time — so the plugin's
    get_all_downloads must literally not be called when its name is
    in the exclude list. Pin that semantic explicitly (a test that
    just checks IDs would pass even if soulseek was called and
    returned [])."""
    engine = DownloadEngine()
    sl_plugin = _FakePlugin('soulseek', downloads=[_FakeStatus('sl-1', 'soulseek')])
    yt_plugin = _FakePlugin('youtube', downloads=[_FakeStatus('yt-1', 'youtube')])
    engine.register_plugin('soulseek', sl_plugin)
    engine.register_plugin('youtube', yt_plugin)

    # Sentinel — flips True if get_all_downloads is called on soulseek.
    soulseek_called = []
    original_get_all = sl_plugin.get_all_downloads
    async def _tracking_get_all():
        soulseek_called.append(True)
        return await original_get_all()
    sl_plugin.get_all_downloads = _tracking_get_all

    _run_async(engine.get_all_downloads(exclude=('soulseek',)))
    assert soulseek_called == [], (
        "soulseek's get_all_downloads was invoked despite being in exclude — "
        "monitor would still double-fetch slskd"
    )


def test_engine_get_all_downloads_skips_excluded_sources():
    """Per JohnBaumb: monitor pulls slskd transfers via the
    transfers/downloads endpoint earlier in its loop, so engine
    aggregation must skip soulseek to avoid double-fetching."""
    engine = DownloadEngine()
    sl_plugin = _FakePlugin('soulseek', downloads=[_FakeStatus('sl-1', 'soulseek')])
    yt_plugin = _FakePlugin('youtube', downloads=[_FakeStatus('yt-1', 'youtube')])
    td_plugin = _FakePlugin('tidal', downloads=[_FakeStatus('td-1', 'tidal')])
    engine.register_plugin('soulseek', sl_plugin)
    engine.register_plugin('youtube', yt_plugin)
    engine.register_plugin('tidal', td_plugin)

    result = _run_async(engine.get_all_downloads(exclude=('soulseek',)))
    ids = {r.id for r in result}
    assert ids == {'yt-1', 'td-1'}
    assert 'sl-1' not in ids


def test_engine_get_all_downloads_swallows_per_plugin_exceptions():
    """One plugin throwing must NOT take down the whole list — same
    defensive behavior as the legacy orchestrator (matched by
    `try ... except: pass` on every iteration)."""
    engine = DownloadEngine()

    class _BrokenPlugin:
        async def get_all_downloads(self):
            raise RuntimeError("boom")

    yt_plugin = _FakePlugin('youtube', downloads=[_FakeStatus('yt-1', 'youtube')])
    engine.register_plugin('broken', _BrokenPlugin())
    engine.register_plugin('youtube', yt_plugin)

    result = _run_async(engine.get_all_downloads())
    assert [r.id for r in result] == ['yt-1']


def test_engine_get_download_status_returns_first_match():
    engine = DownloadEngine()
    yt_plugin = _FakePlugin('youtube', downloads=[_FakeStatus('shared', 'youtube')])
    td_plugin = _FakePlugin('tidal', downloads=[])
    engine.register_plugin('youtube', yt_plugin)
    engine.register_plugin('tidal', td_plugin)

    result = _run_async(engine.get_download_status('shared'))
    assert result is not None
    assert result.id == 'shared'


def test_engine_cancel_routes_streaming_source_directly():
    """When source_hint is a known streaming-source name (not
    'soulseek'), engine routes the cancel to that specific plugin
    only — doesn't ask every other plugin first."""
    engine = DownloadEngine()
    yt_plugin = _FakePlugin('youtube')
    td_plugin = _FakePlugin('tidal')
    engine.register_plugin('youtube', yt_plugin)
    engine.register_plugin('tidal', td_plugin)

    _run_async(engine.cancel_download('dl-1', 'tidal', remove=False))
    assert yt_plugin.cancel_calls == []
    assert td_plugin.cancel_calls == [('dl-1', 'tidal', False)]


def test_engine_cancel_routes_unknown_source_hint_to_soulseek():
    """A username that's NOT in the plugin registry is a real
    Soulseek peer name — route to the soulseek plugin."""
    engine = DownloadEngine()
    sl_plugin = _FakePlugin('soulseek')
    yt_plugin = _FakePlugin('youtube')
    engine.register_plugin('soulseek', sl_plugin)
    engine.register_plugin('youtube', yt_plugin)

    _run_async(engine.cancel_download('dl-1', 'random_peer_username', remove=False))
    assert sl_plugin.cancel_calls == [('dl-1', 'random_peer_username', False)]
    assert yt_plugin.cancel_calls == []


def test_engine_cancel_falls_back_to_iterating_all_plugins_without_hint():
    """No source hint → ask every plugin until one accepts the
    cancel (returns True). Mirrors legacy orchestrator behavior."""
    engine = DownloadEngine()
    yt_plugin = _FakePlugin('youtube', cancel_result=False)
    td_plugin = _FakePlugin('tidal', cancel_result=True)
    engine.register_plugin('youtube', yt_plugin)
    engine.register_plugin('tidal', td_plugin)

    result = _run_async(engine.cancel_download('dl-1', None, remove=False))
    assert result is True
    # Both plugins were asked; tidal accepted.
    assert len(yt_plugin.cancel_calls) == 1
    assert len(td_plugin.cancel_calls) == 1


def test_engine_clear_all_skips_unconfigured_plugins():
    """Unconfigured plugins are silently skipped (no API call, no
    error) — matches legacy orchestrator's defensive handling."""
    engine = DownloadEngine()
    configured = _FakePlugin('youtube', configured=True, clear_result=True)
    unconfigured = _FakePlugin('tidal', configured=False)
    engine.register_plugin('youtube', configured)
    engine.register_plugin('tidal', unconfigured)

    result = _run_async(engine.clear_all_completed_downloads())
    assert result is True
    assert configured.clear_calls == 1
    assert unconfigured.clear_calls == 0


def test_engine_clear_all_returns_false_when_any_configured_plugin_fails():
    engine = DownloadEngine()
    failing = _FakePlugin('youtube', configured=True, clear_result=False)
    engine.register_plugin('youtube', failing)

    result = _run_async(engine.clear_all_completed_downloads())
    assert result is False


# ---------------------------------------------------------------------------
# Hybrid fallback (Phase F)
# ---------------------------------------------------------------------------


class _FakeSearchPlugin:
    def __init__(self, name, configured=True, search_result=None, raises=None):
        self.name = name
        self._configured = configured
        self._search_result = search_result if search_result is not None else ([], [])
        self._raises = raises
        self.search_calls = 0

    def is_configured(self):
        return self._configured

    async def search(self, query, timeout=None, progress_callback=None):
        self.search_calls += 1
        if self._raises:
            raise self._raises
        return self._search_result


class _FakeDownloadPlugin:
    def __init__(self, name, configured=True, download_result=None, raises=None):
        self.name = name
        self._configured = configured
        self._download_result = download_result
        self._raises = raises
        self.download_calls = []

    def is_configured(self):
        return self._configured

    async def download(self, username, filename, file_size):
        self.download_calls.append((username, filename, file_size))
        if self._raises:
            raise self._raises
        return self._download_result


def test_search_with_fallback_returns_first_non_empty_result():
    engine = DownloadEngine()
    yt = _FakeSearchPlugin('youtube', search_result=([], []))
    td = _FakeSearchPlugin('tidal', search_result=(['track1'], []))
    qz = _FakeSearchPlugin('qobuz', search_result=(['track2'], []))
    engine.register_plugin('youtube', yt)
    engine.register_plugin('tidal', td)
    engine.register_plugin('qobuz', qz)

    tracks, _ = _run_async(engine.search_with_fallback('q', ['youtube', 'tidal', 'qobuz']))
    assert tracks == ['track1']
    # Tidal short-circuits — qobuz never queried.
    assert yt.search_calls == 1
    assert td.search_calls == 1
    assert qz.search_calls == 0


def test_search_with_fallback_skips_unconfigured_plugins():
    engine = DownloadEngine()
    yt = _FakeSearchPlugin('youtube', configured=False)
    td = _FakeSearchPlugin('tidal', configured=True, search_result=(['hit'], []))
    engine.register_plugin('youtube', yt)
    engine.register_plugin('tidal', td)

    tracks, _ = _run_async(engine.search_with_fallback('q', ['youtube', 'tidal']))
    assert tracks == ['hit']
    assert yt.search_calls == 0  # skipped


def test_search_with_fallback_continues_after_per_source_exception():
    engine = DownloadEngine()
    yt = _FakeSearchPlugin('youtube', raises=RuntimeError("yt down"))
    td = _FakeSearchPlugin('tidal', search_result=(['fallback-hit'], []))
    engine.register_plugin('youtube', yt)
    engine.register_plugin('tidal', td)

    tracks, _ = _run_async(engine.search_with_fallback('q', ['youtube', 'tidal']))
    assert tracks == ['fallback-hit']


def test_search_with_fallback_returns_empty_when_chain_exhausted():
    engine = DownloadEngine()
    yt = _FakeSearchPlugin('youtube', search_result=([], []))
    td = _FakeSearchPlugin('tidal', search_result=([], []))
    engine.register_plugin('youtube', yt)
    engine.register_plugin('tidal', td)

    tracks, _ = _run_async(engine.search_with_fallback('q', ['youtube', 'tidal']))
    assert tracks == []
    assert yt.search_calls == 1
    assert td.search_calls == 1


def test_download_with_fallback_returns_first_accepted_download_id():
    """Phase F bug fix: legacy hybrid download routed to one source
    via username hint with no retry. Engine now falls through chain."""
    engine = DownloadEngine()
    yt = _FakeDownloadPlugin('youtube', download_result=None)  # refuses
    td = _FakeDownloadPlugin('tidal', download_result='td-id')
    engine.register_plugin('youtube', yt)
    engine.register_plugin('tidal', td)

    result = _run_async(engine.download_with_fallback(
        'youtube', 'v||t', 0, ['youtube', 'tidal'],
    ))
    assert result == 'td-id'
    assert len(yt.download_calls) == 1  # tried first
    assert len(td.download_calls) == 1  # took over


def test_download_with_fallback_promotes_username_hint_to_head():
    """A username hint that matches a source-chain entry tries that
    source FIRST regardless of declared chain order."""
    engine = DownloadEngine()
    yt = _FakeDownloadPlugin('youtube', download_result='yt-id')
    td = _FakeDownloadPlugin('tidal', download_result='td-id')
    engine.register_plugin('youtube', yt)
    engine.register_plugin('tidal', td)

    # Chain says tidal-first, but username hint promotes youtube.
    result = _run_async(engine.download_with_fallback(
        'youtube', 'v||t', 0, ['tidal', 'youtube'],
    ))
    assert result == 'yt-id'
    assert len(yt.download_calls) == 1
    assert len(td.download_calls) == 0  # never reached


def test_download_with_fallback_returns_none_when_all_refuse():
    engine = DownloadEngine()
    yt = _FakeDownloadPlugin('youtube', download_result=None)
    td = _FakeDownloadPlugin('tidal', download_result=None)
    engine.register_plugin('youtube', yt)
    engine.register_plugin('tidal', td)

    result = _run_async(engine.download_with_fallback(
        'youtube', 'v||t', 0, ['youtube', 'tidal'],
    ))
    assert result is None
    assert len(yt.download_calls) == 1
    assert len(td.download_calls) == 1


def test_download_with_fallback_continues_past_exception():
    engine = DownloadEngine()
    yt = _FakeDownloadPlugin('youtube', raises=RuntimeError("yt died"))
    td = _FakeDownloadPlugin('tidal', download_result='td-id')
    engine.register_plugin('youtube', yt)
    engine.register_plugin('tidal', td)

    result = _run_async(engine.download_with_fallback(
        'youtube', 'v||t', 0, ['youtube', 'tidal'],
    ))
    assert result == 'td-id'


# ---------------------------------------------------------------------------
# Cin bug 1: alias resolution on cancel_download
# ---------------------------------------------------------------------------


def test_register_plugin_records_aliases():
    """Aliases passed to register_plugin resolve to the canonical plugin
    via get_plugin. Cin caught engine.cancel_download routing 'deezer_dl'
    to soulseek because the alias never made it to the engine."""
    engine = DownloadEngine()
    deezer = _FakePlugin('deezer')
    engine.register_plugin('deezer', deezer, aliases=('deezer_dl',))

    assert engine.get_plugin('deezer') is deezer
    assert engine.get_plugin('deezer_dl') is deezer


def test_cancel_download_resolves_alias_to_canonical_plugin():
    """The legacy 'deezer_dl' source_hint must route to the deezer
    plugin, not fall through to soulseek. This was Cin's bug 1 —
    cancel of a Deezer download silently no-op'd."""
    engine = DownloadEngine()
    soulseek = _FakePlugin('soulseek')
    deezer = _FakePlugin('deezer')
    engine.register_plugin('soulseek', soulseek)
    engine.register_plugin('deezer', deezer, aliases=('deezer_dl',))

    _run_async(engine.cancel_download('dl-1', 'deezer_dl', remove=False))
    assert deezer.cancel_calls == [('dl-1', 'deezer_dl', False)]
    assert soulseek.cancel_calls == []


# ---------------------------------------------------------------------------
# Cin bug 3: atomic update_record_unless_state
# ---------------------------------------------------------------------------


def test_update_record_unless_state_applies_when_state_not_blocked():
    engine = DownloadEngine()
    engine.add_record('youtube', 'dl-1', {'state': 'InProgress, Downloading'})

    applied = engine.update_record_unless_state(
        'youtube', 'dl-1',
        {'state': 'Completed, Succeeded', 'progress': 100.0},
        skip_if_state_in=('Cancelled',),
    )
    assert applied is True
    assert engine.get_record('youtube', 'dl-1')['state'] == 'Completed, Succeeded'
    assert engine.get_record('youtube', 'dl-1')['progress'] == 100.0


def test_update_record_unless_state_skips_when_state_blocked():
    """A worker-thread terminal write must NOT clobber a Cancelled
    state set by the user. Returns False so caller knows the patch
    was skipped."""
    engine = DownloadEngine()
    engine.add_record('youtube', 'dl-1', {'state': 'Cancelled'})

    applied = engine.update_record_unless_state(
        'youtube', 'dl-1',
        {'state': 'Completed, Succeeded'},
        skip_if_state_in=('Cancelled',),
    )
    assert applied is False
    assert engine.get_record('youtube', 'dl-1')['state'] == 'Cancelled'


def test_update_record_unless_state_returns_false_for_missing_record():
    engine = DownloadEngine()
    applied = engine.update_record_unless_state(
        'youtube', 'never-existed',
        {'state': 'Completed, Succeeded'},
        skip_if_state_in=('Cancelled',),
    )
    assert applied is False
