# 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 os
import logging
import functools
from typing import (
    Literal, List, Optional, NamedTuple, Callable, Dict, Any
)
import yaml

# project
import kiwi.defaults as defaults

from kiwi.defaults import Defaults
from kiwi.utils.size import StringToSize
from kiwi.utils.checksum import Checksum
from kiwi.exceptions import (
    KiwiRuntimeConfigFormatError,
    KiwiRuntimeConfigFileError
)

log = logging.getLogger('kiwi')

RUNTIME_CONFIG: Optional[Dict[str, Any]] = None


class ShasumT(NamedTuple):
    suffix: str
    digest: Callable


class RuntimeConfig:
    """
    **Implements reading of runtime configuration files:**

    1. vendor: /usr/share/kiwi/kiwi.yml + /usr/share/kiwi/kiwi.yml.d/*.yml
    2. admin: /etc/kiwi.yml + /etc/kiwi.yml.d/*.yml
    3. ~/.config/kiwi/config.yml
    4. Check for --config provided from the CLI

    The KIWI runtime configuration file is a yaml formatted file
    containing information to control the behavior of the tools
    used by KIWI on the build host.

    :param bool reread: reread runtime config
    """
    def __init__(self, reread: bool = False):
        global RUNTIME_CONFIG

        if RUNTIME_CONFIG is None or reread:
            custom_config_file = defaults.CUSTOM_RUNTIME_CONFIG_FILE
            config_files = []
            # 1. vendor
            config_file = defaults.USR_RUNTIME_CONFIG_FILE
            if os.path.exists(config_file):
                config_files.append(config_file)
            config_files += self._read_drop_ins_dir(
                defaults.USR_RUNTIME_CONFIG_DIR
            )
            # 2. admin
            config_file = defaults.ETC_RUNTIME_CONFIG_FILE
            if os.path.exists(config_file):
                config_files.append(config_file)
            config_files += self._read_drop_ins_dir(
                defaults.ETC_RUNTIME_CONFIG_DIR
            )
            # 3. home
            if self._home_path():
                config_file = os.sep.join(
                    [self._home_path(), '.config', 'kiwi', 'config.yml']
                )
                if os.path.exists(config_file):
                    config_files.append(config_file)
            # 4. cmdline
            if custom_config_file:
                config_file = custom_config_file
                if not os.path.isfile(config_file):
                    raise KiwiRuntimeConfigFileError(
                        f'Custom config file {config_file!r} not found'
                    )
                config_files.append(config_file)
            # read all config files...
            RUNTIME_CONFIG = {}
            for config_file in config_files:
                log.info(
                    f'Reading runtime config file: {config_file!r}'
                )
                with open(config_file, 'r') as config:
                    RUNTIME_CONFIG.update(yaml.safe_load(config) or {})

    def get_credentials_verification_metadata_signing_key_file(self) -> str:
        """
        Return verification metadata signing key file, used for
        signature creation of rootfs verification metadata:

        credentials:
          - verification_metadata_signing_key_file: ...

        There is no default value for this setting available

        :return: file path name or ''

        :rtype: str
        """
        signing_key_file = self._get_attribute(
            element='credentials',
            attribute='verification_metadata_signing_key_file'
        )
        return signing_key_file if signing_key_file else ''

    def get_obs_download_server_url(self) -> str:
        """
        Return URL of buildservice download server in:

        obs:
          - download_url: ...

        if no configuration exists the downloadserver from
        the Defaults class is returned

        :return: URL type data

        :rtype: str
        """
        obs_download_server_url = self._get_attribute(
            element='obs', attribute='download_url'
        )
        return obs_download_server_url if obs_download_server_url else \
            Defaults.get_obs_download_server_url()

    def get_obs_api_server_url(self) -> str:
        """
        Return URL of buildservice API server in:

        obs:
          - api_url: ...

        if no configuration exists the API server from
        the Defaults class is returned

        :return: URL type data

        :rtype: str
        """
        obs_api_server_url = self._get_attribute(
            element='obs', attribute='api_url'
        )
        return obs_api_server_url if obs_api_server_url else \
            Defaults.get_obs_api_server_url()

    def get_obs_api_credentials(self) -> List[str]:
        """
        Return OBS API credentials if configured:

        obs:
          - user:
              - user_name: user_credentials

        :return: List of Dicts with credentials per user

        :rtype: list
        """
        obs_users = self._get_attribute(element='obs', attribute='user') or []
        return obs_users

    def is_obs_public(self) -> bool:
        """
        Check if the buildservice configuration is public or private in:

        obs:
          - public: true|false

        if no configuration exists we assume to be public

        :return: True or False

        :rtype: bool
        """
        obs_public = self._get_attribute(element='obs', attribute='public')
        if obs_public is None:
            # if the privacy attribute is not set we assume to be public
            obs_public = True
        return bool(obs_public)

    def get_package_changes(self, default: bool = True) -> bool:
        """
        Return boolean value to express if the image build and bundle
        should contain a .changes file. The .changes file contains
        the package changelog information from all packages installed
        into the image.

        bundle:
          - has_package_changes: true|false

        By default the creation is switched on.
        When building in the Open Build Service the default is
        switched off because obs provides a .report file containing
        the same information.

        :param bool default: Default value

        :return: True or False

        :rtype: bool
        """
        bundle_package_changes = self._get_attribute(
            element='bundle', attribute='has_package_changes'
        )
        if bundle_package_changes is None:
            if Defaults.is_buildservice_worker():
                bundle_package_changes = False
            else:
                bundle_package_changes = default
        return bool(bundle_package_changes)

    def get_bundle_compression(self, default: bool = True) -> bool:
        """
        Return boolean value to express if the image bundle should
        contain XZ compressed image results or not.

        bundle:
          - compress: true|false

        If compression of image build results is activated the size
        of the bundle is smaller and the download speed increases.
        However the image must be uncompressed before use

        If no compression is explicitly configured, the provided
        default value applies

        :param bool default: Default value

        :return: True or False

        :rtype: bool
        """
        bundle_compress = self._get_attribute(
            element='bundle', attribute='compress'
        )
        if bundle_compress is None:
            bundle_compress = default
        return bool(bundle_compress)

    def get_checksum_handler(
        self,
        source_filename: str,
        target_filename: Optional[str] = None,
        default: str = '256',
        bundle_lookup: bool = False
    ) -> ShasumT:
        """
        Return a ShasumT with information about the configured
        shasum suffix name and the digest(Checksum) callable. The
        following configuration setting allows to configure the
        size of the checksum:

        shasum:
          - size: 256

        bundle:
          - shasum_size: "256"

        Instructs kiwi to use the provided shasum size. Supported
        values are 256 (default) and 512. In case of an unsupported
        value the default is used. A value from the bundle section
        takes precedence over the global shasum size specified
        in the shasum section for creating bundle results. If no
        information for bundle results is specified, the global
        shasum size or the default applies.

        :param str source_filename: filename to calculate checksum for
        :param str target_filename: filename to write checksum to
        :param str default: default size set to 256

        :rtype: ShasumT
        """
        supported_shasums = {
            '256': ShasumT(
                suffix='.sha256',
                digest=functools.partial(
                    Checksum(source_filename).sha256, target_filename
                )
            ),
            '512': ShasumT(
                suffix='.sha512',
                digest=functools.partial(
                    Checksum(source_filename).sha512, target_filename
                )
            )
        }
        shasum_size = self._get_attribute(
            element='shasum', attribute='size'
        )
        if bundle_lookup:
            bundle_shasum_size = self._get_attribute(
                element='bundle', attribute='shasum_size'
            )
            if bundle_shasum_size:
                shasum_size = bundle_shasum_size
        if supported_shasums.get(shasum_size):
            return supported_shasums[shasum_size]
        return supported_shasums['256']

    def get_xz_options(self) -> Optional[List[str]]:
        """
        Return list of XZ compression options in:

        xz:
          - options: ...

        if no configuration exists None is returned

        :return:
            Contains list of options

            .. code:: python

                ['--option=value']

        :rtype: list
        """
        xz_options = self._get_attribute(element='xz', attribute='options')
        return xz_options.split() if xz_options else None

    def get_container_compression(self) -> bool:
        """
        Return compression for container images

        container:
          - compress: xz|none|true|false

        if no or invalid configuration data is provided, the default
        compression from the Defaults class is returned

        :return: True or False

        :rtype: bool
        """
        container_compression = self._get_attribute(
            element='container', attribute='compress'
        )
        if container_compression is None:
            return Defaults.get_container_compression()
        elif 'xz' == container_compression or container_compression is True:
            return True
        elif 'none' == container_compression or container_compression is False:
            return False
        else:
            log.warning(
                'Skipping invalid container compression: {0}'.format(
                    container_compression
                )
            )
            return Defaults.get_container_compression()

    def get_iso_tool_category(self) -> str:
        """
        Return tool category which should be used to build iso images

        iso:
          - tool_category: xorriso

        if no or invalid configuration exists the default tool category
        from the Defaults class is returned

        :return: A name

        :rtype: str
        """
        iso_tool_category = self._get_attribute(
            element='iso', attribute='tool_category'
        )
        if not iso_tool_category:
            return Defaults.get_iso_tool_category()
        elif 'xorriso' in iso_tool_category:
            return iso_tool_category
        else:
            log.warning(
                'Skipping invalid iso tool category: {0}'.format(
                    iso_tool_category
                )
            )
            return Defaults.get_iso_tool_category()

    def get_iso_media_tag_tool(self) -> Literal['checkmedia', 'isomd5sum']:
        """
        Return media tag tool used to checksum iso images

        iso:
          - media_tag_tool: checkmedia

        if no or invalid configuration exists the default media tagger
        from the Defaults class is returned

        :return: A name

        :rtype: str
        """
        iso_media_tag_tool = self._get_attribute(
            element='iso', attribute='media_tag_tool'
        )
        if not iso_media_tag_tool:
            return Defaults.get_iso_media_tag_tool()
        elif 'checkmedia' in iso_media_tag_tool:
            return iso_media_tag_tool
        elif 'isomd5sum' in iso_media_tag_tool:
            return iso_media_tag_tool
        else:
            log.warning(
                'Skipping invalid iso media tag tool: {0}'.format(
                    iso_media_tag_tool
                )
            )
            return Defaults.get_iso_media_tag_tool()

    def get_oci_archive_tool(self) -> str:
        """
        Return OCI archive tool which should be used on creation of
        container archives for OCI compliant images, e.g docker

        oci:
          - archive_tool: umoci

        if no configuration exists the default tool from the
        Defaults class is returned

        :return: A name

        :rtype: str
        """
        oci_archive_tool = self._get_attribute(
            element='oci', attribute='archive_tool'
        )
        return oci_archive_tool or Defaults.get_oci_archive_tool()

    def get_mapper_tool(self) -> str:
        """
        Return partition mapper tool

        mapper:
          - part_mapper: partx

        if no configuration exists the default tool from the
        Defaults class is returned

        :return: A name

        :rtype: str
        """
        part_mapper_tool = self._get_attribute(
            element='mapper', attribute='part_mapper'
        )
        return part_mapper_tool or Defaults.get_part_mapper_tool()

    def get_max_size_constraint(self) -> Optional[int]:
        """
        Returns the maximum allowed size of the built image. The value is
        returned in bytes and it is specified in build_constraints element
        with the max_size attribute. The value can be specified in bytes or
        it can be specified with m=MB or g=GB.

        build_constraints:
          - max_size: 700m

        if no configuration exists None is returned

        :return: byte value or None

        :rtype: int
        """
        max_size = self._get_attribute(
            element='build_constraints', attribute='max_size'
        )
        return StringToSize.to_bytes(max_size) if max_size else None

    def get_disabled_runtime_checks(self) -> List[str]:
        """
        Returns disabled runtime checks. Checks can be disabled with:

        runtime_checks:
            - disable: check_container_tool_chain_installed

        if the provided string does not match any RuntimeChecker method it is
        just ignored.
        """
        disabled_checks = self._get_attribute(
            element='runtime_checks', attribute='disable'
        ) or []
        for check in disabled_checks:
            log.warning(f'Runtime check: {check}: disabled')
        return disabled_checks

    def _get_attribute(self, element: str, attribute: str):
        if RUNTIME_CONFIG:
            try:
                if element in RUNTIME_CONFIG:
                    for attribute_dict in RUNTIME_CONFIG[element]:
                        if attribute in attribute_dict:
                            return attribute_dict[attribute]
            except Exception as issue:
                raise KiwiRuntimeConfigFormatError(
                    f'{type(issue).__name__}: {issue}'
                )

    def _home_path(self) -> str:
        return os.environ.get('HOME') or ''

    def _read_drop_ins_dir(self, config_dir: str) -> List[str]:
        config_files = []
        if os.path.isdir(config_dir):
            for config_file in sorted(os.listdir(config_dir)):
                if config_file.endswith('.yml'):
                    config_file_path = os.path.normpath(
                        os.sep.join([config_dir, config_file])
                    )
                    config_files.append(config_file_path)
        return config_files
