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

import time
import json
import re
from threading import Thread
from modules import kodi_utils, settings

logger, path_exists, translate_path = kodi_utils.logger, kodi_utils.path_exists, kodi_utils.translate_path
monitor, is_playing, get_visibility = kodi_utils.monitor, kodi_utils.player.isPlaying, kodi_utils.get_visibility
get_property, set_property, clear_property = kodi_utils.get_property, kodi_utils.set_property, kodi_utils.clear_property
get_setting, set_setting, make_settings_dict = kodi_utils.get_setting, kodi_utils.set_setting, kodi_utils.make_settings_dict


OBSOLETE_PROFILE_SETTING_IDS = (
	# Débrideurs retirés d'alkoFlix. Ces clés peuvent rester dans les profils
	# ayant migré depuis d'anciennes versions, mais elles ne doivent plus être
	# relues ni conservées dans le cache de réglages.
	'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.
	# Depuis 1.4.30, seul le mode Qualité est conservé.
	'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 définitivement de resources/settings.xml en 1.4.42.
	# Le comportement est maintenant forcé directement dans settings.py / service.py.
	'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 le
	# gestionnaire de réglages Kodi tant que l'ancienne fenêtre est ouverte.
	'ext_dialog_highlight', 'int_dialog_highlight', 'provider.external',
	# Webshare a été retiré des sources visibles : l'ancien interrupteur ne doit
	# plus réactiver quoi que ce soit dans les profils ayant testé les WIP.
	'provider.webshare', 'hoster.webshare.url',
	# Hydracker/Darki retiré temporairement des sources intégrées.
	'provider.darki', 'hoster.darki.api_url', 'hoster.darki.api_key',
	'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 = {}

OBSOLETE_SETTINGS_CLEANUP_MARKER = 'obsolete_settings_cleanup_v157_wip14_hydracker.done'
YGG_SOURCE_MIGRATION_MARKER = 'ygg_source_wip7.done'
DEFAULT_INTEGRATED_SOURCES_MARKER = 'default_integrated_sources_wip11.done'
DEFAULT_RESULT_CONTROLS_MARKER = 'default_result_controls_v1412.done'


LEGACY_BOOL_SETTING_VALUES = {
	'media.click_action': {'0': 'false', '1': 'true'},
	'info_extra.window': {'0': 'false', '1': 'true'}
}


def _settings_line_value(line):
	match = re.search(r'value=([\"\'])(.*?)\1', line)
	if match: return match.group(2)
	match = re.search(r'>(.*?)</setting>', line)
	return match.group(1) if match else None


def _replace_settings_line_value(line, value):
	if ' value=' in line:
		return re.sub(r'value=([\"\'])(.*?)\1', lambda match: 'value=%s%s%s' % (match.group(1), value, match.group(1)), line, count=1)
	if '</setting>' in line:
		return re.sub(r'>(.*?)</setting>', '>%s</setting>' % value, line, count=1)
	return line



LEGACY_MIGRATION_MARKER_ALIASES = {
	DEFAULT_INTEGRATED_SOURCES_MARKER: ('default_integrated_sources_wip1.done',)
}


def _legacy_marker_path(marker_name):
	return kodi_utils.get_addoninfo('profile') + marker_name


def _legacy_marker_names(marker_name):
	return (marker_name,) + tuple(LEGACY_MIGRATION_MARKER_ALIASES.get(marker_name, ()))


def _remove_legacy_marker(marker_name):
	for legacy_name in _legacy_marker_names(marker_name):
		try:
			legacy_path = _legacy_marker_path(legacy_name)
			if path_exists(legacy_path): kodi_utils.delete_file(legacy_path)
		except Exception as exc:
			logger('alkoFlix legacy migration marker cleanup skipped', '%s | %s: %s' % (legacy_name, type(exc).__name__, exc))


def _migration_db_connect():
	dbcon = kodi_utils.database_connect(kodi_utils.maincache_db)
	dbcon.execute("""CREATE TABLE IF NOT EXISTS maincache (id text unique, data text, expires integer)""")
	return dbcon


def _migration_key(marker_name):
	return 'migration:%s' % marker_name


def _migration_done(marker_name):
	# Les anciennes versions écrivaient plusieurs fichiers *.done directement dans addon_data.
	# Depuis 1.4.35, l'état des migrations est rangé dans maincache.db pour garder
	# le dossier addon_data propre. Les anciens fichiers sont convertis puis supprimés.
	legacy_paths = [_legacy_marker_path(name) for name in _legacy_marker_names(marker_name)]
	try:
		dbcon = _migration_db_connect()
		dbcur = dbcon.cursor()
		dbcur.execute("""SELECT id FROM maincache WHERE id=?""", (_migration_key(marker_name),))
		if dbcur.fetchone():
			dbcon.close()
			_remove_legacy_marker(marker_name)
			return True
		if any(path_exists(path) for path in legacy_paths):
			dbcur.execute("""INSERT OR REPLACE INTO maincache VALUES (?, ?, ?)""", (_migration_key(marker_name), str(int(time.time())), 0))
			dbcon.commit()
			dbcon.close()
			_remove_legacy_marker(marker_name)
			return True
		dbcon.close()
		return False
	except Exception as exc:
		# Si la base est momentanément verrouillée, on respecte encore l'ancien marqueur
		# pour éviter de réappliquer une migration ponctuelle.
		if any(path_exists(path) for path in legacy_paths): return True
		logger('alkoFlix migration state check skipped', '%s | %s: %s' % (marker_name, type(exc).__name__, exc))
		return False


def _mark_migration_done(marker_name):
	try:
		dbcon = _migration_db_connect()
		dbcon.execute("""INSERT OR REPLACE INTO maincache VALUES (?, ?, ?)""", (_migration_key(marker_name), str(int(time.time())), 0))
		dbcon.commit()
		dbcon.close()
		_remove_legacy_marker(marker_name)
	except Exception as exc:
		# Ne plus recréer de fichiers *.done dans addon_data : si maincache.db
		# est temporairement indisponible, la migration pourra simplement être
		# retentée au prochain démarrage. C'est plus propre et sans impact sur
		# les comptes, favoris ou sources utilisateur.
		logger('alkoFlix migration state write skipped', '%s | %s: %s' % (marker_name, type(exc).__name__, exc))


def _mark_obsolete_settings_cleanup_done():
	_mark_migration_done(OBSOLETE_SETTINGS_CLEANUP_MARKER)


def _mark_simple_migration_done(marker_name):
	_mark_migration_done(marker_name)


def normalizeYggIntegratedSourceSettings(profile_xml=None):
	# Sécurité 1.5.7-hosters-wip7 : ne jamais forcer un choix utilisateur au démarrage.
	# L'ancienne migration activait YGG automatiquement si son marqueur manquait;
	# après les essais Hosters, cela pouvait rallumer des sources désactivées.
	if _migration_done(YGG_SOURCE_MIGRATION_MARKER): return
	try:
		logger('alkoFlix YGG source migration', 'skipped - user settings preserved')
	except Exception:
		pass
	_mark_simple_migration_done(YGG_SOURCE_MIGRATION_MARKER)


def normalizeDefaultIntegratedSources(profile_xml=None):
	# Sécurité 1.5.7-hosters-wip7 : les valeurs par défaut existent déjà dans
	# resources/settings.xml. On ne réécrit plus provider.comet/provider.ygg au
	# démarrage, sinon un OFF choisi par l'utilisateur peut redevenir ON.
	if _migration_done(DEFAULT_INTEGRATED_SOURCES_MARKER): return
	try:
		logger('alkoFlix integrated source defaults', 'skipped - user settings preserved')
	except Exception:
		pass
	_mark_simple_migration_done(DEFAULT_INTEGRATED_SOURCES_MARKER)


def normalizeDefaultResultControls(profile_xml=None):
	# Sécurité 1.5.7-hosters-wip7 : ne plus forcer les contrôles de résultats
	# au démarrage. Les defaults restent dans settings.xml; les choix existants
	# de l'utilisateur doivent rester intouchables.
	if _migration_done(DEFAULT_RESULT_CONTROLS_MARKER): return
	try:
		logger('alkoFlix result controls defaults', 'skipped - user settings preserved')
	except Exception:
		pass
	_mark_simple_migration_done(DEFAULT_RESULT_CONTROLS_MARKER)



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
	# Ancien menu Favoris : 0 = titre, 1 = date de sortie, 2 = date d'ajout.
	# Anciens tris Watchlist : 0 = titre, 1 = date d'ajout, 2 = date de sortie.
	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')
		# Sur les anciens tris globaux, 0 = décroissant, 1 = croissant.
		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'

def normalizeDefaultPersonalSortSettings(profile_xml=None):
	# v1.4.32 : les watchlists et favoris ont maintenant leur propre tri par défaut
	# dans Affichage & Navigation. On migre les anciens réglages lorsqu'ils existent,
	# sans écraser les nouveaux choix déjà présents dans le profil Kodi.
	profile_xml = profile_xml or (kodi_utils.get_addoninfo('profile') + 'settings.xml')
	if not path_exists(profile_xml): return
	try:
		file_obj = kodi_utils.open_file(profile_xml, 'r')
		text = file_obj.read()
		file_obj.close()
		if isinstance(text, bytes): text = text.decode('utf-8', 'ignore')
		if not str(text or '').strip(): return
		import xml.etree.ElementTree as ET
		root = ET.fromstring(text)
		values = {item.get('id'): _get_xml_setting_value(item) for item in root.findall('setting') if item.get('id')}
	except Exception as exc:
		return logger('alkoFlix personal default sort migration skipped', '%s: %s' % (type(exc).__name__, exc))
	changed = []
	try:
		if 'default_sort.watchlist' not in values and 'sort.watchlist' in values:
			sort_key = _legacy_default_sort_key('watchlist', values.get('sort.watchlist'), values.get('sort.watchlist.order'))
			set_setting('default_sort.watchlist', _personal_sort_index_from_key(sort_key))
			changed.append('watchlist=%s' % sort_key)
		if 'default_sort.favorites' not in values and 'sort.favorites' in values:
			sort_key = _legacy_default_sort_key('favorites', values.get('sort.favorites'), values.get('sort.favorites.order'))
			set_setting('default_sort.favorites', _personal_sort_index_from_key(sort_key))
			changed.append('favorites=%s' % sort_key)
		for setting_id, fallback in (('default_sort.watchlist', '4'), ('default_sort.favorites', '4')):
			try: selected = int(get_setting(setting_id, fallback))
			except: selected = int(fallback)
			if selected < 0 or selected > 5:
				set_setting(setting_id, fallback)
				changed.append('%s=%s' % (setting_id, fallback))
	except Exception as exc:
		logger('alkoFlix personal default sort migration failed', '%s: %s' % (type(exc).__name__, exc))
	if changed:
		try: clear_property('alkoflix_settings')
		except: pass
		logger('alkoFlix personal default sort migration', ', '.join(changed))

def normalizeDisplayNavigationDefaults():
	# v1.4.42 : les anciens réglages cachés retirés de settings.xml ne sont
	# plus recréés au démarrage. Les fonctions settings.py imposent déjà les
	# valeurs sûres. Ici on ne garde que la normalisation des réglages visibles.
	changed = []
	try:
		current_limit = int(get_setting('page_limit', '20'))
	except:
		current_limit = 20
	if current_limit < 1 or current_limit > 500:
		try:
			set_setting('page_limit', '20')
			changed.append('page_limit=20')
		except Exception as exc:
			logger('alkoFlix page limit default skipped', str(exc))
	if changed:
		try: clear_property('alkoflix_settings')
		except: pass
		logger('alkoFlix display/navigation defaults', ', '.join(changed))

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


def _set_xml_setting_value(item, value):
	value = str(value or '')
	# Kodi écrit généralement les réglages en attribut value avec les formats récents.
	# Si un ancien profil utilise du texte, l'attribut reste quand même accepté et évite
	# d'insérer un contenu textuel dans un noeud auto-fermant.
	item.set('value', value)
	item.text = None


def normalizeObsoleteProfileSettings(profile_xml=None, marker_path=None):
	# Migration ponctuelle : les anciennes clés de profil sont nettoyées une seule fois.
	# Après succès, un état de migration en base évite de relire settings.xml
	# à chaque démarrage. Le paramètre marker_path reste accepté pour compatibilité
	# avec d'anciens appels, mais les marqueurs visibles *.done ne sont plus créés.
	if _migration_done(OBSOLETE_SETTINGS_CLEANUP_MARKER): return
	profile_xml = profile_xml or (kodi_utils.get_addoninfo('profile') + 'settings.xml')
	if not path_exists(profile_xml):
		_mark_obsolete_settings_cleanup_done()
		logger('alkoFlix obsolete settings cleanup', 'done - no profile settings file')
		return
	try:
		file_obj = kodi_utils.open_file(profile_xml, 'r')
		text = file_obj.read()
		file_obj.close()
		if isinstance(text, bytes): text = text.decode('utf-8', 'ignore')
		if not str(text or '').strip():
			_mark_obsolete_settings_cleanup_done()
			logger('alkoFlix obsolete settings cleanup', 'done - empty profile settings file')
			return
		import xml.etree.ElementTree as ET
		root = ET.fromstring(text)
	except Exception as exc:
		return logger('alkoFlix obsolete settings cleanup skipped', '%s: %s' % (type(exc).__name__, exc))
	removed, migrated = [], []
	try:
		current_values = {item.get('id'): _get_xml_setting_value(item) for item in root.findall('setting') if item.get('id')}
		# Avant de supprimer les anciens tris cachés, on récupère leur valeur
		# dans les nouveaux réglages visibles. Sinon un ancien profil pourrait
		# perdre son tri par défaut lors du nettoyage automatique.
		for legacy_group in ('watchlist', 'favorites'):
			new_id = 'default_sort.%s' % legacy_group
			old_id = 'sort.%s' % legacy_group
			if 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))
				target = next((item for item in root.findall('setting') if item.get('id') == new_id), None)
				if target is None:
					target = ET.SubElement(root, 'setting', {'id': new_id})
				_set_xml_setting_value(target, _personal_sort_index_from_key(sort_key))
				current_values[new_id] = _personal_sort_index_from_key(sort_key)
				migrated.append('%s->%s' % (old_id, new_id))
		for old_id, new_id in LEGACY_SETTING_RENAMES.items():
			old_value = str(current_values.get(old_id, '') or '').strip()
			if old_value and not str(current_values.get(new_id, '') or '').strip():
				target = next((item for item in root.findall('setting') if item.get('id') == new_id), None)
				if target is None:
					target = ET.SubElement(root, 'setting', {'id': new_id})
				_set_xml_setting_value(target, old_value)
				migrated.append('%s->%s' % (old_id, new_id))
		for item in list(root.findall('setting')):
			setting_id = item.get('id')
			if setting_id in OBSOLETE_PROFILE_SETTING_IDS:
				root.remove(item)
				removed.append(setting_id)
		if removed or migrated:
			updated_xml = '<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>\n%s' % ET.tostring(root, encoding='unicode')
			file_obj = kodi_utils.open_file(profile_xml, 'w')
			file_obj.write(updated_xml)
			file_obj.close()
			if migrated: logger('alkoFlix obsolete settings migrated', ', '.join(migrated))
			if removed: logger('alkoFlix obsolete settings cleanup', 'removed=%s | %s' % (len(removed), ', '.join(removed)))
		else:
			logger('alkoFlix obsolete settings cleanup', 'done - nothing to clean')
		_mark_obsolete_settings_cleanup_done()
	except Exception as exc:
		logger('alkoFlix obsolete settings cleanup failed', '%s: %s' % (type(exc).__name__, exc))

def normalizeLegacyBoolSettings(profile_xml=None):
	# Compatibilité WIP : certains réglages étaient auparavant des sélecteurs 0/1.
	# Maintenant qu'ils sont des booléens Kodi, il faut convertir les anciennes valeurs
	# directement dans settings.xml, sinon Kodi refuse de les charger correctement.
	profile_xml = profile_xml or (kodi_utils.get_addoninfo('profile') + 'settings.xml')
	if not path_exists(profile_xml): return
	try:
		file_obj = kodi_utils.open_file(profile_xml, 'r')
		text = file_obj.read()
		file_obj.close()
		if isinstance(text, bytes): text = text.decode('utf-8', 'ignore')
	except Exception as exc:
		return logger('alkoFlix settings legacy bool normalize skipped', '%s: %s' % (type(exc).__name__, exc))
	changed = 0
	new_lines = []
	for line in str(text or '').splitlines(True):
		match = re.search(r'id=[\"\']([^\"\']+)[\"\']', line)
		setting_id = match.group(1) if match else ''
		mapping = LEGACY_BOOL_SETTING_VALUES.get(setting_id)
		if mapping:
			value = _settings_line_value(line)
			new_value = mapping.get(str(value or '').strip())
			if new_value is not None:
				updated = _replace_settings_line_value(line, new_value)
				if updated != line:
					line = updated
					changed += 1
		new_lines.append(line)
	if not changed: return
	try:
		file_obj = kodi_utils.open_file(profile_xml, 'w')
		file_obj.write(''.join(new_lines))
		file_obj.close()
		for setting_id, mapping in LEGACY_BOOL_SETTING_VALUES.items():
			for old_value, new_value in mapping.items():
				if get_setting(setting_id, '') == old_value:
					set_setting(setting_id, new_value)
		logger('alkoFlix settings legacy bool normalize', 'converted=%s' % changed)
	except Exception as exc:
		logger('alkoFlix settings legacy bool normalize failed', '%s: %s' % (type(exc).__name__, exc))



def normalizeDebridAccountFlags():
	# Les débrideurs connectés doivent toujours rester actifs.
	# Les interrupteurs correspondants servent uniquement d'information visuelle dans les réglages.
	changed = []
	for prefix, token_id in (('ad', 'ad.token'), ('rd', 'rd.token'), ('tb', 'tb.token')):
		try:
			if not get_setting(token_id, ''): continue
			for setting_id in ('%s.enabled' % prefix, '%s.torrent.enabled' % prefix):
				if get_setting(setting_id, 'true') != 'true':
					set_setting(setting_id, 'true')
					changed.append(setting_id)
		except Exception as exc:
			logger('alkoFlix debrid settings normalize skipped', '%s: %s' % (prefix, exc))
	if changed: logger('alkoFlix debrid settings normalize', ', '.join(changed))

def initializeDatabases():
	from modules.cache import check_databases
	logger('alkoFlix', 'InitializeDatabases Service Starting')
	check_databases()
	return logger('alkoFlix', 'InitializeDatabases Service Finished')


def normalizeCertificationDefaults():
	# Anciennes WIP utilisaient États-Unis/MPAA comme valeur par défaut.
	# Pour alkoFlix FR, le reset de l’onglet Métadonnées doit revenir sur France.
	try:
		country = (get_setting('certification.country', '') or '').strip().upper()
		display = get_setting('certification.country_display', '') or ''
		rating = get_setting('parental.rating', '') or ''
		old_default_display = display in ('', 'États-Unis (MPAA)', 'United States (MPAA)')
		if country in ('', '0', 'US') and old_default_display:
			set_setting('certification.country', 'FR')
			set_setting('certification.country_display', 'France')
			if rating in ('', 'PG-13', 'PG', 'R', 'NC-17', 'G'):
				set_setting('parental.rating', '12')
				set_setting('parental.rating_display', '12')
	except Exception as e:
		logger('alkoFlix certification defaults normalize skipped', str(e))

def normalizeSingleDeviceFavouritesSettings(refresh=True):
	# Par défaut, les favoris ne sont pas synchronisés avec alkoPastes.
	# Les options réseau sont permises seulement si l'utilisateur indique avoir
	# d'autres appareils à synchroniser.
	try:
		multi_device = str(get_setting('alkopastes.favourites_multi_device', 'false')).strip().lower() == 'true'
		legacy_single_device = str(get_setting('alkopastes.favourites_single_device', 'false')).strip().lower() == 'true'
		if multi_device and not legacy_single_device: return False
		changed = []
		if legacy_single_device:
			set_setting('alkopastes.favourites_single_device', 'false')
			changed.append('alkopastes.favourites_single_device')
		if not multi_device:
			for setting_id in ('alkopastes.favourites_sync_enabled', 'alkopastes.favourites_auto_restore_enabled', 'alkopastes.favourites_sync_pending'):
				if str(get_setting(setting_id, 'false')).strip().lower() == 'true':
					set_setting(setting_id, 'false')
					changed.append(setting_id)
			if get_setting('alkopastes.favourites_sync_pending_at', '0') not in ('', '0'):
				set_setting('alkopastes.favourites_sync_pending_at', '0')
				changed.append('alkopastes.favourites_sync_pending_at')
		if changed:
			logger('alkoPastes favourites multi-device normalize', ', '.join(changed))
			if refresh: _rebuild_settings_cache(50)
		return bool(changed)
	except Exception as e:
		logger('alkoPastes favourites multi-device normalize skipped', str(e))
	return False

def _rebuild_settings_cache(delay=0):
	# Ne vide plus le cache avant d'avoir relu un settings.xml valide.
	# Si Kodi émet onSettingsChanged pendant une écriture incomplète du fichier,
	# on garde les anciens réglages au lieu de retomber sur les valeurs par défaut.
	try:
		if delay: kodi_utils.sleep(int(delay))
	except Exception: pass
	try:
		settings_dict = make_settings_dict()
		if settings_dict is None:
			logger('alkoFlix settings cache rebuild skipped', 'settings.xml unreadable; keeping previous cache')
		return settings_dict
	except Exception as exc:
		logger('alkoFlix settings cache rebuild failed', str(exc))
	return None


def _set_setting_if_changed(setting_id, value):
	try:
		value = str(value)
		if str(get_setting(setting_id, '')) == value: return False
		set_setting(setting_id, value)
		return True
	except Exception as exc:
		logger('alkoFlix setting update failed', '%s | %s' % (setting_id, exc))
		return False


def checkSettingsFile():
	logger('alkoFlix', 'CheckSettingsFile Service Starting')
	# On vide le cache une seule fois, puis on reconstruit le dictionnaire après
	# les normalisations. Cela évite deux reconstructions coûteuses au démarrage.
	clear_property('alkoflix_settings')
	profile_dir = kodi_utils.get_addoninfo('profile')
	profile_xml = profile_dir + 'settings.xml'
	if not path_exists(profile_xml):
		kodi_utils.make_directorys(profile_dir)
		kodi_utils.sleep(500)
	normalizeLegacyBoolSettings(profile_xml)
	normalizeObsoleteProfileSettings(profile_xml)
	normalizeYggIntegratedSourceSettings(profile_xml)
	normalizeDefaultIntegratedSources(profile_xml)
	normalizeDefaultResultControls(profile_xml)
	normalizeDisplayNavigationDefaults()
	normalizeDefaultPersonalSortSettings(profile_xml)
	normalizeDebridAccountFlags()
	normalizeCertificationDefaults()
	normalizeSingleDeviceFavouritesSettings(refresh=False)
	# Les anciens réglages kodi_menu_cache / trakt.sync_refresh_widgets sont retirés
	# de settings.xml en 1.4.42. Le comportement reste forcé en dur plus bas et
	# dans settings.py, sans recréer de clés cachées dans le profil utilisateur.
	try: settings.normalize_results_xml_style_display()
	except Exception as e: logger('alkoFlix results selector display normalize skipped', str(e))
	try:
		from modules.alkopastes_userdata_backup import normalize_alkoflix_userdata_role
		normalize_alkoflix_userdata_role(refresh=False)
	except Exception as e: logger('alkoPastes userdata role normalize startup skipped', str(e))
	try:
		from modules import parental_control
		parental_control.lock_settings()
	except Exception as e: logger('alkoFlix parental lock startup skipped', str(e))
	make_settings_dict()
	set_property('alkoflix_kodi_menu_cache', 'false')
	set_property('alkoflix_rli_fix', get_setting('rli_fix'))
	try: handleFavouritesSourceChange('', get_setting('root.favourites.source', '0'))
	except Exception as e: logger('alkoFlix favourites startup sync skipped', str(e))
	return logger('alkoFlix', 'CheckSettingsFile Service Finished')

def handleFavouritesSourceChange(previous_source='', current_source='', force=False):
	try:
		previous_source = str(previous_source if previous_source not in (None, '') else get_setting('root.favourites.source', '0'))
		current_source = str(current_source if current_source not in (None, '') else get_setting('root.favourites.source', '0'))
		if current_source not in ('1', '2', '3'): return
		if not force and previous_source == current_source and get_setting('favourites.sync.initialized.source', '') == current_source: return
		def _run():
			try:
				from modules.favourites_sync import import_external_favourites
				import_external_favourites(int(current_source), force=True)
			except Exception as exc:
				logger('alkoFlix favourites source sync failed', str(exc))
		thread = Thread(target=_run)
		thread.daemon = True
		thread.start()
	except Exception as exc:
		logger('alkoFlix favourites source change handler failed', str(exc))

def handleCatalogueManifestChange(previous_url='', current_url=''):
	previous_url = (previous_url or '').strip()
	current_url = (current_url or '').strip()
	if previous_url == current_url: return
	logger('alkoFlix', 'Catalogue custom manifest setting changed')
	try:
		from catalogue.catalogs import clear_catalogue_cache
		clear_catalogue_cache()
	except Exception as e:
		logger('Catalogue manifest cache clear failed', str(e))
	try:
		from caches.navigator_cache import navigator_cache
		# Force la prochaine ouverture du menu racine à relire la DB au lieu de
		# réutiliser les propriétés de fenêtre déjà en mémoire.
		navigator_cache.delete_memory_cache('RootList', 'default')
		navigator_cache.delete_memory_cache('RootList', 'edited')
	except Exception as e:
		logger('Catalogue root menu cache clear failed', str(e))
	try:
		if 'alkoflix' in kodi_utils.get_infolabel('Container.PluginName').lower():
			kodi_utils.container_refresh()
	except Exception as e:
		logger('Catalogue root refresh skipped', str(e))

def databaseMaintenance():
	from modules.cache import clean_databases
	current_time = int(time.time())
	next_clean = current_time + 259200 # 3 days
	due_clean = int(get_setting('database.maintenance.due', '0'))
	if current_time < due_clean: return
	logger('alkoFlix', 'Database Maintenance Service Starting')
	clean_databases(current_time, database_check=False, silent=True)
	set_setting('database.maintenance.due', str(next_clean))
	return logger('alkoFlix', 'Database Maintenance Service Finished')

def viewsSetWindowProperties():
	logger('alkoFlix', 'ViewsSetWindowProperties Service Starting')
	kodi_utils.set_view_properties()
	return logger('alkoFlix', 'ViewsSetWindowProperties Service Finished')

def reuseLanguageInvokerCheck():
	import xml.etree.ElementTree as ET
	logger('alkoFlix', 'ReuseLanguageInvokerCheck Service Starting')
	addon_xml = translate_path('special://home/addons/plugin.video.alkoflix/addon.xml')
	tree = ET.parse(addon_xml)
	root = tree.getroot()
	current_addon_setting = get_setting('reuse_language_invoker', 'false')
	text = '[B]ReuseLanguageInvoker[/B] PARAMÈTRE INCOMPATIBLE [CR]alkoFlix rechargera votre profil pour actualiser le fichier addon.xml'
	item, refresh = next(root.iter('reuselanguageinvoker'), None), False
	if item is None: kodi_utils.notification(text.split('[CR]')[0])
	if not item is None and not item.text == current_addon_setting:
		item.text = current_addon_setting
		tree.write(addon_xml)
		refresh = True
	if refresh and kodi_utils.confirm_dialog(text=text):
		kodi_utils.execute_builtin('LoadProfile(%s)' % kodi_utils.get_infolabel('system.profilename'))
	return logger('alkoFlix', 'ReuseLanguageInvokerCheck Service Finished')

def autoRun():
	logger('alkoFlix', 'AutoRun Service Starting')
	# L'ancien démarrage automatique n'est plus proposé : si un profil l'avait activé,
	# il est désactivé proprement au lancement du service pour éviter une ouverture forcée.
	if get_setting('auto_start_alkoflix', 'false') == 'true':
		set_setting('auto_start_alkoflix', 'false')
		logger('alkoFlix', 'Auto-start disabled: legacy setting was true')
	return logger('alkoFlix', 'AutoRun Service Finished')

def clearSubs():
	logger('alkoFlix', 'Clear Subtitles Service Starting')
	sub_formats = ('.srt', '.ssa', '.smi', '.sub', '.idx')
	subtitle_path = 'special://temp/'
	for i in kodi_utils.list_dirs(subtitle_path)[1]:
		if i.startswith('alkoFlixSubs_') or i.endswith(sub_formats):
			kodi_utils.delete_file(subtitle_path + i)
	return logger('alkoFlix', 'Clear Subtitles Service Finished')

def cleanObsoleteUiFiles():
	logger('alkoFlix', 'Obsolete UI Cleanup Service Starting')
	obsolete_files = (
		'special://home/addons/plugin.video.alkoflix/resources/skins/Default/1080i/extras.xml',
	)
	for obsolete_file in obsolete_files:
		try:
			if path_exists(obsolete_file):
				kodi_utils.delete_file(obsolete_file)
				logger('alkoFlix', 'Obsolete UI Cleanup Removed: %s' % obsolete_file)
		except Exception as exc:
			logger('alkoFlix', 'Obsolete UI Cleanup Failed: %s - %s' % (obsolete_file, exc))
	return logger('alkoFlix', 'Obsolete UI Cleanup Service Finished')

def _version_tuple(version):
	import re
	numbers = [int(i) for i in re.findall(r'\d+', str(version or ''))]
	while len(numbers) < 3: numbers.append(0)
	return tuple(numbers)

def _installed_alkoflix_version():
	import xml.etree.ElementTree as ET
	try:
		addon_xml = translate_path('special://home/addons/plugin.video.alkoflix/addon.xml')
		root = ET.parse(addon_xml).getroot()
		return root.get('version') or kodi_utils.get_addoninfo('version')
	except Exception:
		return kodi_utils.get_addoninfo('version')

def _remote_alkoflix_version():
	import re, requests
	version_url = 'https://paste.lesalkodiques.com/raw/GvhcmrrZ'
	response = requests.get(version_url, headers={'Cache-Control': 'no-cache'}, timeout=15.0)
	response.raise_for_status()
	match = re.search(r'\d+(?:\.\d+)*', response.text.strip())
	return match.group(0) if match else ''

def _open_install_from_zip():
	logger('alkoFlix', 'Update Check Service Opening InstallFromZip')
	kodi_utils.execute_builtin('InstallFromZip')

def checkAlkoFlixUpdate(force=False):
	current_time = int(time.time())
	next_check = int(get_setting('alkoflix.update_check.due', '0'))
	if not force and current_time < next_check: return
	set_setting('alkoflix.update_check.due', str(current_time + 43200)) # 12 heures
	logger('alkoFlix', 'Update Check Service Starting')
	try:
		installed_version = _installed_alkoflix_version()
		remote_version = _remote_alkoflix_version()
		if not remote_version:
			return logger('alkoFlix', 'Update Check Service Finished - No remote version found')
		if _version_tuple(remote_version) <= _version_tuple(installed_version):
			return logger('alkoFlix', 'Update Check Service Finished - No update available')

		text = (
			'Une nouvelle version de alkoFlix est disponible.[CR][CR]'
			'Votre version : %s[CR]'
			'Nouvelle version : %s[CR][CR]'
			'Voulez-vous ouvrir l\'écran d\'installation depuis un fichier ZIP ?'
		) % (installed_version, remote_version)
		logger('alkoFlix', 'Update Check Service Update Available - Installed: %s / Remote: %s' % (installed_version, remote_version))
		if not kodi_utils.confirm_dialog(heading='alkoFlix', text=text, ok_label='Oui', cancel_label='Non', top_space=True):
			return logger('alkoFlix', 'Update Check Service Finished - Update declined by user')
		_open_install_from_zip()

	except Exception as exc:
		logger('alkoFlix', 'Update Check Service Failed: %s' % exc)
		kodi_utils.notification('Vérification de mise à jour impossible', 3000)
	return logger('alkoFlix', 'Update Check Service Finished')

def alkoFlixUpdateMonitor():
	logger('alkoFlix', 'Update Monitor Service Starting')
	while not monitor.abortRequested():
		while is_playing() or get_visibility('Container().isUpdating') or get_property('alkoflix_pause_services') == 'true':
			if monitor.waitForAbort(10): return
		try: checkAlkoFlixUpdate()
		except Exception as exc: logger('alkoFlix', 'Update Monitor Loop Failed: %s' % exc)
		if monitor.waitForAbort(3600): break
	return logger('alkoFlix', 'Update Monitor Service Finished')

def alkoPastesUserdataBackupMonitor():
	logger('alkoFlix', 'alkoPastes Auto Backup Monitor Service Starting')
	while not monitor.abortRequested():
		while is_playing() or get_visibility('Container().isUpdating') or get_property('alkoflix_pause_services') == 'true':
			if monitor.waitForAbort(10): return
		try:
			from modules.alkopastes_userdata_backup import automatic_alkoflix_userdata_backup
			automatic_alkoflix_userdata_backup()
		except Exception as exc:
			logger('alkoFlix', 'alkoPastes Auto Backup Monitor Failed: %s' % exc)
		if monitor.waitForAbort(3600): break
	return logger('alkoFlix', 'alkoPastes Auto Backup Monitor Service Finished')



def alkoPastesFavouritesRestoreMonitor():
	logger('alkoFlix', 'alkoPastes Favourites Restore Monitor Service Starting')
	startup_restore_done = False
	while not monitor.abortRequested():
		while is_playing() or get_visibility('Container().isUpdating') or get_property('alkoflix_pause_services') == 'true':
			if monitor.waitForAbort(10): return
		try:
			from modules.alkopastes_backup import automatic_alkopastes_favourites_restore, automatic_alkopastes_favourites_cleanup
			automatic_alkopastes_favourites_restore(startup=not startup_restore_done)
			startup_restore_done = True
			automatic_alkopastes_favourites_cleanup()
		except Exception as exc:
			startup_restore_done = True
			logger('alkoFlix', 'alkoPastes Favourites Restore Monitor Failed: %s' % exc)
		if monitor.waitForAbort(300): break
	return logger('alkoFlix', 'alkoPastes Favourites Restore Monitor Service Finished')

def _parental_current_window_ids():
	try:
		import xbmcgui
		return xbmcgui.getCurrentWindowId(), xbmcgui.getCurrentWindowDialogId()
	except Exception:
		return 0, 0


def _parental_addon_settings_window_active():
	# Kodi expose la fenêtre des réglages d'extension de manière variable selon le skin.
	# La détection par ID de dialogue est plus fiable que les seules conditions visibility.
	try:
		current_window, current_dialog = _parental_current_window_ids()
		if current_window == 10140 or current_dialog == 10140:
			return True
	except Exception:
		pass
	conditions = (
		'Window.IsActive(addonsettings)', 'Window.IsVisible(addonsettings)',
		'Window.IsActive(addonsettingsdialog)', 'Window.IsVisible(addonsettingsdialog)',
		'Window.IsActive(DialogAddonSettings.xml)', 'Window.IsVisible(DialogAddonSettings.xml)',
		'Window.IsActive(10140)', 'Window.IsVisible(10140)'
	)
	try:
		for condition in conditions:
			if get_visibility(condition): return True
	except Exception:
		pass
	return False


def parentalSettingsLockMonitor():
	logger('alkoFlix', 'Parental Settings Lock Monitor Service Starting')
	settings_window_seen_after_unlock = False
	while not monitor.abortRequested():
		try:
			if get_setting('parental.settings_unlocked', 'false') == 'true':
				from modules import parental_control
				settings_active = _parental_addon_settings_window_active()
				if settings_active:
					settings_window_seen_after_unlock = True
					set_property('alkoflix_parental_settings_window_seen', 'true')
				else:
					seen = settings_window_seen_after_unlock or get_property('alkoflix_parental_settings_window_seen') == 'true'
					unlocked_at = int(get_property('alkoflix_parental_settings_unlocked_at') or '0')
					# Le délai court évite de reverrouiller pendant le RunPlugin de déverrouillage,
					# mais force le verrouillage si Kodi ne signale jamais correctement la fenêtre.
					stale_unlock = unlocked_at and int(time.time()) - unlocked_at >= 10
					if seen or stale_unlock:
						parental_control.lock_settings()
						settings_window_seen_after_unlock = False
			else:
				settings_window_seen_after_unlock = False
		except Exception as exc:
			logger('alkoFlix', 'Parental Settings Lock Monitor Failed: %s' % exc)
		if monitor.waitForAbort(1): break
	return logger('alkoFlix', 'Parental Settings Lock Monitor Service Finished')

def traktMonitor():
	logger('alkoFlix', 'TraktMonitor Service Starting')
	trakt_service_string = 'TraktMonitor Service Update %s - %s'
	update_string = 'Next Update in %s minutes...'
	if not kodi_utils.get_property('alkoflix_traktmonitor_first_run') == 'true':
		try:
			if _trakt_account_active():
				from caches.trakt_cache import clear_trakt_list_contents_data, trakt_indicators_empty, clear_all_trakt_cache_data
				for i in ('user_lists', 'liked_lists', 'my_lists'): clear_trakt_list_contents_data(i)
				if trakt_indicators_empty():
					clear_all_trakt_cache_data(reason='startup_empty_indicators')
		except Exception as exc: logger('alkoFlix', 'TraktMonitor startup Trakt cache clear skipped: %s' % exc)
		try:
			if _mdblist_account_active():
				from indexers.mdblist_api import clear_mdbl_cache
				clear_mdbl_cache()
		except Exception as exc: logger('alkoFlix', 'TraktMonitor startup MDBList cache clear skipped: %s' % exc)
		try:
			if _tmdb_watchlist_sync_active():
				from indexers.tmdb_api import clear_tmdbl_cache
				clear_tmdbl_cache()
		except Exception as exc: logger('alkoFlix', 'TraktMonitor startup TMDb cache clear skipped: %s' % exc)
		kodi_utils.set_property('alkoflix_traktmonitor_first_run', 'true')
	while not monitor.abortRequested():
		while is_playing() or get_visibility('Container().isUpdating') or get_property('alkoflix_pause_services') == 'true':
			monitor.waitForAbort(10)
		if not kodi_utils.get_property('alkoflix_traktmonitor_first_run') == 'true':
			monitor.waitForAbort(5)
		value, interval = settings.trakt_sync_interval()
		next_update_string = update_string % value
		if _trakt_account_active():
			try:
				from indexers.trakt_api import trakt_sync_activities
				status = trakt_sync_activities()
			except Exception: status = 'failed'
			if status == 'success':
				logger('alkoFlix', trakt_service_string % ('alkoFlix TraktMonitor - Success', 'Trakt Update Performed'))
				if settings.trakt_sync_refresh_widgets():
					if _widget_refresh_throttled('TraktMonitor'):
						logger('alkoFlix', trakt_service_string % ('alkoFlix TraktMonitor - Widgets Refresh', 'Setting Activated. Widget Refresh Performed'))
					else:
						logger('alkoFlix', trakt_service_string % ('alkoFlix TraktMonitor - Widgets Refresh', 'Setting Activated. Widget Refresh Throttled'))
				else: logger('alkoFlix', trakt_service_string % ('alkoFlix TraktMonitor - Widgets Refresh', 'Setting Disabled. Skipping Widget Refresh'))
			elif status == 'no account':
				logger('alkoFlix', trakt_service_string % ('alkoFlix TraktMonitor - Aborted. No Trakt Account Active', next_update_string))
			elif status == 'failed':
				logger('alkoFlix', trakt_service_string % ('alkoFlix TraktMonitor - Failed. Error from Trakt', next_update_string))
			else:# 'not needed'
				logger('alkoFlix', trakt_service_string % ('alkoFlix TraktMonitor - Success. No Changes Needed', next_update_string))
		else:
			logger('alkoFlix', trakt_service_string % ('alkoFlix TraktMonitor - Skipped. No Trakt Account Active', next_update_string))
		if _mdblist_account_active():
			try:
				from indexers.mdblist_api import mdbl_sync_activities
				status = mdbl_sync_activities()
			except Exception: status = 'failed'
			if status == 'success':
				logger('alkoFlix', trakt_service_string % ('alkoFlix MDBListMonitor - Success', 'MDBList Update Performed'))
				if settings.trakt_sync_refresh_widgets():
					if _widget_refresh_throttled('MDBListMonitor'):
						logger('alkoFlix', trakt_service_string % ('alkoFlix MDBListMonitor - Widgets Refresh', 'Setting Activated. Widget Refresh Performed'))
					else:
						logger('alkoFlix', trakt_service_string % ('alkoFlix MDBListMonitor - Widgets Refresh', 'Setting Activated. Widget Refresh Throttled'))
				else: logger('alkoFlix', trakt_service_string % ('alkoFlix MDBListMonitor - Widgets Refresh', 'Setting Disabled. Skipping Widget Refresh'))
			elif status == 'no account':
				logger('alkoFlix', trakt_service_string % ('alkoFlix MDBListMonitor - Aborted. No MDBList Account Active', next_update_string))
			elif status == 'failed':
				logger('alkoFlix', trakt_service_string % ('alkoFlix MDBListMonitor - Failed. Error from MDBList', next_update_string))
			else:# 'not needed'
				logger('alkoFlix', trakt_service_string % ('alkoFlix MDBListMonitor - Success. No Changes Needed', next_update_string))
		else:
			logger('alkoFlix', trakt_service_string % ('alkoFlix MDBListMonitor - Skipped. No MDBList Account Active', next_update_string))
		if _tmdb_watchlist_sync_active():
			try:
				from indexers.tmdb_api import tmdb_clean_watchlist
				status = tmdb_clean_watchlist(silent=True)
				if status: logger('alkoFlix', 'TMDB Lists Service Update - Success. %s' % status)
			except Exception as exc: logger('alkoFlix', 'TMDB Lists Service Update - Failed. %s' % exc)
		else:
			logger('alkoFlix', 'TMDB Lists Service Update - Skipped. Watchlist sync inactive')
		monitor.waitForAbort(interval)
	return logger('alkoFlix', 'TraktMonitor Service Finished')
def premAccntNotification():
	logger('alkoFlix', 'Debrid Account Expiry Notification Service Starting')
	from importlib import import_module
	for user, expires, module, cls in (
		('ad.account_id', 'ad.expires', 'alldebrid_api', 'AllDebridAPI'),
		('rd.username', 'rd.expires', 'real_debrid_api', 'RealDebridAPI'),
		('tb.account_id', 'tb.expires', 'torbox_api', 'TorBoxAPI')
	):
		try:
			if not get_setting(user): continue
			if limit := int(get_setting(expires, '7')):
				module = 'debrids.%s' % module
				cls = getattr(import_module(module), cls)
				days_remaining = cls().days_remaining()
				if not days_remaining is None and days_remaining <= limit:
					kodi_utils.notification('%s expires in %s days' % (cls.__name__, days_remaining))
		except: pass
	return logger('alkoFlix', 'Debrid Account Expiry Notification Service Finished')


def upnextPlayAction(data):
	def _run(params):
		try:
			if isinstance(params, str):
				try: params = json.loads(params)
				except: pass
			if isinstance(params, list) and params:
				params = params[0]
			if not isinstance(params, dict):
				logger('alkoFlix', 'UpNext Play Action Failed - Invalid data: %s' % str(params))
				return kodi_utils.notification('Up Next : données invalides', 3000)

			params = dict(params)
			handoff = params.pop('nextep_handoff', '')
			if handoff: kodi_utils.set_property('alkoflix_nextep_handoff', handoff)
			# Up Next peut maintenant servir autant le mode lecture automatique
			# que le mode sélection de source. On respecte donc l'intention
			# transmise dans play_info, avec une exception volontaire : si un
			# bingeGroup est transmis, l'enchaînement Up Next peut choisir
			# automatiquement un lien similaire au premier lien sélectionné.
			requested_autoplay = str(params.get('autoplay', 'true')).lower()
			binge_autoplay = str(params.get('upnext_binge_autoplay', 'false')).lower() in ('true', '1', 'yes', 'on')
			params['autoplay'] = 'true' if binge_autoplay else ('false' if requested_autoplay in ('false', '0', 'no', 'off') else 'true')
			params['media_type'] = 'episode'

			from modules.sources import SourceSelect
			try: SourceSelect.nextep_params.clear()
			except: pass

			logger('alkoFlix', 'UpNext Plugin Play Action Starting - S%sE%s' % (params.get('season'), params.get('episode')))
			if handoff:
				# Laisse au lecteur courant le temps de finaliser le scrobble Trakt
				# avant de lancer la sélection/lecture de l’épisode suivant.
				kodi_utils.sleep(1500)
			SourceSelect.factory(params)
			logger('alkoFlix', 'UpNext Plugin Play Action Finished')
		except Exception as exc:
			logger('alkoFlix', 'UpNext Play Action Failed: %s' % exc)
			try: kodi_utils.notification('Up Next : lecture impossible', 3000)
			except: pass

	Thread(target=_run, args=(data,)).start()

def registerUpNextProvider():
	try:
		try:
			import AddonSignals
		except Exception:
			from addon import signals as AddonSignals

		signal = '%s_play_action' % kodi_utils.get_addoninfo('id')
		AddonSignals.registerSlot('upnextprovider', signal, upnextPlayAction)
		logger('alkoFlix', 'UpNext Provider Registered: %s' % signal)
	except Exception as exc:
		logger('alkoFlix', 'UpNext Provider Registration Failed: %s' % exc)


def _delayed_service_runner(target, delay=0, label=''):
	# Démarre certains services non critiques avec un léger délai pour éviter que
	# tous les appels réseau/cache se lancent au même moment au démarrage de Kodi.
	try:
		delay = int(delay or 0)
	except Exception:
		delay = 0
	if delay > 0:
		try: logger('alkoFlix', '%s Service Delayed Start - %ss' % (label or getattr(target, '__name__', 'Service'), delay))
		except Exception: pass
		if monitor.waitForAbort(delay): return
	try:
		return target()
	except Exception as exc:
		logger('alkoFlix', '%s Service Failed: %s' % (label or getattr(target, '__name__', 'Service'), exc))


def _service_thread(target, delay=0, label=''):
	thread = Thread(target=_delayed_service_runner, args=(target, delay, label or getattr(target, '__name__', 'Service')))
	thread.daemon = True
	return thread

def _setting_true(setting_id, default='false'):
	try: return str(get_setting(setting_id, default)).strip().lower() in ('true', '1', 'yes', 'on')
	except Exception: return False


def _setting_text(setting_id, default=''):
	try: return str(get_setting(setting_id, default) or '').strip()
	except Exception: return str(default or '').strip()


def _setting_int(setting_id, default=0):
	try: return int(_setting_text(setting_id, str(default)) or default)
	except Exception: return int(default or 0)


def _trakt_account_active():
	return bool(_setting_text('trakt.token') or _setting_text('trakt_user'))


def _mdblist_account_active():
	return bool(_setting_text('mdblist.access_token') or _setting_text('mdblist.token') or _setting_text('mdblist_user'))


def _tmdb_watchlist_sync_active():
	return bool(_setting_text('tmdb.token') and _setting_true('tmdblist.watchlist_sync'))


def _alkopastes_connected():
	return bool(_setting_text('alkopastes.api_key_user'))


def _parental_lock_monitor_enabled():
	# Le moniteur de verrouillage sert uniquement si un PIN/contrôle parental existe
	# ou si une ancienne session est restée déverrouillée.
	return bool(_setting_text('parental.pin_hash') or _setting_true('parental.enabled') or _setting_true('parental.settings_unlocked'))


def _favourites_pending_sync_monitor_enabled():
	return _alkopastes_connected() and _setting_true('alkopastes.favourites_multi_device') and _setting_true('alkopastes.favourites_sync_enabled')


def _favourites_restore_monitor_enabled():
	# Le même moniteur gère aussi le compactage/nettoyage périodique des favoris.
	# On le garde donc actif si la récupération OU l'envoi auto est activé en multi-appareils.
	return _alkopastes_connected() and _setting_true('alkopastes.favourites_multi_device') and (
		_setting_true('alkopastes.favourites_auto_restore_enabled') or _setting_true('alkopastes.favourites_sync_enabled')
	)


def _external_accounts_monitor_enabled():
	return bool(_trakt_account_active() or _mdblist_account_active() or _tmdb_watchlist_sync_active())


def _debrid_expiry_monitor_enabled():
	# Inutile de lancer la vérification si les notifications d'expiration sont à 0 jour.
	return bool(
		((_setting_text('ad.token') or _setting_text('ad.account_id')) and _setting_int('ad.expires', 0) > 0) or
		((_setting_text('rd.token') or _setting_text('rd.username')) and _setting_int('rd.expires', 0) > 0) or
		((_setting_text('tb.token') or _setting_text('tb.account_id')) and _setting_int('tb.expires', 0) > 0)
	)


def _userdata_backup_monitor_enabled():
	if not _alkopastes_connected(): return False
	secondary = _setting_true('alkopastes.userdata_secondary_installation_v2') or _setting_true('alkopastes.userdata_secondary_installation')
	multi_device = _setting_true('alkopastes.userdata_multi_device') or _setting_true('alkopastes.userdata_master_installation') or secondary
	if _setting_true('alkopastes.userdata_auto_backup') and not secondary:
		return True
	if _setting_true('alkopastes.userdata_auto_restore') and multi_device and secondary and _setting_text('alkopastes.userdata_multi_backup_code'):
		return True
	return False


def _alkopaste_source_enabled():
	return _setting_true('provider.alkopaste')


def _background_service_specs():
	# Conditions centralisées : on évite de lancer des moniteurs réseau/cache quand
	# la fonctionnalité correspondante n'est pas configurée. Les services critiques
	# ou légers restent actifs.
	return (
		(parentalSettingsLockMonitor, 0, 'Parental Settings Lock Monitor', _parental_lock_monitor_enabled, 'PIN/contrôle parental inactif'),
		(alkoPastesFavouritesSyncMonitor, 8, 'alkoPastes Favourites Pending Sync Monitor', _favourites_pending_sync_monitor_enabled, 'sync favoris désactivée ou alkoPastes non connecté'),
		(externalFavouritesSyncMonitor, 10, 'External Favourites Sync Monitor', None, ''),
		(traktMonitor, 12, 'TraktMonitor', _external_accounts_monitor_enabled, 'aucun compte Trakt/MDBList/TMDb à synchroniser'),
		(alkoPastesFavouritesRestoreMonitor, 20, 'alkoPastes Favourites Restore Monitor', _favourites_restore_monitor_enabled, 'récupération/nettoyage favoris inactif'),
		(alkoFlixUpdateMonitor, 30, 'Update Monitor', None, ''),
		(premAccntNotification, 45, 'Debrid Account Expiry Notification', _debrid_expiry_monitor_enabled, 'aucun débrideur connecté'),
		(alkoPastesUserdataBackupMonitor, 60, 'alkoPastes Auto Backup Monitor', _userdata_backup_monitor_enabled, 'sauvegarde/restauration auto alkoPastes inactive'),
		(alkoPasteSourceSyncMonitor, 90, 'ALKO Source Sync Monitor', _alkopaste_source_enabled, 'source ALKO désactivée')
	)


def _background_service_threads():
	threads = []
	for target, delay, label, enabled_func, disabled_reason in _background_service_specs():
		try:
			if enabled_func is not None and not enabled_func():
				logger('alkoFlix', '%s Service Skipped - %s' % (label, disabled_reason or 'inactive'))
				continue
		except Exception as exc:
			# En cas d'erreur de lecture d'un réglage, on démarre le service par prudence
			# afin de ne pas couper une fonctionnalité active.
			logger('alkoFlix', '%s Service Condition Failed - starting defensively: %s' % (label, exc))
		threads.append(_service_thread(target, delay, label))
	return tuple(threads)



def _widget_refresh_throttled(reason='', minimum_delay=120):
	# Kodi peut devenir lourd si plusieurs services rafraîchissent les widgets
	# dans la même minute. On autorise le premier refresh, puis on ignore les doublons.
	try:
		current_time = int(time.time())
		last_refresh = int(get_property('alkoflix_widget_refresh_last') or '0')
		if current_time - last_refresh < int(minimum_delay or 120):
			logger('alkoFlix', 'Widget Refresh Skipped - throttle | %s | remaining=%ss' % (reason or 'service', int(minimum_delay or 120) - (current_time - last_refresh)))
			return False
		set_property('alkoflix_widget_refresh_last', str(current_time))
		kodi_utils.widget_refresh()
		logger('alkoFlix', 'Widget Refresh Performed - %s' % (reason or 'service'))
		return True
	except Exception as exc:
		logger('alkoFlix', 'Widget Refresh Failed - %s | %s' % (reason or 'service', exc))
		return False


def alkoPasteSourceSyncMonitor():
	logger('alkoFlix', 'ALKO Source Sync Monitor Service Starting')
	while not monitor.abortRequested():
		while is_playing() or get_visibility('Container().isUpdating') or get_property('alkoflix_pause_services') == 'true':
			if monitor.waitForAbort(10): return
		try:
			from magneto.alkopaste import sync_alkopaste_database
			result = sync_alkopaste_database(force=False, rebuild=False)
			if isinstance(result, dict) and result.get('ok') and not result.get('skipped'):
				logger('alkoFlix', 'ALKO Source Sync Monitor updated | liens=%s' % result.get('links', 0))
		except Exception as exc:
			logger('alkoFlix', 'ALKO Source Sync Monitor Failed: %s' % exc)
		# Vérifie périodiquement, mais la synchro elle-même reste limitée à 24 h.
		if monitor.waitForAbort(3600): break
	return logger('alkoFlix', 'ALKO Source Sync Monitor Service Finished')


def externalFavouritesSyncMonitor():
	logger('alkoFlix', 'External Favourites Sync Monitor Service Starting')
	while not monitor.abortRequested():
		while is_playing() or get_visibility('Container().isUpdating') or get_property('alkoflix_pause_services') == 'true':
			if monitor.waitForAbort(10): return
		try:
			from modules.favourites_background_sync import flush_pending_external_favourites_sync
			flush_pending_external_favourites_sync(max_items=8)
		except Exception as exc:
			logger('alkoFlix', 'External Favourites Sync Monitor Failed: %s' % exc)
		if monitor.waitForAbort(10): break
	return logger('alkoFlix', 'External Favourites Sync Monitor Service Finished')


def alkoPastesFavouritesSyncMonitor():
	logger('alkoFlix', 'alkoPastes Favourites Pending Sync Monitor Service Starting')
	while not monitor.abortRequested():
		while is_playing() or get_visibility('Container().isUpdating') or get_property('alkoflix_pause_services') == 'true':
			if monitor.waitForAbort(10): return
		try:
			from modules.alkopastes_backup import pending_favourites_sync_due, flush_pending_favourites_sync
			if pending_favourites_sync_due():
				flush_pending_favourites_sync(reason='service_debounce')
		except Exception as exc:
			logger('alkoFlix', 'alkoPastes Favourites Pending Sync Monitor Failed: %s' % exc)
		if monitor.waitForAbort(10): break
	return logger('alkoFlix', 'alkoPastes Favourites Pending Sync Monitor Service Finished')

class AlkoFlixMonitor(kodi_utils.xbmc_monitor):
	def __enter__(self):
		# Les services optionnels sont construits après checkSettingsFile(), quand les
		# réglages hérités ont été normalisés et que le cache settings est frais.
		self.threads = ()
		return self

	def __exit__(self, exc_type, exc_value, traceback):
		for i in self.threads:
			try:
				if i.is_alive(): i.join()
			except RuntimeError: pass

	def startUpServices(self):
		try: initializeDatabases()
		except: pass
		try: checkSettingsFile()
		except: pass
		try:
			settings.certification_country()
		except Exception as exc:
			logger('alkoFlix Parental certification display sync failed', str(exc))
		try: databaseMaintenance()
		except: pass
		try: viewsSetWindowProperties()
		except: pass
		try: reuseLanguageInvokerCheck()
		except: pass
		try: self.threads = _background_service_threads()
		except Exception as exc:
			logger('alkoFlix', 'Background Services Build Failed: %s' % exc)
			self.threads = ()
		for i in self.threads:
			try: i.start()
			except Exception as exc: logger('alkoFlix', 'Background Service Start Failed: %s' % exc)
		try: autoRun()
		except: pass
		try: clearSubs()
		except: pass
		try: cleanObsoleteUiFiles()
		except: pass
		try: registerUpNextProvider()
		except: pass

	def onScreensaverActivated(self):
		set_property('alkoflix_pause_services', 'true')

	def onScreensaverDeactivated(self):
		clear_property('alkoflix_pause_services')

	def onSettingsChanged(self):
		previous_catalogue_manifest = get_setting('catalogue.custom.manifest_url', '')
		previous_favourites_source = get_setting('root.favourites.source', '0')
		previous_favourites_multi_device = get_setting('alkopastes.favourites_multi_device', 'false')
		previous_favourites_auto_restore = get_setting('alkopastes.favourites_auto_restore_enabled', 'false')
		previous_favourites_auto_restore_interval = get_setting('alkopastes.favourites_auto_restore_interval', '0')
		previous_widget_hide_watched = get_setting('widget_hide_watched', 'false')
		previous_menu_hide_watched = get_setting('menu_hide_watched', 'false')
		previous_userdata_auto_restore = get_setting('alkopastes.userdata_auto_restore', 'false')
		previous_userdata_auto_backup = get_setting('alkopastes.userdata_auto_backup', 'false')
		if _rebuild_settings_cache(50) is None:
			logger('alkoFlix settings changed skipped', 'settings.xml unreadable; no source/cache changes applied')
			return
		current_catalogue_manifest = get_setting('catalogue.custom.manifest_url', '')
		current_favourites_source = get_setting('root.favourites.source', '0')
		current_favourites_multi_device = get_setting('alkopastes.favourites_multi_device', 'false')
		current_favourites_auto_restore = get_setting('alkopastes.favourites_auto_restore_enabled', 'false')
		current_favourites_auto_restore_interval = get_setting('alkopastes.favourites_auto_restore_interval', '0')
		current_widget_hide_watched = get_setting('widget_hide_watched', 'false')
		current_menu_hide_watched = get_setting('menu_hide_watched', 'false')
		try:
			from modules.alkopastes_userdata_backup import normalize_alkoflix_userdata_role
			normalize_alkoflix_userdata_role(refresh=False)
			_rebuild_settings_cache(50)
		except Exception as exc:
			logger('alkoPastes userdata role normalize setting update failed', str(exc))
		current_userdata_auto_restore = get_setting('alkopastes.userdata_auto_restore', 'false')
		current_userdata_auto_backup = get_setting('alkopastes.userdata_auto_backup', 'false')
		set_property('alkoflix_kodi_menu_cache', 'false')
		set_property('alkoflix_rli_fix', get_setting('rli_fix'))
		handleCatalogueManifestChange(previous_catalogue_manifest, current_catalogue_manifest)
		handleFavouritesSourceChange(previous_favourites_source, current_favourites_source)
		if previous_widget_hide_watched != current_widget_hide_watched:
			try: kodi_utils.ok_dialog(heading='alkoFlix', text='La modification prendra effet lors du prochain redémarrage Kodi.', top_space=True)
			except Exception as exc: logger('alkoFlix hide watched restart dialog failed', str(exc))
		if previous_favourites_multi_device != current_favourites_multi_device or current_favourites_multi_device != 'true':
			try:
				if normalizeSingleDeviceFavouritesSettings(refresh=False):
					_rebuild_settings_cache()
			except Exception as exc:
				logger('alkoPastes favourites multi-device setting update failed', str(exc))
			current_favourites_auto_restore = get_setting('alkopastes.favourites_auto_restore_enabled', 'false')
		current_favourites_auto_restore_interval = get_setting('alkopastes.favourites_auto_restore_interval', '0')
		if previous_favourites_auto_restore != current_favourites_auto_restore or previous_favourites_auto_restore_interval != current_favourites_auto_restore_interval:
			try:
				if _set_setting_if_changed('alkopastes.favourites_auto_restore_due', '0'):
					_rebuild_settings_cache()
			except Exception as exc:
				logger('alkoPastes favourites auto restore setting update failed', str(exc))
		if previous_userdata_auto_restore != current_userdata_auto_restore:
			try:
				if _set_setting_if_changed('alkopastes.userdata_auto_restore_due', '0'):
					_rebuild_settings_cache()
			except Exception as exc:
				logger('alkoPastes userdata auto restore setting update failed', str(exc))
		if previous_userdata_auto_backup != current_userdata_auto_backup:
			try:
				if _set_setting_if_changed('alkopastes.userdata_auto_backup_due', '0'):
					_rebuild_settings_cache()
			except Exception as exc:
				logger('alkoPastes userdata auto backup setting update failed', str(exc))

	def onNotification(self, sender, method, data):
		if method == 'System.OnSleep': set_property('alkoflix_pause_services', 'true')
		elif method == 'System.OnWake': clear_property('alkoflix_pause_services')


logger('alkoFlix', 'Main Monitor Service Starting')
logger('alkoFlix', 'Settings Monitor Service Starting')

with AlkoFlixMonitor() as alkoflix:
	alkoflix.startUpServices()
	alkoflix.waitForAbort()

logger('alkoFlix', 'Settings Monitor Service Finished')
logger('alkoFlix', 'Main Monitor Service Finished')
