import importlib, importlib.util, json, sys, types, unittest
from pathlib import Path
from urllib.parse import parse_qsl, urlsplit
ROOT=Path(__file__).resolve().parents[2]
sys.path.insert(0,str(ROOT))
settings={};executed=[];windows={};selections=[];infos={};current_window=[11120]
translations={}
import re
for block in (ROOT/'resources/language/resource.language.cs_cz/strings.po').read_text().split('\n\n'):
 m=re.search(r'msgctxt "#(\d+)".*?msgstr "(.*?)"',block,re.S)
 if m: translations[int(m[1])]=m[2]
class Addon:
 def __init__(self,*a):pass
 def getLocalizedString(self,k):return translations.get(k,str(k))
 def getSetting(self,k):return settings.get(k,'')
 def setSetting(self,k,v):settings[k]=v
class Item:
 def __init__(self,label='',**kw):self.label=label;self.props={};self.path=kw.get('path','');self.menu=[]
 def getProperty(self,k):return self.props.get(k,'')
 def setProperty(self,k,v):self.props[k]=str(v)
 def getLabel(self):return self.label
 def getPath(self):return self.path
 def setPath(self,p):self.path=p
 def addContextMenuItems(self,m,replace=False):self.menu=list(m) if replace else self.menu+list(m)
class Window:
 def __init__(self,n):self.props=windows.setdefault(n,{})
 def setProperty(self,k,v):self.props[k]=v
 def getProperty(self,k):return self.props.get(k,'')
 def clearProperty(self,k):self.props.pop(k,None)
 def getFocusId(self):return self.props.get('_focus',50)
class Dialog:
 def contextmenu(self,labels):return selections.pop(0)
xbmc=types.ModuleType('xbmc');xbmc.executebuiltin=lambda command,wait=False:executed.append(command);xbmc.getInfoLabel=lambda s:infos.get(s,'');xbmc.getSkinDir=lambda:'skin.milionar';xbmc.getCondVisibility=lambda s:False
sys.modules['xbmc']=xbmc
sys.modules['xbmcaddon']=types.SimpleNamespace(Addon=Addon)
sys.modules['xbmcgui']=types.SimpleNamespace(ListItem=Item,Window=Window,Dialog=Dialog,WindowXMLDialog=object,getCurrentWindowId=lambda:current_window[0])
output=[]
plugin=types.ModuleType('xbmcplugin')
def single(h,p,i,f=False,n=0):output.append((p,i,f));return True
def multiple(h,items,n=0):output.extend(items);return True
plugin.addDirectoryItem=single;plugin.addDirectoryItems=multiple;sys.modules['xbmcplugin']=plugin
shipped=json.loads((ROOT/'resources/defaults/skin.json').read_text())
sections={sec: [dict(w) for w in data['widgets']] for sec,data in shipped['sections'].items()}
slots=types.ModuleType('wcs.skin.slots');slots.SECTIONS={sec:(100,[]) for sec in sections};slots.SLOTS=6;slots.TITLES={w['type']:100 for rows in sections.values() for w in rows};slots.current=lambda sec,n: sections[sec][n-1] if n<=len(sections[sec]) else {'type':'off'}
sys.modules['wcs.skin.slots']=slots
sys.modules['wcs.skin.defaults']=types.SimpleNamespace(load=lambda:shipped)
sys.modules['wcs.skin.hero_contract']=types.SimpleNamespace(SECTIONS=frozenset(('cont','my_movies','my_series','vinyl')),BUTTON_SLOTS=4)
from wcs.skin import context as c, context_profiles as p
c.hero_config=lambda params:(False,None)
class Model:
 def _play_items(self,k,t,s,e):return [('PLAY','play'),('SOURCE','source')] if k in ('movie','episode') else [('NEXT','next')]
 def _state_items(self,*a):return [('WATCHED','watched'),('HIDE','hide')]
 def _list_items(self,*a):return [('LIST','list'),('CHANNEL','channel')]
 def _discovery_items(self,*a):return [('DETAIL','detail'),('SIMILAR','similar'),('GAME','game')]
sys.modules['wcs.library2.menu']=types.SimpleNamespace(Menu=Model)
sys.modules['wcs.user_data']=types.SimpleNamespace(**{'load_favorite_'+role+'s':lambda addon:[] for role in ('actor','director','writer')})
def data(item):return c.unpack(item.getProperty('milionar.ctxdata'))
def attach(query,props=None,folder=False,identity='',row=''):
 i=Item('Žluťoučký film');i.props.update(props or {});c.attach(c.BASE+'?'+query,i,folder,identity,row);return i
class Checks(unittest.TestCase):
 def setUp(self):settings.clear();executed.clear();selections.clear();output.clear();windows.clear();infos.clear();current_window[0]=11120
 def test_no_kodi_wrapper_entry_in_milionar_skin(self):
  self.assertEqual(c.entry({'type':'movie','tmdb':'42'},visible=True),[])
  self.assertEqual(attach('action=show_wcs_details&media_type=movie&tmdb_id=42').menu,[])
 def test_other_skin_retains_explicit_addon_entry(self):
  from unittest.mock import patch
  with patch.object(xbmc,'getSkinDir',return_value='skin.estuary'):
   self.assertIn('action=ctx_open',c.entry({'type':'movie','tmdb':'42'})[0][1])
 def test_context_reads_parent_focus_not_modal_control(self):
  Window(11120).props['_focus']=501
  infos['System.CurrentControlID']='996'
  self.assertEqual(c.focused_control(),'501')
 def test_native_search_opens_only_shared_menu(self):
  infos['Container(50).ListItem.Property(milionar.ctxdata)']=c.pack({'type':'movie','tmdb':'42'})
  selections.append(-1);c.native_context()
  self.assertEqual(executed,['Dialog.Close(contextmenu,true)'])
  self.assertFalse(Window(10000).getProperty('milionar.ctx.native'))
 def test_native_browsing_opens_only_shared_menu(self):
  current_window[0]=10025
  infos['Container(50).ListItem.Property(milionar.ctxdata)']=c.pack({'type':'tv','tmdb':'42'})
  selections.append(-1);c.native_context()
  self.assertEqual(executed,['Dialog.Close(contextmenu,true)'])
 def test_cached_untagged_addon_item_uses_shared_builder(self):
  infos['Container(50).ListItem.FolderPath']=c.BASE+'?action=show_wcs_details&media_type=movie&tmdb_id=42'
  infos['Container(50).ListItem.Label']='Matrix'
  d=c.unpack(c.focused_data('50'));self.assertEqual((d['type'],d['tmdb'],d['title']),('movie','42','Matrix'))
 def test_native_own_menu_guard_prevents_recursion(self):
  Window(10000).setProperty('milionar.ctx.active','1');c.native_context();self.assertEqual(executed,[])
 def test_native_foreign_items_reveal_original_menu_once(self):
  current_window[0]=10025
  infos['Container(50).ListItem.FolderPath']='plugin://another.addon/?item=42'
  c.native_context();self.assertEqual(executed,['Dialog.Close(contextmenu,true)','Action(ContextMenu)'])
  self.assertEqual(Window(10000).getProperty('milionar.ctx.native'),'1')
  c.native_context();self.assertEqual(len(executed),2)
 def test_empty_search_controls_do_not_open_kodi_actions(self):
  c.native_context();self.assertEqual(executed,['Dialog.Close(contextmenu,true)'])
 def test_home_widget_opens_directly_without_kodi_actions(self):
  from unittest.mock import patch
  from wcs.skin import hero_buttons
  current_window[0]=10000
  infos['Container(50).ListItem.Property(milionar.ctxdata)']=c.pack({'type':'movie','tmdb':'42'})
  selections.append(-1)
  with patch.object(hero_buttons,'focused_widget',return_value=''):
   c.home_context()
  self.assertEqual(executed,[])
 def test_native_xml_keeps_generated_actions_hidden_until_dispatch(self):
  from xml.etree import ElementTree as ET
  xml=ET.parse(ROOT/'resources/skin.milionar/xml/DialogContextMenu.xml').getroot()
  onload=xml.find('onload');self.assertIn('skin_context_native',onload.text)
  self.assertIn('milionar.ctx.active',onload.get('condition'))
  self.assertIn('milionar.ctx.native',onload.get('condition'))
  self.assertIn('ClearProperty(milionar.ctx.native',xml.find('onunload').text)
  for visible in xml.findall('controls/control/visible'):
   self.assertIn('milionar.ctx.active',visible.text);self.assertIn('milionar.ctx.native',visible.text)
 def test_shipped_profiles_cover_every_item_type(self):
  self.assertEqual(set(shipped[p.SETTING]),set(p.PROFILES))
  for profile,menu in shipped[p.SETTING].items():
   self.assertEqual(p.defaults('shared:'+profile),menu)
 def test_shipped_defaults_are_independent_of_widget_overrides(self):
  row=sections['tmdb'][0];old=row['menu'];row['menu']={'main':['detail'],'more':[],'hidden':['play']}
  try:
   self.assertEqual(p.defaults('shared:movie'),shipped[p.SETTING]['movie'])
   self.assertEqual(p.config('movie')['main'],['detail'])
   self.assertIn('play',p.config('movie')['hidden'])
  finally:row['menu']=old
 def test_snapshot_preserves_shared_defaults(self):
  spec=importlib.util.spec_from_file_location('snapshot_defaults',ROOT/'scripts/snapshot_defaults.py')
  snapshot=importlib.util.module_from_spec(spec);spec.loader.exec_module(snapshot)
  xml=ROOT/'resources/defaults/skin.json'
  snapshot.SKIN_JSON=str(xml)
  widgets={}
  snapshot.read_settings=lambda path: widgets
  self.assertEqual(snapshot.skin_defaults()[p.SETTING],shipped[p.SETTING])
 def test_profiles_have_unique_actions_and_valid_labels(self):
  for k in p.PROFILES:
   cfg=c.normalize('shared:'+k)
   flat=sum((cfg[g] for g in ('main','more','hidden')),[])
   self.assertEqual(len(flat),len(set(flat)),k);self.assertEqual(set(flat),set(p.catalog('shared:'+k)))
   for a in flat:self.assertTrue(c.label('shared:'+k,a))
 def test_author_movie_defaults(self):
  self.assertEqual(p.config('movie')['main'],['play','detail','watched','channel','list'])
  self.assertIn('source',p.config('movie')['hidden'])
 def test_author_series_defaults(self):self.assertEqual(p.config('tv')['main'],['folder','next','watched','channel','list'])
 def test_author_episode_defaults(self):self.assertEqual(p.config('episode')['main'],['play','restart','season','watched'])
 def test_persistence_independent_of_later_widget_changes(self):
  first=p.config('movie'); row=sections['tmdb'][0];old=row['menu'];row['menu']={'main':['detail'],'more':[],'hidden':['play']}
  try:self.assertEqual(p.config('movie'),first)
  finally:row['menu']=old
 def test_normalize_removes_duplicates_preserves_hidden(self):
  x=c.normalize('shared:movie',{'main':['detail','detail'],'more':['detail'],'hidden':['source','nonsense']})
  self.assertEqual(x['main'],['detail']);self.assertEqual(x['hidden'],['source']);self.assertNotIn('source',x['more'])
 def test_search_and_browse_share_movie_profile(self):
  for q in ('action=show_wcs_details&media_type=movie&tmdb_id=42','action=skin_run&run=action%3Dshow_wcs_details%26media_type%3Dmovie%26tmdb_id%3D42'):
   i=attach(q);self.assertEqual(p.key(data(i)),'movie');top,more=c.parts(data(i));self.assertTrue(any('profile=movie' in cmd for _,cmd in more))
 def test_original_widget_hidden_order_and_origin(self):
  row=sections['tmdb'][0];i=attach('action=show_wcs_details&media_type=movie&tmdb_id=42',identity=row['uid'],row=row['type'])
  top,more=c.parts(data(i));self.assertEqual([cmd.split('&ctx=')[0] for _,cmd in top],['play','detail','watched','channel','list'])
  self.assertFalse(any(cmd=='source' for _,cmd in top+more));self.assertTrue(any('skin_context_edit&ctx=' in cmd for _,cmd in more))
 def test_widget_descendant_navigation_keeps_origin(self):
  row=sections['tmdb'][1];i=attach('action=mlist&kind=seasons&tmdb=42',folder=True,identity=row['uid'],row=row['type']);d=data(i)
  self.assertEqual(d['ctx'],row['uid']);self.assertIn('ctx=',d['path']);self.assertEqual(d['type'],'tv')
 def test_season_and_all_seasons_are_distinct(self):
  i=attach('action=mlist&kind=episodes&tmdb=42&s=0',{'milionar.type':'season','milionar.tmdb':'42','milionar.s':'0'},True)
  self.assertEqual(data(i)['type'],'season');self.assertTrue(any('action=mlist' in x[1] for x in c.parts(data(i))[0]))
  i=attach('action=mlist&kind=episodes&tmdb=42&s=all',{'milionar.type':'tv','milionar.tmdb':'42','milionar.s':'all'},True);self.assertEqual(data(i)['type'],'folder')
 def test_unknown_page_is_folder_not_film(self):self.assertEqual(data(attach('action=show_similar_movies&tmdb_id=42&page=2',folder=True))['type'],'folder')
 def test_person_search_keeps_role(self):
  i=attach('action=display_director_dialog&director_id=77');self.assertEqual(data(i)['type'],'person');self.assertEqual(data(i)['role'],'director');self.assertEqual(p.key(data(i)),'person')
 def test_channel_playback_has_no_global_watch_actions(self):
  a=c.video_actions({'type':'episode','tmdb':'42','s':'1','e':'2','channel_id':'abc'})
  self.assertFalse(set(a)&{'watched','hide_continue','source','restart','next_source'});self.assertIn('mytv_channel_id=abc',a['play'][1])
 def test_cinema_origin(self):
  i=attach('action=play_movie_from_addon&tmdb_id=42&mycinema_channel_id=abc');self.assertEqual(p.key(data(i)),'channel_movie')
 def test_legacy_specific_actions_are_preserved(self):
  i=Item('Soubor');c.add_legacy(i,[('Informace','RunPlugin('+c.BASE+'?action=info&ident=x)'),('Stáhnout','RunPlugin('+c.BASE+'?action=download_file&ident=x)')]);c.attach(c.BASE+'?action=play&ident=x',i,False,'','')
  top,more=c.parts(data(i));self.assertTrue(any('download_file' in cmd for _,cmd in top));self.assertTrue(any('action=info' in cmd for _,cmd in more));self.assertEqual(len(i.menu),0)
 def test_unicode_and_extra_round_trip(self):
  d={'type':'movie','title':'A, B & Žluťoučký "kůň"','tmdb':'42','extras':c.encode({'history':['Smazat','RunPlugin(x)']})};self.assertEqual(c.unpack(c.pack(d)),d)
 def test_history_adapter_keeps_movie_and_removal(self):
  from wcs.ui.ContextMenu import create_recently_played_context_menu
  i=Item('film');c.add_legacy(i,create_recently_played_context_menu(Addon(),idx=3,media_type='movie',tmdb_id=42));self.assertEqual(data(i)['type'],'movie');self.assertIn('history',c.decode(data(i)['extras']))
 def test_repeated_wrapping_never_leaks_previous_origin(self):
  c.install_listing_context({'ctx':'old','ctxrow':'my_series_next'});c.install_listing_context({});plugin.addDirectoryItem(1,c.BASE+'?action=show_wcs_details&media_type=movie&tmdb_id=42',Item('film'))
  self.assertEqual(len(output),1);self.assertEqual(data(output[0][1]).get('ctx',''),'');self.assertIs(plugin._milionar_directory_originals[0],single)
 def test_remote_stream_path_and_plugin_command_are_separate(self):
  i=Item('song');d={'type':'vinyl','kind':'song','id':'2','path':c.BASE+'?action=vinyl_item&kind=song&id=2'};i.setProperty('milionar.ctxdata',c.pack(d));i.setProperty('milionar.exec','RunPlugin('+d['path']+')');stream='https://stream.invalid/song/2';c.attach(stream,i,False,'','')
  self.assertEqual(i.getPath(),stream);self.assertIn('plugin://',i.getProperty('milionar.exec'));self.assertIn('plugin://',data(i)['path'])
 def test_callback_and_guard_cleanup(self):
  called=[];selections.extend([0]);c.open_menu({'type':'file'},actions={'play':('Play',lambda:called.append(1))});self.assertEqual(called,[1]);self.assertEqual(Window(10000).getProperty('milionar.ctx.active'),'')
 def test_back_from_more_returns_to_main(self):
  selections.extend([1,-1,-1]);c.open_menu({'type':'file'},actions={'play':('Play',lambda:None)});self.assertFalse(selections)
 def test_manager_reachable_when_every_action_hidden(self):
  settings[p.SETTING]=json.dumps({'file':{'main':[],'more':[],'hidden':list(p.PROFILES['file'][2])}});top,more=c.parts({'type':'file','extras':c.encode({'play':['play','local:play']})});self.assertFalse(top);self.assertEqual(len(more),1);self.assertIn('skin_context_edit',more[0][1])
 def test_person_in_movie_widget_uses_shared_person_menu(self):
  row=sections['tmdb'][0];top,more=c.parts({'type':'person','id':'77','role':'actor','ctx':row['uid'],'title':'Actor'});self.assertTrue(any('profile=person' in cmd for _,cmd in more));self.assertFalse(any('skin_slots' in cmd for _,cmd in more))
class EdgeChecks(unittest.TestCase):
 setUp=Checks.setUp
 def test_vinyl_navigation_has_folder_menu_and_music_target(self):
  i=attach('action=vinyl_browse&row=vinyl_playlists',folder=True)
  self.assertEqual(data(i)['type'],'folder')
  top,more=c.parts(data(i));self.assertIn('ActivateWindow(Music,',top[0][1])
 def test_actual_all_seasons_metadata(self):
  i=attach('action=mlist&kind=episodes&tmdb=42&s=all',{'milionar.type':'season','milionar.tmdb':'42','milionar.s':'-1'},True)
  self.assertEqual(data(i)['type'],'folder');top,more=c.parts(data(i));self.assertIn('s=all',top[0][1]);self.assertFalse(any(cmd=='watched' for _,cmd in top+more))
 def test_supplement_does_not_restore_omitted_widget_actions(self):
  row=sections['mytv'][1]
  top,more=c.parts({'type':'episode','tmdb':'42','s':'1','e':'2','ctx':row['uid']})
  self.assertFalse(any(cmd in ('list','watched','source') for _,cmd in top+more))
 def test_history_and_file_extras_merge(self):
  i=Item('file');c.add_legacy(i,[('Info','RunPlugin('+c.BASE+'?action=info&ident=x)')])
  c.add_legacy(i,c.entry({'type':'file','extras':c.encode({'download':['Download','RunPlugin('+c.BASE+'?action=download_file&ident=x)']})}))
  self.assertEqual(set(c.decode(data(i)['extras'])),{'file_info','download'})
 def test_dialog_capture_preserves_explicit_widget(self):
  dialog=types.SimpleNamespace(_context_uid='explicit',_context_row='row')
  c.capture_dialog(dialog);self.assertEqual(dialog._context_uid,'explicit')
 def test_plain_folder_keeps_widget_origin_but_uses_shared_menu(self):
  row=sections['tmdb'][0];i=attach('action=tmdb_movies_genres',folder=True,identity=row['uid'],row=row['type'])
  top,more=c.parts(data(i));self.assertIn('ctx=',top[0][1]);self.assertTrue(any('profile=folder' in cmd for _,cmd in more))
 def test_exception_clears_native_guard(self):
  original=Dialog.contextmenu
  def fail(self,labels):raise RuntimeError('cancelled')
  Dialog.contextmenu=fail
  try:
   with self.assertRaises(RuntimeError):c.open_menu({'type':'file'},actions={'play':('Play',lambda:None)})
   self.assertEqual(Window(10000).getProperty('milionar.ctx.active'),'')
  finally:Dialog.contextmenu=original
 def test_hero_excludes_dialog_local_actions(self):
  sys.modules['wcs.skin.hero_contract'].BUTTON_SLOTS=4
  from wcs.skin import hero_buttons
  for row in ('widget_tmdb_popular_movies','widget_mytv_channels','vinyl_playlists'):
   self.assertFalse(set(hero_buttons.catalog(row))&set(c.CONTEXT_ONLY))
 def test_raw_file_menu_plays_media_instead_of_invoking_plugin(self):
  item=Item('File');item.setProperty('IsPlayable','true');c.attach('https://files.invalid/test.mp4',item,False,'','')
  self.assertEqual(data(item)['type'],'file');top,more=c.parts(data(item));self.assertEqual(top[0][1],'PlayMedia(https://files.invalid/test.mp4)')
if __name__=='__main__':unittest.main()
