"""Focused offline Vinyl cache/refresh tests. All HTTP and Kodi surfaces are synthetic."""
import json
import os
import sys
import tempfile
import types
import unittest
from unittest.mock import patch
from urllib.parse import parse_qs, urlsplit

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import stubs

stubs.install()

import xbmc
import xbmcaddon
import xbmcgui
import xbmcplugin
from wcs.vinyl import actions, client, widgets
from wcs.library2 import store
from wcs.metadata import TMDbCache


class VinylPerformanceTests(unittest.TestCase):
    def setUp(self):
        self.directory = tempfile.TemporaryDirectory(prefix='vinyl-performance-')
        self.addCleanup(self.directory.cleanup)
        self.addCleanup(patch.stopall)
        patch.object(client, 'PROFILE', self.directory.name).start()
        patch.object(store, '_override_path', os.path.join(self.directory.name, 'library.db')).start()
        patch.object(TMDbCache, '_purged', False).start()
        patch.object(xbmcaddon.Addon, 'settings', {'vinyl_token': 'synthetic', 'vinyl_user_id': '11'}).start()
        self.calls, self.changed, self.requested, self.forced_requests = [], [], [], []
        self.configs = {'songs': [('source', 20)]}
        patch.object(widgets, 'configurations', lambda: {k: list(v) for k, v in self.configs.items()}).start()
        refresh = types.ModuleType('wcs.skin.refresh')
        refresh.changed = lambda rows: self.changed.append(set(rows))
        def request_refresh(rows, force=False):
            self.requested.append(set(rows))
            self.forced_requests.append(force)
        refresh.request = request_refresh
        patch.dict(sys.modules, {'wcs.skin.refresh': refresh}).start()
        requests = types.ModuleType('requests')
        requests.request = self.http
        patch.dict(sys.modules, {'requests': requests}).start()
        self.songs = [{'id': i, 'title': 'Track {:04d}'.format(501 - i), 'artist': 'Artist',
                       'album': 'Album', 'duration': 180, 'cover_version': 1} for i in range(1, 501)]
        self.favorite_songs = self.songs[:100]
        self.playlists = [{'id': 1, 'name': 'Owned', 'is_owner': True},
                          {'id': 2, 'name': 'Favorite', 'is_favorite': True}]
        self.fail_paths = set()
        self.malformed = set()
        self.after_request = None
        xbmcplugin.ITEMS.clear()
        xbmcplugin.ENDED.clear()

    def http(self, method, url, **kwargs):
        path = url[len(client.BASE):]
        parsed = urlsplit(path)
        self.calls.append((method, parsed.path, parse_qs(parsed.query), kwargs.get('json')))
        status = 503 if parsed.path in self.fail_paths else 200
        if parsed.path == 'browse/songs/by-ids':
            data = [song for song in self.songs if song['id'] in kwargs['json']['ids']]
        elif method != 'GET':
            data = {'is_favorite': self.songs[int(parsed.path.split('/')[1]) - 1].get('is_favorite')} if (
                parsed.path.startswith('songs/') and parsed.path.endswith('/favorite')) else {}
        elif parsed.path in ('songs', 'songs/favorites'):
            entries = self.songs if parsed.path == 'songs' else self.favorite_songs
            query = parse_qs(parsed.query)
            if query.get('sort') == ['title']:
                entries = sorted(entries, key=lambda entry: entry['title'].casefold())
            count, offset = int(query['limit'][0]), int(query['offset'][0])
            data = {'songs': entries[offset:offset + count], 'total': len(entries)}
        elif parsed.path == 'featured':
            data = {'curatorPlaylists': self.playlists[:1], 'playlists': self.playlists[1:], 'topTracks': self.songs[:28]}
        elif parsed.path == 'playlists':
            data = {'playlists': self.playlists}
        elif parsed.path == 'radio/sessions/all':
            data = {'sessions': []}
        elif parsed.path.startswith('songs/'):
            data = next(song for song in self.songs if song['id'] == int(parsed.path.split('/')[1]))
        else:
            key = parsed.path.rsplit('/', 1)[-1]
            data = {key: []}
        if parsed.path in self.malformed:
            data = {}
        if self.after_request:
            self.after_request()
        return types.SimpleNamespace(status_code=status, ok=status < 400, json=lambda: data)

    def write(self, source, entries, user=None, **extra):
        path = client.cache_path(source, user)
        os.makedirs(os.path.dirname(path), exist_ok=True)
        with open(path, 'w', encoding='utf-8') as handle:
            json.dump(dict({'items': entries, 'built': 1}, **extra), handle)
        return path

    def bytes(self, path):
        with open(path, 'rb') as handle:
            return handle.read()

    def test_one_source_fetch_and_bounded_home_limits(self):
        for count in (10, 20, 50):
            with self.subTest(count=count):
                self.calls.clear()
                self.configs = {'songs': [('source', count)]}
                self.assertTrue(widgets.refresh(sources=['songs']))
                self.assertEqual(len(client.cached('songs')), count)
                self.assertEqual(len(self.calls), 1)
                self.assertEqual(self.calls[0][1:3], ('songs', {'sort': ['created_at'], 'order': ['DESC'],
                                                              'limit': [str(count)], 'offset': ['0']}))

    def test_shared_endpoint_fetch_is_deduplicated_and_only_selected_sources_return(self):
        self.assertEqual(set(client.sources(['curator', 'discover'])), {'curator', 'discover'})
        self.assertEqual([call[1] for call in self.calls], ['featured'])
        self.calls.clear()
        result = client.sources(['favorite_playlists'])
        self.assertEqual(result, {'favorite_playlists': self.playlists[1:]})
        self.assertEqual([call[1] for call in self.calls], ['playlists'])

    def test_refresh_defaults_to_enabled_sources_only_and_off_does_no_work(self):
        self.configs = {'curator': [('source', 20)], 'discover': [('source', 20)]}
        self.assertTrue(widgets.refresh())
        # a playlist list has no genres: one detail per shown playlist, once (remembered by its updated_at)
        self.assertEqual([call[1] for call in self.calls], ['featured', 'playlists/1', 'playlists/2'])
        self.calls.clear()
        self.assertTrue(widgets.refresh())
        self.assertEqual([call[1] for call in self.calls], ['featured'])
        self.calls.clear()
        self.configs = {}
        self.assertTrue(widgets.refresh())
        self.assertEqual(self.calls, [])

    def test_title_uses_separate_global_sorted_page_and_random_is_bounded_stable(self):
        self.configs = {'songs': [('source', 10), ('title', 20), ('random', 50)]}
        self.assertTrue(widgets.refresh(['songs']))
        self.assertEqual([int(call[2]['limit'][0]) for call in self.calls], [200, 20])
        self.assertEqual(client.cached('songs', 'title')[0]['id'], 500)
        first = widgets.build({'row': 'vinyl_songs', 'sort': 'random', 'limit': '50', 'click': 'play_from'})
        second = widgets.build({'row': 'vinyl_songs', 'sort': 'random', 'limit': '50', 'click': 'play_from'})
        self.assertEqual([entry[0] for entry in first], [entry[0] for entry in second])
        self.assertEqual(len(self.calls), 2)  # Home builds never fetched.
        self.changed.clear()
        self.assertTrue(widgets.refresh(['songs']))
        self.assertEqual(self.changed, [])

    def test_failure_retains_cache_and_partial_success_publishes_independently(self):
        song_path = self.write('songs', self.songs[:20])
        before = self.bytes(song_path)
        self.fail_paths.add('songs')
        self.assertFalse(widgets.refresh(['songs', 'albums']))
        self.assertEqual(self.bytes(song_path), before)
        self.assertEqual(client.cached('albums'), [])
        self.assertEqual(self.changed, [{'vinyl_albums'}])
        self.assertEqual(len([call for call in self.calls if call[1] == 'songs']), 1)

    def test_empty_success_replaces_old_cache_atomically_without_repeated_revision(self):
        old = self.write('favorite_songs', self.favorite_songs[:20])
        self.favorite_songs = []
        replacements = []
        replace = os.replace

        def atomic(source, target):
            self.assertEqual(client.cached('favorite_songs'), self.songs[:20])
            self.assertEqual(json.loads(self.bytes(source))['items'], [])
            replacements.append(target)
            replace(source, target)

        with patch.object(widgets.os, 'replace', atomic):
            self.assertTrue(widgets.refresh(['favorite_songs']))
        self.assertEqual(replacements, [old])
        self.assertEqual(client.cached('favorite_songs'), [])
        self.assertEqual(self.changed, [{'vinyl_favorite_songs'}])
        self.changed.clear()
        self.assertTrue(widgets.refresh(['favorite_songs']))
        self.assertEqual(self.changed, [])
        self.requested.clear()
        self.assertEqual(widgets.build({'row': 'vinyl_favorite_songs'}), [])
        self.assertEqual(self.requested, [])

    def test_invalid_payload_and_failed_title_generation_keep_previous_cache(self):
        self.configs = {'songs': [('title', 20)]}
        path = self.write('songs', self.songs[:20])
        before = self.bytes(path)
        self.malformed.add('songs')
        self.assertFalse(widgets.refresh(['songs']))
        self.assertEqual(self.bytes(path), before)
        self.malformed.clear()
        original = self.http

        def fail_title(method, url, **kwargs):
            if 'sort=title' in url:
                return types.SimpleNamespace(status_code=503, ok=False)
            return original(method, url, **kwargs)

        with patch.object(sys.modules['requests'], 'request', fail_title):
            self.assertFalse(widgets.refresh(['songs']))
        self.assertEqual(self.bytes(path), before)
        self.assertEqual(self.changed, [])

    def test_unchanged_hidden_metadata_does_not_publish_but_visible_changes_do(self):
        self.configs = {'playlists': [('source', 10)]}
        self.assertTrue(widgets.refresh(['playlists']))
        self.changed.clear()
        self.playlists[0].update(updated_at='later', server_path='private', cover_songs=[
            {'id': 3, 'cover_version': 1, 'server_path': 'private', 'token': 'hidden'}])
        self.assertTrue(widgets.refresh(['playlists']))
        self.assertEqual(self.changed, [{'vinyl_playlists'}])
        self.changed.clear()
        self.playlists[0]['updated_at'] = 'even later'
        self.playlists[0]['cover_songs'][0]['token'] = 'changed'
        self.assertTrue(widgets.refresh(['playlists']))
        self.assertEqual(self.changed, [])
        self.assertNotIn(b'server_path', self.bytes(client.cache_path('playlists')))
        self.assertNotIn(b'token', self.bytes(client.cache_path('playlists')))
        self.playlists[0]['name'] = 'Renamed'
        self.assertTrue(widgets.refresh(['playlists']))
        self.assertEqual(self.changed, [{'vinyl_playlists'}])

    def test_changes_outside_displayed_limit_do_not_publish(self):
        self.configs = {'playlists': [('source', 10)]}
        self.playlists = [{'id': i, 'name': str(i), 'is_owner': True} for i in range(30)]
        self.assertTrue(widgets.refresh(['playlists']))
        self.changed.clear()
        self.playlists[-1]['name'] = 'Changed outside Home'
        self.assertTrue(widgets.refresh(['playlists']))
        self.assertEqual(self.changed, [])

    def test_atomic_write_failure_retains_old_cache_and_removes_temporary_file(self):
        path = self.write('songs', [{'id': 9, 'title': 'Old'}])
        before = self.bytes(path)
        with patch.object(widgets.os, 'replace', side_effect=OSError('synthetic failure')):
            self.assertFalse(widgets.refresh(['songs']))
        self.assertEqual(self.bytes(path), before)
        self.assertEqual(os.listdir(os.path.dirname(path)), [os.path.basename(path)])
        self.assertEqual(self.changed, [])

    def test_account_and_session_change_during_fetch_never_publish(self):
        path = self.write('songs', [{'id': 9, 'title': 'Old'}])
        before = self.bytes(path)
        self.after_request = lambda: xbmcaddon.Addon.settings.update(vinyl_user_id='22')
        self.assertFalse(widgets.refresh(['songs']))
        self.assertEqual(self.bytes(path), before)
        self.assertEqual(client.cached('songs'), [])
        self.assertEqual(self.changed, [])
        xbmcaddon.Addon.settings['vinyl_user_id'] = '11'
        self.after_request = lambda: xbmcaddon.Addon.settings.update(vinyl_token='new synthetic session')
        self.assertFalse(widgets.refresh(['songs']))
        self.assertEqual(self.bytes(path), before)

    def test_logout_clears_old_account_even_on_network_failure_and_blocks_public_private_cache(self):
        paths = [self.write(source, [{'id': 1}]) for source in client.SOURCES]
        self.write('songs', [{'id': 99}], user='')  # Legacy/corrupt anonymous private cache cannot leak.
        public = self.write('discover', [{'id': 3, 'name': 'Public'}], user='')
        self.fail_paths.add('auth/logout')
        actions.logout()
        self.assertEqual(client.account(), '')
        self.assertTrue(all(not os.path.exists(path) for path in paths))
        self.assertEqual(client.cached('songs'), [])
        self.assertEqual(client.cached('discover'), [{'id': 3, 'name': 'Public'}])
        self.assertTrue(os.path.exists(public))
        self.assertEqual(self.changed, [set(widgets.ROWS)])
        self.assertEqual(widgets.build({'row': 'vinyl_songs'}), [])

    def test_anonymous_private_source_fetch_never_contacts_provider(self):
        xbmcaddon.Addon.settings.clear()
        self.assertEqual(client.sources(['songs', 'favorite_songs']), {'songs': [], 'favorite_songs': []})
        self.assertEqual(self.calls, [])

    def test_widget_miss_requests_only_its_row_and_legacy_cache_requests_upgrade(self):
        self.assertEqual(widgets.build({'row': 'vinyl_songs'}), [])
        self.assertEqual(self.requested, [{'vinyl_songs'}])
        self.write('songs', self.songs[:100])
        self.assertEqual(len(widgets.build({'row': 'vinyl_songs', 'limit': '10'})), 10)
        self.assertEqual(self.requested, [{'vinyl_songs'}, {'vinyl_songs'}])
        self.assertEqual(self.calls, [])

    def test_low_power_constructs_only_capped_offscreen_items_and_reduces_artwork(self):
        xbmcaddon.Addon.settings['skin_low_power'] = 'true'
        self.configs = {'songs': [('source', widgets._limit('50'))]}
        self.assertTrue(widgets.refresh(['songs']))
        original = xbmcgui.ListItem
        created = []

        def recording(*args, **kwargs):
            created.append(kwargs)
            return original(*args, **kwargs)

        with patch.object(xbmcgui, 'ListItem', recording):
            result = widgets.build({'row': 'vinyl_songs', 'limit': '50'})
        self.assertEqual(len(result), 10)
        self.assertTrue(all(kwargs['offscreen'] for kwargs in created))
        art = result[0][1].art['thumb']
        self.assertIn('size=300', art)
        self.assertIn('v=1', art)
        self.assertTrue(art.endswith('|Authorization=Bearer%20anonymous'))
        self.assertEqual(len(self.calls), 1)

    def test_interactive_listing_fetches_only_requested_twenty_song_page(self):
        self.write('songs', self.songs[:10], version=2, limits={'source': 10})
        for offset, count in ((0, 21), (20, 22), (480, 21)):
            xbmcplugin.ITEMS.clear()
            self.calls.clear()
            with patch.object(xbmcgui.ListItem, 'getMusicInfoTag', xbmcgui.ListItem.getVideoInfoTag, create=True):
                actions.browse(77, {'action': 'vinyl_browse', 'row': 'vinyl_songs', 'offset': str(offset)})
            self.assertEqual(len(xbmcplugin.ITEMS[77]), count)
            self.assertEqual(len(self.calls), 1)
            self.assertEqual(self.calls[0][2]['offset'], [str(offset)])
            self.assertEqual(self.calls[0][2]['limit'], ['20'])
            li = xbmcplugin.ITEMS[77][0][1]
            data = parse_qs(urlsplit(li.props['milionar.exec'][10:-1]).query)
            self.assertEqual(len(data['ids'][0].split(',')), 20)
            links = [parse_qs(urlsplit(item[0]).query) for item in xbmcplugin.ITEMS[77][20:]]
            expected = ([offset - 20] if offset else []) + ([offset + 20] if offset < 480 else [])
            self.assertEqual([int(link['offset'][0]) for link in links], expected)

    def test_genre_open_fetches_one_page_without_expanding_collection(self):
        def provider(method, url, **kwargs):
            return self.http(method, client.BASE + 'songs?' + url.split('?', 1)[1], **kwargs)

        with patch.object(sys.modules['requests'], 'request', provider), patch.object(
                xbmcgui.ListItem, 'getMusicInfoTag', xbmcgui.ListItem.getVideoInfoTag, create=True):
            actions.run(77, {'action': 'vinyl_item', 'kind': 'genre', 'name': 'House', 'click': 'open', 'offset': '20'})
        self.assertEqual(len(self.calls), 1)
        self.assertEqual(self.calls[0][2]['limit'], ['20'])
        self.assertEqual(self.calls[0][2]['offset'], ['20'])
        self.assertEqual(len(xbmcplugin.ITEMS[77]), 22)

    def test_play_from_keeps_displayed_order_and_fetches_only_missing_queue_ids(self):
        self.configs = {'favorite_songs': [('random', 20)]}
        self.assertTrue(widgets.refresh(['favorite_songs']))
        built = widgets.build({'row': 'vinyl_favorite_songs', 'limit': '20', 'sort': 'random', 'click': 'play_from'})
        params = {key: values[0] for key, values in parse_qs(urlsplit(built[5][0]).query).items()}
        self.calls.clear()
        result = actions._songs(params)
        expected = [int(parse_qs(urlsplit(path).query)['id'][0]) for path, _li, _folder in built[5:]]
        self.assertEqual([song['id'] for song in result], expected)
        self.assertEqual(self.calls, [])
        params['ids'] = '400,300,200'
        params['id'] = '300'
        self.assertEqual([song['id'] for song in actions._songs(params)], [300, 200])
        self.assertEqual([call[1] for call in self.calls], ['browse/songs/by-ids'])

    def test_favorite_write_requests_only_dependency_and_context_refresh_is_deferred(self):
        context = types.ModuleType('wcs.vinyl.context')

        def edit(params):
            client.request('songs/1/favorite', 'PATCH')
            self.assertTrue(widgets.refresh())  # The existing context.py call is intercepted.

        context.run = edit
        with patch.dict(sys.modules, {'wcs.vinyl.context': context}):
            actions.run(-1, {'action': 'vinyl_context', 'op': 'favorite', 'kind': 'song', 'id': '1'})
        self.assertEqual(self.requested, [{'vinyl_favorite_songs', 'vinyl_songs'}])
        self.assertEqual(self.forced_requests, [True])
        self.assertEqual([(call[0], call[1]) for call in self.calls], [('PATCH', 'songs/1/favorite')])
        self.assertEqual(self.changed, [])

    def test_failed_write_requests_nothing_and_playlist_edits_never_target_all_songs(self):
        self.fail_paths.add('songs/1/favorite')
        with self.assertRaises(RuntimeError):
            client.request('songs/1/favorite', 'PATCH')
        self.assertEqual(self.requested, [])
        client.request('playlists/1/songs', 'POST', {'song_id': 1})
        self.assertEqual(self.requested, [{'vinyl_playlists', 'vinyl_favorite_playlists',
                                         'vinyl_curator', 'vinyl_discover', 'vinyl_radios', 'vinyl_top_tracks'}])
        client.request('playlists/1/favorite', 'PATCH')
        self.assertEqual(self.requested[-1], {'vinyl_favorite_playlists', 'vinyl_playlists'})

    def test_all_favorite_dependencies_and_successful_bodyless_delete(self):
        for path, source in (('browse/albums/Album/favorite?artist=A', 'favorite_albums'),
                             ('browse/artists/favorite?artist=A', 'favorite_artists')):
            client.request(path, 'PATCH')
            self.assertEqual(self.requested[-1], {'vinyl_' + source, 'vinyl_' + source[len('favorite_'):]})
        with patch.object(sys.modules['requests'], 'request', return_value=types.SimpleNamespace(status_code=204, ok=True)):
            self.assertIsNone(client.request('playlists/1', 'DELETE'))
        self.assertNotIn('vinyl_songs', self.requested[-1])
        self.assertTrue(all(self.forced_requests))

    def test_context_membership_outside_200_uses_one_track_and_survives_favorite_toggle(self):
        from wcs.vinyl import context
        self.write('favorite_songs', self.songs[:200])
        self.songs[399]['is_favorite'] = True
        data = {'kind': 'song', 'id': '400'}
        self.assertTrue(context.favorite(data))
        self.assertEqual([(call[0], call[1]) for call in self.calls], [('GET', 'songs/400')])
        self.calls.clear()
        self.songs[399]['is_favorite'] = False
        client.request('songs/400/favorite', 'PATCH')
        self.calls.clear()
        self.assertFalse(context.favorite(data))
        self.assertEqual(self.calls, [])

    def test_context_unknown_membership_does_not_claim_nonfavorite_and_no_anonymous_lookup(self):
        from wcs.vinyl import context
        data = {'kind': 'song', 'id': '400'}
        self.assertIsNone(context.favorite(data))  # Detail payload without is_favorite.
        self.assertNotIn('favorite', context.actions(data))
        self.fail_paths.add('songs/400')
        self.assertIsNone(context.favorite(data))
        self.assertNotIn('favorite', context.actions(data))
        self.calls.clear()
        xbmcaddon.Addon.settings.clear()
        self.assertNotIn('favorite', context.actions(data))
        self.assertEqual(self.calls, [])

    def test_context_label_tracks_authoritative_single_song_and_rejects_scope_switch(self):
        from wcs.vinyl import context
        data = {'kind': 'song', 'id': '400'}
        self.songs[399]['is_favorite'] = '1'
        with patch.object(context, 'text', side_effect=lambda number: str(number)):
            self.assertEqual(context.actions(data)['favorite'][0], '32613')
            self.songs[399]['is_favorite'] = '0'
            client.request('songs/400/favorite', 'PATCH')
            self.assertEqual(context.actions(data)['favorite'][0], '32612')
        xbmcaddon.Addon.settings['vinyl_token'] = 'another synthetic session'
        self.after_request = lambda: xbmcaddon.Addon.settings.update(vinyl_user_id='22')
        self.assertIsNone(context.favorite(data))

    def test_context_source_flags_and_positive_membership_are_network_free(self):
        from wcs.vinyl import context
        self.write('songs', [], sorts={'title': [{'id': 400, 'is_favorite': True}]})
        self.assertTrue(context.favorite({'kind': 'song', 'id': '400'}))
        self.write('songs', [{'id': 400, 'is_favorite': False}])
        self.assertFalse(context.favorite({'kind': 'song', 'id': '400'}))
        self.assertTrue(context.favorite({'kind': 'song', 'id': '401', 'is_favorite': '1'}))
        self.write('favorite_songs', [{'id': 402}])
        self.assertTrue(context.favorite({'kind': 'song', 'id': '402'}))
        self.assertEqual(self.calls, [])

    def test_context_patch_delta_overrides_stale_source_and_positive_cache_until_refresh(self):
        from wcs.vinyl import context
        self.write('songs', [{'id': 400, 'is_favorite': True}])
        self.write('favorite_songs', [{'id': 400}])
        self.songs[399]['is_favorite'] = False
        client.request('songs/400/favorite', 'PATCH')
        self.calls.clear()
        self.assertFalse(context.favorite({'kind': 'song', 'id': '400', 'is_favorite': True}))
        self.assertFalse(context.favorite({'kind': 'song', 'id': '400'}))
        self.assertEqual(self.calls, [])
        self.assertEqual(self.requested, [{'vinyl_favorite_songs', 'vinyl_songs'}])
        self.assertEqual(self.forced_requests, [True])
        xbmcaddon.Addon.settings['vinyl_user_id'] = '22'
        self.assertIsNone(client.favorite_state('400'))

    def test_context_unknown_lookup_is_cached_uses_short_timeout_and_never_pages(self):
        from wcs.vinyl import context
        data = {'kind': 'song', 'id': '400'}
        self.songs[399]['is_favorite'] = True
        original = sys.modules['requests'].request
        with patch.object(sys.modules['requests'], 'request', wraps=original) as request:
            self.assertTrue(context.favorite(data))
            self.assertTrue(context.favorite(data))
        self.assertEqual(len(self.calls), 1)
        self.assertEqual(request.call_args.kwargs['timeout'], (2, 3))
        self.assertEqual(self.calls[0][1], 'songs/400')
        xbmcaddon.Addon.settings['vinyl_token'] = 'new synthetic session'
        self.fail_paths.add('songs/400')
        self.calls.clear()
        self.assertIsNone(context.favorite(data))
        self.assertIsNone(context.favorite(data))
        self.assertEqual(len(self.calls), 1)

    def test_non_song_bounded_membership_absence_is_unknown_without_network(self):
        from wcs.vinyl import context
        for kind, source, data in (
                ('playlist', 'playlists', {'id': '400', 'name': 'Outside page'}),
                ('album', 'albums', {'id': '400', 'name': 'Outside page', 'artist': 'Artist'}),
                ('artist', 'artists', {'name': 'Outside page'})):
            with self.subTest(kind=kind):
                entry = dict(data, id=400)
                self.write('favorite_' + source, [{'id': i, 'name': str(i)} for i in range(200)])
                self.write(source, [])
                params = dict(data, kind=kind)
                self.assertIsNone(context.favorite(params))
                with patch.object(xbmc, 'getLocalizedString', return_value='Oblíbené'):
                    toggle = context.actions(params)['favorite']
                    self.assertEqual(toggle[0], 'Oblíbené')
                    self.assertIn('op=favorite', toggle[1])
                self.assertFalse(context.favorite(dict(params, is_favorite='0')))
                self.assertTrue(context.favorite(dict(params, is_favorite='1')))
                self.write(source, [dict(entry, is_favorite=False)])
                self.assertFalse(context.favorite(params))
                self.write(source, [dict(entry, is_favorite=True)])
                self.assertTrue(context.favorite(params))
                self.write(source, [])
                self.write('favorite_' + source, [entry])
                self.assertTrue(context.favorite(params))
        self.assertEqual(self.calls, [])

    def test_configuration_reads_numeric_limits_and_deduplicates_across_sections(self):
        with patch.object(widgets, 'configurations', VinylPerformanceTests.real_configurations):
            with patch.object(xbmc, 'getCondVisibility', return_value=True), patch.object(
                    xbmc, 'getInfoLabel', side_effect=lambda label: {
                        'Skin.String(mil.vinyl.1.type)': 'vinyl_songs',
                        'Skin.String(mil.vinyl.1.limit)': '50',
                        'Skin.String(mil.vinyl.1.sort)': 'title',
                        'Skin.String(mil.cont.2.type)': 'vinyl_songs',
                        'Skin.String(mil.cont.2.limit)': '50',
                        'Skin.String(mil.cont.2.sort)': 'title',
                    }.get(label, '')):
                self.assertEqual(widgets.configurations(), {'songs': [('title', 50)]})
                xbmcaddon.Addon.settings['skin_low_power'] = 'true'
                self.assertEqual(widgets.configurations(), {'songs': [('title', 10)]})
            with patch.object(xbmc, 'getCondVisibility', return_value=False), patch.object(xbmc, 'getInfoLabel', return_value=''):
                self.assertEqual(widgets.configurations(), {source: [('source', 10)] for source in (
                    'curator', 'genres', 'discover', 'playlists', 'favorite_songs', 'artists')})

    def test_album_artist_genre_collection_keeps_complete_paging(self):
        def provider(method, url, **kwargs):
            if '/songs?' in url:
                url = client.BASE + 'songs?' + url.split('?', 1)[1]
            return self.http(method, url, **kwargs)

        with patch.object(sys.modules['requests'], 'request', provider):
            for kind, name in (('album', 'Album'), ('artists', 'Artist'), ('genres', 'Genre')):
                self.calls.clear()
                songs = client.collection(kind, '1', name, 'Artist')
                self.assertEqual(len(songs), 500)
                self.assertEqual([int(call[2]['offset'][0]) for call in self.calls], [0, 200, 400])

    real_configurations = staticmethod(widgets.configurations)


if __name__ == '__main__':
    unittest.main()
