#!/usr/bin/env python


__license__   = 'GPL v3'
__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
__docformat__ = 'restructuredtext en'

import io
import re
import sys
import time
from collections import OrderedDict
from threading import Thread
from urllib.parse import urlencode
from zlib import decompressobj

from calibre import prints
from calibre.constants import DEBUG, numeric_version
from calibre.gui2.store import StorePlugin
from calibre.utils.config import JSONConfig


class VersionMismatch(ValueError):

    def __init__(self, ver):
        ValueError.__init__(self, 'calibre too old')
        self.ver = ver


def download_updates(ver_map={}, server='https://code.calibre-ebook.com'):
    from calibre.utils.https import get_https_resource_securely
    data = {k:str(v) for k, v in ver_map.items()}
    data['ver'] = '1'
    url = f'{server}/stores?{urlencode(data)}'
    # We use a timeout here to ensure the non-daemonic update thread does not
    # cause calibre to hang indefinitely during shutdown
    raw = get_https_resource_securely(url, timeout=90.0)

    while raw:
        name, raw = raw.partition(b'\0')[0::2]
        name = name.decode('utf-8')
        d = decompressobj()
        src = d.decompress(raw)
        src = src.decode('utf-8').lstrip('\ufeff')
        # Python complains if there is a coding declaration in a unicode string
        src = re.sub(r'^#.*coding\s*[:=]\s*([-\w.]+)', '#', src, flags=re.MULTILINE)
        # Translate newlines to \n
        src = io.StringIO(src, newline=None).getvalue()
        yield name, src
        raw = d.unused_data


class Stores(OrderedDict):

    CHECK_INTERVAL = 24 * 60 * 60

    def builtins_loaded(self):
        self.last_check_time = 0
        self.version_map = {}
        self.cached_version_map = {}
        self.name_rmap = {}
        for key, val in self.items():
            prefix, name = val.__module__.rpartition('.')[0::2]
            if prefix == 'calibre.gui2.store.stores' and name.endswith('_plugin'):
                module = sys.modules[val.__module__]
                sv = getattr(module, 'store_version', None)
                if sv is not None:
                    name = name.rpartition('_')[0]
                    self.version_map[name] = sv
                    self.name_rmap[name] = key
        self.cache_file = JSONConfig('store/plugin_cache')
        self.load_cache()

    def load_cache(self):
        # Load plugins from on disk cache
        remove = set()
        pat = re.compile(r'^store_version\s*=\s*(\d+)', re.M)
        for name, src in self.cache_file.items():
            try:
                key = self.name_rmap[name]
            except KeyError:
                # Plugin has been disabled
                m = pat.search(src[:512])
                if m is not None:
                    try:
                        self.cached_version_map[name] = int(m.group(1))
                    except (TypeError, ValueError):
                        pass
                continue

            try:
                obj, ver = self.load_object(src, key)
            except VersionMismatch as e:
                self.cached_version_map[name] = e.ver
                continue
            except Exception:
                import traceback
                prints('Failed to load cached store:', name)
                traceback.print_exc()
            else:
                if not self.replace_plugin(ver, name, obj, 'cached'):
                    # Builtin plugin is newer than cached
                    remove.add(name)

        if remove:
            with self.cache_file:
                for name in remove:
                    del self.cache_file[name]

    def check_for_updates(self):
        if hasattr(self, 'update_thread') and self.update_thread.is_alive():
            return
        if time.time() - self.last_check_time < self.CHECK_INTERVAL:
            return
        self.last_check_time = time.time()
        try:
            self.update_thread.start()
        except (RuntimeError, AttributeError):
            self.update_thread = Thread(target=self.do_update)
            self.update_thread.start()

    def join(self, timeout=None):
        hasattr(self, 'update_thread') and self.update_thread.join(timeout)

    def download_updates(self):
        ver_map = {name:max(ver, self.cached_version_map.get(name, -1))
            for name, ver in self.version_map.items()}
        try:
            updates = download_updates(ver_map)
        except Exception:
            import traceback
            traceback.print_exc()
        else:
            yield from updates

    def do_update(self):
        replacements = {}

        for name, src in self.download_updates():
            try:
                key = self.name_rmap[name]
            except KeyError:
                # Plugin has been disabled
                replacements[name] = src
                continue
            try:
                obj, ver = self.load_object(src, key)
            except VersionMismatch as e:
                self.cached_version_map[name] = e.ver
                replacements[name] = src
                continue
            except Exception:
                import traceback
                prints('Failed to load downloaded store:', name)
                traceback.print_exc()
            else:
                if self.replace_plugin(ver, name, obj, 'downloaded'):
                    replacements[name] = src

        if replacements:
            with self.cache_file:
                for name, src in replacements.items():
                    self.cache_file[name] = src

    def replace_plugin(self, ver, name, obj, source):
        if ver > self.version_map[name]:
            if DEBUG:
                prints('Loaded', source, 'store plugin for:',
                       self.name_rmap[name], 'at version:', ver)
            self[self.name_rmap[name]] = obj
            self.version_map[name] = ver
            return True
        return False

    def load_object(self, src, key):
        namespace = {}
        builtin = self[key]
        exec(src, namespace)
        ver = namespace['store_version']
        cls = None
        for x in namespace.values():
            if (isinstance(x, type) and issubclass(x, StorePlugin) and x is not
                StorePlugin):
                cls = x
                break
        if cls is None:
            raise ValueError('No store plugin found')
        if cls.minimum_calibre_version > numeric_version:
            raise VersionMismatch(ver)
        return cls(builtin.gui, builtin.name, config=builtin.config,
                   base_plugin=builtin.base_plugin), ver


if __name__ == '__main__':
    st = time.time()
    count = 0
    for name, code in download_updates():
        count += 1
        print(name)
        print(code.encode('utf-8'))
        print('\n', '_'*80, '\n', sep='')
    print(f'Time to download all {count} plugins: {time.time() - st:.2f} seconds')
