# -*- coding: utf-8 -*-
# plugin.video.alkoflix
# resources/lib/modules/kodi_utils.py

import json
import sqlite3 as database
from urllib.parse import urlencode, parse_qsl
import xbmc, xbmcgui, xbmcplugin, xbmcvfs
from xbmcaddon import Addon

window, dialog, progressDialog, progressDialogBG = xbmcgui.Window(10000), xbmcgui.Dialog(), xbmcgui.DialogProgress(), xbmcgui.DialogProgressBG()
player, xbmc_player, monitor, xbmc_monitor, execJSONRPC = xbmc.Player(), xbmc.Player, xbmc.Monitor(), xbmc.Monitor, xbmc.executeJSONRPC
get_infolabel, get_addoninfo, get_visibility = xbmc.getInfoLabel, Addon().getAddonInfo, xbmc.getCondVisibility
window_xml_info_action, window_xml_dialog = xbmcgui.ACTION_SHOW_INFO, xbmcgui.WindowXMLDialog
window_xml_closing_actions = (xbmcgui.ACTION_PARENT_DIR, xbmcgui.ACTION_PREVIOUS_MENU, xbmcgui.ACTION_STOP, xbmcgui.ACTION_NAV_BACK)
window_xml_selection_actions = (xbmcgui.ACTION_SELECT_ITEM, xbmcgui.ACTION_MOUSE_START)
window_xml_context_actions = (xbmcgui.ACTION_CONTEXT_MENU, xbmcgui.ACTION_MOUSE_RIGHT_CLICK, xbmcgui.ACTION_MOUSE_LONG_CLICK)
window_xml_left_action, window_xml_right_action = xbmcgui.ACTION_MOVE_LEFT, xbmcgui.ACTION_MOVE_RIGHT
window_xml_up_action, window_xml_down_action = xbmcgui.ACTION_MOVE_UP, xbmcgui.ACTION_MOVE_DOWN

navigator_db   = 'special://profile/addon_data/plugin.video.alkoflix/navigator.db'
watched_db     = 'special://profile/addon_data/plugin.video.alkoflix/watched.db'
favourites_db  = 'special://profile/addon_data/plugin.video.alkoflix/favourites.db'
views_db       = 'special://profile/addon_data/plugin.video.alkoflix/views.db'
trakt_db       = 'special://profile/addon_data/plugin.video.alkoflix/traktcache4.db'
mdbl_db        = 'special://profile/addon_data/plugin.video.alkoflix/mdblcache.db'
maincache_db   = 'special://profile/addon_data/plugin.video.alkoflix/maincache.db'
metacache_db   = 'special://profile/addon_data/plugin.video.alkoflix/metacache.db'
debridcache_db = 'special://profile/addon_data/plugin.video.alkoflix/debridcache.db'
external_db    = 'special://profile/addon_data/plugin.video.alkoflix/providerscache.db'
databases_path = 'special://profile/addon_data/plugin.video.alkoflix/'
packages_path  = 'special://home/addons/packages/'

current_dbs           = ('settings.xml', 'debridcache.db', 'favourites.db', 'maincache.db', 'metacache.db',
						'navigator.db', 'providerscache.db', 'traktcache4.db', 'mdblcache.db', 'views.db', 'watched.db', 'alkocache.db')
movie_dict_removals   = ('tmdblogo', 'fanart_added', 'cast', 'poster', 'rootname', 'imdb_id', 'tmdb_id', 'tvdb_id', 'all_trailers',
						'fanart', 'banner', 'clearlogo', 'clearart', 'landscape', 'discart', 'original_title', 'english_title', 'extra_info',
						'alternative_titles', 'country_codes', 'fanarttv_fanart', 'fanarttv_poster', 'fanart2', 'poster2', 'meta_language')
tvshow_dict_removals  = ('tmdblogo', 'fanart_added', 'cast', 'poster', 'rootname', 'imdb_id', 'tmdb_id', 'tvdb_id', 'all_trailers',
						'fanart', 'banner', 'clearlogo', 'clearart', 'landscape', 'discart', 'original_title', 'english_title', 'extra_info',
						'alternative_titles', 'country_codes', 'fanarttv_fanart', 'fanarttv_poster', 'fanart2', 'poster2', 'meta_language',
						'total_episodes', 'total_seasons', 'total_aired_eps', 'season_summary', 'season_data')
episode_dict_removals = ('thumb', 'guest_stars', 'episode_type', 'meta_language')
myvideos_db_paths     = {19: '119', 20: '121', 21: '131', 22: '139'}

from modules.log_sanitizer import sanitize_log_text

def _addon_debug_enabled():
	try: return Addon().getSetting('debug.enabled') == 'true'
	except Exception: return False


def _is_routine_log(heading, function):
	# Les détails répétitifs restent disponibles lorsque le débogage est activé.
	# En usage normal, le log doit garder seulement les actions utiles et les erreurs.
	text = ('%s %s' % (heading or '', function or '')).lower()
	important_markers = (
		'fail', 'failed', 'error', 'erreur', 'échec', 'echec', 'exception', 'traceback', 'unauthorized',
		'forbidden', 'too many requests', 'service unavailable', 'refusée', 'refusee',
		'http 401', 'http 403', 'http 429', 'http 500', 'http 502', 'http 503', 'http 504',
		'status=401', 'status=403', 'status=429', 'status=500', 'status=502', 'status=503', 'status=504'
	)
	if any(marker in text for marker in important_markers):
		return False
	routine_markers = (
		'service starting',
		'service finished',
		'service delayed start',
		'service skipped',
		'no changes needed',
		'no update available',
		'no remote version found',
		'watchlist sync inactive',
		'traktmonitor service update',
		'trakt update performed',
		'no trakt account active',
		'no mdblist account active',
		'upnext provider registered',
		'widget refresh skipped',
		'widget refresh performed',
		'aucun débrideur connecté',
		'rating action starting',
		'automatic favourites restore skipped',
		'alkopastes favourites debug',
		'alkopastes favourites backup parse',
		'alkoflix favourites initial sync starting',
		'alkoflix favourites initial sync finished',
		'alkoflix favourites initial sync skipped',
		'alkoflix favourites alkopastes sync starting',
		'alkoflix favourites alkopastes sync finished',
		'alkoflix favourites alkopastes sync skipped',
		'alkoflix favourites external sync',
		'alkoflix favourites background sync',
		'alkoflix favourites background tmdb bulk sync',
		'alkoflix favourites alkopastes bulk sync skipped',
		# Détails utiles en diagnostic, mais trop bavards en usage normal.
		'lecture source',
		'alkoflix player',
		'alkoflix subtitles',
		'alkoflix info windows',
		'alkoflix userratings',
		'alldebrid cloud cleanup',
		'tmdb metadata',
		'tmdb keyword search',
		'catalogue provider region fallback',
		'alkopastes userdata backup collect',
		'alkopastes userdata backup payload',
		'alkopastes userdata backup setting',
		'alkopastes userdata restore parse',
		'alkopastes userdata restore write',
		'alkopastes userdata portable cleanup',
		'automatic userdata backup skipped',
		'automatic userdata backup success',
		'automatic userdata restore skipped',
		'automatic userdata restore success',
	)
	if any(marker in text for marker in routine_markers):
		return True

	if 'alkopastes userdata backup' in text:
		return True

	# En mode normal, les succès alkoPastes restent silencieux.
	# Les erreurs HTTP/FAIL/exception passent plus haut via important_markers.
	if 'alkopastes' in text:
		alkopastes_routine_markers = (
			'start |', 'ok |', 'result |', 'done |', 'skip |', 'miss |', 'found |',
			'queued |', 'local_changed |', 'flush_start |', 'flush_ok |', 'flush_skipped |',
			'unchanged |', 'list page=', 'search query=', 'header_only', 'multi_device_disabled',
			'not_authorized', 'sync_disabled', 'missing_backup'
		)
		if any(marker in text for marker in alkopastes_routine_markers):
			return True

	return False


def logger(heading, function):
	if not _addon_debug_enabled() and _is_routine_log(heading, function):
		return
	mask_paste_ids = 'alkoPastes' in str(heading or '') or 'paste' in str(heading or '').lower()
	safe_heading = sanitize_log_text(heading, mask_paste_ids=mask_paste_ids)
	safe_function = sanitize_log_text(function, mask_paste_ids=mask_paste_ids)
	xbmc.log('>> %s <<: %s' % (safe_heading, safe_function), 1)

def database_connect(file, **kwargs):
	return database.connect(translate_path(file), **kwargs)

def media_path(*args):
	path = 'special://home/addons/plugin.video.alkoflix/resources/skins/Default/media/'
	return '%s%s' % (path, '/'.join(args)) if args else path

def clean_art(art):
	# Les menus doivent rester neutres côté fanart.
	# IMPORTANT : on ne remplace pas par transparent.png, car certains skins Kodi
	# affichent alors un fond noir. On retire seulement les valeurs neutres et le
	# fanart officiel de l'addon lorsqu'il est envoyé comme fallback de menu.
	try:
		neutral = media_path('transparent.png')
		addon_fanart = get_addoninfo('fanart')
		blocked = (neutral, addon_fanart, 'alkoflix_fanart.jpg', 'alkoflix_fanart.png')
		cleaned = {}
		for k, v in (art or {}).items():
			if not v: continue
			value = str(v)
			if k == 'fanart' and (value in blocked or value.endswith('/alkoflix_fanart.jpg') or value.endswith('\\alkoflix_fanart.jpg') or value.endswith('/alkoflix_fanart.png') or value.endswith('\\alkoflix_fanart.png')):
				continue
			if value == neutral: continue
			cleaned[k] = v
		return cleaned
	except Exception:
		return {k: v for k, v in (art or {}).items() if v}

def get_property(prop):
	return window.getProperty(prop)

def set_property(prop, value):
	return window.setProperty(prop, value)

def clear_property(prop):
	return window.clearProperty(prop)

def addon(addon_id='plugin.video.alkoflix'):
	return Addon(id=addon_id)

def addon_installed(addon_id):
	# Vérifie rapidement si une extension est présente dans Kodi.
	return get_visibility('System.HasAddon(%s)' % addon_id)

def addon_enabled(addon_id):
	# Vérifie si l'extension est installée ET activée.
	# Certains services, comme Up Next, peuvent être présents mais désactivés.
	try:
		payload = {
			'jsonrpc': '2.0',
			'id': 0,
			'method': 'Addons.GetAddonDetails',
			'params': {'addonid': addon_id, 'properties': ['enabled']}
		}
		result = json.loads(execJSONRPC(json.dumps(payload)))
		return result.get('result', {}).get('addon', {}).get('enabled') is True
	except:
		return addon_installed(addon_id)

def set_addon_enabled(addon_id, enabled=True):
	# Active ou désactive une extension déjà installée via JSON-RPC.
	try:
		payload = {
			'jsonrpc': '2.0',
			'id': 0,
			'method': 'Addons.SetAddonEnabled',
			'params': {'addonid': addon_id, 'enabled': enabled}
		}
		result = json.loads(execJSONRPC(json.dumps(payload)))
		return result.get('result') == 'OK'
	except:
		return False

def ensure_addon_installed(addon_id, addon_label=None, purpose_text=None, require_enabled=True):
	# Vérifie qu'une extension requise est disponible avant de lancer une fonction dépendante.
	# Si elle est absente ou désactivée, Kodi propose de l'installer ou de l'activer.
	addon_label = addon_label or addon_id
	installed = addon_installed(addon_id)
	enabled = addon_enabled(addon_id) if installed else False
	if installed and (enabled or not require_enabled): return True
	action = 'activer' if installed else 'installer'
	if not purpose_text:
		purpose_text = 'Cette fonction nécessite l’extension [B]%s[/B].' % addon_label
	text = '%s[CR][CR]Désirez-vous %s [B]%s[/B] maintenant ?' % (purpose_text, action, addon_id)
	if not confirm_dialog(text=text, ok_label='Oui', cancel_label='Non', top_space=True): return False
	if installed:
		set_addon_enabled(addon_id, True)
	else:
		execute_builtin('InstallAddon(%s)' % addon_id)
	# L'installation peut ouvrir une fenêtre Kodi. On laisse un court délai avant de revalider.
	sleep(1000)
	installed = addon_installed(addon_id)
	enabled = addon_enabled(addon_id) if installed else False
	return installed and (enabled or not require_enabled)

def add_item(handle, url, listitem, isFolder):
	xbmcplugin.addDirectoryItem(handle, url, listitem, isFolder)

def add_items(handle, item_list):
	xbmcplugin.addDirectoryItems(handle, item_list)

def set_content(handle, content):
	xbmcplugin.setContent(handle, content)

def set_category(handle, category):
	xbmcplugin.setPluginCategory(handle, category)

def set_sort_method(handle, method):
	if method == 'episodes': sort_method = xbmcplugin.SORT_METHOD_EPISODE
	elif method == 'files': sort_method = xbmcplugin.SORT_METHOD_FILE
	elif method == 'label': sort_method = xbmcplugin.SORT_METHOD_LABEL_IGNORE_THE  # Ignore l'article anglais "The" lors du tri.
	else: sort_method = xbmcplugin.SORT_METHOD_UNSORTED
	xbmcplugin.addSortMethod(handle, sort_method)

def end_directory(handle, cacheToDisc=None):
	if cacheToDisc is None: cacheToDisc = False
	xbmcplugin.endOfDirectory(handle, cacheToDisc=cacheToDisc)

def set_resolvedurl(handle, item):
	xbmcplugin.setResolvedUrl(handle, True, item)

def make_cast_list(cast=None):
	if not cast: return []
	return [xbmc.Actor(**actor) for actor in cast]

def make_playlist(_type='video'):
	return xbmc.PlayList(xbmc.PLAYLIST_VIDEO) if _type == 'video' else xbmc.PlayList(xbmc.PLAYLIST_MUSIC)

def convert_language(lang, format='long'):
	return xbmc.convertLanguage(lang, xbmc.ISO_639_2 if format == 'long' else xbmc.ISO_639_1)

def supported_media():
	return xbmc.getSupportedMedia('video')

def path_exists(path):
	return xbmcvfs.exists(path)

def make_directory(path):
	xbmcvfs.mkdir(path)

def make_directorys(path):
	xbmcvfs.mkdirs(path)

def open_file(file, mode='r'):
	return xbmcvfs.File(file, mode)

def copy_file(source, destination):
	return xbmcvfs.copy(source, destination)

def delete_file(_file):
	xbmcvfs.delete(_file)

def rename_file(old, new):
	xbmcvfs.rename(old, new)

def list_dirs(location):
	return xbmcvfs.listdir(location)

def make_listitem():
	return xbmcgui.ListItem(offscreen=True)


def set_listitem_video_info(listitem, info=None):
	"""Applique les métadonnées vidéo sans déclencher l'avertissement setInfo() de Kodi 20+."""
	info = info or {}
	try:
		if get_kodi_version() < 20:
			listitem.setInfo('video', info)
			return listitem
	except:
		return listitem
	try:
		try: tag = listitem.getVideoInfoTag(offscreen=True)
		except TypeError: tag = listitem.getVideoInfoTag()
	except:
		return listitem
	def _string(value):
		return '' if value is None else str(value)
	def _int(value, default=0):
		try: return int(value)
		except: return default
	def _float(value, default=0.0):
		try: return float(value)
		except: return default
	def _list(value):
		if not value: return []
		if isinstance(value, (list, tuple)): return [str(i) for i in value if i]
		return [i.strip() for i in str(value).split(',') if i.strip()]
	def _call(method, value, transform=None):
		if value in (None, ''): return
		try:
			func = getattr(tag, method)
		except:
			return
		try:
			func(transform(value) if transform else value)
		except:
			pass
	_call('setTitle', info.get('title'), _string)
	_call('setOriginalTitle', info.get('originaltitle') or info.get('original_title'), _string)
	_call('setTvShowTitle', info.get('tvshowtitle'), _string)
	_call('setPlot', info.get('plot'), _string)
	_call('setPlotOutline', info.get('plotoutline'), _string)
	_call('setTagLine', info.get('tagline'), _string)
	_call('setMediaType', info.get('mediatype'), _string)
	_call('setYear', info.get('year'), _int)
	_call('setSeason', info.get('season'), _int)
	_call('setEpisode', info.get('episode'), _int)
	_call('setDuration', info.get('duration'), _int)
	_call('setMpaa', info.get('mpaa'), _string)
	_call('setIMDBNumber', info.get('imdbnumber') or info.get('imdb_id'), _string)
	_call('setTrailer', info.get('trailer'), _string)
	_call('setPremiered', info.get('premiered'), _string)
	_call('setFirstAired', info.get('aired'), _string)
	_call('setPlaycount', info.get('playcount'), _int)
	_call('setRating', info.get('rating'), _float)
	_call('setUserRating', info.get('userrating') or info.get('user_rating'), _int)
	_call('setVotes', info.get('votes'), _int)
	_call('setGenres', info.get('genre') or info.get('genres'), _list)
	_call('setCountries', info.get('country') or info.get('countries'), _list)
	_call('setDirectors', info.get('director') or info.get('directors'), _list)
	_call('setWriters', info.get('writer') or info.get('writers'), _list)
	_call('setStudios', info.get('studio') or info.get('studios'), _list)
	return listitem

def local_string(string):
	try:
		_string = int(string)
	except:
		return string

	try:
		_string = str(Addon().getLocalizedString(_string))
	except:
		try:
			_string = str(Addon(id='plugin.video.alkoflix').getLocalizedString(_string))
		except:
			return str(string)

	return _string or str(string)

def translate_path(path):
	return xbmcvfs.translatePath(path)

def sleep(time):
	return xbmc.sleep(time)

def execute_builtin(command):
	return xbmc.executebuiltin(command)

def get_kodi_version():
	return int(get_infolabel('System.BuildVersion')[0:2])

def skin_location():
	return 'xilfokla.oediv.nigulp/snodda/emoh//:laiceps'[::-1]

def current_skin_id():
	# Retourne l'identifiant du skin actif pour adapter les actions dépendantes du skin.
	try: return xbmc.getSkinDir()
	except: return ''

def current_skin():
	return xbmc.getSkinDir()

def current_window_id():
	# Les fenêtres XMLDialog personnalisées de Kodi utilisent souvent des IDs
	# temporaires (ex. 13000). Les passer à xbmcgui.Window(id) peut écrire
	# « Window id does not exist » dans le log même si l'exception est attrapée.
	# On les évite donc avant l'appel Python, et on retombe sur la fenêtre Home.
	try:
		window_id = xbmcgui.getCurrentWindowId()
		if int(window_id) >= 13000: return window
		return xbmcgui.Window(window_id)
	except Exception:
		try: return window
		except Exception: return xbmcgui.Window(10000)

def get_video_database_path():
	return 'special://profile/Database/MyVideos%s.db' % myvideos_db_paths[get_kodi_version()]

def show_busy_dialog():
	return execute_builtin('ActivateWindow(busydialognocancel)')

def hide_busy_dialog():
	execute_builtin('Dialog.Close(busydialognocancel)')
	execute_builtin('Dialog.Close(busydialog)')

def close_all_dialog():
	execute_builtin('Dialog.Close(all,true)')

def container_content():
	return get_infolabel('Container.Content')

def external_browse():
	return 'alkoflix' not in get_infolabel('Container.PluginName')

def widget_refresh():
	return execute_builtin('UpdateLibrary(video,special://skin/foo)')

def container_refresh():
	return execute_builtin('Container.Refresh')

def navigation_update(url_params):
	# Depuis un widget d'accueil, Container.Update() ne cible pas toujours un vrai
	# conteneur vidéo alkoFlix. On ouvre alors explicitement la route dans Videos
	# pour que les choix contextuels (tri, aller à la page) soient exécutés.
	url = build_url(url_params)
	if external_browse():
		return execute_builtin('ActivateWindow(Videos,%s,return)' % url)
	return execute_builtin('Container.Update(%s)' % url)

def ok_dialog(heading='alkoFlix', text='', highlight='dodgerblue', ok_label=local_string(32839), top_space=False):
	if isinstance(heading, int): heading = local_string(heading)
	if isinstance(text, int): text = local_string(text)
	if not text: top_space, text = True, local_string(32760)
	if top_space: text = '[CR]%s' % text
	return dialog.ok(heading, text)

def confirm_dialog(heading='alkoFlix', text='', highlight='dodgerblue', ok_label=local_string(32839), cancel_label=local_string(32840), top_space=False, default_control=11):
	if isinstance(heading, int): heading = local_string(heading)
	if isinstance(text, int): text = local_string(text)
	if isinstance(ok_label, int): ok_label = local_string(ok_label)
	if isinstance(cancel_label, int): cancel_label = local_string(cancel_label)
	if not text: text = '[CR]%s' % local_string(32580)
	elif top_space: text = '[CR]%s' % text
	return dialog.yesno(heading, text, cancel_label, ok_label)

def select_dialog(function_list, **kwargs):
	heading = kwargs.get('heading', 'alkoFlix')
	items_json = kwargs.get('items', '[]')
	multi_choice = kwargs.get('multi_choice', 'false') == 'true'
	use_details = kwargs.get('use_details', 'false') == 'true'
	preselect = kwargs.get('preselect', [])

	try:
		items = json.loads(items_json) if isinstance(items_json, str) else (items_json or [])
	except:
		items = []

	labels = []
	detailed_items = []
	for item in items:
		line1 = item.get('line1', '') if isinstance(item, dict) else str(item)
		line2 = item.get('line2', '') if isinstance(item, dict) else ''
		icon = item.get('icon', '') if isinstance(item, dict) else ''
		if line2:
			labels.append(f"{line1} - {line2}")
		else:
			labels.append(line1)
		if use_details:
			try:
				listitem = make_listitem()
				listitem.setLabel(line1)
				if line2: listitem.setLabel2(line2)
				if icon: listitem.setArt({'icon': icon, 'thumb': icon})
				detailed_items.append(listitem)
			except:
				detailed_items = []
				use_details = False

	if not labels:
		labels = [str(i) for i in function_list]

	if multi_choice:
		selection = dialog.multiselect(heading, labels, preselect=preselect)
		if selection in (None, []):
			return None
		return [function_list[i] for i in selection]

	preselected = preselect[0] if preselect else -1
	if use_details and detailed_items and len(detailed_items) == len(function_list):
		try: selection = dialog.select(heading, detailed_items, preselect=preselected, useDetails=True)
		except TypeError: selection = dialog.select(heading, labels, preselect=preselected)
		except: selection = dialog.select(heading, labels, preselect=preselected)
	else:
		selection = dialog.select(heading, labels, preselect=preselected)
	if selection in (-1, None):
		return None
	return function_list[selection]

def _read_text_file(file):
	with open_file(file) as f:
		try:
			return f.readBytes().decode('utf-8-sig', errors='ignore')
		except AttributeError:
			text = f.read()
			if isinstance(text, bytes): text = text.decode('utf-8-sig', errors='ignore')
			return text


def _filter_kodi_log_errors(text):
	lines = []
	for line in str(text or '').splitlines(keepends=True):
		if line and line[0].isdigit():
			lines += [line]
		elif lines:
			lines[-1] += line
	return ''.join(i for i in reversed(lines) if any(x in i.lower() for x in ('exception', 'error')))


def _prepare_kodi_log_text(text=None, file=None, errors_only=False):
	# Point unique de préparation du log Kodi avant affichage, export ou envoi.
	if file and text is None:
		text = _read_text_file(file)
	if errors_only:
		text = _filter_kodi_log_errors(text)
	return sanitize_log_text(text or '', mask_paste_ids=True)


def show_text(heading, text=None, file=None, font_size='small', kodi_log=False):
	if isinstance(heading, int):
		heading = local_string(heading)
	heading = heading.replace('[B]', '').replace('[/B]', '').replace('[I]', '').replace('[/I]', '')
	if kodi_log:
		errors_only = confirm_dialog(text=local_string(32855), ok_label=local_string(32824), cancel_label=local_string(32828), top_space=True)
		text = _prepare_kodi_log_text(text=text, file=file, errors_only=errors_only)
	elif file:
		text = _read_text_file(file)
	dialog.textviewer(heading, text or '')
	return None

def notification(line1, time=3000, icon=None, sound=False):
	if isinstance(line1, int): line1 = local_string(line1)
	icon = icon or get_addoninfo('icon')
	dialog.notification('alkoFlix', line1, icon, time, sound)

def choose_view(view_type, content):
	from sys import argv
	__handle__ = int(argv[1])
	label = local_string(32547)
	fanart = ''
	icon = media_path('settings.png')
	params_url = build_url({'mode': 'set_view', 'view_type': view_type})
	listitem = make_listitem()
	listitem.setLabel(label)
	listitem.setArt({'icon': icon, 'poster': icon, 'thumb': icon, 'fanart': fanart, 'banner': icon})
	add_item(__handle__, params_url, listitem, False)
	set_content(__handle__, content)
	end_directory(__handle__)
	set_view_mode(view_type, content)

def set_view(view_type):
	view_id = str(current_window_id().getFocusId())
	dbcon = database_connect(views_db, isolation_level=None)
	dbcur = dbcon.cursor()
	dbcur.execute("""PRAGMA synchronous = OFF""")
	dbcur.execute("""PRAGMA journal_mode = OFF""")
	dbcur.execute("""INSERT OR REPLACE INTO views VALUES (?, ?)""", (view_type, view_id))
	set_view_property(view_type, view_id)
	notification(get_infolabel('Container.Viewmode').upper(), 1500)

def set_view_property(view_type, view_id):
	set_property('alkoflix_%s' % view_type, view_id)

def set_view_properties():
	dbcon = database_connect(views_db, isolation_level=None)
	dbcur = dbcon.cursor()
	dbcur.execute("""SELECT * FROM views""")
	view_ids = dbcur.fetchall()
	for item in view_ids: set_property('alkoflix_%s' % item[0], item[1])

def set_view_mode(view_type, content='files'):
	if external_browse(): return
	view_id = get_property('alkoflix_%s' % view_type)
	hold = 0
	if not view_id:
		try:
			dbcon = database_connect(views_db, isolation_level=None)
			dbcur = dbcon.cursor()
			dbcur.execute("""SELECT view_id FROM views WHERE view_type = ?""", (str(view_type),))
			view_id = dbcur.fetchone()[0]
		except: return
	try:
		sleep(100)
		while not container_content() == content:
			hold += 1
			if hold < 5000: sleep(1)
			else: return
		if view_id: execute_builtin('Container.SetViewMode(%s)' % view_id)
	except: return

def clear_view(view_type):
	if not confirm_dialog(): return
	try:
		dbcon = database_connect(views_db, isolation_level=None)
		dbcur = dbcon.cursor()
		dbcur.execute("""PRAGMA synchronous = OFF""")
		dbcur.execute("""PRAGMA journal_mode = OFF""")
		dbcur.execute("""SELECT view_type FROM views""")
		for item in dbcur.fetchall():
			dbcur.execute("""DELETE FROM views WHERE view_type = ?""", (item[0],))
			clear_property('alkoflix_%s' % item[0])
		dbcon = database_connect('special://profile/Database/ViewModes6.db')
		dbcur = dbcon.cursor()
		dbcur.execute("""DELETE FROM view WHERE path LIKE 'plugin://plugin.video.alkoflix/%'""")
		dbcon.commit()
		dbcon.close()
	except: return notification(32574, 1500)
	notification(32576, 1500)

def build_url(url_params):
	return f"{'/xilfokla.oediv.nigulp//:nigulp'[::-1]}?{urlencode(url_params)}"

def _is_numeric_page(value):
	try:
		return str(int(str(value))).isdigit() and int(str(value)) > 0
	except:
		return False


def next_page_context(url_params):
	# Ajoute une navigation directe par numéro de page sur les entrées "Page suivante".
	# Dans Kodi, l'appui long ouvre le menu contextuel : c'est donc l'endroit
	# le plus stable pour proposer un saut de page sans modifier le clic normal.
	try:
		if not _is_numeric_page(url_params.get('new_page')): return []
		target_params = dict(url_params)
		target_params.pop('handle', None)
		jump_url = build_url({'mode': 'page_jump_choice', 'target': json.dumps(target_params, ensure_ascii=False)})
		return [('Aller à la page...', 'RunPlugin(%s)' % jump_url)]
	except:
		return []


def _clear_navigation_state(menu_item):
	# Les liens "Page suivante" peuvent transporter un offset interne
	# (tmdb_full_skip) utilisé par l'option "Tenter d'afficher des pages
	# complètes". Lorsqu'on relance un tri depuis le menu contextuel, cet
	# offset ne doit pas survivre : sinon la nouvelle requête démarre au
	# milieu d'une ancienne page TMDb.
	try:
		for key in ('new_page', 'new_letter', 'tmdb_full_skip'):
			menu_item.pop(key, None)
	except: pass
	return menu_item


def _normalised_menu_item(url_params, name='', iconImage='', isFolder=True):
	try:
		menu_item = dict(url_params or {})
		menu_item.pop('handle', None)
		if not menu_item.get('mode'): return {}
		name = name or menu_item.get('name', '')
		iconImage = iconImage or menu_item.get('iconImage', '')
		if name: menu_item['name'] = name
		if iconImage: menu_item['iconImage'] = iconImage
		if not isFolder: menu_item['isFolder'] = 'false'
		return menu_item
	except:
		return {}


def menu_editor_context(url_params, name='', iconImage='', isFolder=True):
	# Ajoute les actions de base de l'éditeur de menus aux dossiers dynamiques.
	# Le menu_item est transmis explicitement afin de ne pas dépendre de
	# ListItem.FileNameAndPath, parfois vide ou incomplet dans les menus générés.
	try:
		menu_item = _normalised_menu_item(url_params, name, iconImage, isFolder)
		if not menu_item: return []
		name = name or menu_item.get('name', '')
		iconImage = iconImage or menu_item.get('iconImage', '')
		base_params = {'name': name, 'iconImage': iconImage, 'menu_item': json.dumps(menu_item, ensure_ascii=False)}
		return [
			(local_string(32730), 'RunPlugin(%s)' % build_url({'mode': 'menu_editor.add_external', **base_params})),
			(local_string(32731), 'RunPlugin(%s)' % build_url({'mode': 'menu_editor.shortcut_folder_add_item', **base_params}))
		]
	except:
		return []


def tmdb_genre_sort_context(url_params, name='', iconImage='', isFolder=True):
	# Ajoute un tri TMDb optionnel aux dossiers Discover natifs.
	# Le clic normal reste inchangé : le choix se fait seulement depuis le menu contextuel.
	try:
		if not isFolder: return []
		menu_item = _normalised_menu_item(url_params, name, iconImage, isFolder)
		if not menu_item: return []
		mode = str(menu_item.get('mode') or '')
		action = str(menu_item.get('action') or '')
		if mode not in ('build_movie_list', 'build_tvshow_list'): return []
		if not action.startswith('tmdb_'): return []
		# Les listes publiques TMDb se trient localement pour respecter leur contenu exact.
		# Elles passent donc par list_sort_context(), pas par le tri Discover régional.
		if action == 'tmdb_movies_public_list': return []
		# Les menus anime sont volontairement exclus : ils reposent justement sur
		# des langues/origines asiatiques et ne doivent pas être vidés par ce filtre.
		if '_anime_' in action or 'moviesanime' in action or 'tvanime' in action: return []
		# Ces dossiers ne sont pas des Discover filtrables ou ont leur propre logique.
		excluded = ('_search', '_collection', '_curated_collections', '_similar', '_recommendations', '_person_', '_title_year', '_discover')
		if any(token in action for token in excluded): return []
		if action.endswith('_language') and str(menu_item.get('language_code') or '').lower() not in ('fr', 'en'): return []
		menu_item = _clear_navigation_state(menu_item)
		target = json.dumps(menu_item, ensure_ascii=False)
		return [('Trier par...', 'RunPlugin(%s)' % build_url({'mode': 'genre_sort_choice', 'target': target}))]
	except:
		return []


def list_sort_context(url_params, name='', iconImage='', isFolder=True):
	# Ajoute un tri local optionnel aux dossiers de listes.
	# Les listes utilisateurs/publiques sont triées telles quelles, sans filtre régional.
	try:
		if not isFolder: return []
		menu_item = _normalised_menu_item(url_params, name, iconImage, isFolder)
		if not menu_item: return []
		mode = str(menu_item.get('mode') or '')
		action = str(menu_item.get('action') or '')
		personal_trakt_actions = ('trakt_collection', 'trakt_watchlist', 'trakt_favorites')
		history_actions = ('in_progress_movies', 'watched_movies', 'in_progress_tvshows', 'trakt_in_progress_tvshows', 'watched_tvshows')
		if mode in ('build_trakt_list', 'build_mdb_list'):
			if not menu_item.get('list_id'): return []
		elif mode == 'build_movie_list' and action == 'tmdb_movies_public_list':
			if not menu_item.get('list_id'): return []
		elif mode in ('build_movie_list', 'build_tvshow_list') and action in personal_trakt_actions + history_actions:
			pass
		elif mode == 'build_in_progress_episode':
			pass
		else:
			return []
		menu_item = _clear_navigation_state(menu_item)
		target = json.dumps(menu_item, ensure_ascii=False)
		return [('Trier par...', 'RunPlugin(%s)' % build_url({'mode': 'list_sort_choice', 'target': target}))]
	except:
		return []


def add_dir(__handle__, url_params, list_name, iconImage=None, fanartImage=None, isFolder=True):
	context_name = url_params.get('name') or list_name
	is_next_page = 'new_page' in url_params and _is_numeric_page(url_params.get('new_page'))
	if is_next_page:
		list_name = f"{list_name} > {url_params['new_page']}"
	# Les entrées techniques (ex. Page suivante) ne doivent fournir aucun fanart.
	# Cela laisse le skin afficher son fond/fallback naturel au lieu d'un fond noir.
	# Ne pas utiliser get_addoninfo('fanart') comme fallback : Kodi afficherait
	# alors le fanart de l'addon dans les menus qui n'ont pas de vraie image.
	if is_next_page: fanart = ''
	else: fanart = fanartImage or ''
	icon = iconImage or media_path('item_next.png')
	url = build_url(url_params)
	listitem = make_listitem()
	listitem.setLabel(list_name)
	listitem.setArt(clean_art({
		'icon': icon, 'poster': icon, 'thumb': icon, 'fanart': fanart, 'banner': icon,
		'landscape': icon
	}))
	cm = []
	try:
		cm += next_page_context(url_params)
		cm += tmdb_genre_sort_context(url_params, context_name, iconImage or icon, isFolder)
		cm += list_sort_context(url_params, context_name, iconImage or icon, isFolder)
	except: pass
	if cm: listitem.addContextMenuItems(cm, replaceItems=False)
	add_item(__handle__, url, listitem, isFolder)

def remove_meta_keys(dict_item, dict_removals):
	for k in dict_removals: dict_item.pop(k, None)
	return dict_item

def volume_checker(volume_setting):
	# 0% == -60db, 100% == 0db
	try:
		if get_visibility('Player.Muted'): return
		from modules.utils import string_alphanum_to_num
		max_volume = int(min(int(volume_setting), 100))
		current_volume_db = int(string_alphanum_to_num(get_infolabel('Player.Volume').split('.')[0]))
		current_volume_percent = int(100 - ((float(current_volume_db)/60)*100))
		if current_volume_percent > max_volume: execute_builtin('SetVolume(%d)' % int(max_volume))
	except: pass

def focus_index(index, sleep_time=100):
	sleep(sleep_time)
	current_window = current_window_id()
	focus_id = current_window.getFocusId()
	try: current_window.getControl(focus_id).selectItem(index)
	except: pass

def clean_settings_window_properties():
	clear_property('alkoflix_settings')
	notification(32576, 1500)

def fetch_kodi_imagecache(image):
	result = None
	try:
		dbcon = database_connect('special://profile/Database/Textures13.db')
		dbcur = dbcon.cursor()
		dbcur.execute("""SELECT cachedurl FROM texture WHERE url = ?""", (image,))
		result = dbcur.fetchone()[0]
	except: pass
	return result

def set_setting(setting_id, value):
	# Garde le cache interne synchronisé quand un réglage est modifié depuis
	# un dialogue custom lancé par les settings Kodi.
	Addon().setSetting(setting_id, value)
	clear_property('alkoflix_settings')

def refresh_addon_settings_window(delay=100, reopen=False):
	# Ne pas fermer/réouvrir ni forcer Container.Refresh dans les réglages Kodi.
	# Certains skins/plateformes annulent les valeurs en cours d’édition si la
	# fenêtre est rafraîchie trop agressivement. On vide seulement le cache interne.
	try:
		clear_property('alkoflix_settings')
	except: pass
	return

def clear_source_manifest(params):
	# Supprime proprement les manifests de sources personnelles depuis les réglages Kodi.
	# La liste blanche évite qu'un paramètre arbitraire puisse être vidé via l'URL plugin.
	target = str((params or {}).get('target', '')).strip()
	allowed = {
		'aiostreams': ('aiostreams_custom.manifest_url', 'aiostreams.manifest_url'),
		'custom_1': ('custom_1.manifest_url',),
		'custom_2': ('custom_2.manifest_url',)
	}
	setting_ids = allowed.get(target)
	if not setting_ids:
		return notification(32574, 1500)
	try:
		for setting_id in setting_ids:
			set_setting(setting_id, '')
		clear_property('alkoflix_settings')
		notification(33249, 1500)
	except Exception as exc:
		logger('clear source manifest failed', '%s | %s' % (target, exc))
		notification(32574, 1500)


def get_setting(setting_id, fallback=None):
	try: settings_dict = json.loads(get_property('alkoflix_settings'))
	except: settings_dict = make_settings_dict()
	if settings_dict is None: settings_dict = get_setting_fallback(setting_id)
	value = settings_dict.get(setting_id, '')
	if fallback is None: return value
	if value == '': return fallback
	return value

def get_setting_fallback(setting_id):
	return {setting_id: Addon().getSetting(setting_id)}

def make_settings_dict():
	import xml.etree.ElementTree as ET
	settings_dict = None
	try:
		profile_dir = 'special://profile/addon_data/plugin.video.alkoflix/'
		profile_xml = profile_dir + 'settings.xml'
		if not path_exists(profile_xml):
			make_directorys(profile_dir)
			sleep(500)
		with open_file(profile_xml) as xml_file:
			settings_text = xml_file.read()
		if isinstance(settings_text, bytes): settings_text = settings_text.decode('utf-8', 'ignore')
		if not str(settings_text or '').strip():
			raise ValueError('settings.xml empty')
		root = ET.fromstring(settings_text)
		settings_dict = {
			item.get('id'): (item.get('value') if item.get('value') is not None else (item.text or ''))
			for item in root.iter('setting')
			if item.get('id')
		}
		set_property('alkoflix_settings', json.dumps(settings_dict))
	except Exception as e:
		logger('make_settings_dict error', str(e))
		# Kodi peut déclencher onSettingsChanged pendant que settings.xml est brièvement
		# vide/en cours d'écriture. Dans ce cas, on conserve le dernier cache valide
		# au lieu de retomber sur les valeurs par défaut et de changer de source.
		try:
			cached_settings = json.loads(get_property('alkoflix_settings') or '{}')
			if cached_settings: settings_dict = cached_settings
		except Exception: pass
	return settings_dict

def clean_settings():
	# Nettoie les anciens paramètres sans réinitialiser les comptes, favoris, sources ou sauvegardes.
	# Important : ne jamais importer service.py ici. Ce fichier lance le monitor Kodi au chargement.
	# Cette action est appelée depuis les réglages, elle doit donc rester courte et autonome.
	import xml.etree.ElementTree as ET
	logger('clean settings', 'starting')
	addon_ids = 'plugin.video.alkoflix'
	default_xml = 'special://home/addons/%s/resources/settings.xml' % addon_ids
	profile_xml = 'special://profile/addon_data/%s/settings.xml' % addon_ids

	obsolete_profile_setting_ids = set((
		# Débrideurs retirés.
		'pm.token', 'pm.refresh', 'pm.expires', 'pm.account_id', 'pm.username', 'pm.enabled',
		'pm.torrent.enabled', 'pm.hoster.enabled', 'pm.priority', 'provider.pm_cloud',
		'pm_cloud.title_filter', 'check.pm_cloud', 'results.sort_pmcloud_first',
		'store_torrent.premiumize', 'premiumize.enabled', 'premiumize.token',
		'oc.token', 'oc.refresh', 'oc.expires', 'oc.account_id', 'oc.username', 'oc.enabled',
		'oc.torrent.enabled', 'oc.hoster.enabled', 'oc.priority', 'provider.oc_cloud',
		'oc_cloud.title_filter', 'check.oc_cloud', 'results.sort_occloud_first',
		'store_torrent.offcloud', 'offcloud.enabled', 'offcloud.token',
		'ed.token', 'ed.refresh', 'ed.expires', 'ed.account_id', 'ed.username', 'ed.enabled',
		'ed.torrent.enabled', 'ed.hoster.enabled', 'ed.priority', 'provider.ed_cloud',
		'ed_cloud.title_filter', 'check.ed_cloud', 'results.sort_edcloud_first',
		'store_torrent.easydebrid', 'easydebrid.enabled', 'easydebrid.token',
		# Anciennes couleurs/options de mise en évidence retirées de l'interface.
		'hoster.identify', 'torrent.identify',
		'provider.ad_colour', 'provider.rd_colour', 'provider.tb_colour',
		'provider.comet_colour', 'provider.aiostreams_colour', 'provider.debrid_cloud_colour',
		'provider.folders_colour', 'provider.c411_colour', 'provider.torr9_colour', 'provider.tr4ker_colour',
		'provider.pm_colour', 'provider.oc_colour', 'provider.ed_colour', 'provider.free_colour',
		'provider.custom_1_colour', 'provider.custom_2_colour',
		'provider.custom_stremio1_colour', 'provider.custom_stremio2_colour',
		# Réglages cachés retirés de resources/settings.xml en 1.4.42.
		'include_year_in_title', 'single_ep_display', 'single_ep_format',
		'use_season_title', 'thumb_fanart', 'highlight.type', 'tmdb.try_full_pages',
		'kodi_menu_cache', 'kodi_menu_cache_removed_1416', 'trakt.sync_refresh_widgets',
		'sort.favorites', 'sort.favorites.order', 'sort.watchlist', 'sort.watchlist.order',
		# Anciens réglages retirés mais encore susceptibles d'être réécrits par Kodi
		# lorsque la fenêtre des réglages est ouverte pendant le nettoyage.
		'ext_dialog_highlight', 'int_dialog_highlight', 'provider.external',
		'sort.collection', 'sort.collection.order', 'sort.progress', 'sort.watched',
		'results.language_filter', 'results.language_display', 'results.language',
		'results.sort_order', 'results.sort_order_display', 'results.sort_size'
	))
	legacy_setting_renames = {}

	def _xml_value(item):
		return item.get('value') if item.get('value') is not None else (item.text or '')

	def _xml_set(item, value):
		item.set('value', str(value or ''))
		item.text = None

	def _get_node(root, setting_id):
		return next((item for item in root.findall('setting') if item.get('id') == setting_id), None)

	def _set_xml_if_changed(root, setting_id, value):
		node = _get_node(root, setting_id)
		if node is None:
			node = ET.SubElement(root, 'setting', {'id': setting_id})
		if str(_xml_value(node)) == str(value): return False
		_xml_set(node, value)
		return True

	def _personal_sort_index_from_key(sort_key):
		mapping = {
			'title_asc': '0', 'title_desc': '1',
			'date_desc': '2', 'date_asc': '3',
			'added_desc': '4', 'added_asc': '5'
		}
		return mapping.get(str(sort_key or '').strip(), '4')

	def _legacy_default_sort_key(setting_id, sort_value, order_value):
		try: sort_value = int(str(sort_value or '').strip())
		except: sort_value = 2 if setting_id == 'favorites' else 1
		try: order_value_int = int(str(order_value or '').strip())
		except: order_value_int = 1 if setting_id == 'favorites' else 0
		if setting_id == 'favorites':
			base = {0: 'title', 1: 'date', 2: 'added'}.get(sort_value, 'added')
			desc = order_value_int == 1
		else:
			base = {0: 'title', 1: 'added', 2: 'date'}.get(sort_value, 'added')
			desc = order_value_int == 0
		if base == 'title': return 'title_desc' if desc else 'title_asc'
		if base == 'date': return 'date_desc' if desc else 'date_asc'
		return 'added_desc' if desc else 'added_asc'

	try:
		if not path_exists(profile_xml):
			clear_property('alkoflix_settings')
			logger('clean settings', 'no profile settings file')
			return ok_dialog(text='Aucun fichier de paramètres utilisateur à nettoyer.', top_space=True)
		with open_file(default_xml) as xml_file:
			default_text = xml_file.read()
		if isinstance(default_text, bytes): default_text = default_text.decode('utf-8', 'ignore')
		default_root = ET.fromstring(default_text)
		active_settings = {item.get('id') for item in default_root.iter('setting') if item.get('id')}

		with open_file(profile_xml) as xml_file:
			profile_text = xml_file.read()
		if isinstance(profile_text, bytes): profile_text = profile_text.decode('utf-8', 'ignore')
		if not str(profile_text or '').strip():
			clear_property('alkoflix_settings')
			logger('clean settings', 'empty profile settings file')
			return ok_dialog(text='Le fichier de paramètres utilisateur est vide. Aucun ancien paramètre à nettoyer.', top_space=True)
		root = ET.fromstring(profile_text)

		current_values = {item.get('id'): _xml_value(item) for item in root.findall('setting') if item.get('id')}
		migrated_settings = []
		for old_id, new_id in legacy_setting_renames.items():
			old_value = str(current_values.get(old_id, '') or '').strip()
			if old_value and new_id in active_settings and not str(current_values.get(new_id, '') or '').strip():
				target = _get_node(root, new_id)
				if target is None: target = ET.SubElement(root, 'setting', {'id': new_id})
				_xml_set(target, old_value)
				migrated_settings.append('%s->%s' % (old_id, new_id))

		# Migrer les anciens tris cachés avant suppression des clés obsolètes.
		for legacy_group in ('watchlist', 'favorites'):
			new_id = 'default_sort.%s' % legacy_group
			old_id = 'sort.%s' % legacy_group
			if new_id in active_settings and new_id not in current_values and old_id in current_values:
				sort_key = _legacy_default_sort_key(legacy_group, current_values.get(old_id), current_values.get('%s.order' % old_id))
				if _set_xml_if_changed(root, new_id, _personal_sort_index_from_key(sort_key)):
					migrated_settings.append('%s->%s' % (old_id, new_id))
					current_values[new_id] = _personal_sort_index_from_key(sort_key)

		removed_settings = []
		for item in list(root.findall('setting')):
			setting_id = item.get('id')
			if not setting_id: continue
			if setting_id not in active_settings or setting_id in obsolete_profile_setting_ids:
				root.remove(item)
				removed_settings.append(setting_id)

		normalized_settings = []
		# Les anciennes options cachées retirées de settings.xml sont supprimées
		# plutôt que normalisées : leurs valeurs sûres sont imposées en code.
		try:
			limit_value = str(current_values.get('page_limit', '') or '').strip()
			current_limit = int(limit_value or '20')
		except: current_limit = 20
		if 'page_limit' in active_settings and (current_limit < 1 or current_limit > 500):
			if _set_xml_if_changed(root, 'page_limit', '20'):
				normalized_settings.append('page_limit=20')

		legacy_bool_values = {
			'media.click_action': {'0': 'false', '1': 'true'},
			'info_extra.window': {'0': 'false', '1': 'true'}
		}
		for setting_id, mapping in legacy_bool_values.items():
			if setting_id in active_settings:
				node = _get_node(root, setting_id)
				if node is not None:
					new_value = mapping.get(str(_xml_value(node) or '').strip())
					if new_value is not None and _set_xml_if_changed(root, setting_id, new_value):
						normalized_settings.append('%s=%s' % (setting_id, new_value))

		for setting_id, fallback in (('default_sort.watchlist', '4'), ('default_sort.favorites', '4')):
			if setting_id not in active_settings: continue
			node = _get_node(root, setting_id)
			raw = _xml_value(node) if node is not None else fallback
			try: selected = int(str(raw or '').strip())
			except: selected = int(fallback)
			if selected < 0 or selected > 5:
				if _set_xml_if_changed(root, setting_id, fallback):
					normalized_settings.append('%s=%s' % (setting_id, fallback))

		updated_xml = '<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>\n%s' % ET.tostring(root, encoding='unicode')
		with open_file(profile_xml, 'w') as xml_file: xml_file.write(updated_xml)
		clear_property('alkoflix_settings')
		make_settings_dict()

		legacy_marker_names = (
			'obsolete_settings_cleanup_v145_wip34.done',
			'ygg_source_wip7.done',
			'default_integrated_sources_wip1.done',
			'default_integrated_sources_wip11.done',
			'default_result_controls_v1412.done'
		)
		legacy_markers_removed = []
		try:
			dbcon = database_connect(maincache_db)
			dbcon.execute("""CREATE TABLE IF NOT EXISTS maincache (id text unique, data text, expires integer)""")
			for marker_name in legacy_marker_names:
				marker_path = 'special://profile/addon_data/%s/%s' % (addon_ids, marker_name)
				if not path_exists(marker_path): continue
				dbcon.execute("""INSERT OR REPLACE INTO maincache VALUES (?, ?, ?)""", ('migration:%s' % marker_name, 'imported-from-legacy-marker', 0))
				try: delete_file(marker_path)
				except Exception: pass
				legacy_markers_removed.append(marker_name)
			dbcon.commit()
			dbcon.close()
		except Exception as exc:
			logger('clean settings legacy marker cleanup skipped', '%s: %s' % (type(exc).__name__, exc))

		removed_count = len(removed_settings)
		migrated_count = len(migrated_settings)
		normalized_count = len(normalized_settings)
		marker_count = len(legacy_markers_removed)
		logger('clean settings', 'removed=%s migrated=%s normalized=%s markers=%s' % (removed_count, migrated_count, normalized_count, marker_count))
		if removed_settings: logger('clean settings removed', ', '.join(removed_settings[:80]))
		if migrated_settings: logger('clean settings migrated', ', '.join(migrated_settings[:80]))
		if normalized_settings: logger('clean settings normalized', ', '.join(normalized_settings[:80]))
		if legacy_markers_removed: logger('clean settings markers removed', ', '.join(legacy_markers_removed))

		if removed_count or migrated_count or normalized_count or marker_count:
			message = 'Nettoyage terminé.[CR][CR]Paramètres supprimés : %s[CR]Paramètres migrés : %s[CR]Paramètres corrigés : %s[CR]Fichiers obsolètes supprimés : %s[CR][CR]Vos comptes, favoris, sources et sauvegardes ne sont pas supprimés.' % (removed_count, migrated_count, normalized_count, marker_count)
		else:
			message = "Aucun ancien paramètre trouvé.[CR][CR]Votre configuration est déjà propre.[CR][CR]Si vous venez d'installer une mise à jour, le nettoyage a probablement déjà été effectué automatiquement au démarrage.[CR][CR]Vos comptes, favoris, sources et sauvegardes ne sont pas supprimés."
		ok_dialog(text=message, top_space=True)
	except Exception as exc:
		logger('clean settings failed', '%s: %s' % (type(exc).__name__, exc))
		notification(32574, 1500)

def open_settings(query, addon='plugin.video.alkoflix'):
	hide_busy_dialog()
	if query:
		try:
			button, control = 100, 80
			menu, function = query.split('.')
			execute_builtin('Addon.OpenSettings(%s)' % addon)
			execute_builtin('SetFocus(%i)' % (int(menu) - button))
			execute_builtin('SetFocus(%i)' % (int(function) - control))
		except: execute_builtin('Addon.OpenSettings(%s)' % addon)
	else: execute_builtin('Addon.OpenSettings(%s)' % addon)

def toggle_language_invoker():
	import xml.etree.ElementTree as ET
	close_all_dialog()
	sleep(100)
	current_addon_setting = get_setting('reuse_language_invoker', 'true')
	new_value = 'false' if current_addon_setting == 'true' else 'true'
	if not confirm_dialog(text=local_string(32979) % (current_addon_setting.upper(), new_value.upper())): return
	if new_value == 'true' and not confirm_dialog(text=32980, top_space=True): return
	addon_xml = translate_path('special://home/addons/plugin.video.alkoflix/addon.xml')
	tree = ET.parse(addon_xml)
	root = tree.getroot()
	item = next(root.iter('reuselanguageinvoker'), None)
	if item is None: return notification(32574, 1500)
	item.text = new_value
	tree.write(addon_xml)
	set_setting('reuse_language_invoker', new_value)
	ok_dialog(text=32981, top_space=True)
	execute_builtin('LoadProfile(%s)' % get_infolabel('system.profilename'))

def _make_qr_image_url(url, size='420x420'):
	from urllib.parse import urlencode
	return 'https://api.qrserver.com/v1/create-qr-code/?%s' % urlencode({'size': size, 'data': url})

def _download_qr_image(url, destination):
	import requests
	try:
		folder = '/'.join(destination.split('/')[:-1])
		if folder and not path_exists(folder): make_directorys(folder)
		response = requests.get(_make_qr_image_url(url), timeout=20.0)
		response.raise_for_status()
		f = open_file(destination, 'wb')
		try: f.write(response.content)
		finally: f.close()
		if path_exists(destination): return destination
	except Exception as exc:
		logger('upload_logfile', 'QR download failed: %s' % exc)
	return ''

def paste_result_dialog(url, qr_image='', clipboard_status=None):
	if clipboard_status is True:
		clipboard_message = 'Le lien a été copié dans le presse-papiers.'
	elif clipboard_status is False:
		clipboard_message = 'La copie automatique dans le presse-papiers n’est pas disponible sur cet appareil.'
	else:
		clipboard_message = 'Le lien peut être copié manuellement au besoin.'
	message = 'Log envoyé vers alkoPastes :[CR]%s[CR][CR]%s' % (url, clipboard_message)
	if not qr_image:
		return ok_dialog(text=message, top_space=True)
	message = '%s[CR][CR]Afficher le code QR maintenant ?' % message
	if confirm_dialog(text=message, ok_label='Afficher le QR', cancel_label='Fermer', top_space=True):
		execute_builtin('ShowPicture(%s)' % qr_image)


def _copy_text_to_clipboard(text):
	try:
		from alko.source_utils import copy2clip
		return bool(copy2clip(text))
	except Exception:
		return False

def _get_alkopastes_api_key():
	default_api_key = '135e78a79a84713d11e4c0fa269097fa99e46b5ffb3eab9a6982f1005e0e972d'
	try:
		api_mode = Addon().getSetting('alkopastes.api_mode')
		user_api_key = Addon().getSetting('alkopastes.api_key_user').strip()
	except Exception:
		api_mode = get_setting('alkopastes.api_mode', '0')
		user_api_key = get_setting('alkopastes.api_key_user', '').strip()
	if str(api_mode) == '1' and user_api_key:
		return user_api_key
	return default_api_key

def upload_logfile():
	log_file = 'special://logpath/kodi.log'
	api_url = 'https://paste.lesalkodiques.com/api.php?action=paste'
	api_key = _get_alkopastes_api_key()
	if not path_exists(log_file): return ok_dialog(text='Aucun fichier log Kodi trouvé.', top_space=True)
	confirm_text = 'Envoyer le fichier kodi.log vers alkoPastes ?[CR][CR]Le paste sera non listé et expirera dans 1 mois.[CR][CR]Les URLs, magnets, hashes, noms de fichiers, codes paste, clés API, tokens et identifiants IPTV/M3U seront masqués avant l’envoi.'
	if not confirm_dialog(text=confirm_text, ok_label='Envoyer', cancel_label='Annuler', top_space=True): return
	import requests
	from datetime import datetime
	show_busy_dialog()
	try:
		text = _prepare_kodi_log_text(file=log_file)
		if not text or not text.strip():
			hide_busy_dialog()
			return ok_dialog(text='Le fichier log Kodi est vide.', top_space=True)
		title = 'Log Kodi alkoFlix v%s - %s' % (get_addoninfo('version'), datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
		payload = {'title': title, 'content': text, 'syntax': 'text', 'visibility': 'unlisted', 'expiry': 'M'}
		headers = {'X-API-Key': api_key, 'Content-Type': 'application/json', 'User-Agent': 'alkoFlix/%s' % get_addoninfo('version')}
		response = requests.post(api_url, data=json.dumps(payload, ensure_ascii=False).encode('utf-8'), headers=headers, timeout=30.0)
		try: result = response.json()
		except Exception:
			hide_busy_dialog()
			return ok_dialog(text='Erreur alkoPastes : réponse JSON invalide.[CR][CR]%s' % sanitize_log_text(response.text[:500], mask_paste_ids=True), top_space=True)
		if response.status_code not in (200, 201) or not result.get('success'):
			error_msg = result.get('error') or result.get('message') or response.text[:500] or 'Erreur inconnue'
			error_msg = sanitize_log_text(error_msg, mask_paste_ids=True)
			hide_busy_dialog()
			return ok_dialog(text='Erreur alkoPastes : %s' % error_msg, top_space=True)
		paste = result.get('paste') or {}
		url = paste.get('url') or paste.get('raw_url')
		if not url:
			hide_busy_dialog()
			return ok_dialog(text='Log envoyé, mais aucune URL n’a été retournée par alkoPastes.', top_space=True)
		clipboard_status = _copy_text_to_clipboard(url)
		qr_image = _download_qr_image(url, 'special://profile/addon_data/plugin.video.alkoflix/log_upload_qr.png')
		hide_busy_dialog()
		paste_result_dialog(url, qr_image, clipboard_status)
	except Exception as exc:
		hide_busy_dialog()
		logger('upload_logfile', 'alkoPastes upload failed: %s' % exc)
		notification(32574, 1500)
		ok_dialog(text='L’envoi du log vers alkoPastes a échoué.[CR][CR]%s' % sanitize_log_text(exc, mask_paste_ids=True), top_space=True)

def timeIt(func):
	# Crédit : 123Venom
	import time
	fnc_name = func.__name__
	def wrap(*args, **kwargs):
		started_at = time.perf_counter()
		result = func(*args, **kwargs)
		logger('%s.%s' % (__name__ , fnc_name), (time.perf_counter() - started_at))
		return result
	return wrap

