# -*- coding: utf-8 -*-
"""L5 unit tests: the one context menu and its store actions (docs/plan/milionar-library/080, 040 §5)."""
import json
import os
import sys
import tempfile
import unittest
from datetime import date, timedelta
from unittest.mock import patch
from urllib.parse import unquote

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

stubs.install()

import xbmc  # noqa: E402
import xbmcaddon  # noqa: E402
import xbmcgui  # noqa: E402
import xbmcplugin  # noqa: E402

from wcs.library2 import ctx, jobs, listings, menu, meta, rows, store  # noqa: E402

TODAY = date(2026, 6, 15)


def iso(days):
    return (TODAY + timedelta(days=days)).isoformat()


class MenuTests(unittest.TestCase):
    def setUp(self):
        store.set_path(os.path.join(tempfile.mkdtemp(prefix='milionar-db-'), 'library.db'))
        xbmcaddon.Addon.settings.clear()
        xbmcaddon.Addon.strings.update({32400: 'Resume from {}', 32401: 'Restart', 32402: 'Play', 32403: 'Source', 32404: 'Next ({})',
                                        32405: 'Watched', 32406: 'Unwatched', 32407: 'Hide', 32408: 'Add movies', 32409: 'Remove movies',
                                        32410: 'Add series', 32411: 'Remove series', 32412: 'MyTV', 32413: 'MyCinema', 32414: 'Detail',
                                        32415: 'Similar', 32416: 'Collection', 32417: 'Game', 32418: 'Added to {}', 32419: 'Removed from {}',
                                        32351: 'My movies', 32352: 'My series'})
        self._dates = (rows.date, listings.date, jobs.date)
        rows.date = listings.date = jobs.date = type('D', (), {'today': staticmethod(lambda: TODAY)})
        xbmcgui.NOTIFICATIONS.clear()
        meta.put(meta.key_movie(1), {'id': 1, 'title': 'Film', 'original_title': 'Movie', 'overview': 'o', 'poster_path': '/p.jpg',
                                     'release_date': '2020-01-01', 'belongs_to_collection': {'id': 9}}, 10 ** 9)
        meta.put(meta.key_movie(2), {'id': 2, 'title': 'Solo'}, 10 ** 9)
        meta.put(meta.key_tv(10), {'id': 10, 'name': 'Show', 'status': 'Returning Series', 'first_air_date': '2020-01-01',
                                   'seasons': [{'season_number': 1}, {'season_number': 2}]}, 10 ** 9)
        meta.put(meta.key_season(10, 1), {'episodes': [{'episode_number': i, 'name': 'E{}'.format(i), 'air_date': iso(-50 + i)} for i in (1, 2, 3)]}, 10 ** 9)
        meta.put(meta.key_season(10, 2), {'episodes': [{'episode_number': 1, 'air_date': iso(-5)}, {'episode_number': 2, 'air_date': iso(20)}]}, 10 ** 9)

    def tearDown(self):
        rows.date, listings.date, jobs.date = self._dates

    def labels(self, kind, tmdb, s=None, e=None):
        return [label for label, _b in menu.Menu().build(kind, tmdb, s, e)]

    def commands(self, kind, tmdb, s=None, e=None):
        return dict(menu.Menu().build(kind, tmdb, s, e))

    # ------------------------------------------------------------ menu content
    def test_movie_without_state(self):
        self.assertEqual(self.labels('movie', 1), ['Play', 'Watched', 'Add movies', 'Source', 'MyCinema', 'Detail', 'Similar', 'Collection', 'Game'])
        self.assertEqual(self.labels('movie', 2), ['Play', 'Watched', 'Add movies', 'Source', 'MyCinema', 'Detail', 'Similar', 'Game'])   # no collection

    def test_movie_with_resume_in_list_and_watched_variants(self):
        store.set_in_list('movie', 1, True, {'id': 1})
        store.save_resume('movie', 1, 3725.0, 6000.0, source={'ident': 'abc', 'provider': 'webshare', 'file_name': 'f'})
        commands = self.commands('movie', 1)
        self.assertEqual(list(commands)[:3], ['Resume from 1:02:05', 'Restart', 'Watched'])
        self.assertIn('action=mplay_req&type=movie&tmdb=1&src=last&from=resume', commands['Resume from 1:02:05'])     # same file as before
        self.assertIn('src=dialog', commands['Source'])
        self.assertIn('Hide', commands)
        self.assertIn('Remove movies', commands)
        self.assertNotIn('Add movies', commands)
        store.mark_watched('movie', 1)
        self.assertIn('Unwatched', self.labels('movie', 1))
        self.assertNotIn('Hide', self.labels('movie', 1))

    def test_hidden_from_continue_drops_the_item(self):
        store.save_resume('movie', 1, 600.0, 6000.0)
        store.set_hidden_from_continue('movie', 1, True)
        self.assertNotIn('Hide', self.labels('movie', 1))

    def test_series_and_season_offer_the_next_episode(self):
        store.mark_watched('tv', 10, 1, 1)
        commands = self.commands('tv', 10)
        self.assertEqual(list(commands)[0], 'Next (S01E02)')
        self.assertIn('type=episode&tmdb=10&s=1&e=2&src=auto', commands['Next (S01E02)'])
        self.assertEqual(self.labels('season', 10, 1)[0], 'Next (S01E02)')
        self.assertEqual(self.labels('tv', 10)[-3:], ['Detail', 'Similar', 'Game'])
        self.assertIn('Add series', self.labels('tv', 10))
        self.assertIn('MyTV', self.labels('tv', 10))
        self.assertNotIn('Collection', self.labels('tv', 10))

    def test_unstarted_series_offers_the_first_episode_and_finished_none(self):
        self.assertEqual(self.labels('tv', 10)[0], 'Next (S01E01)')
        for s, e in ((1, 1), (1, 2), (1, 3), (2, 1)):
            store.mark_watched('tv', 10, s, e)
        self.assertNotIn('Next (S02E02)', self.labels('tv', 10))                    # unaired: no play entry

    def test_episode_menu(self):
        store.save_resume('tv', 10, 300.0, 2400.0, 1, 2)
        commands = self.commands('episode', 10, 1, 2)
        self.assertEqual(list(commands)[0], 'Resume from 05:00')
        self.assertIn('type=episode&tmdb=10&s=1&e=2', commands['Resume from 05:00'])
        self.assertIn('Game', commands)
        self.assertIn('season_number=1&episode_number=2', commands['Game'])
        self.assertIn('Hide', commands)

    def test_top_level_is_short_and_the_rest_is_behind_more(self):
        store.set_in_list('movie', 1, True, {'id': 1})
        store.save_resume('movie', 1, 3725.0, 6000.0, source={'ident': 'abc', 'provider': 'webshare', 'file_name': 'f'})
        top, more = menu.Menu().build_parts('movie', 1)
        self.assertEqual([label for label, _b in top], ['Resume from 1:02:05', 'Restart', 'Watched', 'Remove movies'])
        self.assertEqual([label for label, _b in more], ['Source', 'Hide', 'MyCinema', 'Detail', 'Similar', 'Collection', 'Game'])
        top, more = menu.Menu().build_parts('movie', 2)                                  # nothing played yet
        self.assertEqual([label for label, _b in top], ['Play', 'Watched', 'Add movies'])
        for kind, args in (('movie', (1,)), ('tv', (10,)), ('season', (10, 1)), ('episode', (10, 1, 1))):
            top, more = menu.Menu().build_parts(kind, *args)
            self.assertLessEqual(len(top) + 1, 6, kind)                                  # + the "More" entry
        self.assertEqual([label for label, _b in menu.Menu().build_parts('tv', 10)[0]], ['Next (S01E01)', 'Watched', 'Add series'])
        flat = [label for label, _b in menu.Menu().build('movie', 2)]
        self.assertEqual(flat, [l for l, _b in sum(menu.Menu().build_parts('movie', 2), [])])

    def test_open_menu_shows_our_top_then_more_and_runs_the_choice(self):
        shown, commands = self._open_menu({'type': 'movie', 'tmdb': '2'}, [3, 3])
        self.assertEqual(shown[0], ['Play', 'Detail', 'Add movies', 'More'])
        self.assertEqual(shown[1][1:3], ['Source', 'Watched'])
        self.assertIn('ctx_channel', commands[0])
        self.assertEqual(xbmcgui.Window.props.get('milionar.ctx.active', ''), '')

    def _open_menu(self, params, answers):
        shown, commands, scripted = [], [], iter(answers)
        original, run_original = xbmcgui.Dialog.contextmenu, xbmc.executebuiltin
        def native(dialog, labels):
            self.assertEqual(xbmcgui.Window.props.get('milionar.ctx.active'), '1')
            shown.append(labels)
            return next(scripted)
        xbmcgui.Dialog.contextmenu = native
        xbmc.executebuiltin = lambda command, wait=False: commands.append(command)
        xbmcaddon.Addon.strings.update({32420: 'More', 32373: 'Edit widgets', 32378: 'Restart'})
        try:
            ctx.open_menu(params)
        finally:
            xbmcgui.Dialog.contextmenu = original
            xbmc.executebuiltin = run_original
        return shown, commands

    def test_legacy_home_menu_keeps_edit_widgets_entry(self):
        shown, _commands = self._open_menu({'type': 'movie', 'tmdb': '2', 'sec': 'my_movies'}, [3, -1, -1])
        self.assertEqual(shown[1][-1], 'Edit widgets')
        shown, commands = self._open_menu({'type': 'movie', 'tmdb': '2', 'sec': 'my_movies'}, [3, len(shown[1]) - 1])
        self.assertEqual(commands, ['RunPlugin(plugin://plugin.video.milionar/?action=skin_slots&sec=my_movies)'])
        shown, _commands = self._open_menu({'type': 'movie', 'tmdb': '2'}, [3, -1, -1])
        self.assertNotIn('Edit widgets', shown[1])

    # ------------------------------------------------------------ attach to listings and rows
    def test_listings_and_rows_carry_the_menu(self):
        store.set_in_list('movie', 1, True, {'id': 1, 'title': 'Film'})
        store.set_in_list('tv', 10, True, {'id': 10, 'name': 'Show'})
        listings.run(1, {'kind': 'movies'})
        movie = [i for _p, i, _f in xbmcplugin.ITEMS[1] if i.getProperty('milionar.type') == 'movie'][0]
        self.assertIn('Play', [label for label, _b in movie.context])
        buttons = [i for _p, i, _f in xbmcplugin.ITEMS[1] if 'SpecialSort' in i.props]
        self.assertTrue(all(not b.context for b in buttons))                           # buttons have no menu
        listings.run(2, {'kind': 'episodes', 'tmdb': '10', 's': '2'})
        episodes = [i for _p, i, _f in xbmcplugin.ITEMS[2]]
        self.assertTrue(episodes[0].context)
        self.assertFalse(episodes[1].context)                                          # the unaired one
        listings.run(3, {'kind': 'seasons', 'tmdb': '10'})
        everything = [i for _p, i, _f in xbmcplugin.ITEMS[3] if i.getProperty('milionar.s') == '-1'][0]
        self.assertFalse(everything.context)
        self.assertTrue(all(i.context for _p, i, _f in xbmcplugin.ITEMS[3] if i.getProperty('milionar.s') != '-1'))
        built = rows.build('my_movies_all', {'row': 'my_movies_all'})
        self.assertTrue(built[0][1].context)

    # ------------------------------------------------------------ actions
    def test_mark_series_marks_aired_regular_episodes_once_and_unmark_clears(self):
        ctx.mark('tv', 10, watched=True)
        states = store.get_episode_states(10)
        self.assertEqual([(s['season'], s['episode'], s['playcount']) for s in states], [(1, 1, 1), (1, 2, 1), (1, 3, 1), (2, 1, 1)])
        ctx.mark('tv', 10, watched=True)                                                # again: no second count
        self.assertEqual([s['playcount'] for s in store.get_episode_states(10)], [1, 1, 1, 1])
        ctx.mark('season', 10, 1, watched=False)
        self.assertEqual([(s['season'], s['episode'], s['playcount']) for s in store.get_episode_states(10)],
                         [(1, 1, 0), (1, 2, 0), (1, 3, 0), (2, 1, 1)])
        ctx.mark('movie', 1, watched=True)
        self.assertEqual(store.get_state('movie', 1)['playcount'], 1)
        ctx.mark('episode', 10, 2, 1, watched=False)
        self.assertEqual(store.get_state('tv', 10, 2, 1)['playcount'], 0)

    def test_mark_unwatched_clears_a_resume_point(self):
        store.save_resume('movie', 1, 600.0, 6000.0)
        ctx.mark('movie', 1, watched=False)
        self.assertEqual(store.get_state('movie', 1)['resume_pos'], 0)

    def test_list_actions_store_a_snapshot_and_notify(self):
        ctx.set_list('movie', 1, True)
        entry = store.get_list('movie')[0]
        self.assertEqual((entry['id'], entry['title'], entry['year'], entry['poster_path']), (1, 'Film', '2020', '/p.jpg'))
        self.assertEqual(xbmcgui.NOTIFICATIONS[-1], ('Milionář', 'Added to My movies'))
        ctx.set_list('movie', 1, False)
        self.assertEqual(store.get_list('movie'), [])
        ctx.set_list('tv', 10, True)
        self.assertEqual(store.get_list('tv')[0]['name'], 'Show')

    def test_run_dispatches_and_notifies_affected_home_rows(self):
        with patch.object(xbmc, 'executebuiltin') as builtin, patch.object(xbmc, 'executeJSONRPC') as rpc, \
                patch.object(xbmc, 'getCondVisibility', side_effect=lambda c: c == 'Window.IsActive(home)'), \
                patch.object(xbmc, 'getInfoLabel', return_value='plugin://plugin.video.milionar/?action=mlibrary'):
            ctx.run({'action': 'ctx_mark', 'type': 'movie', 'tmdb': '1', 'watched': '1'})
            ctx.run({'action': 'ctx_hide_continue', 'type': 'movie', 'tmdb': '1'})
        self.assertEqual(store.get_state('movie', 1)['playcount'], 1)
        self.assertTrue(store.get_title('movie', 1)['hidden_from_continue_at'])
        commands = [call.args[0] for call in builtin.call_args_list]
        self.assertNotIn('Container.Refresh', commands)
        notifications = [json.loads(call.args[0])['params'] for call in rpc.call_args_list]
        notifications = [params for params in notifications if params.get('message') == 'skin_rows_changed']
        self.assertEqual(len(notifications), 2)
        for notification in notifications:
            names = set(notification['data']['names'])
            self.assertTrue({'cont_movies', 'my_movies_watched', 'my_movies_unwatched'} <= names)
            self.assertTrue({'cont_series', 'my_series_all', 'widget_tmdb_popular_movies'}.isdisjoint(names))

    def test_run_refreshes_only_addon_videos_container(self):
        for folder, refreshes in [('plugin://plugin.video.milionar/?action=mlibrary', True),
                                  ('plugin://plugin.video.other/', False)]:
            with self.subTest(folder=folder), patch.object(xbmc, 'executebuiltin') as builtin, \
                    patch.object(xbmc, 'getCondVisibility', side_effect=lambda c: c == 'Window.IsActive(videos)'), \
                    patch.object(xbmc, 'getInfoLabel', return_value=folder):
                ctx.run({'action': 'ctx_mark', 'type': 'movie', 'tmdb': '1', 'watched': '1'})
            commands = [call.args[0] for call in builtin.call_args_list]
            self.assertEqual(commands.count('Container.Refresh'), int(refreshes))
        self.assertEqual(store.get_state('movie', 1)['playcount'], 1)


class FocusTests(unittest.TestCase):
    """The service publishes the focused Home widget item for the skin's context menu dialog (080)."""

    def setUp(self):
        from wcs.library2 import tracker
        xbmcgui.Window.props.clear()
        self.cond = {'Window.IsActive(home)': True, 'Window.IsActive(contextmenu)': False}
        self.labels = {'System.CurrentControlID': '29200'}
        self._cv, self._il = xbmc.getCondVisibility, xbmc.getInfoLabel
        xbmc.getCondVisibility = lambda c: self.cond.get(c, False)
        xbmc.getInfoLabel = lambda l: self.labels.get(l, '')
        self.engine = tracker.Engine(object(), xbmc.Monitor())

    def tearDown(self):
        xbmc.getCondVisibility, xbmc.getInfoLabel = self._cv, self._il

    def focus(self, kind, tmdb):
        prefix = 'Container(29200).ListItem.Property(milionar.{})'
        self.labels.update({prefix.format('type'): kind, prefix.format('tmdb'): tmdb, prefix.format('s'): '', prefix.format('e'): ''})

    def test_publishes_changes_and_clears_when_the_focus_leaves(self):
        self.focus('movie', '671')
        self.engine.publish_focus()
        self.assertEqual((xbmcgui.Window.props['milionar.focus.type'], xbmcgui.Window.props['milionar.focus.tmdb']), ('movie', '671'))
        self.focus('', '')
        self.engine.publish_focus()
        self.assertNotIn('milionar.focus.type', xbmcgui.Window.props)

    def test_publishes_the_section_of_the_selected_menu_item_with_the_focused_widget(self):
        self.labels['Container(9000).ListItem.Property(id)'] = 'mil_myseries'
        self.focus('tv', '10')
        self.engine.publish_focus()
        self.assertEqual(xbmcgui.Window.props['milionar.focus.sec'], 'my_series')
        self.labels['Container(9000).ListItem.Property(id)'] = 'video'                    # not one of ours: no section
        self.engine.publish_focus()
        self.assertNotIn('milionar.focus.sec', xbmcgui.Window.props)
        self.focus('', '')
        self.engine.publish_focus()
        self.assertNotIn('milionar.focus.tmdb', xbmcgui.Window.props)

    def test_keeps_the_value_while_the_context_menu_is_open_and_ignores_other_windows(self):
        self.focus('tv', '10')
        self.engine.publish_focus()
        self.cond['Window.IsActive(contextmenu)'] = True
        self.focus('', '')
        self.engine.publish_focus()
        self.assertEqual(xbmcgui.Window.props['milionar.focus.tmdb'], '10')
        self.cond.update({'Window.IsActive(contextmenu)': False, 'Window.IsActive(home)': False})
        self.engine.publish_focus()
        self.assertNotIn('milionar.focus.tmdb', xbmcgui.Window.props)                      # left Home: cleared


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