# -*- coding: utf-8 -*-
"""Durable manual marks and Kodi mirrors, isolated from the playback loop."""
import json
import queue
import threading
import time
from datetime import date

import xbmc
import xbmcaddon

from wcs.library2 import meta, next_up, store, tmdb

ADDON_ID = 'plugin.video.milionar'
BATCH = 5
ADOPT_BATCH = 12
_adoptions = queue.Queue(maxsize=4)
_worker_lock = threading.Lock()


def _log(message):
    xbmc.log('[Milionář Jobs] {}'.format(message), xbmc.LOGWARNING)


def text(string_id):
    return xbmcaddon.Addon(ADDON_ID).getLocalizedString(string_id)


def _notify(string_id):
    # A builtin queues the notification on Kodi's UI thread; no dialog/control access here.
    xbmc.executebuiltin('Notification({},{},5000)'.format(
        json.dumps(text(32652), ensure_ascii=False), json.dumps(text(string_id), ensure_ascii=False)))


def _numbers(show, kind, season):
    if not isinstance(show, dict) or not isinstance(show.get('seasons'), list) or not show['seasons']:
        raise ValueError('Missing season coverage')
    entries = {int(s['season_number']): s for s in show['seasons']}
    regular = sorted(n for n in entries if n > 0)
    if kind != 'season' and int(show.get('number_of_seasons') or 0) > len(regular):
        raise ValueError('Incomplete season coverage')
    numbers = [int(season)] if kind == 'season' else regular
    if not numbers or any(n not in entries for n in numbers):
        raise ValueError('Missing requested season')
    return {n: int(entries[n].get('episode_count') or 0) for n in numbers}


def _season_targets(data, number, expected, today):
    if not isinstance(data, dict) or not isinstance(data.get('episodes'), list) or not data['episodes']:
        raise ValueError('Missing episodes')
    if data.get('season_number', number) != number:
        raise ValueError('Wrong season')
    episodes = data['episodes']
    numbers = [int(ep['episode_number']) for ep in episodes]
    unique = set(numbers)
    if (min(numbers) <= 0 or len(unique) != len(numbers) or len(numbers) < expected
            or any(e not in unique for e in range(1, expected + 1))):
        raise ValueError('Incomplete episodes')
    targets = []
    for ep, episode in zip(episodes, numbers):
        air = next_up._parse_date(ep.get('air_date'))
        if air and air <= today:
            targets.append((number, episode))
    return targets


def cached_targets(kind, tmdb_id, season=None):
    """Cache only; never mark a partial season/show or report success for an empty result."""
    try:
        show = meta.get(meta.key_tv(tmdb_id))[0]
        numbers = _numbers(show, kind, season)
        cached = meta.get_many(meta.key_season(tmdb_id, n) for n in numbers)
        targets = []
        for number, expected in numbers.items():
            targets.extend(_season_targets(cached.get(meta.key_season(tmdb_id, number)),
                                           number, expected, date.today()))
        return targets or None
    except (ValueError, TypeError, KeyError, AttributeError):
        return None


def _load_targets(kind, tmdb_id, season=None):
    """Strict worker-only loads; a failed fetch leaves the previous snapshot intact."""
    def complete_show(data):
        _numbers(data, kind, season)

    show = _cached_complete(meta.key_tv(tmdb_id), 'tv/{}'.format(int(tmdb_id)),
                            lambda d: meta.ttl_for_status(d.get('status')), complete_show)
    numbers = _numbers(show, kind, season)
    targets = []
    for number, expected in numbers.items():
        def complete_season(data):
            _season_targets(data, number, expected, date.today())

        data = _cached_complete(meta.key_season(tmdb_id, number),
                                'tv/{}/season/{}'.format(int(tmdb_id), number),
                                lambda d: meta.ttl_for_status(show.get('status')), complete_season)
        targets.extend(_season_targets(data, number, expected, date.today()))
    if not targets:
        raise ValueError('No aired episodes available')
    return targets


def _cached_complete(key, path, ttl, validate):
    def complete(data):
        try:
            validate(data)
            return True
        except (ValueError, TypeError, KeyError, AttributeError):
            return False

    def fetch(path, params):
        data = tmdb._fetch(path, params)
        validate(data)  # _cached must not publish a fresh incomplete response over an older snapshot.
        return data

    return tmdb._cached(key, path, ttl, fetch=fetch, complete=complete, strict=True)


def has_pending(media, tmdb_id):
    conn = store.connect()
    try:
        return conn.execute('SELECT 1 FROM pending_marks WHERE media_type=? AND tmdb_id=? LIMIT 1',
                            (media, int(tmdb_id))).fetchone() is not None
    finally:
        conn.close()


def pending_watched(kind, tmdb_id, season=None, episode=None):
    """Latest queued intent covering this item, so repeated toggles preserve user intent."""
    media = 'movie' if kind == 'movie' else 'tv'
    conn = store.connect()
    try:
        pending = conn.execute('SELECT * FROM pending_marks WHERE media_type=? AND tmdb_id=? ORDER BY id DESC',
                               (media, int(tmdb_id)))
        for mark in pending:
            if (mark['kind'] == kind and mark['season'] == season and mark['episode'] == episode
                    or mark['kind'] == 'tv' and (season is None or season > 0)
                    or mark['kind'] == 'season' and kind == 'episode' and mark['season'] == season):
                return bool(mark['watched'])
    finally:
        conn.close()
    return None


def enqueue(media, tmdb_id, kind, season, episode, watched):
    with store.tx() as conn:
        # INTEGER PRIMARY KEY may reuse an id after reset while a worker still holds the old job.
        sequence = conn.execute("SELECT value FROM flags WHERE key='pending_marks.sequence'").fetchone()
        last_id = conn.execute('SELECT COALESCE(MAX(id),0) FROM pending_marks').fetchone()[0]
        job_id = max(last_id, int(sequence['value']) if sequence else 0) + 1
        conn.execute("INSERT OR REPLACE INTO flags(key,value) VALUES('pending_marks.sequence',?)", (str(job_id),))
        conn.execute('INSERT INTO pending_marks(id,media_type,tmdb_id,kind,season,episode,watched) VALUES(?,?,?,?,?,?,?)',
                     (job_id, media, int(tmdb_id), kind, season, episode, int(watched)))


def pending_marks(limit=1, now=None):
    conn = store.connect()
    try:
        # A backed-off head blocks only later intents for the same title, never other titles.
        return [dict(r) for r in conn.execute(
            'SELECT p.* FROM pending_marks p WHERE p.next_attempt<=? AND NOT EXISTS '
            '(SELECT 1 FROM pending_marks older WHERE older.media_type=p.media_type '
            'AND older.tmdb_id=p.tmdb_id AND older.id<p.id) ORDER BY p.id LIMIT ?',
            (time.time() if now is None else now, limit))]
    finally:
        conn.close()


def process_marks(now=None, limit=1):
    completed = 0
    for pending in pending_marks(limit, now):
        try:
            kind = pending['kind']
            if kind == 'movie':
                targets = [(None, None)]
            elif kind == 'episode':
                targets = [(pending['season'], pending['episode'])]
            else:
                if not pending['attempts']:
                    _notify(32650)
                targets = _load_targets(kind, pending['tmdb_id'], pending['season'])
            store.mark_many(pending['media_type'], pending['tmdb_id'], targets, bool(pending['watched']),
                            pending_id=pending['id'])
            completed += 1
        except Exception as exc:
            delay = min(600, 2 ** min(pending['attempts'] + 1, 10))
            with store.tx() as conn:
                conn.execute('UPDATE pending_marks SET attempts=attempts+1,next_attempt=? WHERE id=?',
                             ((time.time() if now is None else now) + delay, pending['id']))
            _log('mark {} failed: {}: {}'.format(pending['id'], type(exc).__name__, exc))
            if not pending['attempts']:
                _notify(32651)
    return completed


def process_mirrors(now=None):
    from wcs.library2 import mirror
    completed = 0
    for pending in store.pending_mirrors(BATCH, now):
        season = None if pending['season'] == -1 else pending['season']
        episode = None if pending['episode'] == -1 else pending['episode']
        try:
            ok = mirror.push('movie' if pending['media_type'] == 'movie' else 'episode',
                             pending['tmdb_id'], season, episode)
        except Exception as exc:
            _log('mirror failed: {}: {}'.format(type(exc).__name__, exc))
            ok = False
        if ok:
            completed += 1
        else:
            store.retry_mirror(pending)
    return completed


def request_adoption(paths, folder=''):
    work = (list(dict.fromkeys(paths))[:ADOPT_BATCH], folder)
    if not work[0]:
        return
    try:
        _adoptions.put_nowait(work)
    except queue.Full:
        try:
            _adoptions.get_nowait()
        except queue.Empty:
            pass
        _adoptions.put_nowait(work)


def process_adoptions():
    from wcs.library2 import mirror
    try:
        paths, folder = _adoptions.get_nowait()
    except queue.Empty:
        return 0
    changed = mirror.check_paths(paths)
    if (changed and folder and xbmc.getCondVisibility('Window.IsActive(videos)')
            and xbmc.getInfoLabel('Container.FolderPath') == folder):
        xbmc.executebuiltin('Container.Refresh')
    return changed


def loop(monitor):
    # tracker.run and any other service entry can share this worker without replaying heads concurrently.
    if not _worker_lock.acquire(blocking=False):
        return
    try:
        while not monitor.abortRequested():
            for work in (process_mirrors, process_adoptions, process_marks):
                try:
                    work()
                except Exception as exc:
                    _log('{} failed: {}: {}'.format(work.__name__, type(exc).__name__, exc))
            if monitor.waitForAbort(0.5):
                break
    finally:
        _worker_lock.release()
