#!/usr/bin/env python3

# == Rapid Develop Macros in LibreOffice ==

# ~ This file is part of ZAZ.

# ~ https://git.elmau.net/elmau/zaz

# ~ ZAZ is free software: you can redistribute it and/or modify
# ~ it under the terms of the GNU General Public License as published by
# ~ the Free Software Foundation, either version 3 of the License, or
# ~ (at your option) any later version.

# ~ ZAZ is distributed in the hope that it will be useful,
# ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
# ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# ~ GNU General Public License for more details.

# ~ You should have received a copy of the GNU General Public License
# ~ along with ZAZ.  If not, see <https://www.gnu.org/licenses/>.

import argparse
import os
import py_compile
import re
import sys
import zipfile
from datetime import datetime
from pathlib import Path
from shutil import copyfile
from subprocess import call
from xml.etree import ElementTree as ET
from xml.dom.minidom import parseString


from conf import (
    DATA,
    DIRS,
    DOMAIN,
    EXTENSION,
    FILES,
    INFO,
    PATHS,
    TYPE_EXTENSION,
    USE_LOCALES,
    log)


EASYMACRO = 'easymacro.py'


class LiboXML(object):
    CONTEXT = {
        'calc': 'com.sun.star.sheet.SpreadsheetDocument',
        'writer': 'com.sun.star.text.TextDocument',
        'impress': 'com.sun.star.presentation.PresentationDocument',
        'draw': 'com.sun.star.drawing.DrawingDocument',
        'base': 'com.sun.star.sdb.OfficeDatabaseDocument',
        'math': 'com.sun.star.formula.FormulaProperties',
        'basic': 'com.sun.star.script.BasicIDE',
    }
    TYPES = {
        'py': 'application/vnd.sun.star.uno-component;type=Python',
        'pyc': 'application/binary',
        'zip': 'application/binary',
        'xcu': 'application/vnd.sun.star.configuration-data',
        'rdb': 'application/vnd.sun.star.uno-typelibrary;type=RDB',
        'xcs': 'application/vnd.sun.star.configuration-schema',
        'help': 'application/vnd.sun.star.help',
        'component': 'application/vnd.sun.star.uno-components',
    }
    NS_MANIFEST = {
        'manifest_version': '1.2',
        'manifest': 'urn:oasis:names:tc:opendocument:xmlns:manifest:1.0',
        'xmlns:loext': 'urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0',
    }
    NS_DESCRIPTION = {
        'xmlns': 'http://openoffice.org/extensions/description/2006',
        'xmlns:xlink': 'http://www.w3.org/1999/xlink',
        'xmlns:d': 'http://openoffice.org/extensions/description/2006',
    }
    NS_ADDONS = {
        'xmlns:xs': 'http://www.w3.org/2001/XMLSchema',
        'xmlns:oor': 'http://openoffice.org/2001/registry',
    }
    NS_UPDATE = {
        'xmlns': 'http://openoffice.org/extensions/update/2006',
        'xmlns:d': 'http://openoffice.org/extensions/description/2006',
        'xmlns:xlink': 'http://www.w3.org/1999/xlink',
    }

    def __init__(self):
        self._manifest = None
        self._paths = []
        self._path_images = ''
        self._toolbars = []

    def _save_path(self, attr):
        self._paths.append(attr['{{{}}}full-path'.format(self.NS_MANIFEST['manifest'])])
        return

    def _clean(self, name, nodes):
        has_words = re.compile('\\w')

        if not re.search(has_words, str(nodes.tail)):
            nodes.tail = ''
        if not re.search(has_words, str(nodes.text)):
            nodes.text = ''

        for node in nodes:
            if name == 'manifest':
                self._save_path(node.attrib)
            if not re.search(has_words, str(node.tail)):
                node.tail = ''
            if not re.search(has_words, str(node.text)):
                node.text = ''
        return

    def new_manifest(self, data):
        attr = {
            'manifest:version': self.NS_MANIFEST['manifest_version'],
            'xmlns:manifest': self.NS_MANIFEST['manifest'],
            'xmlns:loext': self.NS_MANIFEST['xmlns:loext'],
        }
        self._manifest = ET.Element('manifest:manifest', attr)
        return self.add_data_manifest(data)

    def parse_manifest(self, data):
        ET.register_namespace('manifest', self.NS_MANIFEST['manifest'])
        self._manifest = ET.fromstring(data)
        attr = {'xmlns:loext': self.NS_MANIFEST['xmlns:loext']}
        self._manifest.attrib.update(**attr)
        self._clean('manifest', self._manifest)
        return

    def add_data_manifest(self, data):
        node_name = 'manifest:file-entry'
        attr = {
            'manifest:full-path': '',
            'manifest:media-type': '',
        }
        for path in data:
            if path in self._paths:
                continue
            ext = path.split('.')[-1]
            attr['manifest:full-path'] = path
            attr['manifest:media-type'] = self.TYPES.get(ext, '')
            ET.SubElement(self._manifest, node_name, attr)
        return self._get_xml(self._manifest)

    def new_description(self, data):
        doc = ET.Element('description', self.NS_DESCRIPTION)

        key = 'identifier'
        ET.SubElement(doc, key, data[key])

        key = 'version'
        ET.SubElement(doc, key, data[key])

        key = 'display-name'
        node = ET.SubElement(doc, key)
        for k, v in data[key].items():
            sn = ET.SubElement(node, 'name', {'lang': k})
            sn.text = v

        node = ET.SubElement(doc, 'extension-description')
        for k in data[key].keys():
            attr = {
                'lang': k,
                'xlink:href': f'description/desc_{k}.txt',
            }
            ET.SubElement(node, 'src', attr)

        key = 'icon'
        node = ET.SubElement(doc, key)
        attr = {'xlink:href': f"images/{data[key]}"}
        ET.SubElement(node, 'default', attr)

        key = 'publisher'
        node = ET.SubElement(doc, key)
        for k, v in data[key].items():
            attr = {
                'xlink:href': v['link'],
                'lang': k,
            }
            sn = ET.SubElement(node, 'name', attr)
            sn.text = v['text']

        key = 'display-name'
        node = ET.SubElement(doc, 'registration')
        attr = {
            'accept-by': 'user',
            'suppress-on-update': 'true',
        }
        node = ET.SubElement(node, 'simple-license', attr)
        for k in data[key].keys():
            attr = {
                'xlink:href': f"{DIRS['registration']}/license_{k}.txt",
                'lang': k
            }
            ET.SubElement(node, 'license-text', attr)

        if data['update']:
            node = ET.SubElement(doc, 'update-information')
            ET.SubElement(node, 'src', {'xlink:href': data['update']})

        return self._get_xml(doc)

    def _get_context(self, args):
        if not args:
            return ''
        context = ','.join([self.CONTEXT[v] for v in args.split(',')])
        return context

    def _add_node_value(self, node, name, value='_self'):
        attr = {'oor:name': name, 'oor:type': 'xs:string'}
        sn = ET.SubElement(node, 'prop', attr)
        sn = ET.SubElement(sn, 'value')
        sn.text = value
        return

    def _add_menu(self, id_extension, node, index, menu, in_menu_bar=True):
        if in_menu_bar:
            attr = {
                'oor:name': index,
                'oor:op': 'replace',
            }
            subnode = ET.SubElement(node, 'node', attr)
        else:
            subnode = node

        attr = {'oor:name': 'Title', 'oor:type': 'xs:string'}
        sn1 = ET.SubElement(subnode, 'prop', attr)
        for k, v in menu['title'].items():
            sn2 = ET.SubElement(sn1, 'value', {'xml:lang': k})
            sn2.text = v
        value = self._get_context(menu['context'])
        self._add_node_value(subnode, 'Context', value)

        if 'submenu' in menu:
            sn = ET.SubElement(subnode, 'node', {'oor:name': 'Submenu'})
            for i, m in enumerate(menu['submenu']):
                self._add_menu(id_extension, sn, f'{index}.s{i}', m)
                if m.get('toolbar', False):
                    self._toolbars.append(m)
            return

        value = f"service:{id_extension}?{menu['argument']}"
        self._add_node_value(subnode, 'URL', value)
        self._add_node_value(subnode, 'Target')
        value = f"%origin%/{self._path_images}/{menu['icon']}"
        self._add_node_value(subnode, 'ImageIdentifier', value)
        return

    def new_addons(self, id_extension, data):
        in_menu_bar = data['parent'] == 'OfficeMenuBar'
        self._path_images = data['images']
        attr = {
            'oor:name': 'Addons',
            'oor:package': 'org.openoffice.Office',
        }
        attr.update(self.NS_ADDONS)
        doc = ET.Element('oor:component-data', attr)
        parent = ET.SubElement(doc, 'node', {'oor:name': 'AddonUI'})
        node = ET.SubElement(parent, 'node', {'oor:name': data['parent']})

        op = 'fuse'
        if in_menu_bar:
            op = 'replace'

        attr = {'oor:name': id_extension, 'oor:op': op}
        node = ET.SubElement(node, 'node', attr)

        if in_menu_bar:
            attr = {'oor:name': 'Title', 'oor:type': 'xs:string'}
            subnode = ET.SubElement(node, 'prop', attr)
            for k, v in data['main'].items():
                sn = ET.SubElement(subnode, 'value', {'xml:lang': k})
                sn.text = v

            self._add_node_value(node, 'Target')
            node = ET.SubElement(node, 'node', {'oor:name': 'Submenu'})

        for i, menu in enumerate(data['menus']):
            self._add_menu(id_extension, node, f'm{i}', menu, in_menu_bar)
            if menu.get('toolbar', False):
                self._toolbars.append(menu)

        if self._toolbars:
            attr = {'oor:name': 'OfficeToolBar'}
            toolbar = ET.SubElement(parent, 'node', attr)
            attr = {'oor:name': id_extension, 'oor:op': 'replace'}
            toolbar = ET.SubElement(toolbar, 'node', attr)
            for t, menu in enumerate(self._toolbars):
                self._add_menu(id_extension, toolbar, f't{t}', menu)

        return self._get_xml(doc)

    def _add_shortcut(self, node, key, id_extension, arg):
        attr = {'oor:name': key, 'oor:op': 'fuse'}
        subnode = ET.SubElement(node, 'node', attr)
        subnode = ET.SubElement(subnode, 'prop', {'oor:name': 'Command'})
        subnode = ET.SubElement(subnode, 'value', {'xml:lang': 'en-US'})
        subnode.text = f"service:{id_extension}?{arg}"
        return

    def _get_acceleartors(self, menu):
        if 'submenu' in menu:
            for m in menu['submenu']:
                return self._get_acceleartors(m)

        if not menu.get('shortcut', ''):
            return ''

        return menu

    def new_accelerators(self, id_extension, menus):
        attr = {
            'oor:name': 'Accelerators',
            'oor:package': 'org.openoffice.Office',
        }
        attr.update(self.NS_ADDONS)
        doc = ET.Element('oor:component-data', attr)
        parent = ET.SubElement(doc, 'node', {'oor:name': 'PrimaryKeys'})

        data = []
        for m in menus:
            info = self._get_acceleartors(m)
            if info:
                data.append(info)

        node_global = None
        node_modules = None
        for m in data:
            if m['context']:
                if node_modules is None:
                    node_modules = ET.SubElement(
                        parent, 'node', {'oor:name': 'Modules'})
                for app in m['context'].split(','):
                    node = ET.SubElement(
                        node_modules, 'node', {'oor:name': self.CONTEXT[app]})
                    self._add_shortcut(
                        node, m['shortcut'], id_extension, m['argument'])
            else:
                if node_global is None:
                    node_global = ET.SubElement(
                        parent, 'node', {'oor:name': 'Global'})
                self._add_shortcut(
                    node_global, m['shortcut'], id_extension, m['argument'])

        return self._get_xml(doc)

    def new_update(self, extension, url_oxt):
        doc = ET.Element('description', self.NS_UPDATE)
        ET.SubElement(doc, 'identifier', {'value': extension['id']})
        ET.SubElement(doc, 'version', {'value': extension['version']})
        node = ET.SubElement(doc, 'update-download')
        ET.SubElement(node, 'src', {'xlink:href': url_oxt})
        node = ET.SubElement(doc, 'release-notes')
        return self._get_xml(doc)

    def _get_xml(self, doc):
        xml = parseString(ET.tostring(doc, encoding='utf-8'))
        return xml.toprettyxml(indent='  ', encoding='utf-8').decode('utf-8')


def _exists(path):
    return os.path.exists(path)


def _join(*paths):
    return os.path.join(*paths)


def _mkdir(path):
    return Path(path).mkdir(parents=True, exist_ok=True)


def _save(path, data):
    with open(path, 'w') as f:
        f.write(data)
    return


def _get_files(path, filters=''):
    paths = []
    if filters in ('*', '*.*'):
        filters = ''
    for folder, _, files in os.walk(path):
        if filters:
            pattern = re.compile(r'\.(?:{})$'.format(filters), re.IGNORECASE)
            paths += [_join(folder, f) for f in files if pattern.search(f)]
        else:
            paths += files
    return paths


def _compress_oxt():
    log.info('Compress OXT extension...')

    path_oxt = _join(DIRS['files'], FILES['oxt'])

    z = zipfile.ZipFile(path_oxt, 'w', compression=zipfile.ZIP_DEFLATED)
    root_len = len(os.path.abspath(DIRS['source']))
    for root, dirs, files in os.walk(DIRS['source']):
        relative = os.path.abspath(root)[root_len:]
        for f in files:
            fullpath = _join(root, f)
            file_name = _join(relative, f)
            if file_name == FILES['idl']:
                continue
            z.write(fullpath, file_name, zipfile.ZIP_DEFLATED)
    z.close()

    log.info('Extension OXT created sucesfully...')
    return


def _install_and_test():
    path_oxt = (_join(DIRS['files'], FILES['oxt']),)
    call(PATHS['install'] + path_oxt)
    log.info('Install extension sucesfully...')
    log.info('Start LibreOffice...')
    call(PATHS['soffice'])
    return


def _validate_new():
    path_source = DIRS['source']
    if not _exists(path_source):
        return True

    msg = f'Path: {path_source}, exists, delete first'
    log.error(msg)
    return False


def _create_new_directories():
    path_source = DIRS['source']
    _mkdir(path_source)
    path = _join(path_source, DIRS['meta'])
    _mkdir(path)
    path = _join(path_source, DIRS['description'])
    _mkdir(path)
    path = _join(path_source, DIRS['images'])
    _mkdir(path)
    path = _join(path_source, DIRS['registration'])
    _mkdir(path)
    path = _join(path_source, DIRS['office'])
    _mkdir(path)

    if FILES['easymacro'] or DIRS['pythonpath']:
        path = _join(path_source, 'pythonpath')
        _mkdir(path)

    path = DIRS['files']
    if not _exists(path):
        _mkdir(path)

    msg = 'Created directories...'
    log.info(msg)
    return


def _create_new_files():
    path_source = DIRS['source']

    for k, v in INFO.items():
        file_name = f'license_{k}.txt'
        path = _join(path_source, DIRS['registration'], file_name)
        _save(path, v['license'])

    if TYPE_EXTENSION > 1:
        path = _join(path_source, FILES['idl'])
        _save(path, DATA['idl'])

    path = _join(path_source, FILES['py'])
    _save(path, DATA['py'])


    msg = 'Created files...'
    log.info(msg)
    return


def _validate_update():
    if TYPE_EXTENSION == 1:
        return True

    if not _exists(PATHS['idlc']):
        msg = 'Binary: "idlc" not found'
        log.error(msg)
        return False

    if not _exists(PATHS['include']):
        msg = 'Directory: "include" not found'
        log.error(msg)
        return False

    if not _exists(PATHS['regmerge']):
        msg = 'Binary: "regmerge" not found'
        log.error(msg)
        return False

    path = _join(DIRS['source'], FILES['idl'])
    if not _exists(path):
        msg = f'File: "{FILES["idl"]}" not found'
        log.error(msg)
        return False

    return True


def _compile_idl():
    if TYPE_EXTENSION == 1:
        return

    log.info('Compilate IDL...')
    path_rdb = _join(DIRS['source'], FILES['rdb'])
    path_urd = _join(DIRS['source'], FILES['urd'])

    path = _join(DIRS['source'], FILES['idl'])
    call([PATHS['idlc'], '-I', PATHS['include'], path])
    call([PATHS['regmerge'], path_rdb, '/UCR',  path_urd])
    os.remove(path_urd)

    log.info('Compilate IDL sucesfully...')
    return


def _update_files():
    path_files = DIRS['files']
    if not _exists(path_files):
        _mkdir(path_files)

    path_source = DIRS['source']

    for k, v in INFO.items():
        file_name = FILES['ext_desc'].format(k)
        path = _join(path_source, DIRS['description'], file_name)
        _save(path, v['description'])

    path_logo = EXTENSION['icon'][0]
    if _exists(path_logo):
        file_name = EXTENSION['icon'][1]
        path = _join(path_source, DIRS['images'], file_name)
        copyfile(path_logo, path)

    files = os.listdir(DIRS['images'])
    for f in files:
        if f[-3:].lower() == 'bmp':
            source = _join(DIRS['images'], f)
            target = _join(path_source, DIRS['images'], f)
            copyfile(source, target)

    if FILES['easymacro']:
        source = EASYMACRO
        target = _join(path_source, 'pythonpath', source)
        copyfile(source, target)

    xml = LiboXML()

    path = _join(path_source, DIRS['meta'], FILES['manifest'])
    data = xml.new_manifest(DATA['manifest'])
    _save(path, data)

    path = _join(path_source, FILES['description'])
    data = xml.new_description(DATA['description'])
    _save(path, data)

    if TYPE_EXTENSION == 1:
        path = _join(path_source, FILES['addons'])
        data = xml.new_addons(EXTENSION['id'], DATA['addons'])
        _save(path, data)

        path = _join(path_source, DIRS['office'])
        _mkdir(path)
        path = _join(path_source, DIRS['office'], FILES['shortcut'])
        data = xml.new_accelerators(EXTENSION['id'], DATA['addons']['menus'])
        _save(path, data)


    if TYPE_EXTENSION == 3:
        path = _join(path_source, FILES['addin'])
        _save(path, DATA['addin'])

    if USE_LOCALES:
        msg = "Don't forget generate DOMAIN.pot for locales"
        for lang in EXTENSION['languages']:
            path = _join(path_source, DIRS['locales'], lang, 'LC_MESSAGES')
            Path(path).mkdir(parents=True, exist_ok=True)
        log.info(msg)

    if DATA['update']:
        path_xml = _join(path_files, FILES['update'])
        data = xml.new_update(EXTENSION, DATA['update'])
        _save(path_xml, data)

    _compile_idl()
    return


def _create():
    if not _validate_new():
        return

    _create_new_directories()
    _create_new_files()
    _update_files()

    msg = f"New extension: {EXTENSION['name']} make sucesfully...\n"
    msg += '\tNow, you can install and test: zaz.py -i'
    log.info(msg)
    return


def _get_info_path(path):
    path, filename = os.path.split(path)
    name, extension = os.path.splitext(filename)
    return (path, filename, name, extension)


def _zip_embed(source, files):
    PATH = 'Scripts/python/'
    FILE_PYC = 'easymacro.pyc'

    p, f, name, e = _get_info_path(source)
    now = datetime.now().strftime('_%Y%m%d_%H%M%S')
    path_source = _join(p, name + now + e)
    copyfile(source, path_source)
    target = source

    py_compile.compile(EASYMACRO, FILE_PYC)
    xml = LiboXML()

    path_easymacro = PATH + FILE_PYC
    names = [f[1] for f in files] + [path_easymacro]
    nodes = []
    with zipfile.ZipFile(target, 'w', compression=zipfile.ZIP_DEFLATED) as zt:
        with zipfile.ZipFile(path_source, compression=zipfile.ZIP_DEFLATED) as zs:
            for name in zs.namelist():
                if FILES['manifest'] in name:
                    path_manifest = name
                    xml_manifest = zs.open(name).read()
                elif name in names:
                    continue
                else:
                    zt.writestr(name, zs.open(name).read())

            data = []
            for path, name in files:
                data.append(name)
                zt.write(path, name)

            zt.write(FILE_PYC, path_easymacro)
            data.append(path_easymacro)

            xml.parse_manifest(xml_manifest)
            xml_manifest = xml.add_data_manifest(data)
            zt.writestr(path_manifest, xml_manifest)

    os.unlink(FILE_PYC)
    return


def _embed(args):
    PATH = 'Scripts/python'
    PYTHONPATH = 'pythonpath'

    doc = args.document
    if not doc:
        msg = '-d/--document Path file to embed is mandatory'
        log.error(msg)
        return
    if not _exists(doc):
        msg = 'Path file not exists'
        log.error(msg)
        return

    files = []
    if args.files:
        files = args.files.split(',')
    source = _join(PATHS['profile'], PATH)
    content = os.listdir(source)
    if PYTHONPATH in content:
        content.remove(PYTHONPATH)

    if files:
        files = [(_join(source, f), _join(PATH, f)) for f in files if f in content]
    else:
        files = [(_join(source, f), _join(PATH, f)) for f in content]

    _zip_embed(doc, files)

    log.info('Embedded macros successfully...')
    return


def _locales(args):
    if args.files:
        files = args.files.split(',')
    else:
        files = _get_files(DIRS['source'], 'py')
    paths = ' '.join([f for f in files if not EASYMACRO in f])
    path_pot = _join(DIRS['source'], DIRS['locales'], '{}.pot'.format(DOMAIN))
    call([PATHS['gettext'], '-o', path_pot, paths])
    log.info('POT generate successfully...')
    return


def _update():
    path_locales = _join(DIRS['source'], DIRS['locales'])
    path_pot = _join(DIRS['source'], DIRS['locales'], '{}.pot'.format(DOMAIN))
    if not _exists(path_pot):
        log.error('Not exists file POT...')
        return

    files = _get_files(path_locales, 'po')
    if not files:
        log.error('First, generate files PO...')
        return

    for f in files:
        call([PATHS['msgmerge'], '-U', f, path_pot])
        log.info('\tUpdate:  {}'.format(f))

    log.info('Locales update successfully...')
    return


def _new(args):
    if not args.target:
        msg = 'Add argument target: -t PATH_TARGET'
        log.error(msg)
        return

    if not args.name:
        msg = 'Add argument name: -n name-new-extension'
        log.error(msg)
        return

    path = _join(args.target, args.name)
    _mkdir(path)
    _mkdir(_join(path, 'files'))
    _mkdir(_join(path, 'images'))
    path_logo = 'images/pymacros.png'
    copyfile(path_logo, _join(path, 'images/logo.png'))
    copyfile('zaz.py', _join(path, 'zaz.py'))
    copyfile(EASYMACRO, _join(path, 'easymacro.py'))
    copyfile('conf.py.example', _join(path, 'conf.py'))

    msg = 'Folders and files copy successfully for new extension.'
    log.info(msg)
    msg = f'Change to folder: {path}'
    log.info(msg)
    return


def main(args):
    if args.new:
        _new(args)
        return

    if args.update:
        _update()
        return

    if args.locales:
        _locales(args)
        return

    if args.embed:
        _embed(args)
        return

    if args.create:
        _create()
        return

    if not _validate_update():
        return

    if not args.only_compress:
        _update_files()

    _compress_oxt()

    if args.install:
        _install_and_test()

    log.info('Extension make successfully...')
    return


def _process_command_line_arguments():
    parser = argparse.ArgumentParser(
        description='Make LibreOffice extensions')
    parser.add_argument('-new', '--new', dest='new', action='store_true',
        default=False, required=False)
    parser.add_argument('-t', '--target', dest='target', default='')
    parser.add_argument('-n', '--name', dest='name', default='', required=False)
    parser.add_argument('-c', '--create', dest='create', action='store_true',
        default=False, required=False)
    parser.add_argument('-i', '--install', dest='install', action='store_true',
        default=False, required=False)
    parser.add_argument('-e', '--embed', dest='embed', action='store_true',
        default=False, required=False)
    parser.add_argument('-d', '--document', dest='document', default='')
    parser.add_argument('-f', '--files', dest='files', default='')
    parser.add_argument('-l', '--locales', dest='locales', action='store_true',
        default=False, required=False)
    parser.add_argument('-u', '--update', dest='update', action='store_true',
        default=False, required=False)
    parser.add_argument('-oc', '--only_compress', dest='only_compress',
        action='store_true', default=False, required=False)
    return parser.parse_args()


if __name__ == '__main__':
    args = _process_command_line_arguments()
    main(args)

