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

import re
from urllib.parse import urlparse

try:
	from modules.source_objects import PERSONAL_EXTERNAL_PROVIDERS
except Exception:
	PERSONAL_EXTERNAL_PROVIDERS = ('aiostreams', 'aiostreams_custom', 'custom_1', 'custom_2', 'custom_stremio1', 'custom_stremio2')

PERSONAL_SOURCE_FLAGS = ('aiostreams_direct', 'custom_stremio1_direct', 'custom_stremio2_direct')
DIRECT_SOURCE_FLAGS = PERSONAL_SOURCE_FLAGS + ('usenetstreams_direct',)

QUALITY_RANKS = {'4K': 1, '1080p': 2, '720p': 3, 'SD': 4, 'SCR': 5, 'CAM': 5, 'TELE': 5}
FRENCH_VERSION_PATTERN = (
	r'(^|[^a-z0-9])('
	r'fr|fra|fre|frn|french|truefrench|true\.french|true-french|'
	r'vf|vfi|vff|vfq|vf2|vof|voq|vf-fr|vf-fq|vf-i'
	r')([^a-z0-9]|$)'
)
MULTI_PATTERN = r'(^|[^a-z0-9])(multi|multilangue|multilanguage|multiple\.?languages)([^a-z0-9]|$)'
FRENCH_MULTI_PATTERN = (
	r'(^|[^a-z0-9])('
	r'fr|fra|fre|frn|french|truefrench|true\.french|true-french|'
	r'vf|vfi|vff|vfq|vf2|vof|voq|vf-fr|vf-fq|vf-i|'
	r'multi|multilangue|multilanguage|multiple\.?languages'
	r')([^a-z0-9]|$)'
)
VOSTFR_PATTERN = r'(^|[^a-z0-9])(vostfr|vo\.?stfr|subfrench|french\.?sub|fr\.?sub)([^a-z0-9]|$)'
VFQ_PATTERN = r'(^|[^a-z0-9])(vfq|vf-fq)([^a-z0-9]|$)'

PERSONAL_PROVIDER_ORDER = {
	'aiostreams': 0,
	'aiostreams_custom': 0,
	'custom_1': 1,
	'custom_stremio1': 1,
	'custom_2': 2,
	'custom_stremio2': 2
}

SOURCE_RESULT_DEFAULTS = {
	'provider': '',
	'provider_name': '',
	'debrid': '',
	'cache_provider': '',
	'source': '',
	'scrape_provider': '',
	'quality': 'SD',
	'language': '',
	'extraInfo': '',
	'name': '',
	'URLName': '',
	'name_info': '',
	'size': 0.0,
	'size_label': ''
}

SOURCE_DEBUG_FIELDS = ('provider', 'debrid', 'cache_provider', 'source', 'scrape_provider', 'quality', 'size', 'size_label', 'hash', 'name', 'URLName')

BLANK_DISPLAY_VALUES = ('', 'none', 'null', 'false')


def display_value(value, default='N/A'):
	# Valeur destinée aux logs/debug uniquement : n'altère jamais les champs métier.
	try:
		if value is None: return default
		if isinstance(value, (int, float)):
			return default if value == 0 else str(value)
		value = str(value).strip()
		return default if value.lower() in BLANK_DISPLAY_VALUES else value
	except Exception:
		return default

def source_debug_value(item, key, default='N/A'):
	try:
		if not isinstance(item, dict): return default
		debug_key = '_debug_%s' % key
		if debug_key in item: return display_value(item.get(debug_key), default)
		return display_value(item.get(key), default)
	except Exception:
		return default

def source_debug_file(item, default='N/A'):
	try:
		if not isinstance(item, dict): return default
		return display_value(item.get('_debug_file') or item.get('name') or item.get('URLName') or item.get('name_info'), default)
	except Exception:
		return default

def source_debug_size(item, default='N/A'):
	try:
		if not isinstance(item, dict): return default
		value = display_value(item.get('size_label'), None)
		if value: return value
		value = display_value(item.get('_debug_size'), None)
		if value: return value
		return display_value(item.get('size'), default)
	except Exception:
		return default


CUSTOM_PROVIDER_KEYS = ('custom_1', 'custom_2', 'custom_stremio1', 'custom_stremio2')

def upper_display(value, default='N/A'):
	try:
		value = display_value(value, default)
		return str(value).upper() if value != default else default
	except Exception:
		return default

def selector_custom_source_label(item):
	# Libellé UI court pour les sources personnalisées.
	# Custom 1/2 sont traités comme AIO côté affichage : on essaie d'abord
	# d'indiquer le manifest réel (BGT, WA, SF...), sans toucher à l'ordre
	# ni aux données métier.
	try:
		if not isinstance(item, dict): return ''
		provider_key = _clean_key(item.get('provider'))
		if item.get('custom_stremio1_direct'): provider_key = 'custom_stremio1'
		elif item.get('custom_stremio2_direct'): provider_key = 'custom_stremio2'
		if provider_key not in CUSTOM_PROVIDER_KEYS: return ''
		for key in ('custom_manifest', 'manifest_label', 'provider_name', 'display_name', 'source_site', 'source'):
			label = _selector_manifest_short_label_from_text(item.get(key))
			if label: return label
		label = item.get('provider_name') or item.get('display_name') or ''
		plain = upper_display(label, '')
		if plain and not re.search(r'^CUSTOM(?:\s+STREMIO(?:\s+ADDON)?)?\s*[12]$', plain, re.I):
			return plain
		if provider_key in ('custom_1', 'custom_stremio1'): return 'C1'
		if provider_key in ('custom_2', 'custom_stremio2'): return 'C2'
	except Exception:
		pass
	return ''

CACHE_STATUS_COLORS = {
	'cached': 'FF43D17A',
	'uncached': 'FFFF5A5F',
	'unchecked': 'FFFFC857'
}

DEBRID_SHORT_LABELS = (
	('alldebrid', 'AD'), ('all-debrid', 'AD'),
	('real-debrid', 'RD'), ('realdebrid', 'RD'),
	('premiumize', 'PM'), ('torbox', 'TB'), ('easynews', 'EN')
)

HOSTER_PROVIDER_KEYS = ('alkopaste', 'movix', 'free_telecharger')
HOSTER_SHORT_LABELS = (
	('1fichier', '1F'), ('onefichier', '1F'),
	('rapidgator', 'RG'),
	('turbobit', 'TB'),
	('sendcm', 'SC'), ('send cm', 'SC'),
	('darkibox', 'HYDR'), ('hydracker', 'HYDR'), ('darki', 'HYDR'),
	('webshare', 'WS'),
	('nitroflare', 'NF'),
	('katfile', 'KF'),
	('fikper', 'FK'),
	('ddownload', 'DD'),
	('filejoker', 'FJ'),
	('filemoon', 'FM'),
	('streamtape', 'ST'),
	('uptobox', 'UB'),
	('alldebrid', 'AD'), ('all debrid', 'AD'),
	('realdebrid', 'RD'), ('real debrid', 'RD')
)


AIO_PROVIDER_KEYS = ('aiostreams', 'aiostreams_custom', 'aiostreams_direct')


def _normalize_selector_text(value):
	try:
		value = str(value or '').lower()
		for old, new in (('é', 'e'), ('è', 'e'), ('ê', 'e'), ('ë', 'e'), ('à', 'a'), ('â', 'a'), ('î', 'i'), ('ï', 'i'), ('ô', 'o'), ('ù', 'u'), ('û', 'u'), ('ç', 'c')):
			value = value.replace(old, new)
		return re.sub(r'[^a-z0-9]+', ' ', value).strip()
	except Exception:
		return ''


def _is_aiostreams_result(item):
	try:
		if not isinstance(item, dict): return False
		for key in ('provider', 'debrid', 'provider_name', 'display_name'):
			value = str(item.get(key) or '').lower()
			if 'aiostreams' in value or value in AIO_PROVIDER_KEYS: return True
		return bool(item.get('aiostreams_direct'))
	except Exception:
		return False


def _aio_manifest_short_label_from_text(value):
	try:
		normalized = _normalize_selector_text(value)
		if not normalized: return ''
		if re.search(r'\bbaguettio\b|\bbgt\b', normalized): return 'BGT'
		if re.search(r'\bwa\s*stream\b|\bwastream\b|\bwawacity\b|\bwawa\s*city\b|\bfree\s*telecharg|\bdarki\s*api\b|\bdarkiapi\b|\bwa\s*source\b|\bwasource\b|\bmovix\b', normalized): return 'WA'
		if re.search(r'\bstream\s*fusion\b|\bstreamfusion\b|\bsf\b', normalized): return 'SF'
		if re.search(r'\bcomet\b', normalized): return 'COMET'
		if re.search(r'\btorrentio\b', normalized): return 'TORR'
		if re.search(r'\bmedia\s*fusion\b|\bmediafusion\b', normalized): return 'MF'
		if re.search(r'\bstrem\s*thru\b|\bstremthru\b|\btorz\b', normalized): return 'STZ'
	except Exception:
		pass
	return ''


def _selector_manifest_short_label_from_text(value):
	# Libellé court commun pour les manifests externes (AIO + Custom 1/2).
	# Ne change rien aux données sources : c'est uniquement de l'affichage.
	try:
		label = _aio_manifest_short_label_from_text(value)
		if label: return label
		normalized = _normalize_selector_text(value)
		if not normalized: return ''
		if re.search(r'\bstream\s*fusion\b|\bstreamfusion\b', normalized): return 'SF'
		if re.search(r'\bbaguettio\b|\bbgt\b', normalized): return 'BGT'
		if re.search(r'\bwa\s*stream\b|\bwastream\b', normalized): return 'WA'
	except Exception:
		pass
	return ''


def selector_aio_manifest_label(item):
	# Pour AIOStreams seulement : affiche le manifest réel à la place d'AD/DB
	# dans la colonne de type, afin de distinguer Baguettio, WAStream, StreamFusion, etc.
	try:
		if not _is_aiostreams_result(item): return ''
		label = str((item or {}).get('aio_manifest') or '').strip().upper()
		if label: return label
		for key in ('tracker', 'source_site', 'provider_name', 'display_name', 'source', 'scrape_provider', 'name', 'URLName', 'name_info'):
			label = _selector_manifest_short_label_from_text((item or {}).get(key))
			if label: return label
	except Exception:
		pass
	return ''


def _hoster_short_label_from_value(value):
	try:
		text = str(value or '').strip()
		if not text: return ''
		candidates = [text]
		try:
			parsed = urlparse(text)
			if parsed.netloc:
				host = parsed.netloc.lower().split('@')[-1].split(':')[0]
				if host.startswith('www.'): host = host[4:]
				candidates.insert(0, host)
		except Exception:
			pass
		for candidate in candidates:
			normalized = _normalize_selector_text(candidate).replace(' ', '')
			if not normalized: continue
			for key, label in HOSTER_SHORT_LABELS:
				compact_key = _normalize_selector_text(key).replace(' ', '')
				if compact_key and (compact_key in normalized or normalized in compact_key):
					return label
	except Exception:
		pass
	return ''


def selector_hoster_short_label(item):
	# Pour les liens DDL natifs, affiche le vrai hoster du lien (1F/RG/TB...)
	# au lieu d'un statut générique de cache/débrideur peu parlant comme DB.
	try:
		if not isinstance(item, dict): return ''
		provider = str(item.get('provider') or '').strip().lower()
		scrape_provider = str(item.get('scrape_provider') or '').strip().lower()
		source = str(item.get('source') or '').strip().lower()
		is_hoster = bool(item.get('hoster_source')) or provider in HOSTER_PROVIDER_KEYS or scrape_provider in HOSTER_PROVIDER_KEYS
		if not is_hoster and not _hoster_short_label_from_value(source): return ''
		for key in ('source', 'hoster', 'url', 'name', 'URLName'):
			label = _hoster_short_label_from_value(item.get(key))
			if label: return label
		if source and source not in ('hoster', 'link', 'direct', 'http'):
			return upper_display(source[:4], 'HOST')
		return 'HOST'
	except Exception:
		return ''


def _cache_provider_short_label(cache_provider):
	try:
		value = str(cache_provider or '').lower()
		for key, label in DEBRID_SHORT_LABELS:
			if key in value: return label
		value = value.replace('unchecked', '').replace('uncached', '').strip()
		return upper_display(value[:3], 'DB') if value else 'DB'
	except Exception:
		return 'DB'

def _colored_cache_label(label, status):
	try: return '[COLOR %s][B]%s[/B][/COLOR]' % (CACHE_STATUS_COLORS[status], label)
	except Exception: return label

def selector_cache_status_label(item, pack=False):
	# Libellé court strictement visuel pour la colonne type/source du sélecteur.
	# Le statut non contrôlé reste volontairement distinct du non-caché : il évite
	# d'interroger le débrideur pour tous les résultats seulement pour colorer la liste.
	try:
		cache_provider = str((item or {}).get('cache_provider') or '')
		aio_label = selector_aio_manifest_label(item)
		if aio_label:
			# AIOStreams est une source personnelle externe : on affiche seulement
			# le manifest détecté (BGT, WA, SF, etc.) sans couleur de statut.
			# Comme pour Custom 1/2, l'ordre et les réglages du manifest restent intacts.
			return aio_label
		label = _cache_provider_short_label(cache_provider)
		if 'Unchecked' in cache_provider:
			return _colored_cache_label(label, 'unchecked')
		if 'Uncached' in cache_provider:
			return _colored_cache_label(label, 'uncached')
		label = _colored_cache_label(label, 'cached')
		# La couleur suffit pour indiquer que le lien/pack est déjà exploitable.
		# On n'ajoute plus de suffixe PACK/PCK à côté de AD/RD/TB afin de garder le sélecteur propre.
		return label
	except Exception:
		return 'N/A'


def _cache_status_key(item):
	# Statut strictement visuel du lien dans le sélecteur.
	# Ne déclenche aucun appel débrideur et ne modifie jamais l'ordre des résultats.
	try:
		cache_provider = str((item or {}).get('cache_provider') or '')
		if 'Unchecked' in cache_provider: return 'unchecked'
		if 'Uncached' in cache_provider: return 'uncached'
		if cache_provider: return 'cached'
		return ''
	except Exception:
		return ''

def selector_source_type_badge_icon(item):
	# Indicateur visuel de la nature réelle du résultat :
	# - magnet.png si le résultat repose sur un hash/torrent/magnet ;
	# - link.png si le résultat est déjà un vrai lien direct ;
	# La couleur continue d'indiquer l'état cache quand il existe.
	try:
		item = item or {}
		url = str(item.get('url') or '').strip().lower()
		source = str(item.get('source') or '').strip().lower()
		if url.startswith('magnet:'): return 'magnet.png'
		if item.get('hoster_source') or (item.get('debridonly') is True and not item.get('hash') and source not in ('torrent', 'magnet', 'usenet', 'nzb')):
			return 'link.png'
		if item.get('direct') is False or item.get('debridonly') is True:
			return 'magnet.png'
		if item.get('hash') and not item.get('direct'):
			return 'magnet.png'
		if source in ('torrent', 'magnet'):
			return 'magnet.png'
		if item.get('direct') is True or source in ('direct', 'http', 'hoster', 'link'):
			return 'link.png'
		status = _cache_status_key(item)
		if status in ('unchecked', 'uncached'): return 'magnet.png'
		if status == 'cached': return 'magnet.png' if item.get('hash') else 'link.png'
		if url: return 'link.png'
	except Exception:
		pass
	return 'selector_tag.png'

def selector_source_type_badge_icon_color(item):
	# Les icônes magnet/link restent volontairement blanches partout.
	# Les couleurs de statut restent portées par les libellés (BGT, WA, SF, etc.),
	# pas par le pictogramme : cela évite les rendus jaunes/verts incohérents
	# entre AIOStreams, Custom 1 et Custom 2.
	return 'FFFFFFFF'

def selector_cache_status_icon(item):
	# Ancien petit pictogramme séparé retiré : l'indicateur est maintenant
	# affiché à la place de l'icône d'étiquette du sélecteur.
	return ''

def selector_cache_status_icon_color(item):
	return ''

def selector_source_type_label(item, source=None, pack=False):
	# Priorité UI : nom custom court > hoster réel > état cache court > source brute.
	try:
		custom_label = selector_custom_source_label(item)
		if custom_label: return custom_label
		hoster_label = selector_hoster_short_label(item)
		if hoster_label: return hoster_label
		if isinstance(item, dict) and 'cache_provider' in item:
			return selector_cache_status_label(item, pack=pack)
		return upper_display(source if source is not None else (item or {}).get('source'), 'N/A')
	except Exception:
		return 'N/A'


def selector_source_type_icon(item, source=None, pack=False):
	# Icône compacte conservée uniquement pour les indicateurs natifs du sélecteur.
	# Les sources/sites personnalisés restent affichés en texte court.
	try:
		label = selector_source_type_label(item, source=source, pack=pack)
		plain = str(label or '').strip().upper()
		if plain in ('AD_CLOUD', 'RD_CLOUD', 'TB_CLOUD', 'CLOUD'): return 'selector_cloud.png'
		if plain == 'DIRECT': return 'selector_play.png'
		return ''
	except Exception:
		return ''


def selector_source_site_icon(source_site):
	# Icône compacte conservée uniquement pour les sources cloud natives.
	try:
		plain = str(source_site or '').strip().upper()
		if plain.endswith('_CLOUD') or plain in ('AD_CLOUD', 'RD_CLOUD', 'TB_CLOUD'):
			return 'selector_cloud.png'
		return ''
	except Exception:
		return ''


def selector_source_site_label(source_site):
	# Libellé court pour la colonne site/source du sélecteur.
	# Les noms complets restent dans les données brutes pour les logs/cache.
	try:
		label = display_value(source_site, '')
		if not label or str(label).strip().lower() in ('n/a', 'na'):
			return ''
		# Les manifests imbriqués dans AIOStreams peuvent retourner des libellés du type
		# "AIO | Torr9 (AIOStreams)". Dans le sélecteur, le nom court du site
		# suffit : on retire donc le préfixe AIO et les parenthèses décoratives.
		clean = re.sub(r'^\s*AIO\s*\|\s*', '', str(label), flags=re.I).strip()
		clean = re.sub(r'\s*\([^)]*\)\s*$', '', clean).strip()
		clean = re.sub(r'[()]', ' ', clean)
		clean = re.sub(r'\s+', ' ', clean).strip()
		normalized = _normalize_selector_text(clean)
		if re.search(r'\bbaguettio\b|\bbgt\b', normalized): return 'BGT'
		if re.search(r'\bstream\s*fusion\b|\bstreamfusion\b', normalized): return 'SF'
		if re.search(r'\bnostradamus\b', normalized): return 'NOST'
		if re.search(r'\bgemini\b|\bg3\s*mini\b|\bg3mini\b|\bg3\s*mini\s*(?:track3r|tracker)?\b', normalized): return 'G3MI'
		if re.search(r'\bfree\s*telecharg', normalized): return 'FREE'
		if re.search(r'\bwawa\s*city\b|\bwawacity\b', normalized): return 'WAWA'
		if re.search(r'\bdarki\s*api\b|\bdarkiapi\b|\bdarki\b|\bhydracker\b', normalized): return 'HYDRACKER'
		if re.search(r'\bwa\s*source\b|\bwasource\b', normalized): return 'WA'
		if re.search(r'\balko\s*paste\b|\balkopaste\b|\balko\b', normalized): return 'ALKO'
		if re.search(r'\bmovix\b', normalized): return 'MOVIX'
		if re.search(r'\bc\s*411\b|\bc411\b', normalized): return 'C411'
		if re.search(r'\btorr(?:ent)?\s*9\b|\btorr9\b|\btorrent9\b', normalized): return 'TORR9'
		if re.search(r'\bygg(?:torrent)?\b', normalized): return 'YGG'
		if re.search(r'\bthe\s*old\s*school\b|\bold\s*school\b|\btos\b', normalized): return 'TOS'
		if re.search(r'\bla\s*cale\b|\blacale\b', normalized): return 'CALE'
		if re.search(r'\bgeneration\s*free\b|\bgenerationfree\b|\bgen\s*free\b', normalized): return 'GFREE'
		if re.search(r'\btr4ker(?:\s*net)?\b', normalized): return 'TR4KER'
		if re.search(r'\bg3\s*mini\s*(?:track3r|tracker)?\b|\bg3mini\s*(?:track3r|tracker)?\b', normalized): return 'G3MI'
		return upper_display(clean, 'N/A')
	except Exception:
		return upper_display(source_site, 'N/A')



def selector_source_site_label_for_item(item, source_site):
	# Version contextualisée du libellé site/source.
	# Pour Custom 1/2, évite d'afficher deux fois le nom du manifest
	# (ex. BGT / BGT) quand aucun tracker réel n'est fourni par le manifest.
	try:
		label = selector_source_site_label(source_site)
		custom_label = selector_custom_source_label(item) if isinstance(item, dict) else ''
		if not custom_label:
			return label
		if label and str(label).strip().upper() != str(custom_label).strip().upper():
			return label

		# Le site brut est vide ou correspond au manifest lui-même. On tente alors
		# de retrouver un vrai tracker/site dans les champs textuels visibles, sans
		# confondre le manifest (BGT/WA/SF) avec le site.
		for key in ('tracker', 'source_site', 'indexer', 'site', 'tracker_name', 'name', 'URLName', 'name_info', 'extraInfo'):
			value = (item or {}).get(key) if isinstance(item, dict) else ''
			candidate = selector_source_site_label(value)
			if candidate and str(candidate).strip().upper() != str(custom_label).strip().upper():
				return candidate
		return ''
	except Exception:
		return selector_source_site_label(source_site)

def source_progress_line1(item):
	# Libellé court pour les fenêtres de progression : évite les champs vides et les textes cache trop longs.
	try:
		if not isinstance(item, dict): return 'N/A'
		custom_label = selector_custom_source_label(item)
		parts = []
		scrape_provider = item.get('scrape_provider')
		if scrape_provider and scrape_provider != 'external': parts.append(scrape_provider)
		if custom_label: parts.append(custom_label)
		elif item.get('cache_provider'): parts.append(selector_cache_status_label(item, pack=item.get('package') in ('true', 'show', 'season')))
		provider = item.get('provider')
		if provider and provider != 'external' and not custom_label: parts.append(provider)
		parts = [upper_display(p, '') for p in parts if display_value(p, '')]
		return ' | '.join(parts) if parts else 'N/A'
	except Exception:
		return 'N/A'

def _clean_key(value):
	try: return str(value or '').strip().lower()
	except Exception: return ''

def is_personal_result(item):
	try:
		if not isinstance(item, dict): return False
		# Les trackers intégrés/privés natifs (C411/Torr9/YGG/Comet/Nyaa) ne sont pas
		# des sources personnelles externes. Ils doivent passer dans le tri global
		# demandé dans les réglages, contrairement à AIOStreams/Custom.
		native_providers = ('c411', 'torr9', 'tr4ker', 'ygg', 'alkopaste', 'comet', 'nyaa')
		for key in ('provider', 'scrape_provider'):
			if _clean_key(item.get(key)) in native_providers: return False
		if item.get('personal_source'): return True
		if any(item.get(flag) for flag in PERSONAL_SOURCE_FLAGS): return True
		for key in ('provider', 'debrid', 'source'):
			if _clean_key(item.get(key)) in PERSONAL_EXTERNAL_PROVIDERS: return True
	except Exception:
		pass
	return False

def is_direct_source_result(item):
	try: return any(item.get(flag) for flag in DIRECT_SOURCE_FLAGS)
	except Exception: return False

def personal_result_order(item):
	try:
		if item.get('aiostreams_direct'): return 0
		if item.get('custom_stremio1_direct'): return 1
		if item.get('custom_stremio2_direct'): return 2
		for key in ('provider', 'debrid', 'source'):
			provider = _clean_key(item.get(key))
			if provider in PERSONAL_PROVIDER_ORDER: return PERSONAL_PROVIDER_ORDER[provider]
	except Exception:
		pass
	return 99

def personal_manifest_order(item, default=0):
	try:
		for key in ('manifest_order', '_manifest_order', '_personal_manifest_order'):
			value = item.get(key)
			if value not in ('', None): return int(value)
	except Exception:
		pass
	return default

def sort_personal_results(results):
	# Ordre strict pour AIOStreams / Custom 1 / Custom 2 :
	# 1) bloc source personnel, 2) position originale reçue du manifest, 3) stabilité Python.
	# Ainsi, les traitements débrideur/cache/direct peuvent enrichir les lignes sans
	# jamais réordonner l'affichage imposé par le manifest externe.
	try:
		return [item for _, item in sorted(
			enumerate(results or []),
			key=lambda pair: (
				personal_result_order(pair[1]),
				personal_manifest_order(pair[1], pair[0]),
				pair[0]
			)
		)]
	except Exception:
		return results

def get_provider_rank(item, provider_sort_ranks=None, default=11):
	try:
		provider = item.get('scrape_provider') or item.get('provider') or ''
		debrid = item.get('debrid') or ''
		account_type = str(debrid if provider == 'external' else provider).lower()
		return (provider_sort_ranks or {}).get(account_type, default)
	except Exception:
		return default

def get_quality_rank(quality, default=99):
	try: return QUALITY_RANKS.get(str(quality or 'SD'), default)
	except Exception: return default

def set_sort_ranks(results, provider_sort_ranks=None):
	try:
		for item in results or []:
			item['provider_rank'] = get_provider_rank(item, provider_sort_ranks)
			item['quality_rank'] = get_quality_rank(item.get('quality', 'SD'))
	except Exception:
		pass
	return results

def release_text(item):
	try: return ' '.join(str(item.get(k) or '') for k in ('name_info', 'name', 'URLName', 'extraInfo')).lower()
	except Exception: return ''

def is_vostfr_result(item):
	try: return bool(re.search(VOSTFR_PATTERN, release_text(item), re.I))
	except Exception: return False


def _has_explicit_french_version_marker(text):
	# Vraies mentions de version/piste française : VF, VFF, VF2, VFQ, FR, TRUEFRENCH...
	# MULTI est volontairement traité à part afin de pouvoir classer VF2 MULTI
	# avant un simple MULTI sans version française explicite.
	try: return bool(re.search(FRENCH_VERSION_PATTERN, text or '', re.I))
	except Exception: return False


def _has_multi_marker(text):
	try: return bool(re.search(MULTI_PATTERN, text or '', re.I))
	except Exception: return False


def _has_french_or_multi_audio_marker(text):
	try: return _has_explicit_french_version_marker(text) or _has_multi_marker(text)
	except Exception: return False


def is_explicit_french_audio_result(item):
	try:
		text = release_text(item)
		return _has_explicit_french_version_marker(text)
	except Exception:
		return False


def is_multi_audio_result(item):
	try:
		text = release_text(item)
		return _has_multi_marker(text)
	except Exception:
		return False


def is_french_multi_result(item):
	try:
		text = release_text(item)
		# Un résultat explicitement VOSTFR ne doit jamais être absorbé dans le
		# groupe FR/MULTI uniquement parce que certains providers remplissent
		# language='fr' pour signaler des sous-titres français.
		if is_vostfr_result(item) and not _has_french_or_multi_audio_marker(text):
			return False
		if _has_french_or_multi_audio_marker(text): return True
		return str(item.get('language') or '').lower() == 'fr'
	except Exception:
		return False


def is_vfq_only_result(item):
	# Exclusion optionnelle : VFQ seul, sans MULTI. Un résultat VFQ MULTI reste conservé.
	try:
		text = release_text(item)
		return bool(re.search(VFQ_PATTERN, text, re.I)) and not _has_multi_marker(text)
	except Exception:
		return False


def is_vostfr_only_result(item):
	# Exclusion optionnelle : VOSTFR seul, sans MULTI ni piste française explicite.
	# Un résultat VOSTFR MULTI ou VOSTFR + VF/VFF/VF2/etc. reste conservé.
	try:
		text = release_text(item)
		if not bool(re.search(VOSTFR_PATTERN, text, re.I)): return False
		if _has_multi_marker(text): return False
		# Retire les marqueurs de sous-titres FR avant de vérifier s'il reste
		# une vraie mention audio française. Cela évite de traiter « French Sub »
		# comme une piste audio française.
		audio_text = re.sub(VOSTFR_PATTERN, ' ', text, flags=re.I)
		return not _has_explicit_french_version_marker(audio_text)
	except Exception:
		return False


def language_priority_rank(item):
	# Rang francophone centralisé : FR explicite avant MULTI simple, puis VOSTFR, puis le reste.
	try:
		text = release_text(item)
		if is_vostfr_only_result(item): return 2
		if _has_explicit_french_version_marker(text): return 0
		if _has_multi_marker(text): return 1
		if str(item.get('language') or '').lower() == 'fr': return 1
		if is_vostfr_result(item): return 2
		return 3
	except Exception:
		return 3

PRIVATE_TRACKER_PROVIDERS = ('c411', 'torr9', 'tr4ker')
SOURCE_PRIORITY_WHEN_PRIVATE_FIRST = {
	# Priorité interne à langue équivalente quand l'option trackers privés est activée.
	# Le tri utilisateur reste conservé dans chaque groupe grâce au tri stable.
	# C411 passe avant Torr9 afin que le dédoublonnage conserve C411 quand
	# plusieurs trackers retournent exactement le même hash.
	'c411': 0,
	'torr9': 1,
	'tr4ker': 2,
	'ygg': 3,
	'comet': 4,
	'nyaa': 5,
}


def _source_identity(item):
	try:
		return str(
			(item or {}).get('scrape_provider')
			or (item or {}).get('provider')
			or (item or {}).get('tracker')
			or (item or {}).get('tracker_name')
			or ''
		).lower()
	except Exception:
		return ''


def is_private_tracker_result(item):
	try:
		provider = str((item or {}).get('scrape_provider') or (item or {}).get('provider') or '').lower()
		tracker = str((item or {}).get('tracker') or (item or {}).get('tracker_name') or '').lower()
		return provider in PRIVATE_TRACKER_PROVIDERS or tracker in PRIVATE_TRACKER_PROVIDERS
	except Exception:
		return False


def _source_priority_when_private_first(item):
	try:
		provider = str((item or {}).get('scrape_provider') or (item or {}).get('provider') or '').lower()
		tracker = str((item or {}).get('tracker') or (item or {}).get('tracker_name') or '').lower()
		if provider in PRIVATE_TRACKER_PROVIDERS or tracker in PRIVATE_TRACKER_PROVIDERS:
			return 0
		return min(
			SOURCE_PRIORITY_WHEN_PRIVATE_FIRST.get(provider, 9),
			SOURCE_PRIORITY_WHEN_PRIVATE_FIRST.get(tracker, 9),
			SOURCE_PRIORITY_WHEN_PRIVATE_FIRST.get(_source_identity(item), 9)
		)
	except Exception:
		return 9


def _source_priority_group(items, enabled=False):
	try:
		if not enabled: return items
		# Tri stable : on regroupe seulement par source souhaitée, sans défaire le tri
		# qualité/fournisseur/taille déjà appliqué à l'intérieur de chaque groupe.
		return sorted(items, key=_source_priority_when_private_first)
	except Exception:
		return items


def sort_french_streaming_priority(results, private_trackers_first=False):
	# Priorité absolue de langue : FR explicite > MULTI simple > VOSTFR > autres.
	# Si demandé, l'ordre à langue équivalente devient : C411/Torr9/TR4KER > YGG > Comet > Nyaa > autres.
	try:
		french_explicit = [i for i in results if language_priority_rank(i) == 0]
		multi_only = [i for i in results if language_priority_rank(i) == 1]
		vostfr = [i for i in results if language_priority_rank(i) == 2]
		others = [i for i in results if language_priority_rank(i) == 3]
		return (
			_source_priority_group(french_explicit, private_trackers_first) +
			_source_priority_group(multi_only, private_trackers_first) +
			_source_priority_group(vostfr, private_trackers_first) +
			_source_priority_group(others, private_trackers_first)
		)
	except Exception:
		return results


def filter_french_vostfr_results(results):
	# Filtre strict d'affichage : garde VF/MULTI et VOSTFR seulement.
	# Il s'applique après la collecte et le tri, donc il ne multiplie aucun appel source.
	try: return [i for i in results if language_priority_rank(i) in (0, 1, 2)]
	except Exception: return results


def filter_vfq_only_results(results):
	try: return [i for i in results if not is_vfq_only_result(i)]
	except Exception: return results


def filter_vostfr_only_results(results):
	try: return [i for i in results if not is_vostfr_only_result(i)]
	except Exception: return results


def _resolution_bucket(item):
	try:
		quality = str((item or {}).get('quality') or '').strip().lower()
		text = release_text(item)
		if quality in ('4k', '2160p') or re.search(r'(^|[^0-9])(2160p|4k|uhd)([^0-9]|$)', text, re.I): return '4K'
		if quality == '1080p' or re.search(r'(^|[^0-9])1080p([^0-9]|$)', text, re.I): return '1080p'
		if quality == '720p' or re.search(r'(^|[^0-9])720p([^0-9]|$)', text, re.I): return '720p'
		return 'SD'
	except Exception:
		return 'SD'



def sort_quality_then_language_priority(results, priority_quality='4K', private_trackers_first=False):
	# Priorité Qualité : la résolution choisie passe avant la langue.
	# À l'intérieur de chaque résolution, on conserve la hiérarchie FR explicite > MULTI > VOSTFR > autres.
	try:
		priority_quality = str(priority_quality or '4K')
		buckets = {}
		for item in results or []:
			bucket_quality = _resolution_bucket(item)
			key = (0 if bucket_quality == priority_quality else 1, QUALITY_RANKS.get(bucket_quality, 99))
			if key not in buckets: buckets[key] = []
			buckets[key].append(item)
		sorted_results = []
		# Important : l'ordre d'arrivée des manifests peut commencer par du 4K.
		# On trie donc les groupes eux-mêmes pour que la qualité prioritaire
		# (ex. 1080p) passe réellement avant 4K/720p/SD.
		for key in sorted(buckets):
			sorted_results.extend(sort_french_streaming_priority(buckets[key], private_trackers_first))
		return sorted_results
	except Exception:
		return results

def _select_best_items_preserve_order(results, max_count=0, bucket_func=None):
	# Sélectionne les meilleurs candidats selon cache/seeders, mais retourne toujours
	# les éléments dans l'ordre déjà trié par les réglages utilisateur.
	# Le seed aide donc à choisir quoi conserver lorsque les limites coupent la liste,
	# sans jamais devenir un tri visible dans le sélecteur.
	try: max_count = int(max_count or 0)
	except Exception: max_count = 0
	if max_count <= 0: return results
	try:
		if not bucket_func:
			indexed = list(enumerate(results or []))
			if len(indexed) <= max_count: return results
			selected_indexes = set(i for i, item in sorted(indexed, key=lambda pair: (language_priority_rank(pair[1]), _cache_status_rank(pair[1]), pair[0]))[:max_count])
			return [item for i, item in indexed if i in selected_indexes]

		buckets = {}
		for index, item in enumerate(results or []):
			buckets.setdefault(bucket_func(item), []).append((index, item))
		selected_indexes = set()
		for indexed_items in buckets.values():
			if len(indexed_items) <= max_count:
				selected_indexes.update(index for index, item in indexed_items)
				continue
			selected_indexes.update(
				index for index, item in sorted(indexed_items, key=lambda pair: (language_priority_rank(pair[1]), _cache_status_rank(pair[1]), pair[0]))[:max_count]
			)
		return [item for index, item in enumerate(results or []) if index in selected_indexes]
	except Exception:
		return results


def limit_results_per_resolution(results, max_per_resolution=0):
	try: return _select_best_items_preserve_order(results, max_per_resolution, _resolution_bucket)
	except Exception: return results


def limit_total_results(results, max_total=0):
	try: return _select_best_items_preserve_order(results, max_total)
	except Exception: return results

def is_comet_result(item):
	try:
		provider = str((item or {}).get('scrape_provider') or (item or {}).get('provider') or '').lower()
		tracker = str((item or {}).get('tracker') or (item or {}).get('tracker_name') or '').lower()
		source = str((item or {}).get('source') or '').lower()
		return provider == 'comet' or tracker == 'comet' or source == 'comet'
	except Exception:
		return False


def _seeders_count(item):
	# Aucun appel réseau : on utilise seulement le nombre de seeders déjà fourni
	# par la source. Les champs varient selon les providers, donc on accepte
	# plusieurs noms possibles sans rendre ce tri fragile.
	try:
		for key in ('seeders', 'seeds', 'seed', 'seeder'):
			value = (item or {}).get(key)
			if value in (None, ''): continue
			try: return max(0, int(float(str(value).replace(',', '.'))))
			except Exception: continue
		return 0
	except Exception:
		return 0


def _cache_status_rank(item):
	# Priorité d'affichage avant les limites :
	# 1) liens déjà confirmés cachés ;
	# 2) magnets non vérifiés, classés par seeders décroissants ;
	# 3) vrais non cachés, aussi classés par seeders décroissants.
	# Aucun appel débrideur n'est déclenché ici : on évite de bombarder AllDebrid.
	try:
		cache_provider = str((item or {}).get('cache_provider') or '').lower()
		if 'uncached' in cache_provider: status_rank = 2
		elif 'unchecked' in cache_provider: status_rank = 1
		elif cache_provider: status_rank = 0
		else: status_rank = 1
		return (status_rank, -_seeders_count(item))
	except Exception:
		return (1, 0)


def prioritize_cached_results(results):
	try:
		return sorted(results or [], key=_cache_status_rank)
	except Exception:
		return results

def limit_results_with_comet_fallback(results, max_per_resolution=0, max_total=0, enabled=False, threshold=5):
	# Comet en secours souple : Comet complète seulement une liste trop pauvre.
	# Si plus de 5 résultats non-Comet sont déjà présents, les résultats Comet
	# sont ignorés pour éviter les doublons et garder un sélecteur lisible.
	try:
		try: max_per_resolution = int(max_per_resolution or 0)
		except Exception: max_per_resolution = 0
		try: max_total = int(max_total or 0)
		except Exception: max_total = 0
		try: threshold = int(threshold or 5)
		except Exception: threshold = 5

		if enabled:
			primary = [i for i in results if not is_comet_result(i)]
			# Plus de 5 liens non-Comet exploitables : Comet reste un secours et ne complète pas.
			if len(primary) > threshold:
				results = primary

		# Important : les limites choisissent les meilleurs candidats selon cache/seeders,
		# mais l'ordre visible reste celui déjà produit par les réglages utilisateur.
		if max_per_resolution: results = limit_results_per_resolution(results, max_per_resolution)
		if max_total: results = limit_total_results(results, max_total)
		return results
	except Exception:
		return results

def sort_uncached_torrents(results, display_uncached=False, filterless_search=False):
	try:
		results.sort(key=lambda k: 'Unchecked' in k.get('cache_provider', ''), reverse=False)
		if display_uncached or filterless_search:
			results.sort(key=lambda k: 'Uncached' in k.get('cache_provider', ''), reverse=False)
			return results
		return [i for i in results if not 'Uncached' in i.get('cache_provider', '')]
	except Exception:
		return results

def source_passes_result_filters(item, quality_filter, include_3d=True, size_filter=0, include_unknown_size=True, max_size=None):
	try:
		if item.get('bypass_filter'): return True
		if item.get('quality') not in quality_filter: return False
		if not include_3d and '3D' in item.get('extraInfo', ''): return False
		if not size_filter: return True
		if item.get('scrape_provider', '').startswith('folder'): return True
		size = item.get('size', 0)
		try: size = float(size or 0)
		except Exception: size = 0
		if include_unknown_size: return size <= max_size
		return 0.01 < size <= max_size
	except Exception:
		return False

def apply_source_special_filter(results, key, enable_setting, autoplay=False, hybrid_allowed=False, hdr_key='[B]HDR[/B]', uncached_label='Uncached'):
	try:
		if enable_setting == 1:
			if 'D/VISION' in key and hybrid_allowed:
				return [
					i for i in results
					if i.get('bypass_filter') or all(x in i.get('extraInfo', '') for x in (key, hdr_key)) or key not in i.get('extraInfo', '')
				]
			return [i for i in results if i.get('bypass_filter') or key not in i.get('extraInfo', '')]
		if enable_setting == 2 and autoplay:
			priority_list = [i for i in results if not i.get('bypass_filter') and key in i.get('extraInfo', '')]
			remainder_list = [i for i in results if i not in priority_list]
			return priority_list + remainder_list
		if enable_setting == 3:
			priority_key = lambda k: not k.get('bypass_filter') and key in k.get('extraInfo', '') and uncached_label not in k.get('cache_provider', '')
			results.sort(key=priority_key, reverse=True)
			return results
	except Exception:
		pass
	return results

def normalize_source_result(item):
	# Normalisation défensive et légère : ne modifie pas les valeurs métier existantes,
	# mais garantit les champs de base consommés par le tri, les filtres, le debug et les fenêtres.
	if not isinstance(item, dict): return None
	try:
		result = dict(item)
		original_result = dict(result)
	except Exception: return None
	for key, value in SOURCE_RESULT_DEFAULTS.items():
		if result.get(key) is None: result[key] = value
		elif key not in result: result[key] = value
	try:
		if not result.get('scrape_provider'):
			result['scrape_provider'] = result.get('provider') or result.get('source') or ''
	except Exception:
		pass
	try:
		if not isinstance(result.get('size'), (int, float)):
			result['size'] = float(result.get('size') or 0)
	except Exception:
		result['size'] = 0.0
	try:
		size_label = str(result.get('size_label') or '').strip()
		if float(result.get('size') or 0) <= 0:
			result['size_label'] = ''
		elif re.match(r'^(?:0+(?:[\.,]0+)?)\s*(?:GB|GIB|MB|MIB)?$', size_label, re.I):
			result['size_label'] = ''
	except Exception:
		pass
	try:
		if not result.get('name') and result.get('URLName'):
			result['name'] = result.get('URLName')
		if not result.get('URLName') and result.get('name'):
			result['URLName'] = result.get('name')
	except Exception:
		pass
	try:
		# Champs de debug prêts à afficher : les valeurs vides deviennent N/A sans toucher aux valeurs métier.
		# Ils se basent sur le résultat original pour éviter qu'une valeur interne par défaut
		# comme quality=SD ne donne une fausse impression dans le diagnostic.
		for key in SOURCE_DEBUG_FIELDS:
			result['_debug_%s' % key] = display_value(original_result.get(key))
		result['_debug_file'] = source_debug_file(result)
		result['_debug_size'] = source_debug_size(result)
	except Exception:
		pass
	return result

def normalize_source_results(results):
	normalized = []
	for item in results or []:
		item = normalize_source_result(item)
		if item is not None: normalized.append(item)
	return normalized
