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

from ast import literal_eval
from datetime import datetime, timedelta
import re
from caches import BaseCache, external_db
# from modules.kodi_utils import logger

SELECT_RESULTS = 'SELECT results, expires FROM results_data WHERE provider = ? AND db_type = ? AND tmdb_id = ? AND title = ? AND year = ? AND season = ? AND episode = ?'
# Fallback volontairement plus robuste : le tmdb_id + saison/épisode suffit à identifier
# la même recherche, même si le titre/année normalisé varie légèrement selon le contexte
# (retour depuis lecture, widget, langue de métadonnées, etc.).
SELECT_RESULTS_FALLBACK = 'SELECT results, expires FROM results_data WHERE provider = ? AND db_type = ? AND tmdb_id = ? AND season = ? AND episode = ? ORDER BY expires DESC LIMIT 1'
# Fallback alias : lorsqu'un même média passe tantôt par TMDb, tantôt par IMDb,
# la clé tmdb_id peut changer alors que la recherche est identique côté utilisateur.
# On tente donc ensuite un rapprochement par titre/année/saison/épisode.
SELECT_RESULTS_TITLE_FALLBACK = 'SELECT results, expires FROM results_data WHERE provider = ? AND db_type = ? AND title = ? AND year = ? AND season = ? AND episode = ? ORDER BY expires DESC LIMIT 1'
SELECT_RESULTS_TITLE_YEARLESS_FALLBACK = 'SELECT results, expires FROM results_data WHERE provider = ? AND db_type = ? AND title = ? AND season = ? AND episode = ? ORDER BY expires DESC LIMIT 1'
DELETE_RESULTS = 'DELETE FROM results_data WHERE provider = ? AND db_type = ? AND tmdb_id = ? AND title = ? AND year = ? AND season = ? AND episode = ?'
INSERT_RESULTS = 'INSERT OR REPLACE INTO results_data VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'

# Base longue durée des hashes : les recherches complètes gardent leur TTL court,
# mais les hashes connus peuvent être réutilisés plus longtemps sans rappeler l'API.
CREATE_PROVIDER_HASHES = '''CREATE TABLE IF NOT EXISTS provider_hashes
						(provider text not null, hash text not null, result text, expires integer, last_seen integer,
						unique(provider, hash))'''
CREATE_PROVIDER_HASH_MAP = '''CREATE TABLE IF NOT EXISTS provider_hash_map
						(provider text not null, db_type text not null, media_id text, title text, year text,
						season text, episode text, hash text not null, expires integer,
						unique(provider, db_type, media_id, title, year, season, episode, hash))'''
INSERT_PROVIDER_HASH = 'INSERT OR REPLACE INTO provider_hashes VALUES (?, ?, ?, ?, ?)'
INSERT_PROVIDER_HASH_MAP = 'INSERT OR REPLACE INTO provider_hash_map VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
SELECT_PROVIDER_HASH_MAP = '''SELECT h.result, h.expires FROM provider_hash_map m
							  JOIN provider_hashes h ON h.provider = m.provider AND h.hash = m.hash
							  WHERE m.provider = ? AND m.db_type = ? AND m.media_id = ? AND m.season = ? AND m.episode = ?
							  ORDER BY m.expires DESC, h.last_seen DESC'''
SELECT_PROVIDER_HASH_TITLE_MAP = '''SELECT h.result, h.expires FROM provider_hash_map m
								JOIN provider_hashes h ON h.provider = m.provider AND h.hash = m.hash
								WHERE m.provider = ? AND m.db_type = ? AND m.title = ? AND m.year = ? AND m.season = ? AND m.episode = ?
								ORDER BY m.expires DESC, h.last_seen DESC'''
SELECT_PROVIDER_HASH_TITLE_YEARLESS_MAP = '''SELECT h.result, h.expires FROM provider_hash_map m
									 JOIN provider_hashes h ON h.provider = m.provider AND h.hash = m.hash
									 WHERE m.provider = ? AND m.db_type = ? AND m.title = ? AND m.season = ? AND m.episode = ?
									 ORDER BY m.expires DESC, h.last_seen DESC'''
DELETE_PROVIDER_HASH_MAP_KEY = 'DELETE FROM provider_hash_map WHERE provider = ? AND db_type = ? AND media_id = ? AND title = ? AND year = ? AND season = ? AND episode = ?'
DELETE_PROVIDER_HASH_MAP_SINGLE = 'DELETE FROM provider_hash_map WHERE db_type = ? AND media_id = ?'
DELETE_PROVIDER_HASHES_EXPIRED = 'DELETE FROM provider_hashes WHERE CAST(expires AS INT) <= ?'
DELETE_PROVIDER_HASH_MAP_EXPIRED = 'DELETE FROM provider_hash_map WHERE CAST(expires AS INT) <= ?'
DELETE_PROVIDER_HASH_MAP_HASH = 'DELETE FROM provider_hash_map WHERE provider = ? AND hash = ?'
DELETE_PROVIDER_HASH = 'DELETE FROM provider_hashes WHERE provider = ? AND hash = ?'
SELECT_RESULTS_BY_PROVIDER = 'SELECT provider, db_type, tmdb_id, title, year, season, episode, results, expires FROM results_data WHERE provider = ?'
DELETE_RESULTS_KEY = 'DELETE FROM results_data WHERE provider = ? AND db_type = ? AND tmdb_id = ? AND title = ? AND year = ? AND season = ? AND episode = ?'
UPDATE_RESULTS_KEY = 'UPDATE results_data SET results = ? WHERE provider = ? AND db_type = ? AND tmdb_id = ? AND title = ? AND year = ? AND season = ? AND episode = ?'

PRIVATE_EMPTY_CACHE_PROVIDERS = ('c411', 'torr9', 'ygg')
HASH_CACHE_TTL_HOURS = 24 * 30
MEMORY_RESULTS = {}
SINGLE_DELETE = 'DELETE FROM results_data WHERE db_type = ? AND tmdb_id = ?'
FULL_DELETE = 'DELETE FROM results_data'
FULL_DELETE_HASHES = 'DELETE FROM provider_hashes'
FULL_DELETE_HASH_MAP = 'DELETE FROM provider_hash_map'

class ExternalProvidersCache(BaseCache):
	db_file = external_db

	def __init__(self):
		BaseCache.__init__(self)
		self._ensure_hash_tables()

	def _ensure_hash_tables(self):
		try:
			self.dbcur.execute(CREATE_PROVIDER_HASHES)
			self.dbcur.execute(CREATE_PROVIDER_HASH_MAP)
			self.dbcur.execute('CREATE INDEX IF NOT EXISTS alkoflix_provider_hash_map_media ON provider_hash_map (provider, db_type, media_id, season, episode)')
			self.dbcur.execute('CREATE INDEX IF NOT EXISTS alkoflix_provider_hash_map_title ON provider_hash_map (provider, db_type, title, year, season, episode)')
			self.dbcur.execute('CREATE INDEX IF NOT EXISTS alkoflix_provider_hashes_expire ON provider_hashes (expires)')
			self.dbcur.execute('CREATE INDEX IF NOT EXISTS alkoflix_provider_hash_map_expire ON provider_hash_map (expires)')
		except:
			pass

	def _memory_get(self, source, key):
		try:
			if source not in PRIVATE_EMPTY_CACHE_PROVIDERS: return None, False
			cache_data = MEMORY_RESULTS.get(key)
			if not cache_data: return None, False
			current_time = self._get_timestamp(datetime.now())
			results, expires = cache_data
			if expires <= current_time:
				try: del MEMORY_RESULTS[key]
				except: pass
				return None, True
			return results, False
		except:
			return None, True

	def _memory_set(self, source, key, results, expires):
		try:
			if source in PRIVATE_EMPTY_CACHE_PROVIDERS:
				MEMORY_RESULTS[key] = (results, int(expires))
		except:
			pass

	def _decode_cache_data(self, source, cache_data):
		try:
			if not cache_data: return None, False
			current_time = self._get_timestamp(datetime.now())
			if cache_data[1] <= current_time: return None, True
			result = literal_eval(cache_data[0])
			if not result and source not in PRIVATE_EMPTY_CACHE_PROVIDERS: return None, True
			return result, False
		except:
			return None, True

	def _normalise_hash(self, item):
		try:
			if not isinstance(item, dict): return ''
			value = item.get('hash') or item.get('mhash') or item.get('infoHash') or item.get('infohash') or item.get('info_hash') or ''
			if not value: value = item.get('url') or ''
			match = re.search(r'btih:([A-Fa-f0-9]{40})', str(value), re.I)
			if match: return match.group(1).lower()
			match = re.search(r'\b([A-Fa-f0-9]{40})\b', str(value), re.I)
			if match: return match.group(1).lower()
			value = str(value or '').strip().lower()
			return value if value and len(value) >= 16 else ''
		except:
			return ''

	def _hash_expiry(self, expires):
		try:
			expires = int(expires)
		except:
			expires = 0
		try:
			long_expiry = self._get_timestamp(datetime.now() + timedelta(hours=HASH_CACHE_TTL_HOURS))
			return max(expires, long_expiry)
		except:
			return expires

	def _store_hash_results(self, source, media_type, tmdb_id, title, year, season, episode, results, expires):
		try:
			if source not in PRIVATE_EMPTY_CACHE_PROVIDERS or not results: return
			now = self._get_timestamp(datetime.now())
			expires = self._hash_expiry(expires)
			for item in results or []:
				try:
					if not isinstance(item, dict): continue
					item_hash = self._normalise_hash(item)
					if not item_hash: continue
					stored_item = item.copy()
					stored_item['hash'] = item_hash
					# Ces champs sont recalculés/ajoutés à la lecture du cache; on évite de les figer.
					for key in ('provider_cache_hit', 'local_cache', 'local_cache_label'):
						try: stored_item.pop(key, None)
						except: pass
					self.dbcur.execute(INSERT_PROVIDER_HASH, (source, item_hash, repr(stored_item), int(expires), int(now)))
					self.dbcur.execute(INSERT_PROVIDER_HASH_MAP, (source, media_type, str(tmdb_id or ''), str(title or ''), str(year or ''), str(season or ''), str(episode or ''), item_hash, int(expires)))
				except:
					pass
		except:
			pass

	def _decode_hash_rows(self, source, rows):
		results, seen = [], set()
		try:
			current_time = self._get_timestamp(datetime.now())
			for result_data, expires in rows or []:
				try:
					if int(expires or 0) <= current_time: continue
					item = literal_eval(result_data)
					if not isinstance(item, dict): continue
					item_hash = self._normalise_hash(item)
					key = item_hash or item.get('url') or item.get('name')
					if key and key in seen: continue
					if key: seen.add(key)
					if item_hash: item['hash'] = item_hash
					# Cache froid : résultat reconstruit depuis la base longue durée des hashes.
					# Ce marquage est visuel/debug seulement; il ne modifie pas le tri ni la résolution.
					item['provider_cache_hit'] = True
					item['provider_hash_hit'] = True
					item['local_cache'] = True
					item['local_cache_label'] = 'HASH'
					results.append(item)
				except:
					pass
		except:
			pass
		return results if results else None

	def _get_hash_results_for_scope(self, source, media_type, clean_id, clean_title, clean_year, clean_season, clean_episode):
		try:
			if clean_id:
				self.dbcur.execute(SELECT_PROVIDER_HASH_MAP, (source, media_type, clean_id, clean_season, clean_episode))
				results = self._decode_hash_rows(source, self.dbcur.fetchall())
				if results: return results
			if clean_title:
				self.dbcur.execute(SELECT_PROVIDER_HASH_TITLE_MAP, (source, media_type, clean_title, clean_year, clean_season, clean_episode))
				results = self._decode_hash_rows(source, self.dbcur.fetchall())
				if results: return results
				self.dbcur.execute(SELECT_PROVIDER_HASH_TITLE_YEARLESS_MAP, (source, media_type, clean_title, clean_season, clean_episode))
				results = self._decode_hash_rows(source, self.dbcur.fetchall())
				if results: return results
		except:
			pass
		return None

	def _hash_scope_candidates(self, media_type, season, episode):
		# Cache chaud = recherche exacte 72 h. Cache froid = hashes 30 jours.
		# Après expiration du cache chaud, on tente d'abord la portée exacte, puis les
		# packs déjà connus (saison puis intégrale) avant de laisser l'appel API se faire.
		try:
			clean_season, clean_episode = str(season or ''), str(episode or '')
			scopes, seen = [], set()
			def add(scope):
				if scope in seen: return
				seen.add(scope)
				scopes.append(scope)
			add((clean_season, clean_episode))
			if media_type == 'episode':
				if clean_episode:
					# Épisode exact absent/expiré : un pack saison ou intégrale déjà connu peut suffire.
					if clean_season: add((clean_season, ''))
					add(('', ''))
				elif clean_season:
					# Recherche saison absente/expirée : un pack intégrale déjà connu peut suffire.
					add(('', ''))
			return scopes
		except:
			return [(str(season or ''), str(episode or ''))]

	def _get_hash_results(self, source, media_type, tmdb_id, title, year, season, episode):
		try:
			if source not in PRIVATE_EMPTY_CACHE_PROVIDERS: return None
			clean_id = str(tmdb_id or '')
			clean_title = str(title or '')
			clean_year = str(year or '')
			for clean_season, clean_episode in self._hash_scope_candidates(media_type, season, episode):
				results = self._get_hash_results_for_scope(source, media_type, clean_id, clean_title, clean_year, clean_season, clean_episode)
				if results: return results
		except:
			pass
		return None

	def get(self, source, media_type, tmdb_id, title, year, season, episode):
		result = None
		try:
			exact_args = (source, media_type, tmdb_id, title, year, season, episode)
			result, invalid = self._memory_get(source, exact_args)
			if result is not None: return result
			self.dbcur.execute(SELECT_RESULTS, exact_args)
			cache_data = self.dbcur.fetchone()
			result, invalid = self._decode_cache_data(source, cache_data)
			if result is not None:
				try: self._memory_set(source, exact_args, result, cache_data[1])
				except: pass
				return result
			if cache_data and invalid:
				try: self.delete(source, media_type, tmdb_id, title, year, season, episode)
				except: pass

			# Même identifiant média, mais titre/année légèrement différent selon le chemin d'ouverture.
			self.dbcur.execute(SELECT_RESULTS_FALLBACK, (source, media_type, tmdb_id, season, episode))
			cache_data = self.dbcur.fetchone()
			result, invalid = self._decode_cache_data(source, cache_data)
			if result is not None:
				try: self._memory_set(source, exact_args, result, cache_data[1])
				except: pass
				return result

			# Alias TMDb/IMDb : si le même média arrive avec un autre identifiant, le titre +
			# la portée de recherche permettent de relire le cache sans rappeler l'API.
			# Les hashes/magnets restent ensuite l'identité unique des résultats eux-mêmes.
			if title not in ('', None):
				self.dbcur.execute(SELECT_RESULTS_TITLE_FALLBACK, (source, media_type, title, year, season, episode))
				cache_data = self.dbcur.fetchone()
				result, invalid = self._decode_cache_data(source, cache_data)
				if result is not None:
					try: self._memory_set(source, exact_args, result, cache_data[1])
					except: pass
					return result
				self.dbcur.execute(SELECT_RESULTS_TITLE_YEARLESS_FALLBACK, (source, media_type, title, season, episode))
				cache_data = self.dbcur.fetchone()
				result, invalid = self._decode_cache_data(source, cache_data)
				if result is not None:
					try: self._memory_set(source, exact_args, result, cache_data[1])
					except: pass
					return result

			# Fallback longue durée : si la recherche complète a expiré ou a été écrite sous
			# un autre alias, on reconstruit les résultats depuis la table de hashes.
			result = self._get_hash_results(source, media_type, tmdb_id, title, year, season, episode)
			if result is not None:
				try: self._memory_set(source, exact_args, result, self._hash_expiry(0))
				except: pass
				return result
		except:
			try: self.delete(source, media_type, tmdb_id, title, year, season, episode)
			except: pass
		return result

	def set(self, source, media_type, tmdb_id, title, year, season, episode, results, expire_time):
		if not results and source not in PRIVATE_EMPTY_CACHE_PROVIDERS: return
		try:
			expires = self._get_timestamp(datetime.now() + timedelta(hours=expire_time))
			key = (source, media_type, tmdb_id, title, year, season, episode)
			self._memory_set(source, key, results, expires)
			self.dbcur.execute(INSERT_RESULTS, (source, media_type, tmdb_id, title, year, season, episode, repr(results), int(expires)))
			self._store_hash_results(source, media_type, tmdb_id, title, year, season, episode, results, expires)
		except: pass

	def delete(self, source, media_type, tmdb_id, title, year, season, episode):
		# Ne supprime que le cache de recherche court. Les associations hash↔média
		# restent disponibles plus longtemps pour éviter de rappeler l'API inutilement.
		try: self.dbcur.execute(DELETE_RESULTS, (source, media_type, tmdb_id, title, year, season, episode))
		except: pass

	def _clear_memory_for_provider(self, source):
		try:
			for key in list(MEMORY_RESULTS.keys()):
				try:
					if key and key[0] == source: del MEMORY_RESULTS[key]
				except: pass
		except:
			pass

	def _scrub_hash_from_results_data(self, source, item_hash):
		# Retire le hash des caches chauds (results_data), sinon un hash HS supprimé de
		# provider_hashes pourrait encore revenir pendant 72 h depuis la recherche complète.
		try:
			self.dbcur.execute(SELECT_RESULTS_BY_PROVIDER, (source,))
			rows = self.dbcur.fetchall() or []
			for provider, db_type, tmdb_id, title, year, season, episode, results_data, expires in rows:
				try:
					results = literal_eval(results_data or '[]')
					if not isinstance(results, list): continue
					new_results, changed = [], False
					for item in results:
						if self._normalise_hash(item) == item_hash:
							changed = True
							continue
						new_results.append(item)
					if not changed: continue
					key_args = (provider, db_type, tmdb_id, title, year, season, episode)
					if new_results:
						self.dbcur.execute(UPDATE_RESULTS_KEY, (repr(new_results),) + key_args)
					else:
						self.dbcur.execute(DELETE_RESULTS_KEY, key_args)
				except:
					pass
		except:
			pass

	def invalidate_hash(self, source, item_hash, reason=''):
		# Suppression ciblée d'un hash confirmé inutilisable au clic.
		# Le hash pourra revenir plus tard si l'API du tracker le retourne à nouveau.
		try:
			source = str(source or '').lower().replace('.me', '')
			if source not in PRIVATE_EMPTY_CACHE_PROVIDERS: return False
			item_hash = self._normalise_hash({'hash': item_hash})
			if not item_hash: return False
			self.dbcur.execute(DELETE_PROVIDER_HASH_MAP_HASH, (source, item_hash))
			self.dbcur.execute(DELETE_PROVIDER_HASH, (source, item_hash))
			self._scrub_hash_from_results_data(source, item_hash)
			self._clear_memory_for_provider(source)
			return True
		except:
			return False

	def delete_cache(self):
		try:
			self.dbcur.execute(FULL_DELETE, ())
			self.dbcur.execute(FULL_DELETE_HASH_MAP, ())
			self.dbcur.execute(FULL_DELETE_HASHES, ())
			try: MEMORY_RESULTS.clear()
			except: pass
			self.dbcur.execute("""VACUUM""")
			return 'success'
		except: return 'failure'

	def delete_cache_single(self, media_type, tmdb_id):
		try:
			self.dbcur.execute(SINGLE_DELETE, (media_type, tmdb_id))
			self.dbcur.execute(DELETE_PROVIDER_HASH_MAP_SINGLE, (media_type, str(tmdb_id or '')))
			self.dbcur.execute("""VACUUM""")
			return True
		except: return False
