# -*- coding: utf-8 -*-

"""Utilities to manage external application configuration files."""

from __future__ import annotations

__all__ = [
    'Action', 'merge_configurations',
    'read_configuration_file', 'load_configuration_file', 'apply_configuration', 'load_configuration',
    'load',
]

import enum
import functools
import os
import re
import typing
import uuid

import attr
import yaml

from anaconda_navigator.config import preferences
from anaconda_navigator.config import structures
from anaconda_navigator.static import content
from anaconda_navigator.utils.logs import logger
from . import base
from . import exceptions
from . import validation

if typing.TYPE_CHECKING:
    from . import parsing_utils


EMPTY_MAPPING: typing.Final[typing.Mapping[typing.Any, typing.Any]] = {}

AppRecord = typing.MutableMapping[str, typing.Any]
AppConfiguration = typing.MutableMapping[str, AppRecord]


# ╠════════════════════════════════════════════════════════════════════════════════════════╡ Configuration merging ╞═══╣

class Action(str, enum.Enum):
    """Possible actions on application configurations."""

    PATCH = 'patch'
    PUT = 'put'
    DISCARD = 'discard'


def apply_discard(
        existing: typing.Optional[AppRecord],  # pylint: disable=unused-argument
        update: AppRecord,  # pylint: disable=unused-argument
) -> typing.Optional[AppRecord]:
    """
    Discard existing record.

    .. warning::

        Operation may update provided arguments!
    """
    return None


def apply_patch(
        existing: typing.Optional[AppRecord],
        update: AppRecord,
) -> typing.Optional[AppRecord]:
    """
    Apply patch to the `existing` record.

    .. warning::

        Operation may update provided arguments!
    """
    if existing is None:
        return update
    existing.update(update)
    return existing


def apply_put(
        existing: typing.Optional[AppRecord],  # pylint: disable=unused-argument
        update: AppRecord,
) -> typing.Optional[AppRecord]:
    """
    Put `update` instead of `existing` record.

    .. warning::

        Operation may update provided arguments!
    """
    return update


ActionFunc = typing.Callable[[typing.Optional[AppRecord], AppRecord], typing.Optional[AppRecord]]

ACTIONS: typing.Final[typing.Mapping[str, ActionFunc]] = {
    Action.DISCARD: apply_discard,
    Action.PATCH: apply_patch,
    Action.PUT: apply_put,
}


def merge_configurations(existing: AppConfiguration, update: AppConfiguration) -> AppConfiguration:
    """Update `existing` configuration with values from `update`."""
    key: str
    value: AppRecord
    for key, value in update.items():
        action: ActionFunc = ACTIONS[value.get('action', Action.PATCH)]
        current: typing.Optional[AppRecord] = action(existing.get(key, None), value)
        if current is None:
            existing.pop(key, None)
        else:
            existing[key] = current
    return existing


# ╠═══════════════════════════════════════════════════════════════════════════════════╡ Configuration file reading ╞═══╣

def read_configuration_file(path: str) -> AppConfiguration:
    """Read content of the configuration file."""
    if re.match(r'^[a-z][a-z0-9.+-]*://', path):
        raise NotImplementedError('web resources are not supported yet')

    try:
        if os.path.getsize(path) > preferences.BIGGEST_APPLICATION_CONFIGURATION:
            logger.warning('%s: file is too large', path)
            return {}

        stream: typing.TextIO
        with open(path, encoding='utf-8') as stream:
            return yaml.safe_load(stream.read().replace('\t', '    ')) or {}
    except FileNotFoundError:
        return {}
    except (OSError, UnicodeDecodeError):
        logger.warning('%s: unable to read file', path, exc_info=True)
        return {}
    except yaml.YAMLError:
        logger.warning('%s: unable to parse file', path, exc_info=True)
        return {}


def load_configuration_file(path: str) -> AppConfiguration:
    """Load and cleanup configuration file."""
    result: AppConfiguration = {}

    with validation.validation_to_warning(path, silent=True):
        raw_result: AppConfiguration = read_configuration_file(path)
        validation.OfType(typing.MutableMapping)(raw_result)

        key: str
        value: AppRecord
        for key, value in raw_result.items():
            key = str(key)

            with validation.validation_to_warning(path, silent=True):
                with exceptions.ValidationError.with_field(key):
                    validation.OfType(typing.MutableMapping)(value)

                    validation.ItemGetter('action', default=Action.PATCH).convert(validation.OfChoices(*ACTIONS))(value)

                result[key] = value

    return result


def apply_configuration(
        configuration: AppConfiguration,
        context: 'parsing_utils.ParsingContext',
) -> typing.Sequence[typing.Union[base.BaseApp, base.AppPatch]]:
    """Initialize application according to the configuration."""
    result: typing.List[typing.Union[base.BaseApp, base.AppPatch]] = []

    key: str
    value: AppConfiguration
    for key, value in configuration.items():
        with validation.validation_to_warning(silent=True), exceptions.ValidationError.with_field(key):
            addition: typing.Union[None, base.BaseApp, base.AppPatch] = base.BaseApp.parse_configuration(
                context=context,
                configuration=value,
                app_name=key,
            )
            if addition is not None:
                result.append(addition)

    return result


def load_configuration(
        *paths: str,
        context: 'parsing_utils.ParsingContext',
) -> typing.Sequence[typing.Union[base.BaseApp, base.AppPatch]]:
    """Load configuration for the applications."""
    return apply_configuration(
        configuration=functools.reduce(merge_configurations, map(load_configuration_file, paths), {}),
        context=context,
    )


# ╠═════════════════════════════════════════════════════════════════════════╡ Collecting application configuration ╞═══╣


CollectorPatch = typing.Callable[[AppConfiguration], AppConfiguration]


@attr.s(auto_attribs=True, eq=False, slots=True)
class CollectorSource:  # pylint: disable=too-few-public-methods
    """Single configuration source (file)."""

    path: str = attr.ib(on_setattr=attr.setters.frozen)
    content: typing.Optional[AppConfiguration] = attr.ib(default=None)

    def load(self, *, cache: bool = False, force: bool = False) -> AppConfiguration:
        """Load content of the configuration."""
        if (self.content is not None) and (not force):
            return self.content
        result: AppConfiguration = load_configuration_file(self.path)
        if cache:
            self.content = result
        return result


@attr.s(auto_attribs=True, eq=False, slots=True, frozen=True)
class CollectorStage:
    """
    Single stage of configuration collecting.

    It's a group of sources that should be merged, as well as patches that should be applied to the whole collected
    content (incl. previous stages).
    """

    name: str
    sources: typing.List[CollectorSource] = attr.ib(factory=list)
    patches: typing.List[CollectorPatch] = attr.ib(factory=list)

    def load(
            self,
            *,
            current: typing.Optional[AppConfiguration] = None,
            cache: bool = False,
            force: bool = False,
    ) -> AppConfiguration:
        """Load and merge configurations of all sources in the stage."""
        if current is None:
            current = {}
        return functools.reduce(
            merge_configurations,
            (
                source.load(cache=cache, force=force)
                for source in self.sources
            ),
            current,
        )

    def patch(self, configuration: AppConfiguration) -> AppConfiguration:
        """Apply patches to collected configuration."""
        patch: CollectorPatch
        result: AppConfiguration = configuration
        for patch in self.patches:
            result = patch(result)
        return result


class Collector:
    """Helper class to make configuration collection more structured."""

    __slots__ = ('__stages',)

    def __init__(self) -> None:
        """Initialize new :class:`~Collector` instance."""
        self.__stages: typing.Final[typing.List[CollectorStage]] = []

    def add_stage(self, *, name: typing.Optional[str] = None, index: typing.Optional[int] = None) -> None:
        """
        Register new stage.

        :param name: Optional name of the stage. Required to be unique.
        :param index: Custom location to put new stage in. By default - it is appended to the end.
        """
        if name is None:
            name = uuid.uuid4().hex
        elif any(stage.name == name for stage in self.__stages):
            raise KeyError(name)

        if index is None:
            index = len(self.__stages)

        self.__stages.insert(index, CollectorStage(name=name))

    def add_source(
            self,
            source: str,
            *,
            stage: typing.Union[None, bool, int, str] = None,
            index: typing.Optional[int] = None,
    ) -> None:
        """
        Add new source.

        :param source: Path to configuration file that should be fetched.
        :param stage: Optional stage to put this source in.

                      By default - it is added after all previous items (if there is another source at the top - new one
                      is added to the same stage; if there is a patch at the top - new stage might be created for this
                      source).

                      If :code:`True` - new stage will be created for this source.

                      If :code:`False` - source will be added to the last available stage.

                      If any :code:`str` value - source will be added to the group with corresponding name.

                      If any :code:`int` value - source will be added to the group at corresponding index.
        :param index: Index to put this source at inside the group.
        """
        if stage is None:
            stage = (not self.__stages) or bool(self.__stages[-1].patches)

        target: CollectorStage = self._stage(stage)
        if index is None:
            index = len(target.sources)

        target.sources.insert(index, CollectorSource(source))

    def add_patch(
            self,
            patch: CollectorPatch,
            *,
            stage: typing.Union[None, bool, int, str] = None,
            index: typing.Optional[int] = None,
    ) -> None:
        """
        Add new patch.

        :param patch: Patch function that should be applied to the whole collected configuration before the patch.
        :param stage: Optional stage to put this patch in.

                      By default - it is added after all previous items (in the last available stage, or in the new one
                      otherwise).

                      If :code:`True` - new stage will be created for this patch.

                      If :code:`False` - patch will be added to the last available stage.

                      If any :code:`str` value - patch will be added to the group with corresponding name.

                      If any :code:`int` value - patch will be added to the group at corresponding index.
        :param index: Index to put this patch at inside the group.
        """
        if stage is None:
            stage = not self.__stages

        target: CollectorStage = self._stage(stage)
        if index is None:
            index = len(target.patches)

        target.patches.insert(index, patch)

    def load(self, *, cache: bool = False, force: bool = False) -> AppConfiguration:
        """Collect all configurations according to the collector configuration."""
        result: AppConfiguration = {}

        stage: CollectorStage
        for stage in self.__stages:
            result = stage.patch(stage.load(current=result, cache=cache, force=force))

        return result

    def remove_stage(self, stage: typing.Union[int, str]) -> None:
        """
        Remove whole stage from the process.

        :param stage: Name or index of the stage to remove.
        """
        self.__stages.remove(self._stage(stage))

    def remove_source(self, stage: typing.Union[int, str], source: typing.Optional[int] = None) -> None:
        """
        Remove source from the process.

        :param stage: Name or index of the stage that holds the source.
        :param source: Optional index of a stage to remove. If not provided - all sources in stage will be removed.
        """
        if source is None:
            self._stage(stage).sources.clear()
        else:
            del self._stage(stage).sources[source]

    def remove_patch(self, stage: typing.Union[int, str], patch: typing.Optional[int] = None) -> None:
        """
        Remove patch from the process.

        :param stage: Name or index of the stage that holds the source.
        :param patch: Optional index of a patch to remove. If not provided - all patches in stage will be removed.
        """
        if patch is None:
            self._stage(stage).patches.clear()
        else:
            del self._stage(stage).patches[patch]

    def _stage(self, stage: typing.Union[bool, int, str]) -> CollectorStage:
        """Retrieve stage from the collector."""
        result: CollectorStage
        if stage is True:
            result = CollectorStage(name=uuid.uuid4().hex)
            self.__stages.append(result)
            return result

        if stage is False:
            return self.__stages[-1]

        if isinstance(stage, int):
            return self.__stages[stage]

        for result in self.__stages:
            if result.name == stage:
                return result

        raise KeyError(stage)


def patch_configuration(configuration: AppConfiguration) -> AppConfiguration:
    """
    Apply patches to the whole configuration before enforced configuration stage.

    Might be used to enforce partners tiles at first places and forbid custom ordering of tiles.
    """
    # application: AppRecord
    # for application in configuration.values():
    #     application.pop('rank', None)
    return configuration


def load(context: 'parsing_utils.ParsingContext') -> typing.Sequence[typing.Union[base.BaseApp, base.AppPatch]]:
    """
    Fetch configuration for application tiles (installable and web applications).

    This function first tries to fetch configuration from the cached web resources. If they are empty - it falls back
    to the bundled values.

    These tiles are extended with custom user-defined configurations.
    """
    source: structures.ApplicationSource

    collector: Collector = Collector()
    collector.add_stage(name='base')
    for source in preferences.REMOTE_APPLICATION_SOURCES.iter('base'):
        collector.add_source(source.path)
    collector.add_stage(name='user')
    collector.add_patch(patch_configuration)
    collector.add_stage(name='overrides')
    for source in preferences.REMOTE_APPLICATION_SOURCES.iter('overrides'):
        collector.add_source(source.path)

    if not collector.load():
        collector.remove_source(stage='base')
        collector.add_source(content.BASE_APPS_CONF_PATH, stage='base')

        collector.remove_source(stage='overrides')
        collector.add_source(content.APP_OVERRIDES_CONF_PATH, stage='overrides')

    try:
        item: str
        os.makedirs(preferences.USER_APPLICATIONS_ROOT, exist_ok=True)
        for item in sorted(os.listdir(preferences.USER_APPLICATIONS_ROOT)):
            item = os.path.join(preferences.USER_APPLICATIONS_ROOT, item)
            if os.path.isfile(item):
                collector.add_source(item, stage='user')
    except OSError:
        pass

    return apply_configuration(configuration=collector.load(), context=context)
