"""IMDb catalog/metadata and conservative MovieBox identity matching."""
import copy
import datetime
import re
import os
import threading
import time
import unicodedata
from collections import OrderedDict
from contextlib import contextmanager
from urllib.parse import parse_qsl, quote, unquote, urlsplit
from provider import MovieBox, ProviderError, fetch_json
from quality import Quality, fetch_manifest

VERSION = '1.1.3'
CINEMETA = 'https://v3-cinemeta.strem.io'
GENRES = ['Action', 'Adventure', 'Animation', 'Biography', 'Comedy', 'Crime', 'Documentary', 'Drama', 'Family', 'Fantasy', 'History', 'Horror', 'Mystery', 'Romance', 'Sci-Fi', 'Sport', 'Thriller', 'War', 'Western']
YEAR = datetime.datetime.now(datetime.timezone.utc).year
CATALOGS = []
for kind in ('movie', 'series'):
    suffix = 'Movies' if kind == 'movie' else 'Series'
    for ident, title in [('top', 'Popular'), ('year', 'New'), ('imdbRating', 'Top Rated')]:
        options = [str(y) for y in range(YEAR, 1919, -1)] if ident == 'year' else GENRES
        extras = [{'name': 'genre', 'options': options}, {'name': 'skip'}]
        if ident == 'top':
            extras.insert(0, {'name': 'search'})
        CATALOGS.append({'type': kind, 'id': ident, 'name': 'MovieBox - ' + title + ' ' + suffix, 'genres': options, 'extra': extras})
MANIFEST = {'id': 'org.community.moviebox.addon', 'version': VERSION, 'name': 'MovieBox', 'description': 'Movie and series catalogs, IMDb metadata and MovieBox sources. Catalog inclusion does not guarantee source availability.', 'types': ['movie', 'series'], 'resources': ['catalog', 'meta', 'stream'], 'catalogs': CATALOGS, 'idPrefixes': ['tt'], 'behaviorHints': {'configurable': False}}
MANIFEST['description'] = 'Browse movies and TV series with MovieBox.'


class Cache:
    """Bounded TTL cache with per-key request coalescing and bounded lock stripes."""
    def __init__(self, maximum=768):
        self.maximum = maximum
        self.data = OrderedDict()
        self.guard = threading.Lock()
        self.locks = {}

    @contextmanager
    def lock(self, key):
        with self.guard:
            lock, users = self.locks.get(key, (threading.RLock(), 0))
            self.locks[key] = (lock, users + 1)
        try:
            with lock:
                yield
        finally:
            with self.guard:
                current, users = self.locks[key]
                if users == 1:
                    del self.locks[key]
                else:
                    self.locks[key] = (current, users - 1)

    def get(self, key):
        with self.guard:
            value = self.data.get(key)
            if value is None:
                return False, None
            if value[0] <= time.monotonic():
                del self.data[key]
                return False, None
            self.data.move_to_end(key)
            return True, copy.deepcopy(value[1])

    def put(self, key, value, ttl):
        with self.guard:
            self.data[key] = (time.monotonic() + ttl, copy.deepcopy(value))
            self.data.move_to_end(key)
            while len(self.data) > self.maximum:
                self.data.popitem(last=False)

    def remember(self, key, ttl, factory):
        with self.lock(key):
            found, value = self.get(key)
            if found:
                return value
            value = factory()
            self.put(key, value, ttl)
            return value


def normalize(title):
    title = re.sub(r'\[[^]]*\]', ' ', title)
    title = re.sub(r'\s+(?:S\d+(?:-S?\d+)?|Season\s+\d+)\s*$', '', title, flags=re.I)
    title = re.sub(r'\s*\((?:19|20)\d{2}\)\s*$', '', title)
    title = unicodedata.normalize('NFKD', title).casefold()
    return ''.join(c for c in title if c.isalnum())


def year_of(value):
    match = re.search(r'\b(19\d{2}|20\d{2})\b', str(value or ''))
    return int(match[1]) if match else None


def select_candidates(items, metadata, kind):
    """Require same title/type/year; prefer original-language complete series groups."""
    title = normalize(metadata.get('name', ''))
    expected_year = year_of(metadata.get('year') or metadata.get('releaseInfo') or metadata.get('released'))
    if not title or not expected_year:
        return []
    grouped = {}
    for item in items:
        if not isinstance(item, dict) or int(item.get('subjectType', item.get('stype', 0)) or 0) != (1 if kind == 'movie' else 2):
            continue
        if normalize(str(item.get('title', ''))) != title:
            continue
        ident = str(item.get('subjectId', item.get('id', '')))
        if not ident.isdigit():
            continue
        group = grouped.setdefault(ident, {'subject': ident, 'years': [], 'seasons': set(), 'tagged': False})
        release_year = year_of(item.get('releaseDate', item.get('year')))
        if release_year:
            group['years'].append(release_year)
        if int(item.get('season', 0) or 0) > 0:
            group['seasons'].add(int(item['season']))
        group['tagged'] |= bool(re.search(r'\[[^]]+\]', str(item.get('title', ''))))
    ranked = []
    for value in grouped.values():
        years = value['years']
        if not years or min(years) != expected_year:
            continue
        score = (0 if value['tagged'] else 100) + min(len(value['seasons']), 20)
        ranked.append((score, value['subject']))
    ranked.sort(reverse=True)
    if len(ranked) > 1 and ranked[0][0] == ranked[1][0]:
        # Identical title/year without a reliable identity discriminator: fail closed.
        return []
    return [sid for score, sid in ranked[:3]]


def episode_numbers(data):
    seasons = data.get('seasons', [])
    if isinstance(seasons, dict):
        seasons = seasons.get('seasons', [])
    result = set()
    for season in seasons if isinstance(seasons, list) else []:
        number = int(season.get('se', 0) or 0)
        if not 0 <= number <= 500:
            continue
        if isinstance(season.get('episodeNumbers'), list):
            episodes = season['episodeNumbers']
        elif str(season.get('allEp', '')).strip():
            episodes = re.findall(r'\d+', str(season['allEp']))
        else:
            episodes = range(1, min(int(season.get('maxEp', 0) or 0), 2000) + 1)
        for episode in episodes:
            try:
                ep = int(episode)
                if 1 <= ep <= 2000:
                    result.add((number, ep))
            except (ValueError, TypeError):
                continue
    return result


def stream_ttl(streams, now=None):
    """Reuse known-valid sources for up to five minutes; unknown expiry gets 20s."""
    now = time.time() if now is None else now
    lifetimes = []
    for stream in streams:
        expiries = []
        cookie = stream.get('behaviorHints', {}).get('proxyHeaders', {}).get('request', {}).get('Cookie', '')
        for match in re.finditer(r'(?:^|[:;])(?:t|exp|Expires)=(\d{10,13})(?:[:;]|$)', cookie):
            expiry = int(match[1])
            if expiry > 10**12:
                expiry /= 1000
            expiries.append(expiry)
        lifetimes.append(max(0, min(300, int(min(expiries) - now - 30))) if expiries else 20)
    return min(lifetimes, default=20)


class Addon:
    def __init__(self, provider=None, metadata_fetch=fetch_json, manifest_fetch=None, playback_base=None):
        self.provider = provider or MovieBox()
        self.metadata_fetch = metadata_fetch
        self.cache = Cache()
        self.provider_lock = threading.Lock()
        self.cooldown = 0
        self.playback_base = playback_base
        self.quality = Quality(fetch=manifest_fetch or fetch_manifest,state=os.getenv('MOVIEBOX_STATE'))

    def call_provider(self, function):
        with self.provider_lock:
            if self.cooldown > time.monotonic():
                raise ProviderError('Provider cooling down; please retry later', 429, int(self.cooldown-time.monotonic())+1)
            try:
                return function()
            except ProviderError as error:
                if error.status in (401, 403):
                    self.provider.token = None
                # Respect upstream throttling; no host rotation or rapid retries.
                self.cooldown = time.monotonic() + (max(error.retry_after, 60) if error.status == 429 else 15)
                raise

    def metadata(self, kind, ident):
        def load():
            try:
                return self.metadata_fetch(CINEMETA + '/meta/' + kind + '/' + ident + '.json')
            except ProviderError as error:
                if error.status == 404:
                    return {'meta': None}
                raise
        data = self.cache.remember(('meta', kind, ident), 21600, load)
        meta = data.get('meta')
        if not isinstance(meta, dict) or meta.get('id') != ident or meta.get('type') != kind:
            return None
        return meta

    def resolve(self, kind, ident, meta):
        def load():
            items = self.call_provider(lambda: self.provider.search(meta.get('name', ''), kind))
            selected = select_candidates(items, meta, kind)
            if not selected and len(items) >= 15:
                items += self.call_provider(lambda: self.provider.search(meta.get('name', ''), kind, page=2))
                selected = select_candidates(items, meta, kind)
            return selected
        return self.cache.remember(('match', kind, ident), 3600, load)

    def catalog(self, kind, catalog, extra):
        if catalog not in ('top', 'year', 'imdbRating'):
            return {'metas': []}
        if any(key not in ('skip', 'search', 'genre') for key in extra):
            raise ValueError('Unsupported catalog parameter')
        skip = int(extra.get('skip', '0'))
        if not 0 <= skip <= 100000:
            raise ValueError('Pagination skip must be between 0 and 100000')
        if len(extra.get('search', '')) > 150 or len(extra.get('genre', '')) > 40:
            raise ValueError('Catalog parameter too long')
        if extra.get('search'):
            catalog = 'top'
        if catalog == 'year' and not extra.get('genre'):
            extra = dict(extra, genre=str(YEAR))
        extra = dict(extra, skip=str(skip))
        if catalog == 'year' and not re.fullmatch(r'(19\d{2}|20\d{2})', extra.get('genre', '')):
            raise ValueError('Invalid release year')
        if catalog != 'year' and extra.get('genre') and extra['genre'] not in GENRES + ['Reality-TV', 'Talk-Show', 'Game-Show']:
            raise ValueError('Unsupported genre')
        parameters = '&'.join(quote(k, safe='') + '=' + quote(str(v), safe='') for k,v in sorted(extra.items()) if str(v))
        url = CINEMETA + '/catalog/' + kind + '/' + catalog + ('/' + parameters if parameters else '') + '.json'
        data = self.cache.remember(('catalog', url), 900, lambda: self.metadata_fetch(url))
        if not isinstance(data.get('metas', []), list):
            raise ProviderError('Invalid catalog response')
        return {'metas': [item for item in data.get('metas', [])[:100] if isinstance(item,dict) and re.fullmatch(r'tt\d+', str(item.get('id', ''))) and item.get('type',kind)==kind]}

    def raw_streams(self, kind, ident, season=0, episode=0):
        key = ('streams', kind, ident, season, episode)
        with self.cache.lock(key):
            found, saved = self.cache.get(key)
            if found:
                return saved
            meta = self.metadata(kind, ident)
            if meta is None:
                return {'streams': []}
            if kind == 'series':
                videos = meta.get('videos', [])
                if videos and not any(v.get('season') == season and v.get('episode') == episode for v in videos):
                    self.cache.put(key, {'streams': []}, 120)
                    return {'streams': []}
            candidates = self.resolve(kind, ident, meta)
            sources = []
            for subject in candidates:
                if kind == 'series':
                    available = self.cache.remember(('seasons', subject), 1800, lambda: self.call_provider(lambda: self.provider.seasons(subject)))
                    if (season, episode) not in episode_numbers(available):
                        continue
                sources = self.call_provider(lambda: self.provider.streams(subject, season, episode))
                if sources:
                    break
            unique = {}
            for source in sources:
                source = label_source(source, meta, kind, season, episode)
                unique.setdefault(source['url'], source)
            response = {'streams': list(unique.values())[:12]}
            self.cache.put(key, response, stream_ttl(response['streams']) if unique else 120)
            return response

    def streams(self, kind, ident, season=0, episode=0):
        sources=self.raw_streams(kind,ident,season,episode)['streams']
        return {'streams': self.quality.expand(sources,self.playback_base)}

    def dispatch(self, route):
        if route == '/manifest.json':
            return MANIFEST
        if route == '/health':
            return {'service': 'MovieBox', 'version': VERSION, 'status': 'running'}
        parsed = urlsplit(route)
        if parsed.query:
            raise ValueError('Use Stremio path parameters')
        match = re.fullmatch(r'/(catalog|meta|stream)/(movie|series)/([^/]+?)(?:/([^/]+))?\.json', parsed.path)
        if not match:
            raise LookupError('Unknown route')
        resource, kind, encoded, raw_extra = match.groups()
        identifier = unquote(encoded)
        if resource == 'catalog':
            pairs = parse_qsl(raw_extra or '', keep_blank_values=True)
            if len(dict(pairs)) != len(pairs):
                raise ValueError('Duplicate parameters')
            return self.catalog(kind, identifier, dict(pairs))
        if raw_extra:
            raise ValueError('Extra parameters not accepted here')
        identity = re.fullmatch(r'(tt\d{5,12})(?::(\d{1,3}):(\d{1,4}))?', identifier)
        if not identity:
            return {'streams': []} if resource == 'stream' else {'meta': None}
        ident, season, episode = identity.groups()
        if resource == 'meta':
            if season or episode:
                return {'meta': None}
            return {'meta': self.metadata(kind, ident)}
        if (kind == 'series' and (season is None or episode is None)) or (kind == 'movie' and season is not None):
            return {'streams': []}
        return self.streams(kind, ident, int(season or 0), int(episode or 0))


def label_source(source, metadata, kind, season=0, episode=0):
    """Provide content identity separately from branding for AIO source/footer labels."""
    source = copy.deepcopy(source)
    title = str(metadata.get('name', 'MovieBox')).strip()
    if kind == 'series':
        title += f' S{season:02d}E{episode:02d}'
        video = next((v for v in metadata.get('videos', []) if v.get('season') == season and v.get('episode') == episode), {})
        episode_title = str(video.get('title') or video.get('name') or '').strip()
        if episode_title and normalize(episode_title) != normalize(metadata.get('name', '')):
            title += ' - ' + episode_title
    else:
        year = year_of(metadata.get('year') or metadata.get('releaseInfo') or metadata.get('released'))
        if year:
            title += f' ({year})'
    technical = str(source.get('title', '')).split(' | ')[1:]
    source['name'] = 'MovieBox'
    source['title'] = title + ('\n' + ' | '.join(technical) if technical else '')
    hints = source.setdefault('behaviorHints', {})
    hints['filename'] = re.sub(r'[\x00-\x1f<>:"/\\|?*]', '_', title).strip()
    return source
