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


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

import os
import uuid
from contextlib import suppress
from functools import partial
from threading import Thread

from qt.core import (
    QAction,
    QBuffer,
    QByteArray,
    QDialogButtonBox,
    QIcon,
    QInputDialog,
    QIODevice,
    QKeySequence,
    QLabel,
    QListWidget,
    QListWidgetItem,
    QPixmap,
    QSize,
    QStackedLayout,
    Qt,
    QVBoxLayout,
    QWidget,
    pyqtSignal,
)

from calibre import as_unicode
from calibre.constants import ismacos, iswindows
from calibre.gui2 import Application, choose_files, choose_images, choose_osx_app, elided_text, error_dialog, sanitize_env_vars
from calibre.gui2.progress_indicator import ProgressIndicator
from calibre.gui2.widgets2 import Dialog
from calibre.utils.config import JSONConfig
from calibre.utils.icu import numeric_sort_key as sort_key
from calibre.utils.resources import get_image_path as I
from polyglot.builtins import iteritems, string_or_bytes

ENTRY_ROLE = Qt.ItemDataRole.UserRole


def pixmap_to_data(pixmap):
    ba = QByteArray()
    buf = QBuffer(ba)
    buf.open(QIODevice.OpenModeFlag.WriteOnly)
    pixmap.save(buf, 'PNG')
    return bytearray(ba.data())


def run_program(entry, path, parent):
    import subprocess
    cmdline = entry_to_cmdline(entry, path)
    print('Running Open With commandline:', repr(cmdline))
    try:
        with sanitize_env_vars():
            process = subprocess.Popen(cmdline)
    except Exception as err:
        return error_dialog(
            parent, _('Failed to run'), _(
            'Failed to run program, click "Show details" for more information'),
            det_msg=f'Command line: {cmdline!r}\n{as_unicode(err)}')
    t = Thread(name='WaitProgram', target=process.wait)
    t.daemon = True
    t.start()


def entry_to_icon_text(entry, only_text=False):
    if only_text:
        return entry.get('name', entry.get('Name')) or _('Unknown')
    data = entry.get('icon_data')
    if isinstance(data, str):
        with suppress(Exception):
            from base64 import standard_b64decode
            data = bytearray(standard_b64decode(data))
    if not isinstance(data, (bytearray, bytes)):
        icon = QIcon.ic('blank.png')
    else:
        pmap = QPixmap()
        pmap.loadFromData(bytes(data))
        if pmap.isNull():
            icon = QIcon.ic('blank.png')
        else:
            icon = QIcon(pmap)
    return icon, entry.get('name', entry.get('Name')) or _('Unknown')


if iswindows:
    # Windows {{{
    import subprocess

    from calibre.utils.open_with.windows import load_icon_for_cmdline, load_icon_resource
    from calibre.utils.winreg.default_programs import find_programs, friendly_app_name
    from calibre_extensions import winutil
    oprefs = JSONConfig('windows_open_with')

    def entry_sort_key(entry):
        return sort_key(entry.get('name') or '')

    def icon_for_entry(entry, delete_icon_resource=False, as_data=False):
        res = entry.pop('icon_resource', None) if delete_icon_resource else entry.get('icon_resource')
        if res is None:
            return load_icon_for_cmdline(entry['cmdline'], as_data=as_data)
        try:
            return load_icon_resource(res, as_data=as_data)
        except Exception:
            import traceback
            traceback.print_exc()
        return load_icon_for_cmdline(entry['cmdline'], as_data=as_data)

    def finalize_entry(entry):
        try:
            data = icon_for_entry(entry, delete_icon_resource=True, as_data=True)
        except Exception:
            data = None
            import traceback
            traceback.print_exc()
        if isinstance(data, (bytes, bytearray)) or data is None:
            entry['icon_data'] = data
        return entry

    def change_name_in_entry(entry, newname):
        entry['name'] = newname

    def entry_to_item(entry, parent):
        try:
            icon = icon_for_entry(entry)
        except Exception:
            icon = None
            import traceback
            traceback.print_exc()
        if not icon:
            icon = entry_to_icon_text(entry)[0]
        ans = QListWidgetItem(QIcon(icon), entry.get('name') or _('Unknown'), parent)
        ans.setData(ENTRY_ROLE, entry)
        ans.setToolTip(_('Command line:') + '\n' + entry['cmdline'])

    def choose_manually(filetype, parent):
        ans = choose_files(
            parent, 'choose-open-with-program-manually-win',
            _('Choose a program to open %s files') % filetype.upper(),
            filters=[(_('Executable files'), ['exe', 'bat', 'com', 'cmd'])], select_only_single_file=True)
        if ans:
            ans = os.path.abspath(ans[0])
            if not os.access(ans, os.X_OK):
                error_dialog(parent, _('Cannot execute'), _(
                    'The program %s is not an executable file') % ans, show=True)
                return
            qans = ans.replace('"', r'\"')
            name = friendly_app_name(exe=ans) or os.path.splitext(os.path.basename(ans))[0]
            return {'cmdline':f'"{qans}" "%1"', 'name':name}

    def entry_to_cmdline(entry, path):
        cmdline = entry['cmdline']
        qpath = path.replace('"', r'\"')
        return cmdline.replace('%1', qpath)

    del run_program

    def run_program(entry, path, parent):
        import re
        cmdline = entry_to_cmdline(entry, path)
        flags = subprocess.CREATE_DEFAULT_ERROR_MODE | subprocess.CREATE_NEW_PROCESS_GROUP
        if re.match(r'"[^"]+?(.bat|.cmd|.com)"', cmdline, flags=re.I):
            flags |= subprocess.CREATE_NO_WINDOW
            console = ' (console)'
        else:
            flags |= subprocess.DETACHED_PROCESS
            console = ''
        print(f'Running Open With commandline{console}:', repr(entry['cmdline']), ' |==> ', repr(cmdline))
        try:
            with sanitize_env_vars():
                winutil.run_cmdline(cmdline, flags, 2000)
        except Exception as err:
            return error_dialog(
                parent, _('Failed to run'), _(
                'Failed to run program, click "Show details" for more information'),
                det_msg=f'Command line: {cmdline!r}\n{as_unicode(err)}')
    # }}}

elif ismacos:
    # macOS {{{
    oprefs = JSONConfig('osx_open_with')
    from calibre.utils.open_with.osx import entry_to_cmdline, find_programs, get_bundle_data, get_icon

    def entry_sort_key(entry):
        return sort_key(entry.get('name') or '')

    def finalize_entry(entry):
        entry['extensions'] = tuple(entry.get('extensions', ()))
        data = get_icon(entry.pop('icon_file', None), as_data=True, pixmap_to_data=pixmap_to_data)
        if data:
            entry['icon_data'] = data
        return entry

    def change_name_in_entry(entry, newname):
        entry['name'] = newname

    def entry_to_item(entry, parent):
        icon = get_icon(entry.get('icon_file'), as_data=False)
        if icon is None:
            icon = entry_to_icon_text(entry)[0]
        else:
            icon = QPixmap.fromImage(icon)
        ans = QListWidgetItem(QIcon(icon), entry.get('name') or _('Unknown'), parent)
        ans.setData(ENTRY_ROLE, entry)
        ans.setToolTip(_('Application path:') + '\n' + entry['path'])

    def choose_manually(filetype, parent):
        ans = choose_osx_app(parent, 'choose-open-with-program-manually', _('Choose a program to open %s files') % filetype.upper())
        if ans:
            ans = ans[0]
            if os.path.isdir(ans):
                app = get_bundle_data(ans)
                if app is None:
                    error_dialog(parent, _('Invalid application'), _(
                        '%s is not a valid macOS application bundle.') % ans, show=True)
                    return
                return app
            if not os.access(ans, os.X_OK):
                error_dialog(parent, _('Cannot execute'), _(
                    'The program %s is not an executable file') % ans, show=True)
                return

            return {'path':ans, 'name': os.path.basename(ans)}

    # }}}

else:
    # XDG {{{
    oprefs = JSONConfig('xdg_open_with')
    from calibre.utils.open_with.linux import entry_sort_key, entry_to_cmdline, find_programs

    def change_name_in_entry(entry, newname):
        entry['Name'] = newname

    def entry_to_item(entry, parent):
        icon_path = entry.get('Icon') or I('blank.png')
        if not isinstance(icon_path, string_or_bytes):
            icon_path = I('blank.png')
        ans = QListWidgetItem(QIcon(icon_path), entry.get('Name') or _('Unknown'), parent)
        ans.setData(ENTRY_ROLE, entry)
        comment = (entry.get('Comment') or '')
        if comment:
            comment += '\n'
        ans.setToolTip(comment + _('Command line:') + '\n' + (' '.join(entry['Exec'])))

    def choose_manually(filetype, parent):
        dd = '/usr/bin' if os.path.isdir('/usr/bin') else '~'
        ans = choose_files(parent, 'choose-open-with-program-manually', _('Choose a program to open %s files') % filetype.upper(),
                           select_only_single_file=True, default_dir=dd)
        if ans:
            ans = ans[0]
            if not os.access(ans, os.X_OK):
                error_dialog(parent, _('Cannot execute'), _(
                    'The program %s is not an executable file') % ans, show=True)
                return
            return {'Exec':[ans, '%f'], 'Name':os.path.basename(ans)}

    def finalize_entry(entry):
        icon_path = entry.get('Icon')
        if icon_path:
            ic = QIcon(icon_path)
            if not ic.isNull():
                pmap = ic.pixmap(48, 48)
                if not pmap.isNull():
                    entry['icon_data'] = pixmap_to_data(pmap)
        try:
            entry['MimeType'] = tuple(entry['MimeType'])
        except KeyError:
            entry['MimeType'] = ()
        return entry
# }}}


class ChooseProgram(Dialog):  # {{{

    found = pyqtSignal()

    def __init__(self, file_type='jpeg', parent=None, prefs=oprefs):
        self.file_type = file_type
        self.programs = self.find_error = self.selected_entry = None
        self.select_manually = False
        Dialog.__init__(self, _('Choose a program'), 'choose-open-with-program-dialog', parent=parent, prefs=prefs)
        self.found.connect(self.programs_found, type=Qt.ConnectionType.QueuedConnection)
        self.pi.startAnimation()
        t = Thread(target=self.find_programs)
        t.daemon = True
        t.start()

    def setup_ui(self):
        self.stacks = s = QStackedLayout(self)
        self.w = w = QWidget(self)
        self.w.l = l = QVBoxLayout(w)
        self.pi = pi = ProgressIndicator(self, 256)
        l.addStretch(1), l.addWidget(pi, alignment=Qt.AlignmentFlag.AlignHCenter), l.addSpacing(10)
        w.la = la = QLabel(_('Gathering data, please wait...'))
        f = la.font()
        f.setBold(True), f.setPointSize(28), la.setFont(f)
        l.addWidget(la, alignment=Qt.AlignmentFlag.AlignHCenter), l.addStretch(1)
        s.addWidget(w)

        self.w2 = w = QWidget(self)
        self.l = l = QVBoxLayout(w)
        s.addWidget(w)

        self.la = la = QLabel(_('Choose a program to open %s files') % self.file_type.upper())
        self.plist = pl = QListWidget(self)
        pl.doubleClicked.connect(self.accept)
        pl.setIconSize(QSize(48, 48)), pl.setSpacing(5)
        pl.doubleClicked.connect(self.accept)
        l.addWidget(la), l.addWidget(pl)
        la.setBuddy(pl)

        b = self.bb.addButton(_('&Browse computer for program'), QDialogButtonBox.ButtonRole.ActionRole)
        b.clicked.connect(self.manual)
        l.addWidget(self.bb)

    def sizeHint(self):
        return QSize(600, 500)

    def find_programs(self):
        try:
            self.programs = find_programs(self.file_type.split())
        except Exception:
            import traceback
            self.find_error = traceback.print_exc()
        self.found.emit()

    def programs_found(self):
        if self.find_error is not None:
            error_dialog(self, _('Error finding programs'), _(
                'Failed to find programs on your computer, click "Show details" for'
                ' more information'), det_msg=self.find_error, show=True)
            self.select_manually = True
            return self.reject()
        if not self.programs:
            self.select_manually = True
            return self.reject()
        for entry in self.programs:
            entry_to_item(entry, self.plist)
        self.stacks.setCurrentIndex(1)

    def accept(self):
        ci = self.plist.currentItem()
        if ci is not None:
            self.selected_entry = ci.data(ENTRY_ROLE)
        return Dialog.accept(self)

    def manual(self):
        self.select_manually = True
        self.reject()


oprefs.defaults['entries'] = {}


def choose_program(file_type='jpeg', parent=None, prefs=oprefs):
    oft = file_type = file_type.lower()
    file_type = {'cover_image':'jpeg'}.get(oft, oft)
    d = ChooseProgram(file_type, parent, prefs)
    d.exec()
    entry = choose_manually(file_type, parent) if d.select_manually else d.selected_entry
    if entry is not None:
        entry = finalize_entry(entry)
        entry['uuid'] = str(uuid.uuid4())
        entries = oprefs['entries']
        if oft not in entries:
            entries[oft] = []
        entries[oft].append(entry)
        entries[oft].sort(key=entry_sort_key)
        oprefs['entries'] = entries
        register_keyboard_shortcuts(finalize=True)
    return entry


def populate_menu(menu, connect_action, file_type):
    file_type = file_type.lower()
    for entry in oprefs['entries'].get(file_type, ()):
        icon, text = entry_to_icon_text(entry)
        text = elided_text(text, pos='right')
        sa = registered_shortcuts.get(entry['uuid'])
        if sa is not None:
            text += '\t' + sa.shortcut().toString(QKeySequence.SequenceFormat.NativeText)
        ac = menu.addAction(icon, text)
        connect_action(ac, entry)
    return menu

# }}}


class EditPrograms(Dialog):  # {{{

    def __init__(self, file_type='jpeg', parent=None):
        self.file_type = file_type.lower()
        Dialog.__init__(self, _('Edit the applications used for %s files') % file_type.upper(), 'edit-open-with-programs', parent=parent)

    def setup_ui(self):
        self.l = l = QVBoxLayout(self)
        self.plist = pl = QListWidget(self)
        pl.setIconSize(QSize(48, 48)), pl.setSpacing(5)
        l.addWidget(pl)

        self.bb.clear(), self.bb.setStandardButtons(QDialogButtonBox.StandardButton.Close)
        self.rb = b = self.bb.addButton(_('&Remove'), QDialogButtonBox.ButtonRole.ActionRole)
        b.clicked.connect(self.remove), b.setIcon(QIcon.ic('list_remove.png'))
        self.cb = b = self.bb.addButton(_('Change &icon'), QDialogButtonBox.ButtonRole.ActionRole)
        b.clicked.connect(self.change_icon), b.setIcon(QIcon.ic('icon_choose.png'))
        self.cb = b = self.bb.addButton(_('Change &name'), QDialogButtonBox.ButtonRole.ActionRole)
        b.clicked.connect(self.change_name), b.setIcon(QIcon.ic('modified.png'))
        l.addWidget(self.bb)

        self.populate()

    def sizeHint(self):
        return QSize(600, 400)

    def populate(self):
        self.plist.clear()
        for entry in oprefs['entries'].get(self.file_type, ()):
            entry_to_item(entry, self.plist)

    def change_icon(self):
        ci = self.plist.currentItem()
        if ci is None:
            return error_dialog(self, _('No selection'), _(
                'No application selected'), show=True)
        paths = choose_images(self, 'choose-new-icon-for-open-with-program', _(
            'Choose new icon'))
        if paths:
            ic = QIcon(paths[0])
            if ic.isNull():
                return error_dialog(self, _('Invalid icon'), _(
                    'Could not load image from %s') % paths[0], show=True)
            pmap = ic.pixmap(48, 48)
            if not pmap.isNull():
                entry = ci.data(ENTRY_ROLE)
                entry['icon_data'] = pixmap_to_data(pmap)
                ci.setData(ENTRY_ROLE, entry)
                self.update_stored_config()
                ci.setIcon(ic)

    def change_name(self):
        ci = self.plist.currentItem()
        if ci is None:
            return error_dialog(self, _('No selection'), _(
                'No application selected'), show=True)
        name = ci.data(Qt.ItemDataRole.DisplayRole)
        name, ok = QInputDialog.getText(self, _('Enter new name'), _('New name for {}').format(name), text=name)
        if ok and name:
            entry = ci.data(ENTRY_ROLE)
            change_name_in_entry(entry, name)
            ci.setData(ENTRY_ROLE, entry)
            self.update_stored_config()
            ci.setData(Qt.ItemDataRole.DisplayRole, name)

    def remove(self):
        ci = self.plist.currentItem()
        if ci is None:
            return error_dialog(self, _('No selection'), _(
                'No application selected'), show=True)
        row = self.plist.row(ci)
        self.plist.takeItem(row)
        self.update_stored_config()
        register_keyboard_shortcuts(finalize=True)

    def update_stored_config(self):
        entries = [self.plist.item(i).data(ENTRY_ROLE) for i in range(self.plist.count())]
        oprefs['entries'][self.file_type] = entries
        oprefs['entries'] = oprefs['entries']


def edit_programs(file_type, parent):
    d = EditPrograms(file_type, parent)
    d.exec()
# }}}


registered_shortcuts = {}


def register_keyboard_shortcuts(gui=None, finalize=False):
    if gui is None:
        from calibre.gui2.ui import get_gui
        gui = get_gui()
    if gui is None:
        return
    for unique_name, action in iteritems(registered_shortcuts):
        gui.keyboard.unregister_shortcut(unique_name)
        gui.removeAction(action)
    registered_shortcuts.clear()

    for filetype, applications in iteritems(oprefs['entries']):
        for application in applications:
            text = entry_to_icon_text(application, only_text=True)
            t = _('cover image') if filetype.upper() == 'COVER_IMAGE' else filetype.upper()
            name = _('Open {0} files with {1}').format(t, text)
            ac = QAction(gui)
            unique_name = application['uuid']
            func = partial(gui.open_with_action_triggerred, filetype, application)
            ac.triggered.connect(func)
            gui.keyboard.register_shortcut(unique_name, name, action=ac, group=_('Open with'))
            gui.addAction(ac)
            registered_shortcuts[unique_name] = ac
    if finalize:
        gui.keyboard.finalize()


if __name__ == '__main__':
    from pprint import pprint
    app = Application([])
    pprint(choose_program('pdf'))
    del app
