#!/usr/pkg/bin/python3.13


__license__ = 'GPL v3'
__copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>'

import bz2
import errno
import importlib
import json
import math
import os
import shutil
import sys
from io import BytesIO
from itertools import count
from multiprocessing.pool import ThreadPool
from qt.core import (
    QAbstractItemView, QApplication, QComboBox, QDialog, QDialogButtonBox,
    QFormLayout, QGridLayout, QGroupBox, QIcon, QImage, QImageReader, QLabel,
    QLineEdit, QListWidget, QListWidgetItem, QPen, QPixmap, QProgressDialog, QSize,
    QSpinBox, QSplitter, QStackedLayout, QStaticText, QStyle, QStyledItemDelegate,
    Qt, QTextEdit, QVBoxLayout, QWidget, pyqtSignal, sip
)
from threading import Event, Thread

from calibre import detect_ncpus as cpu_count, fit_image, human_readable, walk
from calibre.constants import cache_dir, config_dir
from calibre.customize.ui import interface_actions
from calibre.gui2 import (
    choose_dir, choose_save_file, empty_index, error_dialog, gprefs, must_use_qt,
    question_dialog, safe_open_url
)
from calibre.gui2.dialogs.progress import ProgressDialog
from calibre.gui2.progress_indicator import ProgressIndicator
from calibre.gui2.widgets2 import Dialog
from calibre.utils.date import utcnow
from calibre.utils.filenames import ascii_filename, atomic_rename
from calibre.utils.https import HTTPError, get_https_resource_securely
from calibre.utils.icu import numeric_sort_key as sort_key
from calibre.utils.img import Canvas, image_from_data, optimize_jpeg, optimize_png
from calibre.utils.zipfile import ZIP_STORED, ZipFile
from polyglot import http_client
from polyglot.builtins import as_bytes, iteritems, reraise
from polyglot.queue import Empty, Queue

IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg'}
THEME_COVER = 'icon-theme-cover.jpg'
THEME_METADATA = 'metadata.json'
BASE_URL = 'https://code.calibre-ebook.com/icon-themes/'

# Theme creation {{{

COVER_SIZE = (340, 272)


def render_svg(filepath):
    must_use_qt(headless=False)
    pngpath = filepath[:-4] + '.png'
    i = QImage(filepath)
    i.save(pngpath)


def read_images_from_folder(path):
    name_map = {}
    path = os.path.abspath(path)
    for filepath in walk(path):
        name = os.path.relpath(filepath, path).replace(os.sep, '/')
        ext = name.rpartition('.')[-1]
        bname = os.path.basename(name)
        if bname.startswith('.') or bname.startswith('_'):
            continue
        if ext == 'svg':
            render_svg(filepath)
            ext = 'png'
            filepath = filepath[:-4] + '.png'
            name = name[:-4] + '.png'
        if ext in IMAGE_EXTENSIONS:
            name_map[name] = filepath
    return name_map


class Theme:

    def __init__(self, title='', author='', version=-1, description='', license='Unknown', url=None, cover=None):
        self.title, self.author, self.version, self.description = title, author, version, description
        self.license, self.cover, self.url = license, cover, url


class Report:

    def __init__(self, path, name_map, extra, missing, theme):
        self.path, self.name_map, self.extra, self.missing, self.theme = path, name_map, extra, missing, theme
        self.bad = {}

    @property
    def name(self):
        return ascii_filename(self.theme.title).replace(' ', '_').replace('.', '_').lower()


def read_theme_from_folder(path):
    path = os.path.abspath(path)
    current_image_map = read_images_from_folder(P('images', allow_user_override=False))
    name_map = read_images_from_folder(path)
    name_map.pop(THEME_COVER, None)
    name_map.pop('blank.png', None)
    current_names = frozenset(current_image_map)
    names = frozenset(name_map)
    extra = names - current_names
    missing = current_names - names
    try:
        with open(os.path.join(path, THEME_METADATA), 'rb') as f:
            metadata = json.load(f)
    except OSError as e:
        if e.errno != errno.ENOENT:
            raise
        metadata = {}
    except ValueError:
        # Corrupted metadata file
        metadata = {}

    def safe_int(x):
        try:
            return int(x)
        except Exception:
            return -1
    g = lambda x, defval='': metadata.get(x, defval)
    theme = Theme(g('title'), g('author'), safe_int(g('version', -1)), g('description'), g('license', 'Unknown'), g('url', None))

    ans = Report(path, name_map, extra, missing, theme)
    try:
        with open(os.path.join(path, THEME_COVER), 'rb') as f:
            theme.cover = f.read()
    except OSError as e:
        if e.errno != errno.ENOENT:
            raise
        theme.cover = create_cover(ans)
    return ans


def icon_for_action(name):
    for plugin in interface_actions():
        if plugin.name == name:
            module, class_name = plugin.actual_plugin.partition(':')[::2]
            mod = importlib.import_module(module)
            cls = getattr(mod, class_name)
            icon = cls.action_spec[1]
            if icon:
                return icon


def default_cover_icons(cols=5):
    count = 0
    for ac in gprefs.defaults['action-layout-toolbar']:
        if ac:
            icon = icon_for_action(ac)
            if icon:
                count += 1
                yield icon
    for x in 'user_profile plus minus series sync tags default_cover'.split():
        yield x + '.png'
        count += 1
    extra = 'search donate cover_flow reader publisher back forward'.split()
    while count < 15 or count % cols != 0:
        yield extra[0] + '.png'
        del extra[0]
        count += 1


def create_cover(report, icons=(), cols=5, size=120, padding=16):
    icons = icons or tuple(default_cover_icons(cols))
    rows = int(math.ceil(len(icons) / cols))
    with Canvas(cols * (size + padding), rows * (size + padding), bgcolor='#eee') as canvas:
        y = -size - padding // 2
        x = 0
        for i, icon in enumerate(icons):
            if i % cols == 0:
                y += padding + size
                x = padding // 2
            else:
                x += size + padding
            if report and icon in report.name_map:
                ipath = os.path.join(report.path, report.name_map[icon])
            else:
                ipath = I(icon, allow_user_override=False)
            with lopen(ipath, 'rb') as f:
                img = image_from_data(f.read())
            scaled, nwidth, nheight = fit_image(img.width(), img.height(), size, size)
            img = img.scaled(int(nwidth), int(nheight), Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.SmoothTransformation)
            dx = (size - nwidth) // 2
            canvas.compose(img, x + dx, y)
    return canvas.export()


def verify_theme(report):
    must_use_qt()
    report.bad = bad = {}
    for name, path in iteritems(report.name_map):
        reader = QImageReader(os.path.join(report.path, path))
        img = reader.read()
        if img.isNull():
            bad[name] = reader.errorString()
    return bool(bad)


class ThemeCreateDialog(Dialog):

    def __init__(self, parent, report):
        self.report = report
        Dialog.__init__(self, _('Create an icon theme'), 'create-icon-theme', parent)

    def setup_ui(self):
        self.splitter = QSplitter(self)
        self.l = l = QVBoxLayout(self)
        l.addWidget(self.splitter)
        l.addWidget(self.bb)
        self.w = w = QGroupBox(_('Theme Metadata'), self)
        self.splitter.addWidget(w)
        l = w.l = QFormLayout(w)
        l.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
        self.missing_icons_group = mg = QGroupBox(self)
        self.mising_icons = mi = QListWidget(mg)
        mi.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
        mg.l = QVBoxLayout(mg)
        mg.l.addWidget(mi)
        self.splitter.addWidget(mg)
        self.title = QLineEdit(self)
        l.addRow(_('&Title:'), self.title)
        self.author = QLineEdit(self)
        l.addRow(_('&Author:'), self.author)
        self.version = v = QSpinBox(self)
        v.setMinimum(1), v.setMaximum(1000000)
        l.addRow(_('&Version:'), v)
        self.license = lc = QLineEdit(self)
        l.addRow(_('&License:'), lc)
        self.url = QLineEdit(self)
        l.addRow(_('&URL:'), self.url)
        lc.setText(_(
            'The license for the icons in this theme. Common choices are'
            ' Creative Commons or Public Domain.'))
        self.description = QTextEdit(self)
        l.addRow(self.description)
        self.refresh_button = rb = self.bb.addButton(_('&Refresh'), QDialogButtonBox.ButtonRole.ActionRole)
        rb.setIcon(QIcon(I('view-refresh.png')))
        rb.clicked.connect(self.refresh)

        self.apply_report()

    def sizeHint(self):
        return QSize(900, 670)

    @property
    def metadata(self):
        self.report.theme.title = self.title.text().strip()  # Needed for report.name to work
        return {
            'title': self.title.text().strip(),
            'author': self.author.text().strip(),
            'version': self.version.value(),
            'description': self.description.toPlainText().strip(),
            'number': len(self.report.name_map) - len(self.report.extra),
            'date': utcnow().date().isoformat(),
            'name': self.report.name,
            'license': self.license.text().strip() or 'Unknown',
            'url': self.url.text().strip() or None,
        }

    def save_metadata(self):
        data = json.dumps(self.metadata, indent=2)
        if not isinstance(data, bytes):
            data = data.encode('utf-8')
        with open(os.path.join(self.report.path, THEME_METADATA), 'wb') as f:
            f.write(data)

    def refresh(self):
        self.save_metadata()
        self.report = read_theme_from_folder(self.report.path)
        self.apply_report()

    def apply_report(self):
        theme = self.report.theme
        self.title.setText((theme.title or '').strip())
        self.author.setText((theme.author or '').strip())
        self.version.setValue(theme.version or 1)
        self.description.setText((theme.description or '').strip())
        self.license.setText((theme.license or 'Unknown').strip())
        self.url.setText((theme.url or '').strip())
        if self.report.missing:
            title =  _('%d icons missing in this theme') % len(self.report.missing)
        else:
            title = _('No missing icons')
        self.missing_icons_group.setTitle(title)
        mi = self.mising_icons
        mi.clear()
        for name in sorted(self.report.missing):
            QListWidgetItem(QIcon(I(name, allow_user_override=False)), name, mi)

    def accept(self):
        mi = self.metadata
        if not mi.get('title'):
            return error_dialog(self, _('No title specified'), _(
                'You must specify a title for this icon theme'), show=True)
        if not mi.get('author'):
            return error_dialog(self, _('No author specified'), _(
                'You must specify an author for this icon theme'), show=True)
        return Dialog.accept(self)


class Compress(QProgressDialog):

    update_signal = pyqtSignal(object, object)

    def __init__(self, report, parent=None):
        total = 2 + len(report.name_map)
        QProgressDialog.__init__(self, _('Losslessly optimizing images, please wait...'), _('&Abort'), 0, total, parent)
        self.setWindowTitle(self.labelText())
        self.setWindowIcon(QIcon(I('lt.png')))
        self.setMinimumDuration(0)
        self.update_signal.connect(self.do_update, type=Qt.ConnectionType.QueuedConnection)
        self.raw = self.prefix = None
        self.abort = Event()
        self.canceled.connect(self.abort.set)
        self.t = Thread(name='CompressIcons', target=self.run_compress, args=(report,))
        self.t.daemon = False
        self.t.start()

    def do_update(self, num, message):
        if num < 0:
            return self.onerror(_('Optimizing images failed, click "Show details" for more information'), message)
        self.setValue(num)
        self.setLabelText(message)

    def onerror(self, msg, details):
        error_dialog(self, _('Compression failed'), msg, det_msg=details, show=True)
        self.close()

    def onprogress(self, num, msg):
        self.update_signal.emit(num, msg)
        return not self.wasCanceled()

    def run_compress(self, report):
        try:
            self.raw, self.prefix = create_themeball(report, self.onprogress, self.abort)
        except Exception:
            import traceback
            self.update_signal.emit(-1, traceback.format_exc())
        else:
            self.update_signal.emit(self.maximum(), '')


def create_themeball(report, progress=None, abort=None):
    pool = ThreadPool(processes=cpu_count())
    buf = BytesIO()
    num = count()
    error_occurred = Event()

    def optimize(name):
        if abort is not None and abort.is_set():
            return
        if error_occurred.is_set():
            return
        try:
            i = next(num)
            if progress is not None:
                progress(i, _('Optimizing %s') % name)
            srcpath = os.path.join(report.path, name)
            ext = srcpath.rpartition('.')[-1].lower()
            if ext == 'png':
                optimize_png(srcpath)
            elif ext in ('jpg', 'jpeg'):
                optimize_jpeg(srcpath)
        except Exception:
            return sys.exc_info()

    errors = tuple(filter(None, pool.map(optimize, tuple(report.name_map))))
    pool.close(), pool.join()
    if abort is not None and abort.is_set():
        return
    if errors:
        e = errors[0]
        reraise(*e)

    if progress is not None:
        progress(next(num), _('Creating theme file'))
    with ZipFile(buf, 'w') as zf:
        for name in report.name_map:
            srcpath = os.path.join(report.path, name)
            with lopen(srcpath, 'rb') as f:
                zf.writestr(name, f.read(), compression=ZIP_STORED)
    buf.seek(0)
    if abort is not None and abort.is_set():
        return None, None
    if progress is not None:
        progress(next(num), _('Compressing theme file'))
    import lzma
    compressed = lzma.compress(buf.getvalue(), format=lzma.FORMAT_XZ, preset=9)
    buf = BytesIO()
    prefix = report.name
    if abort is not None and abort.is_set():
        return None, None
    with ZipFile(buf, 'w') as zf:
        with lopen(os.path.join(report.path, THEME_METADATA), 'rb') as f:
            zf.writestr(prefix + '/' + THEME_METADATA, f.read())
        zf.writestr(prefix + '/' + THEME_COVER, create_cover(report))
        zf.writestr(prefix + '/' + 'icons.zip.xz', compressed, compression=ZIP_STORED)
    if progress is not None:
        progress(next(num), _('Finished'))
    return buf.getvalue(), prefix


def create_theme(folder=None, parent=None):
    if folder is None:
        folder = choose_dir(parent, 'create-icon-theme-folder', _(
            'Choose a folder from which to read the icons'))
        if not folder:
            return
    report = read_theme_from_folder(folder)
    d = ThemeCreateDialog(parent, report)
    if d.exec() != QDialog.DialogCode.Accepted:
        return
    d.save_metadata()
    d = Compress(d.report, parent=parent)
    d.exec()
    if d.wasCanceled() or d.raw is None:
        return
    raw, prefix = d.raw, d.prefix
    dest = choose_save_file(parent, 'create-icon-theme-dest', _(
        'Choose destination for icon theme'),
        [(_('ZIP files'), ['zip'])], initial_filename=prefix + '.zip')
    if dest:
        with lopen(dest, 'wb') as f:
            f.write(raw)

# }}}

# Choose Theme  {{{


def download_cover(cover_url, etag=None, cached=b''):
    url = BASE_URL + cover_url
    headers = {}
    if etag:
        if etag[0] != '"':
            etag = '"' + etag + '"'
        headers['If-None-Match'] = etag
    try:
        response = get_https_resource_securely(url, headers=headers, get_response=True)
        cached = response.read()
        etag = response.getheader('ETag', None) or None
        return cached, etag
    except HTTPError as e:
        if etag and e.code == http_client.NOT_MODIFIED:
            return cached, etag
        raise


def get_cover(metadata):
    cdir = os.path.join(cache_dir(), 'icon-theme-covers')
    try:
        os.makedirs(cdir)
    except OSError as e:
        if e.errno != errno.EEXIST:
            raise

    def path(ext):
        return os.path.join(cdir, metadata['name'] + '.' + ext)
    etag_file, cover_file = map(path, 'etag jpg'.split())

    def safe_read(path):
        try:
            with open(path, 'rb') as f:
                return f.read()
        except OSError as e:
            if e.errno != errno.ENOENT:
                raise
        return b''
    etag, cached = safe_read(etag_file), safe_read(cover_file)
    etag = etag.decode('utf-8')
    cached, etag = download_cover(metadata['cover-url'], etag, cached)
    if cached:
        aname = cover_file + '.atomic'
        with open(aname, 'wb') as f:
            f.write(cached)
        atomic_rename(aname, cover_file)
    if etag:
        with open(etag_file, 'wb') as f:
            f.write(as_bytes(etag))
    return cached or b''


def get_covers(themes, dialog, num_of_workers=8):
    items = Queue()
    for i in themes:
        items.put(i)

    def callback(metadata, x):
        if not sip.isdeleted(dialog) and not dialog.dialog_closed:
            dialog.cover_downloaded.emit(metadata, x)

    def run():
        while True:
            try:
                metadata = items.get_nowait()
            except Empty:
                return
            try:
                cdata = get_cover(metadata)
            except Exception as e:
                import traceback
                traceback.print_exc()
                callback(metadata, e)
            else:
                callback(metadata, cdata)

    for w in range(num_of_workers):
        t = Thread(name='IconThemeCover', target=run)
        t.daemon = True
        t.start()


class Delegate(QStyledItemDelegate):

    SPACING = 10

    def sizeHint(self, option, index):
        return QSize(COVER_SIZE[0] * 2, COVER_SIZE[1] + 2 * self.SPACING)

    def paint(self, painter, option, index):
        QStyledItemDelegate.paint(self, painter, option, empty_index)
        theme = index.data(Qt.ItemDataRole.UserRole)
        if not theme:
            return
        painter.save()
        pixmap = index.data(Qt.ItemDataRole.DecorationRole)
        if pixmap and not pixmap.isNull():
            rect = option.rect.adjusted(0, self.SPACING, COVER_SIZE[0] - option.rect.width(), - self.SPACING)
            painter.drawPixmap(rect, pixmap)
        if option.state & QStyle.StateFlag.State_Selected:
            painter.setPen(QPen(QApplication.instance().palette().highlightedText().color()))
        bottom = option.rect.bottom() - 2
        painter.drawLine(0, bottom, option.rect.right(), bottom)
        if 'static-text' not in theme:
            theme['static-text'] = QStaticText(_(
                '''
            <h1>{title}</h1>
            <p>by <i>{author}</i> with <b>{number}</b> icons [{size}]</p>
            <p>{description}</p>
            <p>Version: {version} Number of users: {usage}</p>
            <p><i>Right click to visit theme homepage</i></p>
            ''').format(title=theme.get('title', _('Unknown')), author=theme.get('author', _('Unknown')),
                       number=theme.get('number', 0), description=theme.get('description', ''),
                       size=human_readable(theme.get('compressed-size', 0)), version=theme.get('version', 1),
                       usage=theme.get('usage', 0),
        ))
        painter.drawStaticText(COVER_SIZE[0] + self.SPACING, option.rect.top() + self.SPACING, theme['static-text'])
        painter.restore()


class DownloadProgress(ProgressDialog):

    ds = pyqtSignal(object)
    acc = pyqtSignal()
    rej = pyqtSignal()

    def __init__(self, parent, size):
        ProgressDialog.__init__(self, _('Downloading icons...'), _(
            'Downloading icons, please wait...'), max=size, parent=parent, icon='download_metadata.png')
        self.ds.connect(self.bar.setValue, type=Qt.ConnectionType.QueuedConnection)
        self.acc.connect(self.accept, type=Qt.ConnectionType.QueuedConnection)
        self.rej.connect(self.reject, type=Qt.ConnectionType.QueuedConnection)

    def downloaded(self, byte_count):
        self.ds.emit(byte_count)

    def queue_accept(self):
        self.acc.emit()

    def queue_reject(self):
        self.rej.emit()


class ChooseTheme(Dialog):

    cover_downloaded = pyqtSignal(object, object)
    themes_downloaded = pyqtSignal()

    def __init__(self, parent=None):
        try:
            self.current_theme = json.loads(I('icon-theme.json', data=True))['title']
        except Exception:
            self.current_theme = None
        Dialog.__init__(self, _('Choose an icon theme'), 'choose-icon-theme-dialog', parent)
        self.finished.connect(self.on_finish)
        self.dialog_closed = False
        self.themes_downloaded.connect(self.show_themes, type=Qt.ConnectionType.QueuedConnection)
        self.cover_downloaded.connect(self.set_cover, type=Qt.ConnectionType.QueuedConnection)
        self.keep_downloading = True
        self.commit_changes = None
        self.new_theme_title = None

    def on_finish(self):
        self.dialog_closed = True

    def sizeHint(self):
        h = self.screen().availableSize().height()
        return QSize(900, h - 75)

    def setup_ui(self):
        self.vl = vl = QVBoxLayout(self)
        self.stack = l = QStackedLayout()
        self.pi = pi = ProgressIndicator(self, 256)
        vl.addLayout(l), vl.addWidget(self.bb)
        self.restore_defs_button = b = self.bb.addButton(_('Restore &default icons'), QDialogButtonBox.ButtonRole.ActionRole)
        b.clicked.connect(self.restore_defaults)
        b.setIcon(QIcon(I('view-refresh.png')))
        self.c = c = QWidget(self)
        self.c.v = v = QVBoxLayout(self.c)
        v.addStretch(), v.addWidget(pi, 0, Qt.AlignmentFlag.AlignCenter)
        self.wait_msg = m = QLabel(self)
        v.addWidget(m, 0, Qt.AlignmentFlag.AlignCenter), v.addStretch()
        f = m.font()
        f.setBold(True), f.setPointSize(28), m.setFont(f)
        self.start_spinner()

        l.addWidget(c)
        self.w = w = QWidget(self)
        l.addWidget(w)
        w.l = l = QGridLayout(w)

        def add_row(x, y=None):
            if isinstance(x, str):
                x = QLabel(x)
            row = l.rowCount()
            if y is None:
                if isinstance(x, QLabel):
                    x.setWordWrap(True)
                l.addWidget(x, row, 0, 1, 2)
            else:
                if isinstance(x, QLabel):
                    x.setBuddy(y)
                l.addWidget(x, row, 0), l.addWidget(y, row, 1)
        add_row(_(
            'Choose an icon theme below. You will need to restart'
            ' calibre to see the new icons.'))
        add_row(_('Current icon theme:') + '\xa0<b>' + (self.current_theme or 'None'))
        self.sort_by = sb = QComboBox(self)
        add_row(_('&Sort by:'), sb)
        sb.addItems([_('Number of icons'), _('Popularity'), _('Name'),])
        sb.setEditable(False), sb.setCurrentIndex(gprefs.get('choose_icon_theme_sort_by', 1))
        sb.currentIndexChanged[int].connect(self.re_sort)
        sb.currentIndexChanged[int].connect(lambda : gprefs.set('choose_icon_theme_sort_by', sb.currentIndex()))
        self.theme_list = tl = QListWidget(self)
        tl.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
        self.delegate = Delegate(tl)
        tl.setItemDelegate(self.delegate)
        tl.itemDoubleClicked.connect(self.accept)
        tl.itemPressed.connect(self.item_clicked)
        add_row(tl)

        t = Thread(name='GetIconThemes', target=self.get_themes)
        t.daemon = True
        t.start()

    def item_clicked(self, item):
        if QApplication.mouseButtons() & Qt.MouseButton.RightButton:
            theme = item.data(Qt.ItemDataRole.UserRole) or {}
            url = theme.get('url')
            if url:
                safe_open_url(url)
            else:
                error_dialog(self, _('No homepage'), _('The {} theme has no homepage').format(theme.get('name', _('Unknown'))), show=True)

    def start_spinner(self, msg=None):
        self.pi.startAnimation()
        self.stack.setCurrentIndex(0)
        self.wait_msg.setText(msg or _('Downloading, please wait...'))

    def end_spinner(self):
        self.pi.stopAnimation()
        self.stack.setCurrentIndex(1)

    @property
    def sort_on(self):
        return {0:'number', 1:'usage', 2:'title'}[self.sort_by.currentIndex()]

    def re_sort(self):
        self.themes.sort(key=lambda x:sort_key(x.get('title', '')))
        field = self.sort_on
        if field == 'number':
            self.themes.sort(key=lambda x:x.get('number', 0), reverse=True)
        elif field == 'usage':
            self.themes.sort(key=lambda x:self.usage.get(x.get('name'), 0), reverse=True)
        self.theme_list.clear()
        for theme in self.themes:
            i = QListWidgetItem(theme.get('title', '') + ' {} {}'.format(theme.get('number'), self.usage.get(theme.get('name'))), self.theme_list)
            i.setData(Qt.ItemDataRole.UserRole, theme)
            if 'cover-pixmap' in theme:
                i.setData(Qt.ItemDataRole.DecorationRole, theme['cover-pixmap'])

    def get_themes(self):

        self.usage = {}

        def get_usage():
            try:
                self.usage = json.loads(bz2.decompress(get_https_resource_securely(BASE_URL + '/usage.json.bz2')))
            except Exception:
                import traceback
                traceback.print_exc()

        t = Thread(name='IconThemeUsage', target=get_usage)
        t.daemon = True
        t.start()

        try:
            self.themes = json.loads(bz2.decompress(get_https_resource_securely(BASE_URL + '/themes.json.bz2')))
        except Exception:
            import traceback
            self.themes = traceback.format_exc()
        t.join()
        if not sip.isdeleted(self):
            self.themes_downloaded.emit()

    def show_themes(self):
        self.end_spinner()
        if not isinstance(self.themes, list):
            error_dialog(self, _('Failed to download list of themes'), _(
                'Failed to download list of themes, click "Show details" for more information'),
                         det_msg=self.themes, show=True)
            self.reject()
            return
        for theme in self.themes:
            theme['usage'] = self.usage.get(theme['name'], 0)
        self.re_sort()
        get_covers(self.themes, self)

    def __iter__(self):
        for i in range(self.theme_list.count()):
            yield self.theme_list.item(i)

    def item_from_name(self, name):
        for item in self:
            if item.data(Qt.ItemDataRole.UserRole)['name'] == name:
                return item

    def set_cover(self, theme, cdata):
        theme['cover-pixmap'] = p = QPixmap()
        try:
            dpr = self.devicePixelRatioF()
        except AttributeError:
            dpr = self.devicePixelRatio()
        if isinstance(cdata, bytes):
            p.loadFromData(cdata)
            p.setDevicePixelRatio(dpr)
        item = self.item_from_name(theme['name'])
        if item is not None:
            item.setData(Qt.ItemDataRole.DecorationRole, p)

    def restore_defaults(self):
        if self.current_theme is not None:
            if not question_dialog(self, _('Are you sure?'), _(
                    'Are you sure you want to remove the <b>%s</b> icon theme'
                    ' and return to the stock icons?') % self.current_theme):
                return
        self.commit_changes = remove_icon_theme
        Dialog.accept(self)

    def accept(self):
        if self.theme_list.currentRow() < 0:
            return error_dialog(self, _('No theme selected'), _(
                'You must first select an icon theme'), show=True)
        theme = self.theme_list.currentItem().data(Qt.ItemDataRole.UserRole)
        url = BASE_URL + theme['icons-url']
        size = theme['compressed-size']
        theme = {k:theme.get(k, '') for k in 'name title version'.split()}
        self.keep_downloading = True
        d = DownloadProgress(self, size)
        d.canceled_signal.connect(lambda : setattr(self, 'keep_downloading', False))

        self.downloaded_theme = None

        def download():
            self.downloaded_theme = buf = BytesIO()
            try:
                response = get_https_resource_securely(url, get_response=True)
                while self.keep_downloading:
                    raw = response.read(1024)
                    if not raw:
                        break
                    buf.write(raw)
                    d.downloaded(buf.tell())
                d.queue_accept()
            except Exception:
                import traceback
                self.downloaded_theme = traceback.format_exc()
                d.queue_reject()

        t = Thread(name='DownloadIconTheme', target=download)
        t.daemon = True
        t.start()
        ret = d.exec()

        if self.downloaded_theme and not isinstance(self.downloaded_theme, BytesIO):
            return error_dialog(self, _('Download failed'), _(
                'Failed to download icon theme, click "Show details" for more information.'), show=True, det_msg=self.downloaded_theme)
        if ret == QDialog.DialogCode.Rejected or not self.keep_downloading or d.canceled or self.downloaded_theme is None:
            return
        dt = self.downloaded_theme

        def commit_changes():
            import lzma
            dt.seek(0)
            f = BytesIO(lzma.decompress(dt.getvalue()))
            f.seek(0)
            remove_icon_theme()
            install_icon_theme(theme, f)
        self.commit_changes = commit_changes
        self.new_theme_title = theme['title']
        return Dialog.accept(self)

# }}}


def remove_icon_theme():
    icdir = os.path.join(config_dir, 'resources', 'images')
    metadata_file = os.path.join(icdir, 'icon-theme.json')
    try:
        with open(metadata_file, 'rb') as f:
            metadata = json.load(f)
    except OSError as e:
        if e.errno != errno.ENOENT:
            raise
        return
    for name in metadata['files']:
        try:
            os.remove(os.path.join(icdir, *name.split('/')))
        except OSError as e:
            if e.errno != errno.ENOENT:
                raise
    os.remove(metadata_file)


def safe_copy(src, destpath):
    tpath = destpath + '-temp'
    with open(tpath, 'wb') as dest:
        shutil.copyfileobj(src, dest)
    atomic_rename(tpath, destpath)


def install_icon_theme(theme, f):
    icdir = os.path.abspath(os.path.join(config_dir, 'resources', 'images'))
    if not os.path.exists(icdir):
        os.makedirs(icdir)
    theme['files'] = set()
    metadata_file = os.path.join(icdir, 'icon-theme.json')
    with ZipFile(f) as zf:
        for name in zf.namelist():
            if '..' in name or name == 'blank.png':
                continue
            base = icdir
            if '/' in name:
                base = os.path.join(icdir, os.path.dirname(name))
                if not os.path.exists(base):
                    os.makedirs(base)
            destpath = os.path.abspath(os.path.join(base, os.path.basename(name)))
            if not destpath.startswith(icdir):
                continue
            with zf.open(name) as src:
                safe_copy(src, destpath)
            theme['files'].add(name)

    theme['files'] = tuple(theme['files'])
    buf = BytesIO(as_bytes(json.dumps(theme, indent=2)))
    buf.seek(0)
    safe_copy(buf, metadata_file)


if __name__ == '__main__':
    from calibre.gui2 import Application
    app = Application([])
    # create_theme('.')
    d = ChooseTheme()
    if d.exec() == QDialog.DialogCode.Accepted and d.commit_changes is not None:
        d.commit_changes()
    del app
