# -*- coding: utf-8 -*-
"""Explicit legacy-list migration, invoked ONLY by the library_migrate plugin action.

pending()/offer() are discovery without UI or providers. Automatic offers require
empty new lists and respect declines. Manual runs reopen and merge populated
lists, including interrupted metadata/Home work. Commit and full completion
have separate fingerprints, so partial work is manually retryable.
"""
import json
import os
import threading
import time

import xbmc
import xbmcaddon
import xbmcgui
import xbmcvfs

from wcs.library2 import meta, store, tmdb

ADDON_ID = 'plugin.video.milionar'
DECLINED = 'watchlists.migration.declined.v2'
COMPLETED = 'watchlists.migration.completed.v2'
STATE = 'watchlists.migration.state.v2'
HOME_TIMEOUT = 20.0
# Same generated slot/container mapping as scripts/skin_harness/gen_slots.py.
SLOT_BASES = {'mytv': 22000, 'mycinema': 23000, 'cont': 24000, 'game': 25000,
              'ai': 26000, 'kraska': 27000, 'tmdb': 28000, 'my_movies': 29000,
              'my_series': 30000, 'vinyl': 31000}


class _Cancelled(Exception):
    pass


def _profile():
    return xbmcvfs.translatePath(xbmcaddon.Addon(ADDON_ID).getAddonInfo('profile'))


def offer(profile_dir=None, manual=False):
    """Return a reviewed snapshot or None (populated/absent/declined/completed).

    Read failures raise; no UI, network, list writes or import flags here.
    Summary counts: counts.movie/tv (raw), existing (unique current membership),
    duplicates (within each source), invalid (individual rows), files (retained).
    Automatic discovery stops before reading legacy files if any new list has
    an entry. Manual discovery always permits safe merges and partial retries.
    """
    if not manual and not store.watchlist_import_empty():
        return None
    snapshot = store.inspect_watchlist_import(profile_dir or _profile())
    if not snapshot['files']:
        return None
    if not manual and not any(snapshot['counts'].values()):
        return None
    fingerprint = snapshot['fingerprint']
    if not manual and fingerprint in (store.get_flag(DECLINED), store.get_flag(COMPLETED)):
        return None
    return snapshot


def pending(profile_dir=None):
    """Service-safe automatic offer gate, never an automatic partial-work retry.

    Broken sources remain discoverable while new lists are empty; once import
    populated them, STATE retains pending metadata/Home work for manual retry.
    """
    try:
        return offer(profile_dir) is not None
    except (OSError, ValueError, UnicodeError):
        return True


def _text(addon, string_id, **values):
    return addon.getLocalizedString(string_id).format(**values)


def _state(snapshot, phase, **details):
    try:
        store.set_flag(STATE, json.dumps(dict(details, fingerprint=snapshot['fingerprint'], phase=phase)))
    except Exception as exc:
        # Recording a failure must not hide its user-facing retry message.
        xbmc.log('[Milionář Library] migration status: ' + type(exc).__name__, xbmc.LOGWARNING)


def _check(progress):
    if progress.iscanceled() or xbmc.Monitor().abortRequested():
        raise _Cancelled()


def _title_complete(data, media_type):
    if not isinstance(data, dict) or not (data.get('title') or data.get('name')):
        return False
    if not isinstance(data.get('genres'), list) or any(not isinstance(g, dict) or 'id' not in g for g in data['genres']):
        return False
    if media_type == 'tv':
        seasons = data.get('seasons')
        return isinstance(seasons, list) and all(isinstance(s, dict) and
            isinstance(s.get('season_number'), int) and s['season_number'] >= 0 for s in seasons)
    return True


def _season_complete(data, expected=None):
    if not isinstance(data, dict) or not isinstance(data.get('episodes'), list):
        return False
    numbers = [e.get('episode_number') if isinstance(e, dict) else None for e in data['episodes']]
    if any(not isinstance(n, int) or isinstance(n, bool) or n <= 0 for n in numbers):
        return False
    return len(numbers) == len(set(numbers)) and (expected is None or len(numbers) >= expected)


def _prepare_metadata(snapshot, progress, addon, fetch=None):
    """Prefer complete local snapshots/caches; fetch only missing explicit work.

    Includes current related lists/activity so their Home rows can be built
    completely. Tests supply fetch and never use a live provider.
    """
    kinds = {kind for kind, name in store.LEGACY_FILES
             if any(os.path.basename(f['path']).startswith(name) for f in snapshot['files'])}
    entries = {}
    for kind in kinds:
        entries.update({(kind, e['id']): e for e in snapshot['entries'][kind]})
        entries.update({(kind, e['id']): e for e in store.get_list(kind)})
        states = store.get_movie_states() if kind == 'movie' else store.get_all_episode_states()
        for tmdb_id in states:
            if (kind, tmdb_id) not in entries:
                title = store.get_title(kind, tmdb_id) or {}
                entries[(kind, tmdb_id)] = json.loads(title.get('legacy_json') or '{}')
    missing = []
    for done, ((kind, tmdb_id), entry) in enumerate(entries.items(), 1):
        _check(progress)
        progress.update(10 + int(65 * done / max(1, len(entries))),
                        _text(addon, 32663, done=done, total=len(entries)))
        try:
            key = meta.key_movie(tmdb_id) if kind == 'movie' else meta.key_tv(tmdb_id)
            cached, _stale = meta.get(key)
            ttl = meta.TTL_MOVIE if kind == 'movie' else meta.ttl_for_status((cached or entry).get('status'))
            if not _title_complete(cached, kind) and _title_complete(entry, kind):
                meta.put(key, entry, ttl)
            data = tmdb._cached(key, '{}/{}'.format(kind, tmdb_id),
                                lambda d: meta.TTL_MOVIE if kind == 'movie' else meta.ttl_for_status(d.get('status')),
                                fetch=fetch, complete=lambda d: _title_complete(d, kind))
            if not _title_complete(data, kind):
                missing.append(key)
                continue
            if kind == 'tv':
                for season in data['seasons']:
                    number = season['season_number']
                    if number <= 0:
                        continue
                    _check(progress)
                    season_key = meta.key_season(tmdb_id, number)
                    expected = season.get('episode_count')
                    cached, _stale = meta.get(season_key)
                    if not _season_complete(cached, expected) and _season_complete(season, expected):
                        meta.put(season_key, season, meta.ttl_for_status(data.get('status')))
                    season_data = tmdb._cached(season_key, 'tv/{}/season/{}'.format(tmdb_id, number),
                                              lambda d: meta.ttl_for_status(data.get('status')), fetch=fetch,
                                              complete=lambda d: _season_complete(d, expected))
                    if not _season_complete(season_data, expected):
                        missing.append(season_key)
        except _Cancelled:
            raise
        except Exception as exc:
            missing.append('{}:{}'.format(kind, tmdb_id))
            xbmc.log('[Milionář Library] migration metadata {}:{}: {}'.format(kind, tmdb_id, type(exc).__name__),
                     xbmc.LOGWARNING)
    return kinds, missing


def _slots(affected):
    result = []
    for sec, base in SLOT_BASES.items():
        for n in range(1, 7):
            prefix = 'mil.{}.{}'.format(sec, n)
            row = xbmc.getInfoLabel('Skin.String({}.type)'.format(prefix))
            if row not in affected:
                continue
            params = {key: xbmc.getInfoLabel('Skin.String({}.{})'.format(prefix, key))
                      for key in ('limit', 'sort', 'style', 'click')}
            params.update(hw=xbmc.getInfoLabel('Skin.String(mil.{}.hidewatched)'.format(sec)),
                          up=xbmc.getInfoLabel('Skin.String(mil.{}.upcoming)'.format(sec)),
                          row=row, action=row, sec=sec, slot=str(n))
            result.append({'section': sec, 'slot': n, 'row': row, 'container': base + n * 100, 'params': params})
    return result


def _prepare_widgets(affected, progress):
    """Warm cached library rows before publishing their new URLs."""
    from wcs.library2 import rows
    slots = _slots(affected)
    for slot in slots:
        _check(progress)
        if slot['row'] in rows.CATALOGUE:
            items = rows.build(slot['row'], slot['params'])
            slot['expected'] = [(item.getLabel(), item.getPath()) for _path, item, _folder in items]
        else:
            from wcs.skin import widgets
            items = widgets.build(slot['row'], slot['params'])
            slot['expected'] = [(item.getLabel(), item.getPath()) for item in items]
    return slots


def _matches(slot):
    """Fast updates can finish between polls: verify the prepared contents."""
    expected = slot.get('expected')
    if expected is None:
        return False
    container = slot['container']
    if xbmc.getInfoLabel('Container({}).NumItems'.format(container)) != str(len(expected)):
        return False
    return all(xbmc.getInfoLabel('Container({}).ListItemAbsolute({}).Label'.format(container, n)) == label and
               xbmc.getInfoLabel('Container({}).ListItemAbsolute({}).FilenameAndPath'.format(container, n)) == path
               for n, (label, path) in enumerate(expected))


def _wait_home(targets, slots, progress, timeout=HOME_TIMEOUT):
    """Wait for publication and visible related containers; timeout is failure.

    Hidden containers reload on visibility from the published slot tokens. A
    visible one must settle after an observed update or match the warm snapshot.
    No unrelated window, skin reload, or unscoped Container.Refresh is used.
    """
    from wcs.skin import refresh
    home = xbmcgui.Window(10000)
    started, stable_since = time.monotonic(), None
    updating = set()
    while time.monotonic() - started < timeout:
        _check(progress)
        ready = all(home.getProperty('milionar.rows.' + row) == str(version) for row, version in targets.items())
        current = refresh.revisions(targets)
        if current != targets:
            # Another writer committed newer data while we waited. Rebuild at
            # its revisions, publish them, and fence our evidence again.
            targets.update(current)
            slots[:] = _prepare_widgets(set(targets), progress)
            refresh.publish(targets)
            refresh.request(targets)
            updating.clear()
            stable_since = None
            xbmc.sleep(50)
            continue
        for slot in slots:
            key = 'milionar.widgets.{}.{}'.format(slot['section'], slot['slot'])
            ready = ready and home.getProperty(key) == str(targets[slot['row']])
            if not xbmc.getCondVisibility('Window.IsVisible(home)'):
                continue
            container = slot['container']
            if not xbmc.getCondVisibility('Control.IsVisible({})'.format(container)):
                continue
            if xbmc.getCondVisibility('Container({}).IsUpdating'.format(container)):
                updating.add(container)
                ready = False
            elif container not in updating and not _matches(slot):
                ready = False
        now = time.monotonic()
        if ready:
            stable_since = now if stable_since is None else stable_since
            if now - stable_since >= 0.5:
                return True
        else:
            stable_since = None
        xbmc.sleep(50)
    return False


def run(params=None):
    """Plugin entry. auto=1 requires empty lists and respects decline/completion.

    Returns status complete/declined/skipped/no_sources/cancelled/pending/failed,
    with counts, pending metadata and target revisions where applicable. Only
    complete persists COMPLETED. Errors/timeout close modal progress as failures.
    """
    if threading.current_thread() is not threading.main_thread():
        raise RuntimeError('Migration UI must run in the plugin main thread')
    params = params or {}
    manual = str(params.get('auto', '0')) != '1'
    addon, dialog = xbmcaddon.Addon(ADDON_ID), xbmcgui.Dialog()
    title = _text(addon, 32660)
    profile = _profile()
    try:
        snapshot = offer(profile, manual=manual)
    except Exception as exc:
        xbmc.log('[Milionář Library] migration discovery: ' + type(exc).__name__, xbmc.LOGWARNING)
        dialog.ok(title, _text(addon, 32667))
        return {'status': 'failed', 'reason': 'source_read'}
    if snapshot is None:
        status = 'skipped' if not manual else 'no_sources'
        if manual:
            dialog.ok(title, _text(addon, 32669))
        return {'status': status}
    summary = _text(addon, 32661, movie=snapshot['counts']['movie'], tv=snapshot['counts']['tv'],
                    existing=snapshot['existing'], duplicates=snapshot['duplicates'], invalid=snapshot['invalid'],
                    files=', '.join(os.path.basename(f['path']) for f in snapshot['files']))
    if not dialog.yesno(title, summary, nolabel=xbmc.getLocalizedString(106), yeslabel=xbmc.getLocalizedString(107)):
        try:
            store.set_flag(DECLINED, snapshot['fingerprint'])
        except Exception as exc:
            xbmc.log('[Milionář Library] migration decline: ' + type(exc).__name__, xbmc.LOGERROR)
            dialog.ok(title, _text(addon, 32667))
            return {'status': 'failed', 'reason': 'decline_save'}
        return {'status': 'declined'}
    progress = xbmcgui.DialogProgress()
    result = {'status': 'pending', 'counts': {}, 'pending': [], 'revisions': {}}
    message_id = 32667
    try:
        progress.create(title, _text(addon, 32662))
        _check(progress)
        # An accepted manual retry revokes the earlier decline/completion. A
        # later interruption must remain discoverable by the service.
        store.set_flag(DECLINED, '')
        store.set_flag(COMPLETED, '')
        _state(snapshot, 'importing')
        result['counts'] = store.import_watchlists(profile, snapshot=snapshot)
        _state(snapshot, 'metadata')
        kinds, missing = _prepare_metadata(snapshot, progress, addon)
        result['pending'] = missing
        from wcs.skin import refresh
        affected = set().union(*(refresh.media_rows(kind, True) for kind in kinds))
        if 'tv' in kinds:
            affected.add('widget_mytv_next')
        # Metadata changes also invalidate snapshots when the list import was a retry/no-op.
        refresh.changed(affected)
        targets = refresh.revisions(affected)
        result['revisions'] = targets
        slots = _prepare_widgets(affected, progress)
        progress.update(90, _text(addon, 32664))
        _state(snapshot, 'home', pending=missing, revisions=targets)
        refresh.publish(affected)
        refresh.request(affected)
        refreshed = _wait_home(targets, slots, progress)
        if missing:
            message_id = 32666
            _state(snapshot, 'pending_metadata', pending=missing, revisions=targets)
        elif not refreshed:
            message_id = 32668
            _state(snapshot, 'pending_home', revisions=targets)
        else:
            _check(progress)
            # Never complete consent for files changed during long metadata work.
            if store.inspect_watchlist_import(profile)['fingerprint'] != snapshot['fingerprint']:
                raise ValueError('Legacy lists changed during migration')
            store.set_flag(COMPLETED, snapshot['fingerprint'])
            _state(snapshot, 'complete', revisions=targets)
            result['status'] = 'complete'
            message_id = 32665
            progress.update(100, _text(addon, message_id))
    except _Cancelled:
        _state(snapshot, 'cancelled')
        result['status'] = 'cancelled'
    except Exception as exc:
        xbmc.log('[Milionář Library] migration failed: ' + type(exc).__name__, xbmc.LOGERROR)
        _state(snapshot, 'failed', error=type(exc).__name__)
        result['status'] = 'failed'
    finally:
        progress.close()
    if result['status'] != 'cancelled':
        dialog.ok(title, _text(addon, message_id, count=len(result['pending'])))
    return result
