#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# build_python_packages.py
#
# Copyright (C) 2023 Franco Masotti (franco \D\o\T masotti {-A-T-} tutanota \D\o\T com)
#
# This program 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.
#
# This program 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 this program.  If not, see <https://www.gnu.org/licenses/>.
#

import logging
import os
import pathlib
import shlex
import signal
import subprocess
import sys

import fpyutils
import platformdirs
import yaml


def _setup_logging() -> logging.Logger:
    # See
    # https://python3docs.franco.net.eu.org/howto/logging.html#logging-advanced-tutorial
    logger = logging.getLogger('build_python_packages.py')
    logger.setLevel(logging.DEBUG)

    # Console logging
    ch = logging.StreamHandler()
    ch.setLevel(logging.DEBUG)

    formatter = logging.Formatter(
        '%(asctime)s - %(name)s - %(levelname)s - %(message)s')
    ch.setFormatter(formatter)

    logger.addHandler(ch)

    return logger


def _read_yaml_file(yaml_file: str) -> dict:
    data: dict = dict()
    if pathlib.Path(yaml_file).is_file():
        data = yaml.load(open(yaml_file, 'r'), Loader=yaml.SafeLoader)

    return data


def _write_yaml_file(data: dict, yaml_file: str) -> dict:
    with open(yaml_file, 'w') as f:
        f.write(yaml.dump(data))


# This class represents a single submodule.
class SubmoduleConfiguration:

    def __init__(self,
                 path: str,
                 skip_repository: bool = False,
                 mark_skip_repository_successfull: bool = False,
                 mark_failed_build_or_upload_successfull: bool = False,
                 relative_base_directory_override: str = str(),
                 override_commands: dict = dict(),
                 ref_checkout: list = list()):
        r"""Create a SubmoduleConfiguration."""
        self.path: str = shlex.quote(path)
        self.skip_repository: bool = skip_repository

        # Common to all SubmoduleConfiguration instances.
        self.mark_skip_repository_successfull = mark_skip_repository_successfull
        self.mark_failed_build_or_upload_successfull = mark_failed_build_or_upload_successfull

        # Remote configuration
        self.relative_base_directory_override: str = relative_base_directory_override
        self.override_commands: dict = override_commands
        self.ref_checkout: list = ref_checkout

    def _execute_override_commands(self, command_type: str = 'pre'):
        cmd: list
        if (isinstance(self.override_commands, dict)
                and self.override_commands != dict()):
            # Command type must be {pre,build,post}
            for block in self.override_commands[command_type]:
                cmd = list()
                for c in self.override_commands[command_type][block]:
                    cmd.append(shlex.quote(c))
                try:
                    subprocess.run(cmd, check=True)
                    logger.info('override command executed correctly')
                except subprocess.CalledProcessError as e:
                    # Print but do not abort the program.
                    logger.warning(e)
                    logger.info('error executing override command')


class Cache:

    def __init__(self):
        r"""Create the Cache structure."""
        self.path: str = str()

        # cache[base_path] = [tag_0, tag_1, ..., tag_n]
        self.cache: dict = dict()

    def _init(self):
        platformdir: platformdirs.AppDirs = platformdirs.AppDirs(
            'build_python_packages')
        platformdir.user_cache_path.mkdir(mode=0o700,
                                          exist_ok=True,
                                          parents=True)
        self.path = pathlib.Path(platformdir.user_cache_dir, 'cache.yml')

    def _read(self):
        self.cache = _read_yaml_file(self.path)
        logger.info('cache read')

    def _write(self):
        _write_yaml_file(self.cache, self.path)
        logger.info('cache written')

    def _update(self, package_path: str, tag: str):
        if tag != str():
            if package_path not in self.cache:
                self.cache[package_path]: list = list()
            self.cache[package_path].append(tag)
            logger.info('cache updated')
        else:
            logger.info('cache not updated because of tagless repository')


class Executables:

    def __init__(self,
                 git: str = 'git',
                 python: str = 'python3',
                 twine: str = 'twine',
                 rm: str = 'rm'):
        r"""Save the paths of all necessary executables."""
        self.git: str = shlex.quote(git)
        self.python: str = shlex.quote(python)
        self.twine: str = shlex.quote(twine)
        self.rm: str = shlex.quote(rm)


class PypiCredentials:

    def __init__(self, url: str, user: str, password: str):
        r"""Save the PyPI credentials to be used for a mirror."""
        self.url = shlex.quote(url)
        self.user = user
        self.password = password


class GitRepository:

    def __init__(self, path: str, executables: Executables):
        r"""Initialize a generic empty GIT repository."""
        self.path: str = shlex.quote(path)
        self.executables: str = executables

    def _get_tags(self) -> list:
        s = subprocess.run([self.executables.git, '-C', self.path, 'tag'],
                           check=True,
                           capture_output=True)
        logger.info('obtained git tags')
        return s.stdout.decode('UTF-8').rstrip().split('\n')

    def _remove_untracked_files(self):
        fpyutils.shell.execute_command_live_output(self.executables.git +
                                                   ' -C ' + self.path +
                                                   ' checkout --force --')
        fpyutils.shell.execute_command_live_output(self.executables.git +
                                                   ' -C ' + self.path +
                                                   ' clean -d -x --force')

    def _get_last_ref_timestamp(self) -> str:
        return subprocess.run(
            [
                self.executables.git, '-C', self.path, 'log', '-1',
                '--pretty=%ct'
            ],
            check=True,
            capture_output=True).stdout.decode('UTF-8').strip()

    def _tag_checkout(self, tag: str):
        # Checkout repository with tags: avoid checking out tagless repositories.
        if tag != str():
            fpyutils.shell.execute_command_live_output(
                shlex.quote(self.executables.git) + ' -C ' + self.path +
                ' checkout ' + tag)


class Dist:

    def __init__(self, path: str, executables: Executables):
        r"""Initialize a Dist which is a subset of a package."""
        self.path = shlex.quote(path)
        self.executables = executables

    def _build(self, git_repository_timestamp: str):
        git_repository_timestamp = shlex.quote(git_repository_timestamp)
        r"""Build the Python package in a reproducable way.

        Remove all dev, pre-releases, etc information from the package name.
        Use a static timestamp.
        See
        https://github.com/pypa/build/issues/328#issuecomment-877028239
        """
        subprocess.run([
            self.executables.python, '-m', 'build', '--sdist', '--wheel',
            '-C--build-option=egg_info', '-C--build-option=--no-date',
            '-C--build-option=--tag-build=', self.path
        ],
                       check=True,
                       env=dict(os.environ,
                                SOURCE_DATE_EPOCH=git_repository_timestamp))

    def _upload(self, pypi_credentials: PypiCredentials):
        r"""Push the compiled package to a remote PyPI server."""
        subprocess.run([
            self.executables.twine, 'upload', '--repository-url',
            pypi_credentials.url, '--non-interactive', '--skip-existing',
            str(pathlib.Path(self.path, 'dist/*'))
        ],
                       check=True,
                       env=dict(os.environ,
                                TWINE_PASSWORD=pypi_credentials.password,
                                TWINE_USERNAME=pypi_credentials.user))


class Package:

    def __init__(self, path: str, tag: str, repo: GitRepository,
                 submodule_configuration: SubmoduleConfiguration, cache: Cache,
                 executables: Executables):
        r"""Initialize a Package which is a subset of a worker."""
        self.path: str = shlex.quote(path)
        # Do not qute tag: str() == '', shlex.quote(str()) == "''"
        self.tag = tag
        self.repo = repo
        self.submodule_configuration: SubmoduleConfiguration = submodule_configuration
        self.cache: Cache = cache
        self.executables = executables

        self.dist: Dist = Dist(self.path, self.executables)

    def _clean(self):
        fpyutils.shell.execute_command_live_output(
            self.executables.rm + ' -rf ' +
            fpyutils.path.add_trailing_slash(self.path) + 'build ' +
            fpyutils.path.add_trailing_slash(self.path) + 'dist')

    def _work(self, pypi_credentials: PypiCredentials) -> bool:
        # Retuns True if build and upload are successfull
        # False otherwise
        update_cache: bool = False
        successfull: bool = False

        self.repo._remove_untracked_files()
        self._clean()

        # Do not checkout empty tags
        if self.tag != str():
            self.repo._tag_checkout(self.tag)

        try:
            self.submodule_configuration._execute_override_commands('pre')

            # Replace build command if necessary.
            if self.submodule_configuration.override_commands != dict():
                self.submodule_configuration._execute_override_commands(
                    'build')
            else:
                self.dist._build(self.repo._get_last_ref_timestamp())

            # Post
            self.submodule_configuration._execute_override_commands('post')

            self.dist._upload(pypi_credentials)

            if self.tag != str():
                update_cache = True
            successfull = True
            logger.info('package build successfully')
        except subprocess.CalledProcessError:
            logger.info('error building package')
            if (self.submodule_configuration.
                    mark_failed_build_or_upload_successfull
                    and self.tag != str):
                successfull = True
                update_cache = True

        if update_cache:
            self.cache._update(pathlib.Path(self.path).stem, self.tag)

        self.repo._remove_untracked_files()

        return successfull


class RepositoryWorker:

    def __init__(self, path: str,
                 submodule_configuration: SubmoduleConfiguration,
                 executables: Executables):
        r"""Initialize a worker which corresponds to a GIT submodule."""
        # Working directory full path of Python code.
        self.path = shlex.quote(path)

        self.submodule_configuration: SubmoduleConfiguration = submodule_configuration
        self.executables: Executables = executables
        self.successfull_tags: int = 0
        self.total_tags: int = 0

    def _work(self, cache: Cache, pypi_credentials: PypiCredentials):
        repo: GitRepository = GitRepository(self.path, self.executables)
        tags: list = repo._get_tags()
        self.total_tags: int = len(tags)
        for i, tag in enumerate(tags):
            logger.info('processing git tag ' + str(i + 1) + ' of ' +
                        str(len(tags)))

            if self.submodule_configuration.skip_repository:
                logger.info('git tag ' + str(i + 1) + ' is skipped')
                if self.submodule_configuration.mark_skip_repository_successfull:
                    self.successfull_tags += 1
                    logger.info('marking skipped git tag ' + str(i + 1) +
                                ' as successfull')
                    cache._update(pathlib.Path(self.path).stem, tag)

            elif (pathlib.Path(self.path).stem in cache.cache
                  and tag in cache.cache[pathlib.Path(self.path).stem]):
                self.successfull_tags += 1
                logger.info('git tag ' + str(i + 1) + ' already in cache')

            else:
                p = Package(
                    path=self.path,
                    tag=tag,
                    repo=repo,
                    submodule_configuration=self.submodule_configuration,
                    cache=cache,
                    executables=self.executables)
                self.successfull_tags += int(p._work(pypi_credentials))


class GitParentRepository(GitRepository):

    def __init__(self, path: str, remote: str, checkout_branch: str,
                 local_sumodules_configuration: dict, cache: Cache,
                 executables: Executables):
        r"""Initialize the main repository."""
        super().__init__(path, executables)

        # usually set to 'origin'
        self.remote: str = shlex.quote(remote)
        self.checkout_branch: str = shlex.quote(checkout_branch)

        # self.configuration = SubmoduleConfiguration.all()
        self.submodules_configuration: dict = dict()

        self.submodules: list = list()
        self.local_sumodules_configuration = local_sumodules_configuration
        self.cache: Cache = cache

        self.total_successfull_tags: int = 0
        self.total_tags: int = 0

    def _get_updates(self):
        logger.info(
            'pulling parent repository changes, this might take a while')
        fpyutils.shell.execute_command_live_output(self.executables.git +
                                                   ' -C ' + self.path +
                                                   ' pull ' + self.remote +
                                                   ' ' + self.checkout_branch)
        fpyutils.shell.execute_command_live_output(
            self.executables.git + ' -C ' + self.path +
            ' submodule foreach --recursive git reset --hard')
        fpyutils.shell.execute_command_live_output(self.executables.git +
                                                   ' -C ' + self.path +
                                                   ' submodule sync')
        # We might need to add the '--recursive' option for 'git submodule update'
        # to build certain packages. This means that we still depend from external
        # services at build time if we use that option.
        fpyutils.shell.execute_command_live_output(
            self.executables.git + ' -C ' + self.path +
            ' submodule update --init --remote')
        fpyutils.shell.execute_command_live_output(
            self.executables.git + ' -C ' + self.path +
            ' submodule foreach git fetch --tags --force')
        logger.info('parent repository changes pulled')

    def _append_local_submodules_configuration(self):
        self.submodules_configuration[
            'local'] = self.local_sumodules_configuration

    # Read the 'configuration.yaml' file in the repository
    def _read_submodules_configuration(self):
        remote_configuration_file = pathlib.Path(self.path,
                                                 'configuration.yaml')
        if remote_configuration_file.is_file():
            self.submodules_configuration = yaml.load(open(
                remote_configuration_file, 'r'),
                                                      Loader=yaml.SafeLoader)
            self.submodules_configuration[
                'remote'] = self.submodules_configuration['submodules']
            del self.submodules_configuration['submodules']
            logger.info(
                'parent repository submodules configuration was read correctly'
            )
        else:
            logger.info('no repository submodules configuration present')
        self._append_local_submodules_configuration()
        logger.info('all submodules configuration was read')

    def _get_submodules(self):
        self.submodules = [
            x for x in pathlib.Path(self.path, 'submodules').iterdir()
        ]
        logger.info('got submodules directories list')

    def _call_worker(self, pypi_credentials: PypiCredentials):
        self._get_submodules()

        signal.signal(signal.SIGINT,
                      lambda signal, frame: self._signal_handler())
        signal.signal(signal.SIGTERM,
                      lambda signal, frame: self._signal_handler())

        for i in range(0, len(self.submodules)):
            logger.info('remaining ' + str(len(self.submodules) - i + 1) +
                        ' submodules')
            d: pathlib.Path = self.submodules[i]

            dirname: str = pathlib.Path(self.path, d).stem
            skip_repository: bool = False
            relative_base_directory_ovr: str = str()
            override_commands: dict = dict()
            ref_checkout: list = list()

            if dirname in self.submodules_configuration['local'][
                    'skip_repository']:
                skip_repository = True
            if dirname in self.submodules_configuration['remote']:
                relative_base_directory_ovr = self.submodules_configuration[
                    'remote'][dirname]['base_directory_override']
                override_commands = self.submodules_configuration['remote'][
                    dirname]['override_commands']
                ref_checkout = self.submodules_configuration['remote'][
                    dirname]['ref_checkout']

            submodule_cfg = SubmoduleConfiguration(
                path=d.stem,
                skip_repository=skip_repository,
                mark_skip_repository_successfull=self.submodules_configuration[
                    'local']['mark_skip_repository_successfull'],
                mark_failed_build_or_upload_successfull=self.
                submodules_configuration['local']
                ['mark_failed_build_or_upload_successfull'],
                relative_base_directory_override=relative_base_directory_ovr,
                override_commands=override_commands,
                ref_checkout=ref_checkout)

            worker = RepositoryWorker(path=str(d),
                                      submodule_configuration=submodule_cfg,
                                      executables=self.executables)
            worker._work(cache=self.cache, pypi_credentials=pypi_credentials)

            self.total_successfull_tags += worker.successfull_tags
            self.total_tags += worker.total_tags

    def _signal_handler(self):
        logger.info('signal received. finished queued workers and writing ' +
                    str(len(self.cache.cache)) +
                    ' repository elements to cache before exit')
        self.cache._write()
        sys.exit(1)

    def _stats(self):
        logger.info('total successfull tags: ' +
                    str(self.total_successfull_tags))
        logger.info('total tags: ' + str(self.total_tags))


class Notify:

    def __init__(self, gotify: dict, email: dict):
        r"""Save data for the notifications."""
        self.message: str = str()
        self.gotify: dict = gotify
        self.email: dict = email

    def _get_message(self, parent_repo: GitParentRepository):
        self.message = ''.join([
            'total tags: ',
            str(parent_repo.total_tags), '\n', 'total successfull tags: ',
            str(parent_repo.total_successfull_tags), '\n',
            'tag successfull ratio: ',
            str(parent_repo.total_successfull_tags / parent_repo.total_tags)
        ])

    def _send(self):
        m = self.gotify['message'] + '\n' + self.message
        if self.gotify['enabled']:
            fpyutils.notify.send_gotify_message(self.gotify['url'],
                                                self.gotify['token'], m,
                                                self.gotify['title'],
                                                self.gotify['priority'])
        if self.email['enabled']:
            fpyutils.notify.send_email(
                self.message, self.email['smtp_server'], self.email['port'],
                self.email['sender'], self.email['user'],
                self.email['password'], self.email['receiver'],
                self.email['subject'])


logger: logging.Logger = _setup_logging()


def main():
    config = _read_yaml_file(shlex.quote(sys.argv[1]))
    if config == dict():
        raise ValueError

    cache = Cache()
    cache._init()
    cache._read()
    execs = Executables(git=config['executables']['git'],
                        python=config['executables']['python'],
                        twine=config['executables']['twine'],
                        rm=config['executables']['rm'])
    pypi = PypiCredentials(config['pypi']['url'], config['pypi']['user'],
                           config['pypi']['password'])
    parent_repo = GitParentRepository(config['repository']['path'],
                                      config['repository']['remote'],
                                      config['repository']['checkout_branch'],
                                      config['submodules'], cache, execs)
    parent_repo._read_submodules_configuration()
    parent_repo._get_updates()
    parent_repo._call_worker(pypi)
    cache._write()

    n = Notify(config['notify']['gotify'], config['notify']['email'])
    n._get_message()
    n._send()


if __name__ == '__main__':
    main()
