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

from __future__ import absolute_import, division, print_function, unicode_literals

from collections import Mapping

import io
import json

from os import path
import os

import re
import sys

from semantic_version import Version

from . _internal import ObjectView
from . _internal import JsonSchema
from . _internal import JsonField, JsonValue
from . _internal import JsonArray, JsonObject, JsonString
from . _internal import JsonDataTypeConverter, JsonFilenameConverter, JsonVersionConverter, JsonVersionSpecConverter

from .. utils import SlimLogger, encode_filename, encode_string, slim_configuration, string, typing

if typing is not None:
    TextIO = typing.TextIO


class AppCommonInformationModelInfo(object):

    def __init__(self, iterable):
        for name, versions in iterable:
            setattr(self, name, tuple(versions))

    # We include this pylint directive because file is recognized as a built-in even though the standard Python library
    # and we commonly use file as an argument name
    # pylint: disable=redefined-builtin
    @classmethod
    def load(cls, file=None):
        if file is None:
            file = path.join(slim_configuration.system_config, 'common-information-models.json')
        if isinstance(file, string):
            with io.open(file, encoding='utf-8') as file:
                return cls._load(file)
        return cls._load(file)

    # region Protected

    @classmethod
    def _load(cls, istream):
        return cls.schema.convert_from(json.load(istream), onerror=SlimLogger.error)

    # endregion

    class Converter(JsonDataTypeConverter):

        def convert_from(self, data_type, value):
            assert isinstance(data_type, JsonObject) and isinstance(value, Mapping)
            return AppCommonInformationModelInfo((name, value[name]) for name in value)

        def convert_to(self, data_type, value):
            raise NotImplementedError()

    schema = JsonSchema('Common information model info', JsonValue(JsonObject(
        any=JsonValue(
            JsonArray(
                JsonValue(JsonString(), converter=JsonVersionConverter()))
        )
    ), converter=Converter()))


class AppCommonInformationModelSpec(ObjectView):

    def __str__(self):
        return ', '.join((name + string(self[name]) for name in self))

    class Converter(JsonDataTypeConverter):

        def __init__(self):
            if self._info is None:
                self._info = AppCommonInformationModelInfo.load()

        def convert_from(self, data_type, value):
            assert isinstance(data_type, JsonObject) and isinstance(value, Mapping)

            def convert(name, version_spec):
                try:
                    versions = getattr(self._info, name)
                except AttributeError:
                    raise ValueError('Expected a common information model name, not ' + encode_string(name))
                if version_spec is None:
                    return name, None
                for version in versions:
                    if version in version_spec:
                        return name, version_spec
                raise ValueError(
                    'Version requirement includes no supported version of Splunk ' + name + ': ' + string(version_spec))

            return AppCommonInformationModelInfo((convert(name, value[name]) for name in value))

        def convert_to(self, data_type, value):
            raise NotImplementedError()

        _info = None


class AppDependency(ObjectView):

    class Converter(JsonDataTypeConverter):

        def convert_from(self, data_type, value):
            assert isinstance(data_type, JsonObject) and isinstance(value, Mapping)
            return AppDependency(((name, value[name]) for name in value))

        def convert_to(self, data_type, value):
            raise NotImplementedError()


class AppForwarderGroup(ObjectView):

    def __init__(self, iterable, onerror=None):

        ObjectView.__init__(self, iterable, onerror)

        if self['description'] is None:
            self['description'] = ''

        requires = self['requires']

        if requires is None:
            self['requires'] = {}
        else:
            for name in requires:
                value = requires[name]
                requires[name] = tuple() if value is None else tuple(value)

        inputs = self['inputs']
        self['inputs'] = tuple() if inputs is None else tuple(inputs)

    class Converter(JsonDataTypeConverter):

        def convert_from(self, data_type, value):
            assert isinstance(data_type, JsonObject) and isinstance(value, Mapping)
            return AppForwarderGroup(((name, value[name]) for name in value))

        def convert_to(self, data_type, value):
            raise NotImplementedError()


class AppSplunkReleaseInfo(object):

    def __init__(self, iterable):
        for name, versions in iterable:
            setattr(self, name, tuple(versions))

    # pylint: disable=redefined-builtin
    @classmethod
    def load(cls, file=None):
        if file is None:
            file = path.join(slim_configuration.system_config, 'splunk-releases.json')
        if isinstance(file, string):
            with io.open(file, encoding='utf-8') as istream:
                return cls._load(istream)
        return cls._load(file)

    # region Protected

    # We include this pylint directive because file is recognized as a built-in even though the standard Python library
    # and we commonly use file as an argument name
    # pylint: disable=redefined-builtin
    @classmethod
    def _load(cls, file):
        return cls.schema.convert_from(json.load(file), onerror=SlimLogger.error)

    # endregion

    class Converter(JsonDataTypeConverter):

        def convert_from(self, data_type, value):
            assert isinstance(data_type, JsonObject) and isinstance(value, Mapping)
            return AppSplunkReleaseInfo(((name, value[name]) for name in value))

        def convert_to(self, data_type, value):
            raise NotImplementedError()

    schema = JsonSchema('Splunk release info', JsonValue(
        JsonObject(
            any=JsonValue(
                JsonArray(JsonValue(JsonString(), converter=JsonVersionConverter()))
            )
        ), converter=Converter()
    ))


class AppSplunkRequirement(ObjectView):

    def __str__(self):
        return ', '.join(('Splunk ' + edition + ' edition ' + string(self[edition]) for edition in self))

    class Converter(JsonDataTypeConverter):

        def __init__(self):
            if self._info is None:
                self._info = AppSplunkReleaseInfo.load()

        def convert_from(self, data_type, value):
            assert isinstance(data_type, JsonObject) and isinstance(value, Mapping)

            def convert(name, version_spec):
                try:
                    versions = getattr(self._info, name)
                except AttributeError:
                    raise ValueError('Expected a Splunk edition name, not ' + encode_string(name))
                if version_spec is None:
                    return name, None
                for version in versions:
                    if version in version_spec:
                        return name, version_spec
                raise ValueError(
                    'Version requirement includes no supported version of Splunk ' + name + ': ' + string(version_spec))

            return AppSplunkRequirement((convert(name, value[name]) for name in value))

        def convert_to(self, data_type, value):
            raise NotImplementedError()

        _info = None


class AppManifest(ObjectView):

    # region Methods

    # pylint disable=redefined-builtin
    def amend(self, app_configuration):
        """ Replaces select fields in the current app manifest with information from app.conf.

        """
        # noinspection PyUnresolvedReferences
        # pylint: disable=no-member
        info = self.info

        info.author = self._get_author(app_configuration)
        info.id = self._get_id(app_configuration)
        info.description = self._get_description(app_configuration)
        info.title = self._get_title(app_configuration)

        def ensure_documentation(name, asset):
            element = info[name]
            if element is None:
                info[name] = ObjectView((
                    ('name', None), ('text', self._get_text(app_configuration, asset)), ('uri', None)
                ))
            elif element.text is None:
                element.text = self._get_text(app_configuration, asset)

        ensure_documentation('license', 'LICENSE')
        ensure_documentation('releaseNotes', 'README')
        ensure_documentation('privacyPolicy', 'privacy-policy')

    # pylint: disable=too-many-locals,too-many-branches
    def print_description(self, ostream):

        # type: (TextIO) -> None

        """ Writes a description of the manifest in a pretty form

        Does not do dependency checking. The caller should not expect any errors from this function.

        Example:
        [info]
        |-- SLIM fictional test app: A SLIM app for testing Splunk extension packaging, partitioning, and operations.
        |  |-- by David Noble (dnoble@splunk.com) at Splunk, Inc.
        |  |-- packaged as com.splunk.addons-fictional@1.0.0
        [dependencies]
        |-- Splunk Add-on for Microsoft Windows packaged as com.splunk.addon-microsoft_windows@4.7.5
        |-- Splunk Add-on for *nix Operating Systems packaged as com.splunk.addon-star_nix@5.2.1
        [tasks]
        [input-groups]
        |-- Microsoft Windows monitoring defines inputs [input_1, input_2, input_3] and requires no dependencies
        |-- *nix monitoring defines inputs [input_4, input_5] and requires [Splunk Add-on for *nix Operating Systems]
        [incompatible-apps]
        [platform-requirements]

        """

        # Add the manifest info/dependency/forwarderGroup sections to the payload

        info = self.get('info')
        dependencies = self.get('dependencies')
        tasks = self.get('tasks')
        input_groups = self.get('inputGroups')
        incompatible_apps = self.get('incompatibleApps')
        platform_requirements = self.get('platformRequirements')

        # Define helper functions for formatting manifest details into a readable format

        def get_title(info):
            info_title = info.title if info.title else info.id.name
            info_title += (': ' + info.description) if 'description' in info and info.description else ''
            return info_title

        def get_author(author):
            info_author = author.name
            info_author += (' (' + author.email + ')') if 'email' in author and author.email else ''
            info_author += (' at ' + author.company) if 'company' in author and author.company else ''
            return info_author

        def get_definition(app_id):
            info_package = app_id.name + ' version ' + string(app_id.version)
            if 'group' in app_id and app_id.group:
                info_package = app_id.group + '-' + info_package
            return info_package

        def get_dependency(app_id, info):
            dependency_info = app_id + ' accepting ' + string(info.version)
            dependency_info += ' (packaged as ' + info.package + ')' if 'package' in info and info.package else ''
            return dependency_info

        def get_inputs(group, info):
            text = group

            if info.inputs:
                text += (' defines inputs [' + ', '.join((name for name in info.inputs))) + ']'
            else:
                text += ' defines no inputs'

            if info.requires:
                text += ' and requires [' + ', '.join((name for name in info.requires)) + ']'
            else:
                text += ' and requires no dependencies'

            return text

        print('[info]', file=ostream)
        if info is not None:
            print('|-- ' + get_title(info), file=ostream)
            if 'author' in info and info.author:
                for author in info.author:
                    print('|  |-- by ' + get_author(author), file=ostream)
            print('|  |-- defined as ' + get_definition(info.id), file=ostream)

        print('[dependencies]', file=ostream)
        if dependencies is not None:
            for app_id, info in dependencies.items():
                print('|-- ' + get_dependency(app_id, info), file=ostream)

        print('[tasks]', file=ostream)
        if tasks is not None:
            for task in tasks:
                print('|--', task, file=ostream)

        print('[input-groups]', file=ostream)
        if input_groups is not None:
            for group, info in input_groups.items():
                print('|-- ' + get_inputs(group, info), file=ostream)

        print('[incompatible-apps]', file=ostream)
        if incompatible_apps is not None:
            for app_id in incompatible_apps:
                print('|--', app_id, 'version range', string(incompatible_apps[app_id]), file=ostream)

        print('[platform-requirements]', file=ostream)
        if platform_requirements is not None:
            print('|--', string(platform_requirements.splunk), file=ostream)

    @classmethod
    def generate(cls, app_configuration, ostream, add_defaults=True):
        """ Generate an AppManifest object from the AppConfiguration object.

        Resulting manifest is saved to the ostream. Caller is required to check for logged errors on return.

        """
        app_root = app_configuration.app_root

        manifest_tuple = (
            ('schemaVersion', cls._schema_version),
            ('info', ObjectView((
                ('title', cls._get_title(app_configuration)),
                ('id', cls._get_id(app_configuration)),
                ('author', cls._get_author(app_configuration)),
                ('releaseDate', None),
                ('description', cls._get_description(app_configuration)),
                ('classification', ObjectView((
                    ('intendedAudience', None),
                    ('categories', []),
                    ('developmentStatus', None)
                ))),
                ('commonInformationModels', None),
                ('license', ObjectView((
                    ('name', None),
                    ('text', cls._get_text(app_configuration, 'LICENSE')),
                    ('uri', None),
                ))),
                ('privacyPolicy', ObjectView((
                    ('name', None),
                    ('text', cls._get_text(app_configuration, 'privacy-policy')),
                    ('uri', None),
                ))),
                ('releaseNotes', ObjectView((
                    ('name', None),
                    ('text', cls._get_text(app_configuration, 'README')),
                    ('uri', None)
                )))
            )))
        )

        manifest_defaults = """
# The following optional sections can be customized and added to the main manifest:
#
# Lists the app dependencies and version requirements
# "dependencies": {
#     "<app-group>:<app-name>": {
#         "version": "*",
#         "package": "<source-package-location>"
#     }
# }
#
# Lists the inputs that belong on the search head rather than forwarders
# "tasks": []
#
# Lists the possible input groups with app dependencies, and inputs that should be included
# "inputGroups": {
#     "<group-name>": {
#         "requires": {
#             "<app-group>:<app-name>": ["<dependent-input-groups>"]
#         },
#         "inputs": ["<defined-inputs>"]
#     }
# }
#
# Lists the app IDs that cannot be installed on the system alongside this app
# "incompatibleApps": {
#     "<app-group>:<app-name>": "<version>"
# }
#
# Specify the platform version requirements for this app
# "platformRequirements": {
#     "splunk": {
#         "Enterprise": "<version>"
#     }
# }
"""
        # Construct the manifest

        current_directory = os.getcwd()
        os.chdir(app_root)

        try:
            manifest = AppManifest(manifest_tuple)
        finally:
            os.chdir(current_directory)

        # Optionally save the manifest

        if ostream is not None:
            if not SlimLogger.error_count():
                with ostream:
                    manifest.save(ostream, indent=True)
                    if add_defaults:
                        ostream.write(manifest_defaults)
            elif ostream != sys.stdout:
                ostream.close()
                os.remove(ostream.name)

        return manifest

    # pylint: disable=redefined-builtin
    # noinspection PyProtectedMember
    @classmethod
    def load(cls, file):
        if isinstance(file, string):
            with io.open(file, encoding='utf-8') as istream:
                app_manifest = cls._load(istream)
        else:
            app_manifest = cls._load(file)
        return app_manifest

    # noinspection PyProtectedMember
    @classmethod
    def schema_version(cls):
        """ Return the schema version being used to handle AppManifest objects. """
        return cls._schema_version

    # endregion

    # region Protected

    _schema_version = '1.0'

    schema = JsonSchema('manifest', JsonValue(required=True, data_type=JsonObject(
        JsonField('schemaVersion', JsonString(), converter=JsonVersionConverter(_schema_version), required=True),
        JsonField('info', required=True, data_type=JsonObject(
            JsonField('title', JsonString()),
            JsonField('id', JsonObject(
                JsonField('group', JsonString()),
                JsonField('name', JsonString(), required=True),
                JsonField('version', JsonString(), converter=JsonVersionConverter())
            ), required=True),
            JsonField('author', JsonArray(JsonValue(JsonObject(
                JsonField('name', JsonString(), required=True),
                JsonField('email', JsonString()),
                JsonField('company', JsonString()),
            )))),
            JsonField('releaseDate', JsonString()),  # TODO: date converter
            JsonField('description', JsonString()),
            JsonField('classification', JsonObject(  # TODO: classification class and class converter
                JsonField('intendedAudience', JsonString()),
                JsonField('categories', JsonArray(JsonValue(JsonString()))),
                JsonField('developmentStatus', JsonString())
            )),
            JsonField('commonInformationModels', JsonObject(any=JsonValue(
                JsonString(), converter=JsonVersionSpecConverter()
            ), converter=AppCommonInformationModelSpec.Converter())),
            JsonField('license', JsonObject(
                JsonField('name', JsonString()),
                JsonField('text', JsonString(), converter=JsonFilenameConverter()),
                JsonField('uri', JsonString())
            )),
            JsonField('privacyPolicy', JsonObject(
                JsonField('name', JsonString()),
                JsonField('text', JsonString(), converter=JsonFilenameConverter()),
                JsonField('uri', JsonString())
            )),
            JsonField('releaseNotes', JsonObject(
                JsonField('name', JsonString()),
                JsonField('text', JsonString(), converter=JsonFilenameConverter()),
                JsonField('uri', JsonString())
            ))
        )),
        JsonField('dependencies', JsonObject(any=JsonValue(
            JsonObject(
                JsonField(
                    'version',
                    JsonString(),
                    converter=JsonVersionSpecConverter(),
                    default=JsonVersionSpecConverter.any_version),
                JsonField('package', JsonString())
            ), converter=AppDependency.Converter()
        ))),
        JsonField('tasks', JsonArray(JsonValue(JsonString()))),
        JsonField('inputGroups', JsonObject(any=JsonValue(
            JsonObject(
                JsonField('requires', JsonObject(any=JsonValue(JsonArray(JsonValue(JsonString()))))),
                JsonField('inputs', JsonArray(JsonValue(JsonString()))),
                JsonField('description', JsonString())
            ), converter=AppForwarderGroup.Converter()
        ))),
        JsonField('incompatibleApps', JsonObject(any=JsonValue(
            JsonString(), converter=JsonVersionSpecConverter(), required=True
        ))),
        JsonField('platformRequirements', JsonObject(
            JsonField('splunk', JsonObject(any=JsonValue(
                JsonString(), converter=JsonVersionSpecConverter(), required=True
            )), converter=AppSplunkRequirement.Converter())
        ))
    )))

    @staticmethod
    def _get_author(app_configuration):
        """ Construct the info.author section of the app manifest (no default)

        """
        # Note that we do not complain that, if any of the author values (name, email, or company) are missing
        # Note also that we do not ensure the name, email, company tuples are unique
        app = app_configuration.get('app')

        if app is not None:

            author = [ObjectView((
                ('name', stanza.name[len('author='):]),
                ('email', stanza.get_value('email')),
                ('company', stanza.get_value('company'))
            )) for stanza in app.stanzas() if stanza.name.startswith('author=')]

            if len(author) > 0:
                return author

        name = app_configuration.get_value('app', 'launcher', 'author')

        if not name:
            author = []  # No author specified
        else:
            author = [ObjectView((
                ('name', name),
                ('email', None),
                ('company', None)
            ))]

        return author

    @staticmethod
    def _get_description(app_configuration):
        return app_configuration.get_value('app', 'launcher', 'description')

    @staticmethod
    def _get_id(app_configuration):
        """ Construct the info.id section of the app manifest

        """
        group, name, version = app_configuration.get_value('app', 'id', ('group', 'name', 'version'))

        def normalize_version(stanza, value):
            if value is None:
                SlimLogger.error('A value for version in the [id] stanza of app.conf is required')
                return '0.0.0'
            try:
                value = Version.coerce(value)
            except ValueError:
                SlimLogger.error(
                    'Expected a semantic version number as the value of version in the [', stanza, '] stanza '
                    'of app.conf, not ', encode_string(value))
                value = '0.0.0'
            else:
                value = string(value)
            return value

        if (group, name, version) == (None, None, None):

            # Legacy code path which is less strict; the [id] stanza of app.conf is absent

            name = AppManifest._get_package_id(app_configuration, path.basename(app_configuration.app_root))
            version = normalize_version('launcher', app_configuration.get_value('app', 'launcher', 'version'))
        else:

            # New code path which is more strict; the [id] stanza of app.conf is present

            # Validate app ID:
            # * <id.name> is
            # * [<id.group>-]<id.name> must equal <package.id>, if <package.id> is specified

            folder_name = path.basename(app_configuration.app_root)

            if name is None:
                SlimLogger.error('A value for name in the [id] stanza of app.conf is required')
                name = folder_name  # short-circuits a downstream error message
            else:
                computed_id = '-'.join(value for value in (group, name) if value is not None)
                alt_id = AppManifest._get_package_id(app_configuration, computed_id)
                if alt_id != computed_id:
                    SlimLogger.error(
                        'The combination of group and name from the [id] stanza of app.conf (', computed_id, ') '
                        'must equal the value of id in the [package] stanza of app.conf (', alt_id, ')'
                    )
                if folder_name != computed_id:
                    SlimLogger.error(
                        'The combination of group and name from the [id] stanza of app.conf (', computed_id, ') '
                        'must equal the name of the app folder (', folder_name, ')'
                    )
                    name = folder_name  # short-circuits a downstream error message

            # Validate app version number:
            # * <id.version> is required
            # * <id.version> must equal <launcher.version>, if <launcher.version> is specified

            version = normalize_version('id', version)
            alt_version = app_configuration.get_value('app', 'launcher', 'version')

            if alt_version is not None:
                alt_version = normalize_version('launcher', alt_version)
                if alt_version != version:
                    SlimLogger.error(
                        'Expected the value of version in the [launcher] stanza of app.conf (',
                        encode_string(alt_version), ' to equal the value of version in the '
                        '[id] stanza of app.conf (', encode_string(version), ')'
                    )

        return ObjectView((('group', group), ('name', name), ('version', version)))

    @staticmethod
    def _get_package_id(app_configuration, default_value):
        value = app_configuration.get_value('app', 'package', 'id')
        if value is None:
            SlimLogger.warning('There is no value for id in the [package] stanza of app.conf')
            value = default_value
        return value

    @staticmethod
    def _get_text(app_configuration, name):
        """ Construct info.[license|privacyPolicy|releaseNotes].text element of the app manifest.

        """
        partial_filename = path.join(app_configuration.app_root, name)
        for extension in '.md', '.rtf', '.txt':
            filename = partial_filename + extension
            if path.isfile(filename):
                return './' + path.basename(filename)
        return None

    @staticmethod
    def _get_title(app_configuration):
        """ Construct the info.title element of the app manifest (no default).

        """
        return app_configuration.get_value('app', 'ui', 'label')

    @classmethod
    def _load(cls, istream):
        """ Load an AppManifest object from `istream`.

        Parse out any comment lines. Caller is required to check for logged errors on return.

        """
        text = ''.join(line for line in istream if re.match(r'\s*#', line) is None)  # confirmed re.match copies no text

        try:
            object_view = json.loads(text, object_pairs_hook=AppManifest._create_object_view)
        except ValueError as error:
            SlimLogger.error('Failed to load app manifest from ', encode_filename(istream.name), ': ', error)
            object_view = ObjectView.empty

        current_directory = os.getcwd()
        os.chdir(path.dirname(istream.name))

        try:
            app_manifest = AppManifest(object_view)
        finally:
            os.chdir(current_directory)

        return app_manifest

    # endregion
    pass  # pylint: disable=unnecessary-pass
