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

import requests
from caches.main_cache import cache_object
from modules import kodi_utils
# logger = kodi_utils.logger

ls, get_setting = kodi_utils.local_string, kodi_utils.get_setting
base_url = 'https://api.alldebrid.com/'
timeout = 10.0
session = requests.Session()
session.custom_errors = requests.exceptions.ConnectionError, requests.exceptions.Timeout
session.mount('https://api.alldebrid.com', requests.adapters.HTTPAdapter(max_retries=1))

# Codes inspirés de la gestion WaStreams, adaptés au flux synchrone Kodi.
AD_RETRY_ERRORS = (
	'LINK_HOST_UNAVAILABLE',
	'LINK_TEMPORARY_UNAVAILABLE',
	'LINK_TOO_MANY_DOWNLOADS',
	'LINK_HOST_FULL',
	'LINK_HOST_LIMIT_REACHED',
	'REDIRECTOR_ERROR',
)
AD_DEAD_LINK_ERRORS = (
	'LINK_PASS_PROTECTED',
)
AD_DIRECT_HOSTS = (
	'1fichier.com',
	'turbobit.net',
	'rapidgator.net',
	'alldebrid.com',
)

class AllDebridAPI:
	icon = 'alldebrid.png'

	@staticmethod
	def flatten_magnet_files(files_list):
		def flatten(items):
			for i in items:
				if not isinstance(i, dict): continue
				if 'e' in i: flatten(i['e'])
				else: files_append(i)
		files = []
		files_append = files.append
		flatten(files_list)
		return files

	@staticmethod
	def normalize_download_link(link):
		# Corrige les URLs AllDebrid mal préfixées retournées par /user/links.
		try:
			import re
			link = str(link or '').strip()
			if not link: return link
			# Certains liens sauvegardés arrivent sous des formes hybrides comme
			# http/https://alldebrid.com/f/..., http://https//alldebrid.com/f/...
			# ou http://https/alldebrid.com/f/.... Il faut les corriger avant
			# toute comparaison, résolution ou lecture.
			# L'interface web peut aussi afficher/copier ces liens avec des espaces
			# autour du slash initial (ex. http /https://...). On compacte l'URL
			# avant les corrections de préfixe.
			link = re.sub(r'\s+', '', link)
			# 1Fichier ajoute parfois un paramètre affilié `af` qui peut empêcher
			# AllDebrid de reconnaître correctement le lien. On le retire sans
			# toucher au vrai identifiant du fichier.
			if '1fichier.com' in link.lower():
				link = re.sub(r'([?&])af=[^&#]*&?', r'\1', link, flags=re.I)
				link = link.replace('?&', '?').rstrip('?&')
			link = re.sub(r'^https?/https?:/{0,2}', 'https://', link, flags=re.I)
			link = re.sub(r'^https?/https/{0,2}', 'https://', link, flags=re.I)
			link = re.sub(r'^https?/http/{0,2}', 'http://', link, flags=re.I)
			link = re.sub(r'^(?:https?:)?//+', 'https://', link, flags=re.I)
			link = re.sub(r'^https?://https?://+', 'https://', link, flags=re.I)
			link = re.sub(r'^https?://https//+', 'https://', link, flags=re.I)
			link = re.sub(r'^https?://https/+', 'https://', link, flags=re.I)
			link = re.sub(r'^https//+', 'https://', link, flags=re.I)
			link = re.sub(r'^http//+', 'http://', link, flags=re.I)
			link = re.sub(r'^(https?:)/{3,}', r'\1//', link, flags=re.I)
		except:
			pass
		return link

	@staticmethod
	def is_alldebrid_file_link(link):
		try:
			from urllib.parse import urlparse
			parsed = urlparse(AllDebridAPI.normalize_download_link(link))
			host = (parsed.netloc or '').lower()
			path = parsed.path or ''
			return host.endswith('alldebrid.com') and path.startswith('/f/')
		except:
			return False

	@staticmethod
	def is_probably_playable_link(link):
		try:
			from urllib.parse import urlparse
			parsed = urlparse(AllDebridAPI.normalize_download_link(link))
			if not parsed.scheme or not parsed.netloc: return False
			# Les pages /f/ d'AllDebrid doivent être déverrouillées avant Kodi.
			if AllDebridAPI.is_alldebrid_file_link(link): return False
			return True
		except:
			return False

	def __init__(self):
		self.token = get_setting('ad.token')
		session.headers.update(self.headers())

	def _request(self, method, path, params=None, data=None):
		url = base_url + path
		try: response = session.request(method, url, params=params, data=data, timeout=timeout)
		except session.custom_errors: return kodi_utils.notification('%s timeout' % __name__)
		if not response.ok: kodi_utils.logger(__name__, f"{response.reason}\n{response.url}")
		if 'json' in response.headers.get('Content-Type', ''):
			try:
				response = response.json()
			except Exception as e:
				try:
					preview = (response.text or '')[:300]
				except:
					preview = 'réponse illisible'
				try:
					token = self.token or ''
					if token: preview = preview.replace(token, '***')
				except:
					pass
				kodi_utils.logger('AllDebrid API', 'Réponse JSON invalide ignorée | endpoint=%s | erreur=%s | réponse=%s' % (path, type(e).__name__, preview))
				return None
		if isinstance(response, dict) and 'data' in response and response.get('status') == 'success': response = response['data']
		return response

	def _get(self, path, params=None):
		return self._request('get', path, params=params)

	def _post(self, path, data=None):
		return self._request('post', path, data=data)


	def _response_preview(self, result, limit=700):
		try:
			import json
			preview = json.dumps(result, ensure_ascii=False, sort_keys=True)
		except:
			try: preview = str(result)
			except: preview = 'réponse illisible'
		token = self.token or ''
		if token: preview = preview.replace(token, '***')
		return preview[:limit]

	def _format_api_error(self, result):
		try:
			if isinstance(result, dict) and 'error' in result:
				error = result.get('error') or {}
				if isinstance(error, dict):
					code = error.get('code') or error.get('type') or 'UNKNOWN'
					message = error.get('message') or error.get('details') or error.get('description') or 'message absent'
					return '%s - %s' % (code, message)
				return str(error)
		except:
			pass
		return ''

	def _raise_unexpected_magnets_response(self, context, result):
		error = self._format_api_error(result)
		if error: message = 'AllDebrid: %s | erreur API=%s' % (context, error)
		else: message = 'AllDebrid: %s | réponse sans clé magnets' % context
		kodi_utils.logger('AllDebrid resolve', '%s | réponse=%s' % (message, self._response_preview(result)))
		raise Exception(message)

	def _extract_magnets(self, result, context):
		try:
			if isinstance(result, dict) and 'magnets' in result:
				magnets = result.get('magnets') or []
				if isinstance(magnets, dict): magnets = [magnets]
				return magnets
		except:
			pass
		self._raise_unexpected_magnets_response(context, result)

	def headers(self):
		return {'Authorization': 'Bearer %s' % self.token}

	def days_remaining(self):
		import datetime
		try:
			account_info = self.account_info()['user']
			expires = datetime.datetime.fromtimestamp(account_info['premiumUntil'])
			days = (expires - datetime.datetime.today()).days
		except: days = None
		return days

	def account_info(self):
		url = 'v4/user'
		result = self._get(url)
		return result

	def torrent_info(self, transfer_id):
		url = 'v4.1/magnet/status'
		params = {'id': transfer_id}
		# Le scraper cloud peut inspecter plusieurs magnets pour trouver un épisode
		# dont le nom réel est dans les fichiers internes. On met donc le statut
		# détaillé en cache court pour éviter de marteler l'API à chaque recherche.
		result = cache_object(self._get, 'alkoflix_ad_torrent_info_%s' % transfer_id, [url, params], False, 0.5)
		magnets = self._extract_magnets(result, 'statut magnet introuvable')
		return magnets[0] if isinstance(magnets, list) and magnets else {}

	def delete_torrent(self, transfer_id):
		url = 'v4/magnet/delete'
		data = {'id': transfer_id}
		result = self._post(url, data)
		return True if not result is None and not 'error' in result else False

	def _iter_response_values(self, item):
		try:
			if isinstance(item, dict):
				for value in item.values():
					for nested_value in self._iter_response_values(value):
						yield nested_value
			elif isinstance(item, (list, tuple)):
				for value in item:
					for nested_value in self._iter_response_values(value):
						yield nested_value
			else:
				yield item
		except:
			return

	def _magnet_matches_hash(self, magnet, info_hash):
		try:
			for value in self._iter_response_values(magnet):
				if info_hash in str(value or '').lower():
					return True
		except:
			pass
		return False

	def delete_torrent_by_hash(self, info_hash, filename=None, attempts=1, sleep_ms=0, retry_delays_ms=None):
		try:
			info_hash = str(info_hash or '').strip().lower()
			if not info_hash: return False
			if retry_delays_ms is None:
				attempts = max(1, int(attempts or 1))
				sleep_ms = max(0, int(sleep_ms or 0))
				retry_delays_ms = [sleep_ms] * (attempts - 1)
			else:
				retry_delays_ms = [max(0, int(i or 0)) for i in retry_delays_ms]
				attempts = len(retry_delays_ms) + 1
			for attempt in range(attempts):
				result = self._get('v4.1/magnet/status') or {}
				magnets = result.get('magnets', []) if isinstance(result, dict) else []
				if isinstance(magnets, dict): magnets = [magnets]
				for magnet in magnets or []:
					try:
						magnet_id = magnet.get('id')
						if not magnet_id: continue
						if self._magnet_matches_hash(magnet, info_hash):
							deleted = self.delete_torrent(magnet_id)
							if deleted:
								kodi_utils.logger('AllDebrid cloud cleanup', 'Magnet supprimé -> tentative=%s/%s | id=%s | hash=%s | fichier=%s' % (attempt + 1, attempts, magnet_id, info_hash, filename or 'N/A'))
							else:
								kodi_utils.logger('AllDebrid cloud cleanup', 'Suppression refusée -> tentative=%s/%s | id=%s | hash=%s | fichier=%s' % (attempt + 1, attempts, magnet_id, info_hash, filename or 'N/A'))
							return deleted
					except:
						pass
				if attempt < attempts - 1:
					delay_ms = retry_delays_ms[attempt]
					kodi_utils.logger('AllDebrid cloud cleanup', 'Magnet pas encore visible -> tentative=%s/%s | prochaine vérification dans %.1fs | hash=%s | fichier=%s' % (attempt + 1, attempts, delay_ms / 1000.0, info_hash, filename or 'N/A'))
					if delay_ms: kodi_utils.sleep(delay_ms)
			kodi_utils.logger('AllDebrid cloud cleanup', 'Aucun magnet à supprimer après %s essai(s) -> hash=%s | fichier=%s' % (attempts, info_hash, filename or 'N/A'))
			return False
		except Exception as e:
			kodi_utils.logger('AllDebrid delete_torrent_by_hash', f'{e} | {info_hash} | {filename}')
			return False

	def _is_direct_hoster_link(self, link):
		try:
			from urllib.parse import urlparse
			link = self.normalize_download_link(link)
			host = (urlparse(str(link or '').strip()).netloc or '').lower().replace('www.', '')
			return any(h in host for h in AD_DIRECT_HOSTS)
		except:
			return False

	def _is_redirector_link(self, link):
		try:
			link = self.normalize_download_link(link)
			lower_link = str(link or '').lower()
			# WaStreams passe par /link/redirector pour les liens protégés ou
			# intermédiaires. Ici on le fait aussi pour tout ce qui n'est pas un
			# hoster direct connu, afin d'éviter d'envoyer dl-protect/redirectors
			# directement à /link/unlock.
			if 'dl-protect.' in lower_link or 'dlprotect.' in lower_link: return True
			if 'tpb.intrustzone.' in lower_link: return True
			if self._is_direct_hoster_link(link): return False
			return True
		except:
			return False

	def _api_error_code(self, result):
		try:
			if isinstance(result, dict):
				error = result.get('error') or {}
				if isinstance(error, dict):
					return error.get('code') or error.get('type') or ''
		except:
			pass
		return ''

	def _handle_link_error(self, result, context, entry):
		code = self._api_error_code(result)
		if code == 'LINK_DOWN':
			kodi_utils.logger(context, 'Lien mort | entrée=%s | erreur=%s' % (entry, code))
			return True
		if code in AD_DEAD_LINK_ERRORS:
			kodi_utils.logger(context, 'Lien protégé/mort ignoré sans nouvelle tentative | entrée=%s | erreur=%s' % (entry, code))
			return True
		if code == 'NO_SERVER':
			kodi_utils.logger(context, 'NO_SERVER - serveur AllDebrid indisponible ou bloqué (VPN/datacenter possible) | entrée=%s' % entry)
			return True
		if code in AD_RETRY_ERRORS:
			kodi_utils.logger(context, 'Erreur temporaire AllDebrid, lien ignoré pour passer au suivant | entrée=%s | erreur=%s' % (entry, code))
			return True
		return False

	def _extract_redirector_links(self, result):
		links = []
		try:
			if isinstance(result, dict):
				links = result.get('links') or result.get('link') or result.get('urls') or []
			else:
				links = result or []
			if isinstance(links, (str, bytes)):
				links = [links]
			elif isinstance(links, dict):
				links = [links]
		except:
			links = []

		redirected = []
		for item in links or []:
			try:
				if isinstance(item, dict):
					url = item.get('link') or item.get('url') or item.get('download') or item.get('href')
				else:
					url = item
				url = self.normalize_download_link(url)
				if url: redirected.append(url)
			except:
				pass
		return redirected

	def _unlock_resolved_link(self, link, original_link=None):
		url = 'v4/link/unlock'
		link = self.normalize_download_link(link)
		if not link: return None
		result = self._get(url, {'link': link})
		try:
			resolved = self.normalize_download_link(result['link'])
			if not resolved or not self.is_probably_playable_link(resolved):
				kodi_utils.logger('AllDebrid unlock', 'Lien déverrouillé non lisible par Kodi | entrée=%s | sortie=%s' % (original_link or link, resolved or 'N/A'))
				return None
			return resolved
		except Exception as e:
			if isinstance(result, dict) and self._handle_link_error(result, 'AllDebrid unlock', original_link or link):
				return None
			error = self._format_api_error(result) if isinstance(result, dict) else ''
			kodi_utils.logger('AllDebrid unlock', 'Échec déverrouillage lien | entrée=%s | erreur=%s | détail=%s' % (original_link or link, error or type(e).__name__, e))
			return None

	def unrestrict_link(self, link):
		link = self.normalize_download_link(link)
		if not link: return None
		# Certains sites comme Wawacity retournent des liens de protection (dl-protect).
		# AllDebrid ne doit pas recevoir ces URLs via /link/unlock directement : il faut
		# d'abord utiliser /link/redirector pour obtenir le vrai hoster, puis déverrouiller
		# le lien final. C'est le même principe que WaStreams côté serveur.
		if self._is_redirector_link(link):
			redirect_result = self._get('v4/link/redirector', {'link': link})
			redirected_links = self._extract_redirector_links(redirect_result)
			if not redirected_links:
				if isinstance(redirect_result, dict) and self._handle_link_error(redirect_result, 'AllDebrid redirector', link):
					return None
				error = self._format_api_error(redirect_result) if isinstance(redirect_result, dict) else ''
				kodi_utils.logger('AllDebrid redirector', 'Aucun lien final extrait | entrée=%s | erreur=%s' % (link, error or 'N/A'))
				return None
			for redirected_link in redirected_links:
				resolved = self._unlock_resolved_link(redirected_link, original_link=link)
				if resolved:
					try: hoster = redirected_link.split('/')[2] if '://' in redirected_link else 'N/A'
					except: hoster = 'N/A'
					kodi_utils.logger('AllDebrid redirector', 'Lien protégé résolu | hoster=%s' % hoster)
					return resolved
			kodi_utils.logger('AllDebrid redirector', 'Tous les liens finaux ont échoué | entrée=%s | candidats=%s' % (link, len(redirected_links)))
			return None
		return self._unlock_resolved_link(link)

	def check_cache(self, hashes):
		data = {'v4/magnets[]': hashes}
		result = self._post('magnet/instant', data)
		return result

	def create_transfer(self, magnet):
		url = 'v4/magnet/upload'
		params = {'magnet': magnet}
		result = self._get(url, params)
		magnets = self._extract_magnets(result, 'upload magnet refusé')
		if not magnets: self._raise_unexpected_magnets_response('upload magnet sans élément exploitable', result)
		return magnets[0].get('id', '')

	def parse_magnet_pack(self, magnet_url, info_hash, errors=False):
		from modules.source_utils import supported_video_extensions
		from modules.source_objects import _is_blocked_file_source, _blocked_file_value
		torrent_id = None
		try:
			if _is_blocked_file_source({'url': magnet_url, 'hash': info_hash}):
				blocked_value = _blocked_file_value({'url': magnet_url, 'hash': info_hash})
				kodi_utils.logger('Sécurité AllDebrid', 'Magnet bloqué avant upload | hash=%s | valeur=%s' % (info_hash, blocked_value or 'N/A'))
				raise Exception('blocked executable magnet: %s' % (blocked_value or 'N/A'))
			extensions = supported_video_extensions()
			kodi_utils.logger('AllDebrid resolve', 'Upload magnet pour analyse | hash=%s' % (info_hash or 'N/A'))
			torrent_id = self.create_transfer(magnet_url)
			kodi_utils.logger('AllDebrid resolve', 'Magnet créé pour analyse | id=%s | hash=%s' % (torrent_id or 'N/A', info_hash or 'N/A'))
			if not torrent_id: raise Exception('AllDebrid: upload magnet sans identifiant exploitable')
			for key in ['completionDate'] * 3:
				kodi_utils.sleep(500)
				torrent_info = self.torrent_info(torrent_id)
				if not isinstance(torrent_info, dict):
					self._raise_unexpected_magnets_response('statut magnet invalide', torrent_info)
				if torrent_info.get(key): break
			else: raise Exception('AllDebrid: magnet non cache ou non prêt après analyse')
			files = torrent_info.get('files') or []
			if not files: raise Exception('AllDebrid: pack magnet sans liste de fichiers exploitable')
			torrent_info['links'] = self.flatten_magnet_files(files)
			blocked_item = next((item for item in torrent_info['links'] if _is_blocked_file_source({'filename': item.get('n'), 'path': item.get('n')})), None)
			if blocked_item:
				blocked_value = _blocked_file_value({'filename': blocked_item.get('n'), 'path': blocked_item.get('n')}) or blocked_item.get('n') or 'N/A'
				kodi_utils.logger('Sécurité AllDebrid', 'Fichier exécutable détecté dans le pack | id=%s | hash=%s | fichier=%s' % (torrent_id or 'N/A', info_hash or 'N/A', blocked_value))
				raise Exception('blocked executable file in magnet pack: %s' % blocked_value)
			kodi_utils.logger('AllDebrid resolve', 'Pack analysé | id=%s | hash=%s | fichiers=%s' % (torrent_id or 'N/A', info_hash or 'N/A', len(torrent_info['links'] or [])))
			return [
				{'link': item['l'],
				 'size': item['s'],
				 'torrent_id': torrent_id,
				 'filename': item['n']}
				for item in torrent_info['links']
				if item['n'].lower().endswith(tuple(extensions))
			]
		except Exception as e:
			if torrent_id:
				deleted = self.delete_torrent(torrent_id)
				kodi_utils.logger('AllDebrid resolve', 'Nettoyage après refus/erreur | id=%s | hash=%s | supprimé=%s | erreur=%s' % (torrent_id, info_hash or 'N/A', deleted, e))
			if errors: raise

	def downloads(self):
		# Historique récent des liens AllDebrid.
		url = 'v4/user/history'
		string = 'alkoflix_ad_downloads'
		return cache_object(self._get, string, url, False, 0.5)

	def saved_links(self):
		# Liens sauvegardés par l'utilisateur dans son compte AllDebrid.
		url = 'v4/user/links'
		string = 'alkoflix_ad_saved_links'
		return cache_object(self._get, string, url, False, 0.5)

	def delete_saved_link(self, link):
		url = 'v4/user/links/delete'
		data = {'links[]': [link]}
		# Certaines versions de requests encodent mieux les tableaux PHP si on passe une liste de tuples.
		try: result = self._post(url, data=[('links[]', link)])
		except: result = self._post(url, data)
		return True if not result is None and not 'error' in result else False

	def user_cloud(self, completed=True):
		url = 'v4.1/magnet/status'
		string = 'alkoflix_ad_user_cloud'
		result = cache_object(self._get, string, url, False, 0.5)
		if completed: result['magnets'] = [i for i in result['magnets'] if i['statusCode'] == 4]
		return result

	def clear_cache(self):
		from modules.kodi_utils import clear_property, path_exists, database_connect, maincache_db
		try:
			if not path_exists(maincache_db): return True
			from caches.debrid_cache import DebridCache
			dbcon = database_connect(maincache_db)
			dbcur = dbcon.cursor()
			# USER CLOUD
			try:
				dbcur.execute("""DELETE FROM maincache WHERE id = ?""", ('alkoflix_ad_user_cloud',))
				clear_property('alkoflix_ad_user_cloud')
				dbcon.commit()
				user_cloud_success = True
			except: user_cloud_success = False
			# DOWNLOAD LINKS
			try:
				dbcur.execute("""DELETE FROM maincache WHERE id = ?""", ('alkoflix_ad_downloads',))
				clear_property('alkoflix_ad_downloads')
				dbcon.commit()
				download_links_success = True
			except: download_links_success = False
			# SAVED LINKS
			try:
				dbcur.execute("""DELETE FROM maincache WHERE id = ?""", ('alkoflix_ad_saved_links',))
				clear_property('alkoflix_ad_saved_links')
				dbcon.commit()
				saved_links_success = True
			except: saved_links_success = False
			# HOSTERS
			try:
				dbcur.execute("""DELETE FROM maincache WHERE id = ?""", ('alkoflix_ad_valid_hosts',))
				clear_property('alkoflix_ad_valid_hosts')
				dbcon.commit()
				hoster_links_success = True
			except: hoster_links_success = False
			# MAGNET DETAILS
			try:
				dbcur.execute("""DELETE FROM maincache WHERE id LIKE ?""", ('alkoflix_ad_torrent_info_%',))
				dbcon.commit()
				torrent_info_success = True
			except: torrent_info_success = False
			dbcon.close()
			# HASH CACHED STATUS
			try:
				DebridCache().clear_debrid_results('ad')
				hash_cache_status_success = True
			except: hash_cache_status_success = False
		except: return False
		if False in (user_cloud_success, download_links_success, saved_links_success, hoster_links_success, torrent_info_success, hash_cache_status_success): return False
		return True

