# coding=utf-8
#
# Copyright © Splunk, Inc. All Rights Reserved.

""" app_installation module

"""

from __future__ import absolute_import, division, print_function, unicode_literals

from collections import Iterable, MutableMapping, OrderedDict
from json import JSONEncoder
from tempfile import mkstemp
from os import path
import os

import io
import shutil
import tarfile

from .. utils import SlimStatus, SlimLogger, slim_configuration, encode_filename, encode_string
from .. utils.internal import string

from . _deployment import AppDependencyGraph, AppDeploymentSpecification
from . _installation import AppInstallationGraph
from . _internal import ObjectView
from . _source import AppSource


# TODO: Remove this redundancy by creating an app._internal module with json decoding/encoding functions (?)
# Also see: app._installation._AppJsonEncoder
# Consider the alternative of putting this under the umbrella of ObjectView instead of creating a protected json module
# or vice versa

class _AppJsonEncoder(JSONEncoder):

    def __init__(self, indent=False):
        if indent:
            separators = None
            indent = 2
        else:
            separators = (',', ':')
            indent = None
        JSONEncoder.__init__(self, ensure_ascii=False, indent=indent, separators=separators)

    # Under Python 2.7 pylint incorrectly asserts AppJsonEncoder.default is hidden by an attribute defined in
    # json.encoder at or about line 162. Code inspection reveals this not to be the case, hence we
    # pylint: disable=method-hidden

    def default(self, value):
        if isinstance(value, Iterable):
            return list(value)
        return JSONEncoder.default(self, value)

_encoder = _AppJsonEncoder()
_encode = _encoder.encode
_iterencode = _encoder.iterencode


class AppServerClass(object):

    def __init__(self, name, object_view, repository, repository_path):

        self._name = string(name)
        self._repository = repository
        self._repository_path = repository_path
        self._workload = frozenset(object_view.workload)

        self.reload(object_view.apps)

    # region Special methods

    def __repr__(self):
        return 'AppServerClass(' + repr(self._name) + ')'

    def __str__(self):
        return _encode(self.to_dict())

    # endregion

    # region Properties

    @property
    def apps(self):
        return self._apps

    @property
    def name(self):
        return self._name

    @property
    def workload(self):
        return self._workload

    # endregion

    # region Methods

    def add_source(self, package_path):

        repository_path = self._repository_path

        try:
            is_tarfile = tarfile.is_tarfile(package_path)
        except OSError as error:
            SlimLogger.error(
                'Cannot add ', encode_filename(package_path), ' to repository directory ',
                encode_filename(repository_path), ': ', error.strerror)
            return None

        if not is_tarfile:
            SlimLogger.error(
                'Cannot add ', encode_filename(package_path), ' to repository directory ',
                encode_filename(repository_path), ' because it is not a source package')
            return None

        package = path.basename(package_path)

        if package in self._repository:
            return package

        try:
            shutil.copy(package_path, self._repository_path)
        except OSError as error:
            SlimLogger.error(
                'Cannot add ', encode_filename(package), ' to repository directory ', encode_filename(repository_path),
                ': ', error.strerror)
            return None

        self._repository[package] = AppSource(path.join(repository_path, package))
        return package

    def describe_app(self, app_id):

        apps = self.apps
        installation = apps.get(app_id)

        if installation is None:
            return None  # The app is not installed on this server class

        installations = apps.describe_installation(installation)
        return AppServerClassUpdate(self, None, installations)

    @classmethod
    def from_deployment_specification(cls, deployment_specification, server_classes):

        info = ObjectView((
            ('workload', deployment_specification.workload),
            ('apps', ObjectView(()))
        ))

        name = deployment_specification.name
        repository = server_classes.repository
        repository_path = server_classes.repository_path

        server_class = AppServerClass(name, info, repository, repository_path)
        server_classes[name] = server_class

        return server_class

    def get_source(self, package):

        try:
            source = self._repository[package]
        except KeyError:
            SlimLogger.error(
                'Package ', encode_filename(package), ' not found in repository directory ', encode_filename(
                    self._repository_path))
            return None

        if source is None:
            repository_path = self._repository_path
            package = path.join(repository_path, package)
            self._repository[package] = source = AppSource(package)

        return source

    def to_dict(self):
        return OrderedDict((
            ("workload", sorted(self.workload, reverse=True)),
            ("apps", self._apps.to_dict())
        ))

    def reload(self, apps):
        self._apps = AppInstallationGraph(self, apps)

    def remove_app(self, app_id):

        installation = self.apps.get(app_id)

        if installation is None:
            return None  # The app is not installed on this server class

        if len(installation.dependents) > 0:
            SlimLogger.error(
                app_id, ' cannot be uninstalled because it is still required by these apps:\n    ',
                '\n    '.join(installation.dependents))
            slim_configuration.payload.set_dependency_requirements(installation.dependents)
            slim_configuration.payload.status = SlimStatus.STATUS_ERROR_DEPENDENCY_REQUIRED
            return None

        self.apps.remove_installation(installation)

    def update_installation(self, app_installation_graph, disable_automatic_resolution=False):
        self.apps.update(app_installation_graph, disable_automatic_resolution)

    # endregion
    pass  # pylint: disable=unnecessary-pass


class AppServerClassCollection(MutableMapping):

    def __init__(self, repository, repository_path, server_classes=None):
        self._collection = OrderedDict() if server_classes is None else OrderedDict(server_classes)
        self._repository = repository
        self._repository_path = repository_path
        self._validate = True

        self._installed_packages = OrderedDict()

        # Compile a list of installed apps and the source package in the repository
        # This is used to account for dependencies defined without a packaged dependency
        for server_class in self._collection.values():
            for installed_app in server_class.apps.values():
                self._installed_packages[installed_app.id] = os.path.basename(installed_app.source.package)

    # region Special methods

    def __delitem__(self, name):
        self._collection.__delitem__(name)

    def __getitem__(self, name):
        return self._collection.__getitem__(name)

    def __iter__(self):
        return self._collection.__iter__()

    def __len__(self):
        return self._collection.__len__()

    def __setitem__(self, name, value):
        self._collection.__setitem__(name, value)

    # endregion

    # region Properties

    @property
    def repository(self):
        return self._repository

    @property
    def repository_path(self):
        return self._repository_path

    @property
    def validate(self):
        return self._validate

    @validate.setter
    def validate(self, value):
        """ Provide the ability to enable or disable validation. This is useful when batch updates are required.
        """
        self._validate = value

    # endregion

    # region Methods

    @classmethod
    def load(cls, file, repository_path):  # pylint: disable=redefined-builtin

        # Load repository

        repository_path = path.abspath(repository_path)
        current_directory = os.getcwd()
        repository = OrderedDict()
        os.chdir(repository_path)

        try:
            directory_listing = os.listdir(repository_path)
        except OSError as error:
            SlimLogger.error('Cannot access repository directory ', encode_filename(repository_path), ': ', error)
        else:
            for name in directory_listing:
                if path.isfile(name) and tarfile.is_tarfile(name):
                    repository[name] = None
        finally:
            os.chdir(current_directory)

        # Read installation graph

        filename = file if isinstance(file, string) else file.name
        server_classes = OrderedDict()

        try:
            if file is filename:
                with io.open(filename, encoding='utf-8') as file:
                    text = file.read()
            else:
                with file:
                    text = file.read()
        except OSError as error:
            SlimLogger.error(
                'Cannot load installation graph from ', encode_filename(filename), ' file: ', error.strerror)
            return

        object_view = ObjectView(text)

        # Create server class collection

        for name in object_view:
            info = object_view[name]
            if name in server_classes:
                SlimLogger.warning('Replacing definition of duplicate server class name in installation graph: ', name)
            server_classes[name] = AppServerClass(name, info, repository, repository_path)

        return AppServerClassCollection(repository, repository_path, server_classes)

    def reload(self):
        """ Reload the installation graph. If validation was previously disabled and we are no longer in a valid state,
            this operation will fail in the same way an initial load() operation may fail.
        """
        for server_class in self._collection.values():
            object_view_apps = ObjectView(string(_encode(server_class.apps.to_dict())))
            server_class.reload(object_view_apps)

    # pylint: disable=too-many-locals
    def add(self, app_source, deployment_specifications, is_external=False, disable_automatic_resolution=False):
        """ Adds `app_source` to the current installation graph

        All server classes referenced by `deployment_specifications` may be affected.

        :param app_source:
        :type app_source: AppSource

        :param deployment_specifications:
        :type deployment_specifications: AppDeploymentSpecification

        :param is_external: is the app "external" (i.e., not installed by the system)
        :type is_external: bool

        :param disable_automatic_resolution:
        :type disable_automatic_resolution: bool

        :return: :const:`None`.

        """
        dependency_graph = AppDependencyGraph(app_source, self.repository_path, self._installed_packages)

        if SlimLogger.error_count():
            return

        error_count = 0

        for deployment_specification in deployment_specifications:

            server_class = self._collection.get(deployment_specification.name)

            if server_class is None:
                server_class = AppServerClass.from_deployment_specification(deployment_specification, self)
                self._collection[deployment_specification.name] = server_class

            installation_graph = AppInstallationGraph.from_dependency_graph(
                server_class, dependency_graph, self._validate, is_external
            )

            if SlimLogger.error_count() > error_count:
                error_count = SlimLogger.error_count()
                continue

            source_specifications = dependency_graph.get_deployment_specifications(deployment_specification)
            app_id = app_source.id

            removal_list = []

            for source in source_specifications:
                specification = source_specifications[source]
                source.validate_deployment_specification(specification)
                installation = installation_graph[source.id]
                installation.update_input_groups(app_id, specification.inputGroups)
                installation.create_deployment_package()
                if not is_external and installation.deployment_package.is_empty:
                    removal_list.append(installation)

            for installation in removal_list:
                if len(installation_graph) == 0:
                    break
                if installation.id not in installation_graph:
                    # This installation must have been removed on a previous iteration because it was a dependency of
                    # another installation in the removal_list
                    continue
                installation_graph.remove_installation(installation)

            server_class.update_installation(installation_graph, disable_automatic_resolution)

    def update_app(self, app_source):
        """ Updates `app_source` on the current installation graph

        Any server class with this app installed may be affected; others are untouched

        :param app_source:
        :type app_source: AppSource

        :return: :const:`None`
        """
        dependency_graph = AppDependencyGraph(app_source, self.repository_path, self._installed_packages)

        if SlimLogger.error_count():
            return

        error_count = 0

        collection = self._collection
        for name in collection:
            server_class = collection[name]
            if server_class.apps.get(app_source.id) is None:
                continue  # This app is not installed on this server class

            # Create the installation graph for this app source
            installation_graph = AppInstallationGraph.from_dependency_graph(
                server_class, dependency_graph, self._validate
            )

            if SlimLogger.error_count() > error_count:
                error_count = SlimLogger.error_count()
                continue

            # Update the server class with this new installation graph
            server_class.update_installation(installation_graph)

    def partition(self, app_source, output_dir, partition_all=True):
        """ Partitions an app into deployment packages

        """
        collection = self._collection
        deployment_packages = []

        for name in collection:
            server_class = collection[name]
            update = server_class.describe_app(app_source.id)
            if update is not None:
                package = update.save(app_source, output_dir, partition_all)
                if package is not None:
                    deployment_packages.append(package)

        if len(deployment_packages) > 0:
            installation_actions_file = path.join(output_dir, 'installation-actions.json')
            with io.open(installation_actions_file, encoding='utf-8', mode='w', newline='') as ostream:
                ostream.write(_encode(slim_configuration.payload.installation_actions))

        return deployment_packages

    def save(self, filename=None):
        graph_json = OrderedDict((
            ((name, server_class.to_dict()) for name, server_class in self._collection.items())
        ))
        slim_configuration.payload.set_installation_graph(graph_json)

        if filename is not None:
            with io.open(filename, encoding='utf-8', mode='w', newline='') as ostream:
                ostream.write(string(_encode(graph_json)))
            output_dir = os.path.dirname(filename)

            if SlimLogger.is_debug_enabled():
                graph_updates_file = path.join(output_dir, 'graph-updates.json')
                with io.open(graph_updates_file, encoding='utf-8', mode='w', newline='') as ostream:
                    ostream.write(_encode(slim_configuration.payload.graph_updates))

    def remove_app(self, app_id, server_classes):
        for name in server_classes:
            self._collection[name].remove_app(app_id)

    def update_installation(self, action, disable_automatic_resolution=False):

        if action.action == 'remove':
            SlimLogger.step('Performing remove action for ' + encode_string(action.args.app_id))
            self.remove_app(action.args.app_id, self._collection)

        elif action.action == 'add' or action.action == 'set':
            SlimLogger.step('Performing add action for ' + encode_string(action.args.app_package))

            package_path = os.path.join(slim_configuration.repository_path, action.args.app_package)
            app_source = AppSource(package_path, None)

            if SlimLogger.error_count():
                return

            forwarder_workloads = action.args.workloads

            forwarder_deployment_specifications = (
                None if forwarder_workloads is None else AppDeploymentSpecification.from_forwarder_workloads(
                    action.args.workloads
                )
            )

            deployment_specifications = AppDeploymentSpecification.get_deployment_specifications(
                [], action.args.combine_search_head_indexer_workloads, forwarder_deployment_specifications
            )

            if SlimLogger.error_count():
                return

            # If we are setting new mappings (instead of adding them), remove installations no longer referenced
            if action.action == 'set':

                # Compute the list of server classes this app will be removed from to match new specifications:
                # - Start with the current list of server classes this app has been added to
                # - Remove the list of server classes this app should remain on
                old_list = [name for name in self._collection if
                            self._collection[name].apps.get(app_source.id) is not None]
                new_list = [deployment_specification.name for deployment_specification in deployment_specifications]
                removal_list = set(old_list) - set(new_list)

                # First remove this app from the server classes no longer needed
                # If we cannot remove the app because of dependency conflicts, we cannot update the mappings
                self.remove_app(app_source.id, removal_list)
                if SlimLogger.error_count():
                    return

            # Add this app to the new deployment specifications
            self.add(app_source,
                     deployment_specifications,
                     is_external=action.args.get("is_external", False),
                     disable_automatic_resolution=disable_automatic_resolution)

        elif action.action == 'update':
            SlimLogger.step('Performing update action for ' + encode_string(action.args.app_package))
            package_path = os.path.join(slim_configuration.repository_path, action.args.app_package)
            app_source = AppSource(package_path, None)

            if not SlimLogger.error_count():
                self.update_app(app_source)

        else:
            SlimLogger.error('Installation action ' + encode_string(action.name) + ' is unknown or not-yet-implemented')

    # endregion
    pass  # pylint: disable=unnecessary-pass


class AppServerClassUpdate(object):

    def __init__(self, server_class, removals, installations):

        self._server_class = server_class
        self._removals = removals
        self._additions = installations

    def save(self, app_source, output_dir, partition_all=True):

        remove = None if self._removals is None else [installation.id for installation in self._removals]

        if self._additions is None:
            add = None
        else:
            arcname = app_source.package_prefix + '-' + self._server_class.name
            add = None

            if partition_all:
                package_handle, package_name = mkstemp(dir=output_dir)
                sub_package_count = 0

                with io.open(package_handle, mode='w+b') as ostream:
                    with tarfile.open(package_name, fileobj=ostream, mode='w:gz') as package:
                        for installation in self._additions:
                            sub_package_name = installation.partition(output_dir)
                            if sub_package_name:
                                sub_package_archive_name = path.join(arcname, path.basename(sub_package_name))
                                package.add(sub_package_name, arcname=sub_package_archive_name)
                                os.remove(sub_package_name)
                                sub_package_count += 1

                if sub_package_count == 0:
                    os.remove(package_name)
                else:
                    add = path.abspath(path.join(output_dir, arcname + '.tar.gz'))
                    os.rename(package_name, add)
            else:
                for installation in self._additions:
                    if installation.id == app_source.id:
                        add = installation.partition(output_dir)
                        break

        slim_configuration.payload.add_installation_action(OrderedDict((
            ('serverClass', self._server_class.name),
            ('remove', remove),
            ('add', add)
        )))

        return add
