# Copyright (c) 2015 SUSE Linux GmbH.  All rights reserved.
#
# This file is part of kiwi.
#
# kiwi 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.
#
# kiwi 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 kiwi.  If not, see <http://www.gnu.org/licenses/>
#
import re
import os
import glob
import logging
import pathlib
from typing import (
    List, Dict
)

# project
from kiwi.command import CommandCallT
from kiwi.command import Command
from kiwi.path import Path
from kiwi.package_manager.base import PackageManagerBase
from kiwi.system.root_bind import RootBind
from kiwi.repository.apt import RepositoryApt
from kiwi.utils.temporary import Temporary

import kiwi.defaults as defaults

from kiwi.exceptions import (
    KiwiDebianBootstrapError,
    KiwiRequestError,
    KiwiFileNotFound
)

log = logging.getLogger('kiwi')


class PackageManagerApt(PackageManagerBase):
    """
    **Implements Installation/Deletion of packages/collections with apt-get**

    :param list apt_get_args:
        apt-get arguments from repository runtime configuration
    :param dict command_env:
        apt-get command environment from repository
        runtime configuration
    """
    def post_init(self, custom_args: List = []) -> None:
        """
        Post initialization method

        Store custom apt-get arguments

        :param list custom_args: custom apt-get arguments
        """
        self.repository: RepositoryApt = self.repository
        self.custom_args = custom_args

        self.deboostrap_minbase: bool = True

        runtime_config = self.repository.runtime_config()
        self.apt_get_args = runtime_config['apt_get_args']
        self.command_env = runtime_config['command_env']
        self.distribution = runtime_config['distribution']
        self.distribution_path = runtime_config['distribution_path']
        self.bootstrap_packages: List[str] = []

    def request_package(self, name: str) -> None:
        """
        Queue a package request

        :param str name: package name
        """
        self.package_requests.append(name)

    def request_collection(self, name: str) -> None:
        """
        Queue a collection request

        There is no collection definition in the deb repo data

        :param str name: unused
        """
        log.warning(
            'Collection(%s) handling not supported for apt-get', name
        )

    def request_product(self, name: str) -> None:
        """
        Queue a product request

        There is no product definition in the deb repo data

        :param str name: unused
        """
        log.warning(
            'Product(%s) handling not supported for apt-get', name
        )

    def request_package_exclusion(self, name: str) -> None:
        """
        Queue a package exclusion(skip) request

        Package exclusion for apt package manager not yet implemented

        :param str name: unused
        """
        log.warning(
            'Package exclusion for (%s) not supported for apt-get', name
        )

    def setup_repository_modules(
        self, collection_modules: Dict[str, List[str]]
    ) -> None:
        """
        Repository modules not supported for apt-get
        The method does nothing in this scope

        :param dict collection_modules: unused
        """
        pass

    def process_install_requests_bootstrap(
        self, root_bind: RootBind = None, bootstrap_package: str = None
    ) -> CommandCallT:
        """
        Process package install requests for bootstrap phase (no chroot)
        Either a manual unpacking strategy or a prebuilt bootstrap
        package can be used to bootstrap a new system.

        :param object root_bind:
            unused
        :param str bootstrap_package:
            package name of a bootstrap package

        :return: process results as command instance

        :rtype: command_call_type
        """
        if bootstrap_package:
            return self._process_install_requests_bootstrap_prebuild_root(
                bootstrap_package
            )
        else:
            return self._process_install_requests_bootstrap()

    def _process_install_requests_bootstrap_prebuild_root(
        self, bootstrap_package: str
    ) -> CommandCallT:
        """
        Process bootstrap phase (no chroot) using a prebuilt bootstrap
        package. The package has to provide a tarball below the
        directory /var/cache/kiwi/bootstrap/PACKAGE_NAME.ARCH.tar.xz
        and will be unpacked to serve as the bootstrap root.

        The optionally listed packages in the kiwi bootstrap section
        will be installed as part of a chroot install and returned
        as command instance

        :param str bootstrap_package:
            package name of the bootstrap package containing the
            bootstrap root as a tarball

        :return: process results as command instance

        :rtype: command_call_type
        """
        # Install prebuilt bootstrap package on build host
        update_command = [
            'apt-get'
        ] + self.apt_get_args + self.custom_args + [
            'update'
        ]
        install_command = [
            'apt-get'
        ] + self.apt_get_args + self.custom_args + [
            'install', bootstrap_package
        ]
        Command.run(
            update_command, self.command_env
        )
        Command.run(
            install_command, self.command_env
        )
        # Unpack prebuilt bootstrap root tarball as new root
        bootstrap_root_tarball = '/var/lib/bootstrap/{0}.{1}.tar.xz'.format(
            bootstrap_package, defaults.PLATFORM_MACHINE
        )
        if not os.path.isfile(bootstrap_root_tarball):
            raise KiwiFileNotFound(
                f'bootstrap tarball: {bootstrap_root_tarball!r} not found'
            )
        Command.run(
            ['tar', '-C', self.root_dir, '-xf', bootstrap_root_tarball]
        )
        # Install eventual bootstrap packages as standard system install
        return self.process_install_requests()

    def _process_install_requests_bootstrap(self) -> CommandCallT:
        """
        Process package install requests for bootstrap phase (no chroot).

        :raises KiwiDebianBootstrapError

        :return: process results in command type

        :rtype: CommandCallT
        """
        # we invoke apt-get install to download all the essential packages.
        # With DPkg::Pre-Install-Pkgs, we specify a shell command that will
        # receive the list of packages that will be installed on stdin.
        # By configuring Debug::pkgDpkgPm=1, apt-get install will not
        # actually execute any dpkg commands, so all it does is download
        # the essential debs and tell us their full in the apt cache without
        # actually installing them.
        try:
            # TODO: Drop once apt 2.5.4 is widely available.
            pathlib.Path(f'{self.root_dir}/var/lib/dpkg').mkdir(
                parents=True, exist_ok=True
            )
            # Older versions of apt look for the status file both within and
            # without the tree, but do not create the status file within the
            # tree.
            pathlib.Path(f'{self.root_dir}/var/lib/dpkg/status.kiwi').touch()
            # Some versions of apt don't create the status file outside the
            # tree, e.g. 2.4.14.
            pathlib.Path('/var/lib/dpkg/status.kiwi').touch()
            pathlib.Path(f'{self.root_dir}/var/lib/dpkg/available').touch()

            if 'apt' not in self.package_requests:
                self.package_requests.append('apt')
            update_cache = [
                'apt-get'
            ] + self.apt_get_args + self.custom_args + [
                'update'
            ]
            result = Command.run(
                update_cache, self.command_env
            )
            log.debug(
                'Apt update: {0} {1}'.format(result.output, result.error)
            )
            package_names = Temporary(prefix='kiwi_debs_').new_file()
            self.bootstrap_packages = [
                '?essential'
            ] + self.package_requests
            download_bootstrap = [
                'apt-get'
            ] + self.apt_get_args + self.custom_args + [
                'install',
                '-oDebug::pkgDPkgPm=1',
                f'-oDPkg::Pre-Install-Pkgs::=cat >{package_names.name}'
            ] + self.bootstrap_packages
            # Download solved bootstrap packages
            result = Command.run(
                download_bootstrap, self.command_env
            )
            log.debug(
                'Apt download: {0} {1}'.format(result.output, result.error)
            )
            # Extract bootstrap packages
            with open(package_names.name) as packages:
                solved_debootstrap_packages = [p.rstrip() for p in packages]
            self.command_env['PATH'] = '$PATH:/usr/bin:/bin:/usr/sbin:/sbin'
            for package in solved_debootstrap_packages:
                Command.run(
                    [
                        'bash', '-c',
                        'dpkg-deb --fsys-tarfile {0} | tar -C {1} -x'.format(
                            package, self.root_dir
                        )
                    ], self.command_env
                )
            # Run package scripts. Unfortuantely Debian based systems
            # requires special sauce for bootstrap. See the exceptions
            # we have to apply below:
            #
            # * manual order is required to make sure users(root) exists
            # * the usr-merge strategy only works after bootstrap
            #
            # 1. Pass: Run package scripts, manual order
            self._run_bootstrap_scripts(
                solved_debootstrap_packages,
                only_for=['base-passwd'], skip=['usrmerge']
            )
            # 2. Pass: Run package scripts in apt order
            self._run_bootstrap_scripts(
                solved_debootstrap_packages,
                skip=['usrmerge']
            )
            self.cleanup_requests()
            return Command.call(
                update_cache, self.command_env
            )
        except Exception as e:
            raise KiwiDebianBootstrapError(
                f'{type(e).__name__}: {format(e)}'
            )

    def process_install_requests(self) -> CommandCallT:
        """
        Process package install requests for image phase (chroot)

        :return: process results in command type

        :rtype: namedtuple
        """
        update_command = ['chroot', self.root_dir, 'apt-get']
        update_command.extend(
            Path.move_to_root(self.root_dir, self.apt_get_args)
        )
        update_command.extend(self.custom_args)
        update_command.append('update')
        Command.run(update_command, self.command_env)

        apt_get_command = ['chroot', self.root_dir, 'apt-get']
        apt_get_command.extend(
            Path.move_to_root(self.root_dir, self.apt_get_args)
        )
        apt_get_command.extend(self.custom_args)
        apt_get_command.append('install')
        # Due to the special bootstrap process, the packages unpacked
        # during bootstrap are not properly listed in the apt index.
        # Therefore the bootstrap packages are added to the install
        # phase which causes an install of this packages again to
        # fix the apt index and provide a consistent system from
        # an apt perspective.
        apt_get_command.extend(self.bootstrap_packages)
        apt_get_command.extend(self._package_requests())

        return Command.call(
            apt_get_command, self.command_env
        )

    def process_delete_requests(self, force: bool = False) -> CommandCallT:
        """
        Process package delete requests (chroot)

        :param bool force: force deletion: true|false

        :raises KiwiRequestError: if none of the packages to delete is
            installed
        :return: process results in command type

        :rtype: namedtuple
        """
        delete_items = []
        for delete_item in self._package_requests():
            try:
                Command.run(
                    ['chroot', self.root_dir, 'dpkg', '-l', delete_item]
                )
                delete_items.append(delete_item)
            except Exception:
                # ignore packages which are not installed
                pass
        if not delete_items:
            raise KiwiRequestError(
                'None of the requested packages to delete are installed'
            )

        # deleting debs for some reason can also trigger package
        # scripts from the libc-bin (glibc) package. This often
        # results in calls like ldconfig which failed unless the
        # system would be really running. Since the deletion
        # happens via chroot and with the system in offline mode
        # many attempts to uninstall a package, even cracefully
        # failed for this reason. So far I only saw the workaround
        # to make ldconfig a noop during the process of uninstall.
        # This is not a nice solution and I dislike it. However,
        # the only one I could come up with to turn the package
        # uninstall in a useful feature in kiwi.
        Command.run(
            [
                'cp',
                f'{self.root_dir}/usr/sbin/ldconfig',
                f'{self.root_dir}/usr/sbin/ldconfig.orig'
            ]
        )
        Command.run(
            [
                'cp',
                f'{self.root_dir}/usr/bin/true',
                f'{self.root_dir}/usr/sbin/ldconfig'
            ]
        )
        if force:
            # force deleting debs only worked well for me when ignoring
            # the pre/post-inst and pre/post-remove codings. No idea why it
            # ends in so many conflicts but if you want to force get rid
            # of stuff this was the only way I could come up with. There
            # are still cases when it does not work depending on the many
            # code that runs on deleting
            for package in delete_items:
                pre_delete_pattern = \
                    f'{self.root_dir}/var/lib/dpkg/info/{package}*.pre*'
                post_delete_pattern = \
                    f'{self.root_dir}/var/lib/dpkg/info/{package}*.post*'
                for delete_file in glob.iglob(pre_delete_pattern):
                    Path.wipe(delete_file)
                for delete_file in glob.iglob(post_delete_pattern):
                    Path.wipe(delete_file)

            apt_get_command = ['chroot', self.root_dir, 'dpkg']
            apt_get_command.extend(
                [
                    '--remove',
                    '--force-remove-reinstreq',
                    '--force-remove-essential',
                    '--force-depends'
                ]
            )
            apt_get_command.extend(delete_items)
        else:
            apt_get_command = ['chroot', self.root_dir, 'apt-get']
            apt_get_command.extend(
                Path.move_to_root(self.root_dir, self.apt_get_args)
            )
            apt_get_command.extend(self.custom_args)
            apt_get_command.extend(['--auto-remove', 'remove'])
            apt_get_command.extend(delete_items)

        return Command.call(
            apt_get_command, self.command_env
        )

    def post_process_delete_requests(
        self, root_bind: RootBind = None
    ) -> None:
        """
        Revert system changes done prior deleting packages

        :param object root_bind: unused
        """
        Command.run(
            [
                'mv',
                f'{self.root_dir}/usr/sbin/ldconfig.orig',
                f'{self.root_dir}/usr/sbin/ldconfig'
            ]
        )

    def update(self) -> CommandCallT:
        """
        Process package update requests (chroot)

        :return: process results in command type

        :rtype: namedtuple
        """
        apt_get_command = ['chroot', self.root_dir, 'apt-get']
        apt_get_command.extend(
            Path.move_to_root(self.root_dir, self.apt_get_args)
        )
        apt_get_command.extend(self.custom_args)
        apt_get_command.append('upgrade')

        return Command.call(
            apt_get_command, self.command_env
        )

    def process_only_required(self) -> None:
        """
        Setup package processing only for required packages
        """
        if '--no-install-recommends' not in self.custom_args:
            self.custom_args.append('--no-install-recommends')
        self.deboostrap_minbase = True

    def process_plus_recommended(self) -> None:
        """
        Setup package processing to also include recommended dependencies.
        """
        if '--no-install-recommends' in self.custom_args:
            self.custom_args.remove('--no-install-recommends')
        self.deboostrap_minbase = False

    def match_package_installed(
        self, package_name: str, package_manager_output: str
    ) -> bool:
        """
        Match expression to indicate a package has been installed

        This match for the package to be installed in the output
        of the apt-get command is not 100% accurate. There might
        be false positives due to sub package names starting with
        the same base package name

        :param str package_name: package_name
        :param str package_manager_output: apt-get status line

        :returns: True|False

        :rtype: bool
        """
        return bool(
            re.match(
                '.*Unpacking {0}.*'.format(re.escape(package_name)),
                package_manager_output
            )
        )

    def match_package_deleted(
        self, package_name: str, package_manager_output: str
    ) -> bool:
        """
        Match expression to indicate a package has been deleted

        :param str package_name: package_name
        :param str package_manager_output: apt-get status line

        :returns: True|False

        :rtype: bool
        """
        return bool(
            re.match(
                '.*Removing {0}.*'.format(re.escape(package_name)),
                package_manager_output
            )
        )

    def _package_requests(self) -> List:
        items = self.package_requests[:]
        self.cleanup_requests()
        return items

    def _run_bootstrap_scripts(
        self, solved_debootstrap_packages: List[str],
        only_for: List[str] = [], skip: List[str] = []
    ):
        # TODO: this should not be needed but without setting
        # the following environment variables no package pre/post
        # script completes its task. I leave it up to the Debian
        # experts to provide a fix if needed.
        self.command_env['DPKG_MAINTSCRIPT_NAME'] = 'true'
        self.command_env['DPKG_MAINTSCRIPT_PACKAGE'] = 'libc6'

        post_script_dir = Temporary(
            prefix='kiwi_debpost.', path=self.root_dir
        ).new_dir()
        for package in solved_debootstrap_packages:
            package_base_name = os.path.basename(package)
            go_ahead = False if only_for else True
            for name in only_for:
                if name in package_base_name:
                    go_ahead = True
                    break
            for name in skip:
                if name in package_base_name:
                    go_ahead = False
                    break
            if not go_ahead:
                continue
            log.debug(
                f'Running pre/post scripts for: {package_base_name}'
            )
            package_metadata_dir = \
                f'{post_script_dir.name}/{os.path.basename(package)}'
            Command.run(
                ['dpkg', '-e', package, package_metadata_dir]
            )
            script_pre = f'{package_metadata_dir}/preinst'
            script_post = f'{package_metadata_dir}/postinst'
            # 1. preinst
            if os.path.exists(script_pre):
                Command.run(['chmod', '755', script_pre])
                Command.run(
                    [
                        'chroot', self.root_dir,
                        f'{script_pre.replace(self.root_dir, "")}', 'install'
                    ], self.command_env
                )
            # 2. postinst
            if os.path.exists(script_post):
                Command.run(['chmod', '755', script_post])
                try:
                    Command.run(
                        [
                            'chroot', self.root_dir,
                            f'{script_post.replace(self.root_dir, "")}',
                            'configure'
                        ], self.command_env
                    )
                except Exception:
                    # Some newer postinst scripts require a non-empty
                    # version number as second parameter.
                    Command.run(
                        [
                            'chroot', self.root_dir,
                            f'{script_post.replace(self.root_dir, "")}',
                            'configure', "0"
                        ], self.command_env
                    )
