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

import hashlib
import json
import sys
import time
import uuid
from datetime import datetime
from urllib.parse import quote, unquote

from modules import kodi_utils
from caches.favourites_cache import (
	favourites_cache,
	MOVIE_FAVOURITE_TYPES,
	TV_FAVOURITE_TYPES,
	SEASON_FAVOURITE_TYPES,
	EPISODE_FAVOURITE_TYPES,
	COLLECTION_FAVOURITE_TYPES,
	LIST_FAVOURITE_TYPES
)

get_setting, set_setting = kodi_utils.get_setting, kodi_utils.set_setting
logger = kodi_utils.logger

API_URL = 'https://paste.lesalkodiques.com/api.php'
BACKUP_SETTING = 'alkopastes.favourites_backup_code'
MULTI_DEVICE_SETTING = 'alkopastes.favourites_multi_device'
SINGLE_DEVICE_LEGACY_SETTING = 'alkopastes.favourites_single_device'
SYNC_SETTING = 'alkopastes.favourites_sync_enabled'
AUTO_RESTORE_SETTING = 'alkopastes.favourites_auto_restore_enabled'
AUTO_RESTORE_INTERVAL_SETTING = 'alkopastes.favourites_auto_restore_interval'
AUTO_RESTORE_DUE_SETTING = 'alkopastes.favourites_auto_restore_due'
AUTO_RESTORE_LAST_HASH_SETTING = 'alkopastes.favourites_auto_restore_last_hash'
AUTO_RESTORE_BACKOFF_COUNT_SETTING = 'alkopastes.favourites_auto_restore_backoff_count'
AUTO_RESTORE_FREQUENCY_MIGRATED_SETTING = 'alkopastes.favourites_auto_restore_frequency_migrated'
AUTO_RESTORE_FREQUENCY_ORDER_MIGRATED_SETTING = 'alkopastes.favourites_auto_restore_frequency_order_migrated'
FAVOURITES_SYNC_PENDING_SETTING = 'alkopastes.favourites_sync_pending'
FAVOURITES_SYNC_PENDING_AT_SETTING = 'alkopastes.favourites_sync_pending_at'
FAVOURITES_SYNC_LAST_FLUSH_SETTING = 'alkopastes.favourites_sync_last_flush'
FAVOURITES_SYNC_LAST_LOCAL_HASH_SETTING = 'alkopastes.favourites_sync_last_local_hash'
FAVOURITES_SYNC_DEBOUNCE_SECONDS = 10
FAVOURITES_CLEANUP_LAST_SETTING = 'alkopastes.favourites_cleanup_last'
FAVOURITES_CLEANUP_INTERVAL_SECONDS = 30 * 24 * 3600
FAVOURITES_TOMBSTONE_RETENTION_SECONDS = 180 * 24 * 3600
FAVOURITES_DEVICE_ID_SETTING = 'alkopastes.userdata_device_id'
FAVOURITES_DEVICE_HASH_SETTING = 'alkopastes.favourites_device_hash'
USERDATA_BACKUP_MODE_SETTING = 'alkopastes.userdata_backup_mode'
USERDATA_INSTALLATION_ROLE_SETTING = 'alkopastes.userdata_installation_role'
USERDATA_MULTI_DEVICE_SETTING = 'alkopastes.userdata_multi_device'
USERDATA_MASTER_INSTALLATION_SETTING = 'alkopastes.userdata_master_installation'
USERDATA_SECONDARY_INSTALLATION_SETTING = 'alkopastes.userdata_secondary_installation_v2'
BACKUP_TITLE = 'alkoFlix favoris - sauvegarde'
BACKUP_VERSION = 5

# Les favoris restent la source locale principale dans favourites.db.
# alkoPastes sert uniquement de miroir optionnel, avec un seul paste privé caché.
FAVOURITE_COUNT_GROUPS = {
	'movies': MOVIE_FAVOURITE_TYPES,
	'tvshows': TV_FAVOURITE_TYPES,
	'seasons': SEASON_FAVOURITE_TYPES,
	'episodes': EPISODE_FAVOURITE_TYPES,
	'people': ('person',),
	'lists': LIST_FAVOURITE_TYPES,
	'collections': COLLECTION_FAVOURITE_TYPES
}

ALL_BACKUP_TYPES = tuple(dict.fromkeys(
	MOVIE_FAVOURITE_TYPES + TV_FAVOURITE_TYPES + SEASON_FAVOURITE_TYPES + EPISODE_FAVOURITE_TYPES + ('person',) + LIST_FAVOURITE_TYPES + COLLECTION_FAVOURITE_TYPES
))

def _all_backup_types():
	return ALL_BACKUP_TYPES

# Anciens champs invisibles issus du système à plusieurs codes paste.
# Ils ne sont plus utilisés et sont vidés automatiquement quand le backup unique est créé/mis à jour.
OBSOLETE_FAVOURITE_BACKUP_SETTINGS = (
	'alkopastes.favourites_movies_code',
	'alkopastes.favourites_tvshows_code',
	'alkopastes.favourites_seasons_code',
	'alkopastes.favourites_episodes_code',
	'alkopastes.favourites_people_code',
	'alkopastes.favourites_collections_code'
)


def has_user_api_key():
	try: return bool(str(get_setting('alkopastes.api_key_user', '')).strip())
	except Exception: return False


def api_key():
	return str(get_setting('alkopastes.api_key_user', '')).strip()


def is_multi_device_secondary_installation():
	return _is_userdata_multi_device_mode() and _is_userdata_secondary_installation()


def favourites_multi_device_mode():
	try: return str(get_setting(MULTI_DEVICE_SETTING, 'false')).strip().lower() == 'true'
	except Exception: return False


def favourites_single_device_mode():
	# Par défaut, la synchronisation des favoris alkoPastes reste inactive.
	# L'utilisateur doit indiquer qu'il a d'autres appareils à synchroniser.
	return not favourites_multi_device_mode()


def _favourites_multi_device_required(action='sync'):
	# Garde-fou côté code : le grisage des réglages ne suffit pas.
	# Tant que le mode multi-appareils n'est pas activé, aucune lecture/écriture
	# alkoPastes liée aux favoris ne doit s'exécuter, même si d'anciennes options
	# cachées sont encore à true ou si une action est appelée directement.
	if not favourites_single_device_mode(): return True
	try:
		changed = False
		changed = _set_setting_if_changed(SYNC_SETTING, 'false') or changed
		changed = _set_setting_if_changed(AUTO_RESTORE_SETTING, 'false') or changed
		changed = _set_setting_if_changed(FAVOURITES_SYNC_PENDING_SETTING, 'false') or changed
		changed = _set_setting_if_changed(FAVOURITES_SYNC_PENDING_AT_SETTING, '0') or changed
		if changed: _refresh_settings_cache()
	except Exception: pass
	logger('alkoPastes favourites multi-device guard', 'SKIP | %s | multi_device_disabled' % (action or 'sync'))
	return False


def favourites_sync_enabled():
	try:
		# Les favoris utilisent une vraie synchronisation multi-appareil.
		# Par défaut, on force la synchro favoris inactive tant que l'utilisateur
		# n'indique pas avoir d'autres appareils à synchroniser.
		if favourites_single_device_mode(): return False
		# Le rôle maître/secondaire reste réservé à la sauvegarde complète alkoFlix.
		return str(get_setting(SYNC_SETTING, 'false')).lower() == 'true'
	except Exception: return False


def favourites_auto_restore_enabled():
	try:
		if favourites_single_device_mode(): return False
		return str(get_setting(AUTO_RESTORE_SETTING, 'false')).lower() == 'true'
	except Exception: return False


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


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('alkoPastes favourites setting update failed', '%s | %s' % (setting_id, exc))
		return False


def _is_userdata_multi_device_mode():
	# Réglage 2026 : le choix visible se fait entre installation maître et secondaire.
	# L'ancien interrupteur interne reste lu seulement pour compatibilité.
	return (_setting_true(USERDATA_MASTER_INSTALLATION_SETTING)
		or _setting_true(USERDATA_SECONDARY_INSTALLATION_SETTING)
		or _setting_true(USERDATA_MULTI_DEVICE_SETTING))


def _is_userdata_secondary_installation():
	# Sécurité : si les deux rôles sont cochés par un ancien réglage cassé,
	# on empêche l'écriture tant que le choix n'est pas réinitialisé.
	if _setting_true(USERDATA_SECONDARY_INSTALLATION_SETTING): return True
	if _setting_true(USERDATA_MASTER_INSTALLATION_SETTING): return False
	return True


def can_write_favourites_backup(show_dialog=False):
	# Compatibilité avec les anciens appels internes.
	# Depuis la synchro multi-appareil des favoris, tous les appareils autorisés
	# peuvent écrire le miroir favoris. Le verrou maître ne concerne que la
	# sauvegarde complète alkoFlix.
	return True


def _backup_code():
	return str(get_setting(BACKUP_SETTING, '')).strip()


def codes_configured():
	return bool(_backup_code())


def core_codes_configured():
	# Compatibilité avec les anciens appels internes : aucun code n'est saisi par l'utilisateur.
	return codes_configured()


def _headers():
	return {
		'X-API-Key': api_key(),
		'Content-Type': 'application/json',
		'User-Agent': 'alkoFlix/%s' % kodi_utils.get_addoninfo('version')
	}


def _mask_code(code):
	code = str(code or '').strip()
	if not code: return ''
	if len(code) <= 6: return code[:2] + '***'
	return code[:3] + '***' + code[-3:]


def _translated_path(path):
	try: return kodi_utils.translate_path(path)
	except Exception: return str(path or '')


def _debug_context(action):
	try:
		logger('alkoPastes favourites debug', '%s | addon=%s | platform=%s | db_path=%s | backup=%s | api_key=%s | sync=%s' % (
			action,
			kodi_utils.get_addoninfo('version'),
			sys.platform,
			_translated_path(getattr(favourites_cache, 'db_file', '')),
			_mask_code(_backup_code()) or 'empty',
			'yes' if has_user_api_key() else 'no',
			'yes' if favourites_sync_enabled() else 'no'
		))
	except Exception as exc:
		logger('alkoPastes favourites debug failed', '%s | %s: %s' % (action, type(exc).__name__, exc))


def _request(method, action, payload=None, params=None, timeout=30.0, log_name='alkoPastes favourites request'):
	import requests
	params = dict(params or {})
	params['action'] = action
	safe_params = dict(params)
	if 'api_key' in safe_params: safe_params['api_key'] = '***'
	if 'id' in safe_params: safe_params['id'] = _mask_code(safe_params.get('id'))
	if 'p_code' in safe_params: safe_params['p_code'] = _mask_code(safe_params.get('p_code'))
	if 'slug' in safe_params: safe_params['slug'] = _mask_code(safe_params.get('slug'))
	payload_keys = sorted(list((payload or {}).keys())) if isinstance(payload, dict) else []
	logger(log_name, 'START | method=%s | action=%s | params=%s | payload_keys=%s' % (method, action, safe_params, payload_keys))
	if method == 'GET':
		response = requests.get(API_URL, params=params, headers=_headers(), timeout=timeout)
	elif method == 'POST':
		response = requests.post(API_URL, params=params, data=json.dumps(payload or {}, ensure_ascii=False).encode('utf-8'), headers=_headers(), timeout=timeout)
	else:
		response = requests.delete(API_URL, params=params, headers=_headers(), timeout=timeout)
	try: data = response.json()
	except Exception:
		data = {'success': False, 'error': response.text[:500] if hasattr(response, 'text') else 'Réponse invalide'}
	if response.status_code not in (200, 201) or not data.get('success'):
		error = data.get('error') or data.get('message') or 'Erreur HTTP %s' % response.status_code
		logger(log_name, 'FAIL | method=%s | action=%s | status=%s | error=%s' % (method, action, response.status_code, error))
		raise Exception(error)
	logger(log_name, 'OK | method=%s | action=%s | status=%s | keys=%s' % (method, action, response.status_code, sorted(list(data.keys()))))
	return data


def _safe_text(value):
	value = str(value or '')
	return value.replace('\r', ' ').replace('\n', ' ').replace('\t', ' ').strip()


def _installation_device_id():
	# Identifiant aléatoire local, commun à la sauvegarde maître et aux favoris.
	# Il n'est pas personnel et n'est pas copié comme autorisation.
	try:
		device_id = str(get_setting(FAVOURITES_DEVICE_ID_SETTING, '') or '').strip()
	except Exception:
		device_id = ''
	if len(device_id) >= 24:
		return device_id
	device_id = uuid.uuid4().hex
	try:
		set_setting(FAVOURITES_DEVICE_ID_SETTING, device_id)
		_refresh_settings_cache()
	except Exception as exc:
		logger('alkoPastes favourites device id save failed', '%s: %s' % (type(exc).__name__, exc))
	return device_id


def _favourites_device_hash():
	# Hash public utilisé uniquement pour départager proprement deux écritures
	# simultanées. Le vrai identifiant local n'est jamais publié.
	digest = hashlib.sha256(('alkoFlix favourites:%s' % _installation_device_id()).encode('utf-8')).hexdigest()
	try:
		if str(get_setting(FAVOURITES_DEVICE_HASH_SETTING, '') or '') != digest:
			set_setting(FAVOURITES_DEVICE_HASH_SETTING, digest)
	except Exception:
		pass
	return digest


def _encode(value):
	return quote(_safe_text(value), safe='')


def _decode(value):
	try: return unquote(str(value or ''))
	except Exception: return str(value or '')


def _summary_code(paste):
	paste = paste or {}
	return str(paste.get('slug') or paste.get('p_code') or paste.get('id') or '').strip()


def _paste_code(result, previous_code=''):
	paste = (result or {}).get('paste') or {}
	return _summary_code(paste) or str(previous_code or '').strip()


def _refresh_settings_cache():
	try: kodi_utils.clear_property('alkoflix_settings')
	except Exception: pass


def _set_backup_code(code):
	code = str(code or '').strip()
	old_code = _backup_code()
	set_setting(BACKUP_SETTING, code)
	_refresh_settings_cache()
	if old_code != code:
		logger('alkoPastes favourites backup setting', 'changed | %s -> %s' % (_mask_code(old_code), _mask_code(code)))
	else:
		logger('alkoPastes favourites backup setting', 'unchanged | %s' % (_mask_code(code) or 'empty'))
	return code


def _clear_obsolete_backup_settings(reason=''):
	cleared = []
	for setting_id in OBSOLETE_FAVOURITE_BACKUP_SETTINGS:
		try:
			if str(get_setting(setting_id, '')).strip():
				set_setting(setting_id, '')
				cleared.append(setting_id)
		except Exception:
			pass
	if cleared:
		_refresh_settings_cache()
		logger('alkoPastes favourites obsolete settings cleared', '%s | %s' % (reason or 'cleanup', ', '.join(cleared)))
	return cleared


def _delete_backup_paste(code, reason=''):
	code = str(code or '').strip()
	if not code: return False
	try:
		_request('DELETE', 'delete', params={'id': code})
		logger('alkoPastes favourites backup delete', 'OK | %s | %s' % (_mask_code(code), reason or 'cleanup'))
		return True
	except Exception as exc:
		logger('alkoPastes favourites backup delete failed', '%s | %s | %s' % (_mask_code(code), reason or 'cleanup', exc))
		return False


def _favourites_db_connection():
	# Les services Kodi peuvent tourner dans des threads différents. Le singleton
	# favourites_cache garde une connexion SQLite créée dans son thread d'origine;
	# ici on ouvre donc une connexion locale courte, propre au thread courant.
	con = kodi_utils.database_connect(kodi_utils.favourites_db, isolation_level=None)
	try:
		con.execute("""CREATE TABLE IF NOT EXISTS favourites (db_type text, tmdb_id text, title text, added_at text, unique (db_type, tmdb_id))""")
		con.execute("""CREATE TABLE IF NOT EXISTS favourite_tombstones (db_type text, tmdb_id text, title text, deleted_at text, PRIMARY KEY (db_type, tmdb_id))""")
		columns = [i[1] for i in con.execute("""PRAGMA table_info(favourites)""").fetchall()]
		if 'added_at' not in columns:
			con.execute("""ALTER TABLE favourites ADD COLUMN added_at text""")
			con.execute("""UPDATE favourites SET added_at = ? WHERE added_at IS NULL OR added_at = ''""", (str(int(time.time())),))
	except Exception as exc:
		logger('alkoPastes favourites db schema failed', '%s: %s' % (type(exc).__name__, exc))
	return con


def _db_favourite_row(db_type, tmdb_id):
	con = None
	try:
		con = _favourites_db_connection()
		row = con.execute("""SELECT title, added_at FROM favourites WHERE db_type = ? AND tmdb_id = ?""", (db_type, str(tmdb_id))).fetchone()
		if row: return {'title': str(row[0] or ''), 'added_at': str(row[1] or '')}
	except Exception as exc:
		logger('alkoPastes favourites db favourite read failed', '%s/%s | %s: %s' % (db_type, tmdb_id, type(exc).__name__, exc))
	finally:
		try:
			if con: con.close()
		except Exception: pass
	return None


def _db_tombstone_row(db_type, tmdb_id):
	con = None
	try:
		con = _favourites_db_connection()
		row = con.execute("""SELECT title, deleted_at FROM favourite_tombstones WHERE db_type = ? AND tmdb_id = ?""", (db_type, str(tmdb_id))).fetchone()
		if row: return {'title': str(row[0] or ''), 'deleted_at': str(row[1] or '')}
	except Exception as exc:
		logger('alkoPastes favourites db tombstone read failed', '%s/%s | %s: %s' % (db_type, tmdb_id, type(exc).__name__, exc))
	finally:
		try:
			if con: con.close()
		except Exception: pass
	return None


def _db_add_favourite_at(db_type, tmdb_id, title='', added_at=''):
	con = None
	try:
		con = _favourites_db_connection()
		added_at = added_at or str(int(time.time()))
		con.execute("""INSERT OR REPLACE INTO favourites (db_type, tmdb_id, title, added_at) VALUES (?, ?, ?, ?)""", (db_type, str(tmdb_id), title or '', added_at))
		con.execute("""DELETE FROM favourite_tombstones WHERE db_type = ? AND tmdb_id = ?""", (db_type, str(tmdb_id)))
		return True
	except Exception as exc:
		logger('alkoPastes favourites db add failed', '%s/%s | %s: %s' % (db_type, tmdb_id, type(exc).__name__, exc))
	finally:
		try:
			if con: con.close()
		except Exception: pass
	return False


def _db_remove_favourite_at(db_type, tmdb_id, title='', deleted_at=''):
	con = None
	try:
		con = _favourites_db_connection()
		deleted_at = deleted_at or str(int(time.time()))
		if not title:
			row = con.execute("""SELECT title FROM favourites WHERE db_type = ? AND tmdb_id = ?""", (db_type, str(tmdb_id))).fetchone()
			title = str(row[0] or '') if row else ''
		con.execute("""INSERT OR REPLACE INTO favourite_tombstones (db_type, tmdb_id, title, deleted_at) VALUES (?, ?, ?, ?)""", (db_type, str(tmdb_id), title or '', deleted_at))
		con.execute("""DELETE FROM favourites WHERE db_type = ? AND tmdb_id = ?""", (db_type, str(tmdb_id)))
		return True
	except Exception as exc:
		logger('alkoPastes favourites db remove failed', '%s/%s | %s: %s' % (db_type, tmdb_id, type(exc).__name__, exc))
	finally:
		try:
			if con: con.close()
		except Exception: pass
	return False


def _raw_favourites_rows(favourite_types=None):
	favourite_types = tuple(favourite_types or _all_backup_types())
	if not favourite_types: return []
	con = None
	try:
		con = _favourites_db_connection()
		placeholders = ','.join('?' for _ in favourite_types)
		return con.execute("""SELECT db_type, tmdb_id, title, added_at FROM favourites WHERE db_type IN (%s) ORDER BY db_type, title COLLATE NOCASE, tmdb_id""" % placeholders, favourite_types).fetchall() or []
	except Exception as exc:
		logger('alkoPastes favourites backup rows failed', 'types=%s | %s: %s' % (','.join(favourite_types), type(exc).__name__, exc))
		return []
	finally:
		try:
			if con: con.close()
		except Exception: pass


def _raw_tombstone_rows(favourite_types=None):
	favourite_types = tuple(favourite_types or _all_backup_types())
	con = None
	try:
		con = _favourites_db_connection()
		if favourite_types:
			placeholders = ','.join('?' for _ in favourite_types)
			rows = con.execute("""SELECT db_type, tmdb_id, title, deleted_at FROM favourite_tombstones WHERE db_type IN (%s)""" % placeholders, favourite_types).fetchall() or []
		else:
			rows = con.execute("""SELECT db_type, tmdb_id, title, deleted_at FROM favourite_tombstones""").fetchall() or []
		return [(i[0], i[1], i[2], i[3]) for i in rows]
	except Exception as exc:
		logger('alkoPastes favourites tombstones rows failed', '%s: %s' % (type(exc).__name__, exc))
		return []
	finally:
		try:
			if con: con.close()
		except Exception: pass


def _local_state_items(favourite_types=None):
	device_hash = _favourites_device_hash()
	items = []
	for db_type, tmdb_id, title, added_at in _raw_favourites_rows(favourite_types or _all_backup_types()):
		tmdb_id = _safe_text(tmdb_id)
		if not tmdb_id: continue
		updated_at = _safe_text(added_at) or str(int(time.time()))
		items.append({'db_type': _safe_text(db_type), 'tmdb_id': tmdb_id, 'title': _safe_text(title), 'added_at': _safe_text(added_at), 'updated_at': updated_at, 'deleted': False, 'device_hash': device_hash})
	for db_type, tmdb_id, title, deleted_at in _raw_tombstone_rows(favourite_types or _all_backup_types()):
		tmdb_id = _safe_text(tmdb_id)
		if not tmdb_id: continue
		deleted_at = _safe_text(deleted_at) or str(int(time.time()))
		items.append({'db_type': _safe_text(db_type), 'tmdb_id': tmdb_id, 'title': _safe_text(title), 'added_at': '', 'updated_at': deleted_at, 'deleted': True, 'device_hash': device_hash})
	return items


def _build_backup_text_from_items(items, header_only=False):
	lines = [
		'# alkoFlix favourites backup',
		'# version: %s' % BACKUP_VERSION,
		'# created: %s' % int(time.time()),
		'db_type\ttmdb_id\ttitle\tadded_at\tupdated_at\tdeleted\tdevice_hash'
	]
	if header_only: return '\n'.join(lines) + '\n'
	for item in sorted(items or [], key=lambda i: (_safe_text(i.get('db_type')), _safe_text(i.get('title')).lower(), _safe_text(i.get('tmdb_id')), '1' if i.get('deleted') else '0')):
		db_type = _safe_text(item.get('db_type'))
		tmdb_id = _safe_text(item.get('tmdb_id'))
		if not db_type or not tmdb_id: continue
		lines.append('%s\t%s\t%s\t%s\t%s\t%s\t%s' % (
			db_type,
			_encode(tmdb_id),
			_encode(item.get('title')),
			_encode(item.get('added_at')),
			_encode(_item_updated_at(item) or str(int(time.time()))),
			'1' if item.get('deleted') else '0',
			_encode(item.get('device_hash'))
		))
	return '\n'.join(lines) + '\n'


def _build_backup_text(favourite_types=None, header_only=False):
	return _build_backup_text_from_items(_local_state_items(favourite_types or _all_backup_types()), header_only=header_only)


def _backup_items_count(favourite_types):
	count = 0
	for row in _raw_favourites_rows(favourite_types):
		try:
			if _safe_text(row[1]): count += 1
		except Exception: pass
	return count


def _backup_counts():
	return {group: _backup_items_count(types) for group, types in FAVOURITE_COUNT_GROUPS.items()}



def _parse_backup_text(text, apply_custom_folders=True):
	# apply_custom_folders est conservé uniquement pour compatibilité d'appel avec d'anciennes WIP.
	items = []
	if not text:
		logger('alkoPastes favourites backup parse', 'empty content')
		return items
	for raw_line in str(text).splitlines():
		line = raw_line.strip('\ufeff').strip()
		if not line or line.startswith('#'): continue
		parts = line.split('\t')
		if parts and parts[0] == 'db_type': continue
		if len(parts) < 3: continue
		db_type = _safe_text(parts[0])
		tmdb_id = _decode(parts[1])
		title = _decode(parts[2])
		added_at = _decode(parts[3]) if len(parts) > 3 else ''
		# Version 4 : updated_at + deleted. Version 5 : device_hash en plus.
		# Anciennes versions : ajout actif seulement.
		updated_at = _decode(parts[4]) if len(parts) > 4 else added_at
		deleted = str(_decode(parts[5]) if len(parts) > 5 else '0').strip().lower() in ('1', 'true', 'yes', 'deleted')
		device_hash = _decode(parts[6]) if len(parts) > 6 else ''
		if db_type and tmdb_id:
			items.append({'db_type': db_type, 'tmdb_id': tmdb_id, 'title': title, 'added_at': added_at, 'updated_at': updated_at or added_at, 'deleted': deleted, 'device_hash': device_hash})
	logger('alkoPastes favourites backup parse', 'content_len=%s | items=%s' % (len(str(text)), len(items)))
	return items


def _timestamp_value(value):
	value = _safe_text(value)
	if not value: return 0
	try: return int(time.mktime(datetime.strptime(value[:19], '%Y-%m-%d %H:%M:%S').timetuple()))
	except Exception:
		try: return int(float(value))
		except Exception: return 0


def _normalised_backup_content_for_hash(text):
	# Ignore les lignes volatiles comme la date de création.
	# Ainsi, une sauvegarde réécrite sans vrai changement ne déclenche pas
	# une réapplication inutile des favoris sur les installations secondaires.
	lines = []
	for raw_line in str(text or '').splitlines():
		line = raw_line.strip('\ufeff').strip()
		if line.lower().startswith('# created:'):
			continue
		lines.append(line)
	return '\n'.join(lines).strip()


def _backup_content_hash(text):
	normalised = _normalised_backup_content_for_hash(text)
	return hashlib.sha256(normalised.encode('utf-8')).hexdigest() if normalised else ''


def _item_updated_at(item):
	return _safe_text((item or {}).get('updated_at') or (item or {}).get('deleted_at') or (item or {}).get('added_at'))


def _item_ts(item):
	return _timestamp_value(_item_updated_at(item))


def _local_active_ts(db_type, tmdb_id):
	row = _db_favourite_row(db_type, tmdb_id)
	return _timestamp_value((row or {}).get('added_at'))


def _local_deleted_ts(db_type, tmdb_id):
	row = _db_tombstone_row(db_type, tmdb_id)
	return _timestamp_value((row or {}).get('deleted_at'))


def _state_key(item):
	return (_safe_text((item or {}).get('db_type')), _safe_text((item or {}).get('tmdb_id')))


def _state_device(item):
	return _safe_text((item or {}).get('device_hash'))


def _state_newer(candidate, current):
	if not current: return True
	candidate_ts = _item_ts(candidate)
	current_ts = _item_ts(current)
	if candidate_ts != current_ts:
		return candidate_ts > current_ts
	# En cas d'égalité exacte, la suppression gagne. Cela évite les favoris zombies
	# quand deux appareils écrivent dans la même seconde.
	candidate_deleted = bool((candidate or {}).get('deleted'))
	current_deleted = bool((current or {}).get('deleted'))
	if candidate_deleted != current_deleted:
		return candidate_deleted
	# Dernier départage déterministe : évite qu'un même conflit oscille selon l'ordre
	# de lecture. Le hash ne contient pas de donnée personnelle.
	return _state_device(candidate) > _state_device(current)


def _merge_item_states(*item_groups):
	state = {}
	for items in item_groups:
		for item in items or []:
			key = _state_key(item)
			if not key[0] or not key[1]: continue
			if _state_newer(item, state.get(key)):
				state[key] = item
	return list(state.values())


def _load_remote_items(code):
	if not code: return [], ''
	content = _get_paste_content(code)
	return _parse_backup_text(content, apply_custom_folders=False), content


def _remote_item_needs_local_apply(item):
	# Le hash du paste indique seulement que le contenu distant n'a pas changé.
	# Il ne garantit pas que la base locale contient réellement ces favoris.
	# On vérifie donc l'état local avant de sauter une restauration automatique.
	db_type = _safe_text((item or {}).get('db_type'))
	tmdb_id = _safe_text((item or {}).get('tmdb_id'))
	if not db_type or not tmdb_id: return False
	local_active = _db_favourite_row(db_type, tmdb_id)
	local_deleted = _db_tombstone_row(db_type, tmdb_id)
	remote_ts = _item_ts(item)
	local_active_ts = _timestamp_value((local_active or {}).get('added_at'))
	local_delete_ts = _timestamp_value((local_deleted or {}).get('deleted_at'))
	if item.get('deleted'):
		if local_delete_ts and (not remote_ts or local_delete_ts >= remote_ts): return False
		if not local_active or not local_active_ts or not remote_ts or remote_ts >= local_active_ts: return True
		return False
	if local_delete_ts and remote_ts and local_delete_ts > remote_ts:
		return False
	if not local_active:
		return True
	if remote_ts and (not local_active_ts or local_active_ts < remote_ts):
		return True
	return False


def _remote_restore_status(items, allowed_types=None):
	allowed = set(allowed_types or _all_backup_types())
	status = {'received': 0, 'active': 0, 'deleted': 0, 'needs_apply': 0, 'skipped_type': 0}
	for item in items or []:
		if item.get('db_type') not in allowed:
			status['skipped_type'] += 1
			continue
		status['received'] += 1
		if item.get('deleted'): status['deleted'] += 1
		else: status['active'] += 1
		try:
			if _remote_item_needs_local_apply(item): status['needs_apply'] += 1
		except Exception as exc:
			logger('alkoPastes favourites restore status item failed', '%s | %s' % (item, exc))
	return status


def _local_active_items_count(allowed_types=None):
	try: return len(_raw_favourites_rows(allowed_types or _all_backup_types()))
	except Exception: return 0


def _sync_hash_from_items(items):
	return _backup_content_hash(_build_backup_text_from_items(items or [], header_only=False))


def _current_local_sync_hash():
	try: return _sync_hash_from_items(_local_state_items(_all_backup_types()))
	except Exception as exc:
		logger('alkoPastes favourites local hash failed', '%s: %s' % (type(exc).__name__, exc))
		return ''


def _set_last_local_sync_hash(value=None):
	try:
		content_hash = value if value is not None else _current_local_sync_hash()
		set_setting(FAVOURITES_SYNC_LAST_LOCAL_HASH_SETTING, str(content_hash or ''))
		return True
	except Exception as exc:
		logger('alkoPastes favourites local hash save failed', '%s: %s' % (type(exc).__name__, exc))
		return False


def _local_state_changed_since_last_sync(log=False):
	current_hash = _current_local_sync_hash()
	last_hash = str(get_setting(FAVOURITES_SYNC_LAST_LOCAL_HASH_SETTING, '') or '').strip()
	changed = bool(current_hash and current_hash != last_hash)
	if changed and log:
		logger('alkoPastes favourites pending sync', 'LOCAL_CHANGED | current=%s | last=%s | local_active=%s' % (
			current_hash[:12], last_hash[:12] if last_hash else 'empty', _local_active_items_count(_all_backup_types())
		))
	return changed


def _compact_favourites_items(items, now=None, retention_seconds=None):
	# Compactage prudent : on conserve tous les favoris actifs, mais on retire les
	# tombstones très anciennes. Les suppressions récentes restent dans le miroir
	# afin qu'un appareil qui n'a pas encore synchronisé ne fasse pas revenir un
	# favori supprimé. Par défaut, une trace de suppression est gardée 180 jours.
	now = int(now or time.time())
	retention_seconds = int(retention_seconds or FAVOURITES_TOMBSTONE_RETENTION_SECONDS)
	kept, removed = [], 0
	for item in _merge_item_states(items or []):
		if not item.get('deleted'):
			kept.append(item)
			continue
		item_ts = _item_ts(item)
		# Si la date est absente ou illisible, on garde la trace par sécurité.
		if item_ts <= 0 or now - item_ts < retention_seconds:
			kept.append(item)
		else:
			removed += 1
	return kept, removed


def _prune_local_tombstones(now=None, retention_seconds=None):
	now = int(now or time.time())
	retention_seconds = int(retention_seconds or FAVOURITES_TOMBSTONE_RETENTION_SECONDS)
	removed = 0
	con = None
	try:
		con = _favourites_db_connection()
		for db_type, tmdb_id, title, deleted_at in _raw_tombstone_rows(_all_backup_types()) or []:
			item_ts = _timestamp_value(deleted_at)
			if item_ts <= 0 or now - item_ts < retention_seconds:
				continue
			con.execute("""DELETE FROM favourite_tombstones WHERE db_type = ? AND tmdb_id = ?""", (db_type, str(tmdb_id)))
			removed += 1
	except Exception as exc:
		logger('alkoPastes favourites cleanup local failed', '%s: %s' % (type(exc).__name__, exc))
	finally:
		try:
			if con: con.close()
		except Exception: pass
	return removed


def automatic_alkopastes_favourites_cleanup(force=False):
	# Nettoyage automatique léger : au plus une fois aux 30 jours, seulement si
	# l'utilisateur a activé la synchro multi-appareils des favoris.
	# Le nettoyage ne fait pas d'import local et ne touche pas aux favoris actifs.
	if favourites_single_device_mode():
		return 'multi_device_disabled'
	if not has_user_api_key():
		return 'not_authorized'
	if str(get_setting(FAVOURITES_SYNC_PENDING_SETTING, 'false')).lower() == 'true':
		return 'pending_sync'
	current_time = int(time.time())
	last_cleanup = _setting_int(FAVOURITES_CLEANUP_LAST_SETTING, 0)
	if not force and last_cleanup > 0 and current_time - last_cleanup < FAVOURITES_CLEANUP_INTERVAL_SECONDS:
		return 'not needed'
	try:
		code = _backup_code() or _find_backup_code()
		if not code:
			set_setting(FAVOURITES_CLEANUP_LAST_SETTING, str(current_time))
			_refresh_settings_cache()
			logger('alkoPastes favourites cleanup', 'SKIP | missing_backup | next=%s' % (current_time + FAVOURITES_CLEANUP_INTERVAL_SECONDS))
			return 'missing_backup'
		remote_items, _remote_content = _load_remote_items(code)
		local_items = _local_state_items(_all_backup_types())
		merged_items = _merge_item_states(remote_items, local_items)
		compacted_items, pruned_items = _compact_favourites_items(merged_items, now=current_time)
		pruned_local = _prune_local_tombstones(now=current_time)
		if pruned_items > 0:
			content = _build_backup_text_from_items(compacted_items, header_only=False)
			new_code = _save_single_backup(content)
			_set_last_restore_hash_for_content(content)
		else:
			new_code = code
		set_setting(FAVOURITES_CLEANUP_LAST_SETTING, str(current_time))
		_refresh_settings_cache()
		logger('alkoPastes favourites cleanup', 'OK | single=%s | remote=%s | local=%s | kept=%s | pruned=%s | pruned_local=%s | next=%s' % (
			_mask_code(new_code), len(remote_items), len(local_items), len(compacted_items), pruned_items, pruned_local, current_time + FAVOURITES_CLEANUP_INTERVAL_SECONDS
		))
		return 'success'
	except Exception as exc:
		set_setting(FAVOURITES_CLEANUP_LAST_SETTING, str(current_time))
		_refresh_settings_cache()
		logger('alkoPastes favourites cleanup failed', '%s: %s | next=%s' % (type(exc).__name__, exc, current_time + FAVOURITES_CLEANUP_INTERVAL_SECONDS))
		return 'failed'


def _set_last_restore_hash_for_content(content):
	try:
		content_hash = _backup_content_hash(content)
		if content_hash:
			set_setting(AUTO_RESTORE_LAST_HASH_SETTING, content_hash)
	except Exception:
		pass


def _get_paste_content(code):
	logger('alkoPastes favourites backup get', 'START | code=%s' % _mask_code(code))
	result = _request('GET', 'get', params={'id': code})
	paste = result.get('paste') or {}
	content = paste.get('content') or ''
	if isinstance(content, (dict, list)): content = json.dumps(content, ensure_ascii=False)
	logger('alkoPastes favourites backup get', 'OK | code=%s | content_len=%s | title=%s' % (_mask_code(code), len(str(content)), str(paste.get('title') or '')[:80]))
	return content


def _matches_backup_title(paste):
	title = str((paste or {}).get('title') or '').strip()
	return title == BACKUP_TITLE or title.startswith(BACKUP_TITLE)


def _find_backup_code():
	logger('alkoPastes favourites backup find', 'START | cached=%s | title=%s' % (_mask_code(_backup_code()), BACKUP_TITLE))
	for page in range(1, 6):
		try:
			result = _request('GET', 'list', params={'page': page, 'limit': 100})
		except Exception as exc:
			logger('alkoPastes favourites backup list failed', 'page=%s | %s' % (page, exc))
			break
		pastes = result.get('pastes') or []
		logger('alkoPastes favourites backup find', 'list page=%s | pastes=%s' % (page, len(pastes)))
		for paste in pastes:
			if not _matches_backup_title(paste): continue
			code = _summary_code(paste)
			if code:
				_set_backup_code(code)
				logger('alkoPastes favourites backup found', 'list | single=%s | page=%s' % (_mask_code(code), page))
				return code
		pagination = result.get('pagination') or {}
		try: pages = int(pagination.get('pages') or 0)
		except Exception: pages = 0
		if not pastes or (pages and page >= pages): break

	for query in (BACKUP_TITLE, 'alkoFlix favoris'):
		try:
			result = _request('GET', 'search', params={'q': query, 'limit': 50})
		except Exception as exc:
			logger('alkoPastes favourites backup search failed', '%s | %s' % (query, exc))
			continue
		search_pastes = result.get('pastes') or []
		logger('alkoPastes favourites backup find', 'search query=%s | pastes=%s' % (query, len(search_pastes)))
		for paste in search_pastes:
			if not _matches_backup_title(paste): continue
			code = _summary_code(paste)
			if code:
				_set_backup_code(code)
				logger('alkoPastes favourites backup found', 'search | single=%s | query=%s' % (_mask_code(code), query))
				return code
	logger('alkoPastes favourites backup find', 'MISS | no matching private backup found')
	return ''


def _save_single_backup(content):
	old_code = _backup_code()
	logger('alkoPastes favourites backup save', 'START | cached=%s | content_len=%s' % (_mask_code(old_code), len(str(content or ''))))
	code = old_code or _find_backup_code()
	payload = {'title': BACKUP_TITLE, 'content': content, 'syntax': 'text', 'visibility': 'private', 'expiry': 'NULL'}
	created_new = False
	if code:
		try:
			result = _request('POST', 'update', payload=payload, params={'id': code})
		except Exception as exc:
			logger('alkoPastes favourites backup update failed', '%s | recreate | %s' % (_mask_code(code), exc))
			result = _request('POST', 'paste', payload=payload)
			created_new = True
	else:
		result = _request('POST', 'paste', payload=payload)
		created_new = True
	new_code = _paste_code(result, code)
	logger('alkoPastes favourites backup save', 'RESULT | old=%s | target=%s | new=%s | created_new=%s' % (_mask_code(old_code), _mask_code(code), _mask_code(new_code), created_new))
	if new_code:
		_set_backup_code(new_code)
		_clear_obsolete_backup_settings('single=%s' % _mask_code(new_code))
		if created_new and code and new_code != code:
			_delete_backup_paste(code, 'replaced_by=%s' % _mask_code(new_code))
	return new_code



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


def mark_favourites_sync_pending(reason=''):
	# Les ajouts/retraits de favoris restent immédiats en local, mais l'écriture
	# alkoPastes est regroupée par le service de fond pour éviter un GET/POST par clic.
	if not has_user_api_key():
		logger('alkoPastes favourites pending sync', 'SKIP | not_authorized | %s' % (reason or ''))
		return False
	if not favourites_sync_enabled():
		logger('alkoPastes favourites pending sync', 'SKIP | sync_disabled | %s' % (reason or ''))
		return False
	try:
		now = str(int(time.time()))
		set_setting(FAVOURITES_SYNC_PENDING_SETTING, 'true')
		set_setting(FAVOURITES_SYNC_PENDING_AT_SETTING, now)
		_refresh_settings_cache()
		logger('alkoPastes favourites pending sync', 'QUEUED | at=%s | reason=%s' % (now, reason or 'change'))
		return True
	except Exception as exc:
		logger('alkoPastes favourites pending sync failed', '%s: %s' % (type(exc).__name__, exc))
		return False


def pending_favourites_sync_due(delay=None):
	if not has_user_api_key() or not favourites_sync_enabled(): return False
	pending = str(get_setting(FAVOURITES_SYNC_PENDING_SETTING, 'false')).lower() == 'true'
	if pending:
		delay = FAVOURITES_SYNC_DEBOUNCE_SECONDS if delay is None else int(delay or 0)
		pending_at = _setting_int(FAVOURITES_SYNC_PENDING_AT_SETTING, 0)
		if pending_at <= 0 or int(time.time()) - pending_at >= delay:
			return True
	# Filet de sécurité : si une action favori n'a pas posé le flag pending
	# (widget/skin/contexte atypique), on détecte quand même le changement local.
	return _local_state_changed_since_last_sync(log=True)


def flush_pending_favourites_sync(force=False, reason=''):
	pending = str(get_setting(FAVOURITES_SYNC_PENDING_SETTING, 'false')).lower() == 'true'
	local_changed = _local_state_changed_since_last_sync(log=True)
	if not force and not pending and not local_changed:
		return {'success': True, 'provider': 'alkoPastes', 'skipped': True, 'reason': 'not_pending'}
	if not has_user_api_key():
		logger('alkoPastes favourites pending sync', 'FLUSH_SKIPPED | not_authorized | %s' % (reason or ''))
		return {'success': False, 'provider': 'alkoPastes', 'error': 'not_authorized'}
	if not favourites_sync_enabled():
		logger('alkoPastes favourites pending sync', 'FLUSH_SKIPPED | sync_disabled | %s' % (reason or ''))
		return {'success': False, 'provider': 'alkoPastes', 'error': 'sync_disabled'}
	if local_changed and not pending and not reason:
		reason = 'local_state_changed'
	elif local_changed and reason == 'service_debounce':
		reason = 'service_local_state_changed'
	logger('alkoPastes favourites pending sync', 'FLUSH_START | reason=%s | pending=%s | local_changed=%s' % (reason or 'manual', pending, local_changed))
	try:
		result = export_local_favourites_to_alkopastes(force=True, merge_remote=True)
		if result.get('success'):
			set_setting(FAVOURITES_SYNC_PENDING_SETTING, 'false')
			set_setting(FAVOURITES_SYNC_LAST_FLUSH_SETTING, str(int(time.time())))
			_set_last_local_sync_hash()
			_refresh_settings_cache()
			logger('alkoPastes favourites pending sync', 'FLUSH_OK | reason=%s' % (reason or 'manual'))
		else:
			logger('alkoPastes favourites pending sync', 'FLUSH_FAILED | reason=%s | error=%s' % (reason or 'manual', result.get('error') or 'unknown'))
		return result
	except Exception as exc:
		logger('alkoPastes favourites pending sync', 'FLUSH_FAILED | reason=%s | %s: %s' % (reason or 'manual', type(exc).__name__, exc))
		return {'success': False, 'provider': 'alkoPastes', 'error': '%s: %s' % (type(exc).__name__, exc)}

def ensure_backup_pastes(header_only=True, dynamic_only=True, force=False):
	# Ancienne entrée interne conservée pour compatibilité ; elle écrit maintenant le backup unique.
	_debug_context('ensure start | header_only=%s | dynamic_only=%s' % (header_only, dynamic_only))
	if not _favourites_multi_device_required('ensure'):
		return {'success': False, 'provider': 'alkoPastes', 'error': 'multi_device_disabled'}
	if not has_user_api_key():
		logger('alkoPastes favourites backup ensure', 'SKIP | not_authorized')
		return {'success': False, 'provider': 'alkoPastes', 'error': 'not_authorized'}
	if not force and not favourites_sync_enabled():
		logger('alkoPastes favourites backup ensure', 'SKIP | sync_disabled')
		return {'success': False, 'provider': 'alkoPastes', 'error': 'sync_disabled'}
	if header_only and not _raw_favourites_rows(_all_backup_types()) and not _raw_tombstone_rows(_all_backup_types()):
		content = _build_backup_text(_all_backup_types(), header_only=True)
		code = _save_single_backup(content)
		_set_last_restore_hash_for_content(content)
		logger('alkoPastes favourites backup ensure', 'OK | single=%s | header_only' % (_mask_code(code) or ''))
		return {'success': True, 'provider': 'alkoPastes', 'code': code, 'counts': _backup_counts()}
	return export_local_favourites_to_alkopastes(force=True, merge_remote=True)


def _normal_excluded_keys(excluded_keys=None):
	keys = set()
	for item in excluded_keys or ():
		try:
			db_type, tmdb_id = item
		except Exception:
			continue
		db_type = _safe_text(db_type)
		tmdb_id = _safe_text(tmdb_id)
		if db_type and tmdb_id: keys.add((db_type, tmdb_id))
	return keys


def _merged_favourites_state(merge_remote=True):
	code = _backup_code() or _find_backup_code()
	remote_items = []
	if merge_remote and code:
		try:
			remote_items, _remote_content = _load_remote_items(code)
		except Exception as exc:
			logger('alkoPastes favourites backup remote merge failed', '%s | %s' % (_mask_code(code), exc))
	local_items = _local_state_items(_all_backup_types())
	merged_items = _merge_item_states(remote_items, local_items)
	return code, merged_items, remote_items, local_items


def _merge_remote_favourites_into_local(excluded_types=None, excluded_keys=None):
	# Compatibilité avec les anciens appels : on ne filtre plus les éléments distants
	# avant fusion. Les timestamps et les tombstones règlent maintenant les conflits.
	code = _backup_code() or _find_backup_code()
	if not code:
		logger('alkoPastes favourites backup merge', 'SKIP | no remote backup')
		return 0
	try:
		remote_items, _remote_content = _load_remote_items(code)
		merged_items = _merge_item_states(remote_items, _local_state_items(_all_backup_types()))
		count = _import_items(merged_items, _all_backup_types())
		logger('alkoPastes favourites backup merge', 'DONE | single=%s | applied=%s | remote=%s' % (_mask_code(code), count, len(remote_items)))
		return count
	except Exception as exc:
		logger('alkoPastes favourites backup merge failed', '%s | %s' % (_mask_code(code), exc))
		return 0


def export_local_favourites_to_alkopastes(force=False, merge_remote=True, excluded_types=None, excluded_keys=None):
	_debug_context('export start')
	if not _favourites_multi_device_required('export'):
		return {'success': False, 'provider': 'alkoPastes', 'error': 'multi_device_disabled'}
	if not has_user_api_key():
		logger('alkoPastes favourites backup export', 'SKIP | not_authorized')
		return {'success': False, 'provider': 'alkoPastes', 'error': 'not_authorized'}
	if not force and not favourites_sync_enabled():
		logger('alkoPastes favourites backup export', 'SKIP | sync_disabled')
		return {'success': False, 'provider': 'alkoPastes', 'error': 'sync_disabled'}
	code, merged_items, remote_items, local_items = _merged_favourites_state(merge_remote=merge_remote)
	applied = _import_items(merged_items, _all_backup_types()) if merge_remote else 0
	content = _build_backup_text_from_items(merged_items, header_only=False)
	new_code = _save_single_backup(content)
	_set_last_restore_hash_for_content(content)
	_set_last_local_sync_hash()
	counts = _backup_counts()
	logger('alkoPastes favourites backup export', 'OK | single=%s | remote=%s | local=%s | merged=%s | applied=%s | %s' % (_mask_code(new_code) or '', len(remote_items), len(local_items), len(merged_items), applied, counts))
	return {'success': True, 'provider': 'alkoPastes', 'counts': counts, 'code': new_code, 'merged': applied}


def _apply_active_item(item):
	db_type = item.get('db_type')
	tmdb_id = item.get('tmdb_id')
	remote_ts = _item_ts(item)
	local_active_ts = _local_active_ts(db_type, tmdb_id)
	local_delete_ts = _local_deleted_ts(db_type, tmdb_id)
	# Une suppression plus récente gagne contre un ancien favori encore présent dans le miroir.
	if local_delete_ts and local_delete_ts > remote_ts:
		return False
	# Si l'ajout local est plus récent ou identique, on ne réécrit pas inutilement.
	if local_active_ts and local_active_ts >= remote_ts:
		return False
	return _db_add_favourite_at(db_type, tmdb_id, item.get('title'), _item_updated_at(item) or item.get('added_at'))


def _apply_deleted_item(item):
	db_type = item.get('db_type')
	tmdb_id = item.get('tmdb_id')
	deleted_at = _item_updated_at(item) or str(int(time.time()))
	remote_ts = _timestamp_value(deleted_at)
	local_active_ts = _local_active_ts(db_type, tmdb_id)
	local_delete_ts = _local_deleted_ts(db_type, tmdb_id)
	if local_delete_ts and local_delete_ts >= remote_ts:
		return False
	# Une suppression distante plus récente efface le favori local et inscrit une trace locale.
	if not local_active_ts or remote_ts >= local_active_ts:
		return _db_remove_favourite_at(db_type, tmdb_id, item.get('title'), deleted_at)
	# Le favori local a été ajouté après la suppression distante : on garde l'ajout local.
	return False


def _import_items(items, allowed_types=None):
	allowed = set(allowed_types or _all_backup_types())
	count = 0
	skipped = 0
	deleted = 0
	for item in items or []:
		if item.get('db_type') not in allowed:
			skipped += 1
			continue
		try:
			if item.get('deleted'):
				if _apply_deleted_item(item): deleted += 1
			elif _apply_active_item(item):
				count += 1
		except Exception as exc:
			logger('alkoPastes favourites backup import item failed', '%s | %s' % (item, exc))
	logger('alkoPastes favourites backup import items', 'DONE | imported=%s | deleted=%s | skipped=%s | received=%s' % (count, deleted, skipped, len(items or [])))
	return count + deleted

def _import_single_backup(code, content=None, items=None):
	logger('alkoPastes favourites backup import', 'START | single=%s' % _mask_code(code))
	if content is None:
		content = _get_paste_content(code)
	if items is None:
		items = _parse_backup_text(content)
	status = _remote_restore_status(items, _all_backup_types())
	count = _import_items(items, _all_backup_types())
	_set_last_restore_hash_for_content(content)
	_set_last_local_sync_hash()
	logger('alkoPastes favourites backup import', 'OK | single=%s | imported=%s | remote_active=%s | remote_deleted=%s | needed=%s | local_active=%s' % (
		_mask_code(code), count, status.get('active', 0), status.get('deleted', 0), status.get('needs_apply', 0), _local_active_items_count(_all_backup_types())
	))
	return {'success': True, 'provider': 'alkoPastes', 'count': count, 'code': code, 'hash': _backup_content_hash(content), 'status': status}


def import_alkopastes_favourites():
	_debug_context('import start')
	if not _favourites_multi_device_required('import'):
		return {'success': False, 'provider': 'alkoPastes', 'count': 0, 'error': 'multi_device_disabled'}
	if not has_user_api_key():
		logger('alkoPastes favourites backup import', 'SKIP | not_authorized')
		return {'success': False, 'provider': 'alkoPastes', 'count': 0, 'error': 'not_authorized'}
	code = _backup_code()
	logger('alkoPastes favourites backup import', 'cached code | %s' % (_mask_code(code) or 'empty'))
	if code:
		try: return _import_single_backup(code)
		except Exception as exc:
			logger('alkoPastes favourites backup import cached code failed', '%s | %s' % (_mask_code(code), exc))
			try: _set_backup_code('')
			except Exception: pass
	code = _find_backup_code()
	if code:
		try: return _import_single_backup(code)
		except Exception as exc:
			logger('alkoPastes favourites backup import searched code failed', '%s | %s' % (_mask_code(code), exc))
	_clear_obsolete_backup_settings('missing_backup')
	logger('alkoPastes favourites backup import', 'No backup found | normal before first export | cached=%s' % (_mask_code(_backup_code()) or 'empty'))
	return {'success': False, 'provider': 'alkoPastes', 'count': 0, 'error': 'missing_backup'}


def _normalize_auto_restore_frequency_setting():
	# Migration WIP13 : l'ancien réglage caché valait souvent "1", ce qui
	# correspondait à une récupération presque chaque minute. Le nouveau défaut
	# demandé est volontairement conservateur : au démarrage seulement.
	value = str(get_setting(AUTO_RESTORE_INTERVAL_SETTING, '0') or '0').strip().lower()
	try:
		migrated = str(get_setting(AUTO_RESTORE_FREQUENCY_MIGRATED_SETTING, 'false') or 'false').strip().lower() == 'true'
	except Exception:
		migrated = False
	if not migrated:
		legacy_values = ('', '1', '1 minute', '30', '30 minutes', 'toutes les 30 minutes', 'chaque 30 minutes', '60', '1 heure', 'chaque heure')
		if value in legacy_values:
			value = '0'
			_set_setting_if_changed(AUTO_RESTORE_INTERVAL_SETTING, value)
		_set_setting_if_changed(AUTO_RESTORE_FREQUENCY_MIGRATED_SETTING, 'true')
		_refresh_settings_cache()
	try:
		order_migrated = str(get_setting(AUTO_RESTORE_FREQUENCY_ORDER_MIGRATED_SETTING, 'false') or 'false').strip().lower() == 'true'
	except Exception:
		order_migrated = False
	if not order_migrated:
		# WIP14 : ordre UI plus logique et plus court : démarrage, 1/jour, 12h, 6h.
		# On migre les anciens index WIP13 pour conserver l'intention de l'utilisateur.
		if value == '1': value = '3'
		elif value == '3': value = '1'
		_set_setting_if_changed(AUTO_RESTORE_INTERVAL_SETTING, value)
		_set_setting_if_changed(AUTO_RESTORE_FREQUENCY_ORDER_MIGRATED_SETTING, 'true')
		_refresh_settings_cache()
	return value


def _auto_restore_interval_seconds():
	# 0 = démarrage. Les autres valeurs suivent l'ordre du menu : 1/jour, 12h, 6h.
	value = _normalize_auto_restore_frequency_setting()
	label_map = {
		'0': 0,
		'startup': 0,
		'startup_only': 0,
		'au démarrage seulement': 0,
		'au demarrage seulement': 0,
		'démarrage': 0,
		'demarrage': 0,
		'1': 24 * 3600,
		'6': 6 * 3600,
		'6 heures': 6 * 3600,
		'toutes les 6 heures': 6 * 3600,
		'2': 12 * 3600,
		'12': 12 * 3600,
		'12 heures': 12 * 3600,
		'toutes les 12 heures': 12 * 3600,
		'3': 6 * 3600,
		'24': 24 * 3600,
		'24 heures': 24 * 3600,
		'une fois par jour': 24 * 3600,
		'1 fois par jour': 24 * 3600,
		'1 fois/jour': 24 * 3600,
		'daily': 24 * 3600,
		'1 jour': 24 * 3600
	}
	try: return int(label_map.get(value, value))
	except Exception: return 0


def _is_service_unavailable_error(value):
	text = str(value or '').strip().lower()
	return ('503' in text or 'service unavailable' in text or 'temporarily unavailable' in text)


def _reset_auto_restore_backoff():
	_set_setting_if_changed(AUTO_RESTORE_BACKOFF_COUNT_SETTING, '0')


def _auto_restore_error_delay(error):
	if not _is_service_unavailable_error(error):
		_reset_auto_restore_backoff()
		return 3600, 0
	try: count = int(get_setting(AUTO_RESTORE_BACKOFF_COUNT_SETTING, '0') or 0) + 1
	except Exception: count = 1
	if count < 1: count = 1
	_set_setting_if_changed(AUTO_RESTORE_BACKOFF_COUNT_SETTING, str(count))
	if count == 1: return 30 * 60, count
	if count == 2: return 60 * 60, count
	return 3 * 60 * 60, count


def _auto_restore_next_due(current_time, interval):
	# Pour "au démarrage seulement", la prochaine vérification automatique est
	# bloquée pour la session courante. Le prochain démarrage relancera une passe.
	if interval <= 0: return current_time + (365 * 24 * 3600)
	return current_time + interval


def automatic_alkopastes_favourites_restore(force=False, startup=False):
	if not _favourites_multi_device_required('auto_restore'):
		return 'multi_device_disabled'
	if not force and not favourites_auto_restore_enabled(): return 'disabled'
	current_time = int(time.time())
	interval = _auto_restore_interval_seconds()
	try: due_time = int(get_setting(AUTO_RESTORE_DUE_SETTING, '0') or 0)
	except Exception: due_time = 0
	if interval <= 0 and not force and not startup: return 'startup only'
	if interval > 0 and not force and current_time < due_time: return 'not needed'
	if not has_user_api_key():
		_reset_auto_restore_backoff()
		set_setting(AUTO_RESTORE_DUE_SETTING, str(current_time + 3600))
		_refresh_settings_cache()
		logger('alkoPastes automatic favourites restore skipped', 'alkoPastes not connected')
		return 'no account'
	try:
		code = _backup_code() or _find_backup_code()
		next_due = _auto_restore_next_due(current_time, interval)
		if not code:
			_reset_auto_restore_backoff()
			set_setting(AUTO_RESTORE_DUE_SETTING, str(next_due))
			_refresh_settings_cache()
			logger('alkoPastes automatic favourites restore skipped', 'missing_backup | next=%s' % next_due)
			return 'missing_backup'
		content = _get_paste_content(code)
		remote_hash = _backup_content_hash(content)
		last_hash = str(get_setting(AUTO_RESTORE_LAST_HASH_SETTING, '') or '').strip()
		items = _parse_backup_text(content, apply_custom_folders=False)
		status = _remote_restore_status(items, _all_backup_types())
		if not force and remote_hash and last_hash == remote_hash and not status.get('needs_apply'):
			_reset_auto_restore_backoff()
			set_setting(AUTO_RESTORE_DUE_SETTING, str(next_due))
			_refresh_settings_cache()
			logger('alkoPastes automatic favourites restore skipped', 'unchanged | hash=%s | remote_active=%s | remote_deleted=%s | local_active=%s | next=%s' % (
				remote_hash[:12], status.get('active', 0), status.get('deleted', 0), _local_active_items_count(_all_backup_types()), next_due
			))
			return 'unchanged'
		if not force and remote_hash and last_hash == remote_hash and status.get('needs_apply'):
			logger('alkoPastes automatic favourites restore forced', 'hash_unchanged_but_local_outdated | hash=%s | needed=%s | remote_active=%s | remote_deleted=%s | local_active=%s' % (
				remote_hash[:12], status.get('needs_apply', 0), status.get('active', 0), status.get('deleted', 0), _local_active_items_count(_all_backup_types())
			))
		result = _import_single_backup(code, content=content, items=items)
		if result.get('success'):
			_reset_auto_restore_backoff()
			if remote_hash: set_setting(AUTO_RESTORE_LAST_HASH_SETTING, remote_hash)
			set_setting(AUTO_RESTORE_DUE_SETTING, str(next_due))
			_refresh_settings_cache()
			logger('alkoPastes automatic favourites restore success', 'count=%s hash=%s next=%s' % (result.get('count', 0), (remote_hash or '')[:12], next_due))
			return 'success'
		error = result.get('error') or 'unknown'
		delay, backoff_count = _auto_restore_error_delay(error)
		next_try = current_time + delay
		set_setting(AUTO_RESTORE_DUE_SETTING, str(next_try))
		_refresh_settings_cache()
		logger('alkoPastes automatic favourites restore skipped', '%s | backoff=%s | next=%s' % (error, backoff_count, next_try))
		return error
	except Exception as exc:
		delay, backoff_count = _auto_restore_error_delay(exc)
		next_try = current_time + delay
		set_setting(AUTO_RESTORE_DUE_SETTING, str(next_try))
		_refresh_settings_cache()
		logger('alkoPastes automatic favourites restore failed', '%s: %s | backoff=%s | next=%s' % (type(exc).__name__, exc, backoff_count, next_try))
		return 'failed'
