#!/usr/bin/env python3

# SPDX-FileCopyrightText: Michal Siedlaczek <michal.siedlaczek@gmail.com>
# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# SPDX-License-Identifier: GPL-3.0-or-later


"""A script installing Hunspell dictionaries.

Use: python -m scripts.dictcli [-h] {list,update,remove-old,install} ...
"""

import argparse
import base64
import json
import os
import sys
import re
import urllib.request
import dataclasses
from typing import Optional

sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from qutebrowser.browser.webengine import spell
from qutebrowser.config import configdata
from qutebrowser.utils import standarddir


API_URL = 'https://chromium.googlesource.com/chromium/deps/hunspell_dictionaries.git/+/main/'


class InvalidLanguageError(Exception):

    """Raised when requesting invalid languages."""

    def __init__(self, invalid_langs):
        msg = 'invalid languages: {}'.format(', '.join(invalid_langs))
        super().__init__(msg)


@dataclasses.dataclass
class Language:

    """Dictionary language specs."""

    code: str
    name: str
    remote_filename: str
    local_filename: Optional[str] = None

    def __post_init__(self):
        if self.local_filename is None:
            self.local_filename = spell.local_filename(self.code)

    @property
    def remote_version(self):
        """Resolve the version of the local dictionary."""
        return spell.version(self.remote_filename)

    @property
    def local_version(self):
        """Resolve the version of the local dictionary."""
        local_filename = self.local_filename
        if local_filename is None:
            return None
        return spell.version(local_filename)


def get_argparser():
    """Get the argparse parser."""
    desc = 'Install and manage Hunspell dictionaries for QtWebEngine.'
    parser = argparse.ArgumentParser(prog='dictcli',
                                     description=desc)
    subparsers = parser.add_subparsers(help='Command', dest='cmd')
    subparsers.required = True
    subparsers.add_parser('list',
                          help='Display the list of available languages.')
    subparsers.add_parser('update',
                          help='Update dictionaries')
    subparsers.add_parser('remove-old',
                          help='Remove old versions of dictionaries.')

    install_parser = subparsers.add_parser('install',
                                           help='Install dictionaries')
    install_parser.add_argument('language',
                                nargs='*',
                                help="A list of languages to install.")

    return parser


def version_str(version):
    return '.'.join(str(n) for n in version)


def print_list(languages):
    """Print the list of available languages."""
    pat = '{:<7}{:<26}{:<8}{:<5}'
    print(pat.format('Code', 'Name', 'Version', 'Installed'))
    for lang in languages:
        remote_version = version_str(lang.remote_version)
        local_version = '-'
        if lang.local_version is not None:
            local_version = version_str(lang.local_version)
            if lang.local_version < lang.remote_version:
                local_version += ' - update available!'
        print(pat.format(lang.code, lang.name, remote_version, local_version))


def valid_languages():
    """Return a mapping from valid language codes to their names."""
    option = configdata.DATA['spellcheck.languages']
    return option.typ.valtype.valid_values.descriptions


def parse_entry(entry):
    """Parse an entry from the remote API."""
    dict_re = re.compile(r"""
        (?P<filename>(?P<code>[a-z]{2}(-[A-Z]{2})?).*\.bdic)
    """, re.VERBOSE)
    match = dict_re.fullmatch(entry['name'])
    if match is not None:
        return match.group('code'), match.group('filename')
    else:
        return None


def language_list_from_api():
    """Return a JSON with a list of available languages from Google API."""
    listurl = API_URL + '?format=JSON'
    with urllib.request.urlopen(listurl) as response:
        # A special 5-byte prefix must be stripped from the response content
        # See: https://github.com/google/gitiles/issues/22
        #      https://github.com/google/gitiles/issues/82
        json_content = response.read()[5:]
    entries = json.loads(json_content.decode('utf-8'))['entries']
    parsed_entries = [parse_entry(entry) for entry in entries]
    return [entry for entry in parsed_entries if entry is not None]


def latest_yet(code2file, code, filename):
    """Determine whether the latest version so far."""
    if code not in code2file:
        return True
    return spell.version(code2file[code]) < spell.version(filename)


def available_languages():
    """Return a list of Language objects of all available languages."""
    lang_map = valid_languages()
    api_list = language_list_from_api()
    code2file = {}
    for code, filename in api_list:
        if latest_yet(code2file, code, filename):
            code2file[code] = filename
    return [
        Language(code, name, code2file[code])
        for code, name in lang_map.items()
        if code in code2file
    ]


def download_dictionary(url, dest):
    """Download a decoded dictionary file."""
    with urllib.request.urlopen(url) as response:
        decoded = base64.decodebytes(response.read())
    with open(dest, 'bw') as dict_file:
        dict_file.write(decoded)


def filter_languages(languages, selected):
    """Filter a list of languages based on an inclusion list.

    Args:
        languages: a list of languages to filter
        selected: a list of keys to select
    """
    filtered_languages = []
    for language in languages:
        if language.code in selected:
            filtered_languages.append(language)
            selected.remove(language.code)
    if selected:
        raise InvalidLanguageError(selected)
    return filtered_languages


def install_lang(lang):
    """Install a single lang given by the argument."""
    lang_url = API_URL + lang.remote_filename + '?format=TEXT'
    if not os.path.isdir(spell.dictionary_dir()):
        msg = '{} does not exist, creating the directory'
        print(msg.format(spell.dictionary_dir()))
        os.makedirs(spell.dictionary_dir())
    print('Downloading {}'.format(lang_url))
    dest = os.path.join(spell.dictionary_dir(), lang.remote_filename)
    download_dictionary(lang_url, dest)
    print('Installed to {}.'.format(dest))


def install(languages):
    """Install languages."""
    for lang in languages:
        print('Installing {}: {}'.format(lang.code, lang.name))
        install_lang(lang)


def update(languages):
    """Update the given languages."""
    installed = [lang for lang in languages if lang.local_version is not None]
    for lang in installed:
        if lang.local_version < lang.remote_version:
            print('Upgrading {} from {} to {}'.format(
                lang.code,
                version_str(lang.local_version),
                version_str(lang.remote_version)))
            install_lang(lang)


def remove_old(languages):
    """Remove old versions of languages."""
    installed = [lang for lang in languages if lang.local_version is not None]
    for lang in installed:
        local_files = spell.local_files(lang.code)
        for old_file in local_files[1:]:
            os.remove(os.path.join(spell.dictionary_dir(), old_file))


def main():
    if configdata.DATA is None:
        configdata.init()
    standarddir.init(None)

    parser = get_argparser()
    argv = sys.argv[1:]
    args = parser.parse_args(argv)
    languages = available_languages()
    if args.cmd == 'list':
        print_list(languages)
    elif args.cmd == 'update':
        update(languages)
    elif args.cmd == 'remove-old':
        remove_old(languages)
    elif not args.language:
        sys.exit('You must provide a list of languages to install.')
    else:
        try:
            install(filter_languages(languages, args.language))
        except InvalidLanguageError as e:
            print(e)


if __name__ == '__main__':
    main()
