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

"""Core structures for third-party application descriptions."""

from __future__ import annotations

__all__ = ['BaseApp', 'BaseWebApp', 'BaseInstallableApp', 'AppPatch']

import abc
import os
import re
import types
import typing
import webbrowser
from anaconda_navigator.static.images import IMAGE_PATH
from anaconda_navigator.utils import constants
from . import detectors
from . import exceptions
from . import import_utils
from . import parsing_utils
from . import validation_utils


if typing.TYPE_CHECKING:
    import typing_extensions
    from anaconda_navigator.api import process
    from anaconda_navigator.api import types as api_types
    from anaconda_navigator.config import user as user_config

    AppPatchField = typing_extensions.Literal['display_name', 'description', 'image_path']

    class AppPatchDelta(typing_extensions.TypedDict, total=False):
        """Change provided by :class:`~AppPatch`."""
        display_name: str
        description: str
        image_path: str


class AppPatch(typing.NamedTuple):
    """Patch for the application defined outside this component."""

    app_name: str = ''
    display_name: typing.Optional[str] = None
    description: typing.Optional[str] = None
    image_path: typing.Optional[str] = None
    is_available: bool = True

    @property
    def delta(self) -> 'AppPatchDelta':  # noqa: D401
        """Dict-like content of the patch."""
        result: 'AppPatchDelta' = {}

        # NOTE: should be replaced with py38+: fields = AppPatchField.__args__

        field: 'AppPatchField'
        fields: typing.Tuple['AppPatchField', ...] = ('display_name', 'description', 'image_path')
        for field in fields:
            current: typing.Any = getattr(self, field)
            if current is not None:
                result[field] = current

        return result

    @typing.overload
    def apply_to(self, instance: AppPatch) -> AppPatch:
        """Modify other AppPatch."""

    @typing.overload
    def apply_to(self, instance: 'api_types.Application') -> 'api_types.Application':
        """Apply all values from a patch to an application description dictionary."""

    def apply_to(
            self,
            instance: typing.Union[AppPatch, 'api_types.Application'],
    ) -> typing.Union[AppPatch, 'api_types.Application']:
        """Apply patch to another instance."""
        if isinstance(instance, AppPatch):
            return instance._replace(  # pylint: disable=protected-access
                is_available=self.is_available,
                **self.delta,
            )

        instance.update(typing.cast('api_types.Application', self.delta))
        return instance


InstallFunction = typing.Callable[['BaseInstallableApp'], None]
InstallExtensionsFunction = typing.Callable[['BaseInstallableApp'], 'process.ProcessWorker']
UpdateConfigFunction = typing.Callable[['BaseInstallableApp', str], None]


class BaseApp(metaclass=abc.ABCMeta):  # pylint: disable=too-few-public-methods,too-many-instance-attributes
    """
    Root description of the application.

    :param app_type: Type of the application (web application, installable, etc.).
    :param app_name: Alias of the application in a package-naming format.
    :param display_name: Name of the application to show in home tile.
    :param description: Description of the application to show in home tile.
    :param image_path: Application icon to show in home tile.
    :param config: Application configuration.
    """

    __app_types__: 'typing_extensions.Final[typing.MutableMapping[str, typing.Type[BaseApp]]]' = {}

    def __init__(  # pylint: disable=too-many-arguments
            self,
            app_type: constants.AppType,
            app_name: str,
            display_name: str,
            description: str,
            image_path: str,
            is_available: bool,
            non_conda: bool,
            config: 'user_config.UserConfig',
    ) -> None:
        """Initialize new :class:`~BaseApp` instance."""
        self.app_type: 'typing_extensions.Final[constants.AppType]' = app_type
        self.app_name: 'typing_extensions.Final[str]' = app_name
        self.display_name: 'typing_extensions.Final[str]' = display_name
        self.description: 'typing_extensions.Final[str]' = description
        self.image_path: 'typing_extensions.Final[str]' = image_path
        self.non_conda: 'typing_extensions.Final[bool]' = non_conda
        self.config: 'typing_extensions.Final[user_config.UserConfig]' = config
        self.is_available: 'typing_extensions.Final[bool]' = is_available

    def __init_subclass__(
            cls,  # pylint: disable=unused-argument
            alias: typing.Optional[constants.AppType] = None,
            **kwargs: typing.Any,
    ) -> None:
        """
        Initialize new :class:`~BaseApp` subclass.

        Used to register new classes in registry.
        """
        if alias is None:
            return

        if alias in cls.__app_types__:
            raise ValueError(f'{alias!r} is already registered')

        cls.__app_types__[alias] = cls

    @property
    def tile_definition(self) -> 'api_types.Application':  # noqa: D401
        """Definition of the application tile."""
        return {
            'app_type': self.app_type,
            'name': self.app_name,
            'display_name': self.display_name,
            'description': self.description,
            'image_path': self.image_path,
            'non_conda': self.non_conda,
        }

    @classmethod
    def parse_configuration(
            cls,
            context: parsing_utils.ParsingContext,
            configuration: typing.Mapping[str, typing.Any],
            app_name: str,
    ) -> typing.Union[None, 'BaseApp', 'AppPatch']:
        """Generate applications according to the `configuration`."""
        validation_utils.of_type(typing.Mapping)(configuration)
        mirror: typing.Dict[str, typing.Any] = dict(configuration)

        app_type: str
        target_cls: typing.Optional[typing.Type[BaseApp]]
        try:
            app_type = validation_utils.pop_mapping_item(mirror, 'app_type')
        except exceptions.ValidationError:
            target_cls = None
        else:
            with exceptions.ValidationError.with_field('app_type'):
                validation_utils.is_str(app_type)
                validation_utils.of_options(*cls.__app_types__)(app_type)
            target_cls = cls.__app_types__[app_type]

        display_name: typing.Optional[str] = cls.__resolve_string_field(
            mirror,
            'display_name',
            required=target_cls is not None,
        )

        description: typing.Optional[str] = cls.__resolve_string_field(
            mirror,
            'description',
            default='',
            required=target_cls is not None,
        )

        image_path: typing.Optional[str] = cls.__resolve_string_field(
            mirror,
            'image_path',
            default='anaconda-icon-256x256.png',
            required=target_cls is not None,
        )
        if image_path is not None:
            image_path = cls.__resolve_image_path(image_path)

        is_available: typing.Union[bool, str] = validation_utils.pop_mapping_item(mirror, 'is_available', True)
        with exceptions.ValidationError.with_field('is_available'):
            validation_utils.of_type(bool, str)(is_available)
            is_available = cls.__resolve_is_available(is_available)

        if target_cls is None:
            validation_utils.mapping_is_empty()(mirror)
            return AppPatch(
                app_name=app_name,
                display_name=display_name,
                description=description,
                image_path=image_path,
                is_available=is_available,
            )

        if not is_available:
            return None

        return target_cls._parse_configuration(  # pylint: disable=protected-access
            context=context,
            configuration=mirror,

            app_name=app_name,
            config=context.user_configuration,
            display_name=display_name,
            description=description,
            image_path=image_path,
            is_available=is_available,
        )

    @staticmethod
    @abc.abstractmethod
    def _parse_configuration(
            context: parsing_utils.ParsingContext,
            configuration: typing.MutableMapping[str, typing.Any],
            **kwargs: typing.Any,  # app_name, display_name, description, image_path, is_available
    ) -> BaseApp:
        """Parse configuration for this particular :class:`~BaseApp`."""

    @staticmethod
    def __resolve_string_field(
            source: typing.Dict[str, typing.Any],
            field: str,
            *,
            default: typing.Optional[str] = None,
            required: bool = True,
    ) -> typing.Optional[str]:
        """Validate and resolve value of `display_name`."""
        result: typing.Optional[str] = None
        try:
            result = validation_utils.pop_mapping_item(source, field)
        except exceptions.ValidationError:
            if required:
                if default is not None:
                    result = default
                else:
                    raise
        else:
            with exceptions.ValidationError.with_field(field):
                validation_utils.is_str(result)
        return result

    @staticmethod
    def __resolve_image_path(value: str) -> str:
        """Validate and resolve value of `image_path`."""
        if os.path.isfile(value):
            return value

        result: str = os.path.join(IMAGE_PATH, value)
        if os.path.isfile(result):
            return result

        raise exceptions.ValidationError()

    @staticmethod
    def __resolve_is_available(value: typing.Union[bool, str]) -> bool:
        """Validate and resolve value of `is_available`."""
        if isinstance(value, bool):
            return value

        result: bool
        func: typing.Callable[[], bool] = import_utils.import_callable(value)
        with validation_utils.catch_exception():
            result = func()
        validation_utils.is_bool(result, 'result')
        return result


class BaseWebApp(BaseApp, metaclass=abc.ABCMeta, alias=constants.AppType.WEB):  # pylint: disable=too-few-public-methods
    """
    Common base for all external web applications.

    Most of the params reused from the :class:`~BaseApp`.

    :param url: Web page to open for the application.
    """

    def __init__(  # pylint: disable=too-many-arguments
            self,
            app_name: str,
            display_name: str,
            description: str,
            image_path: str,
            url: str,
            is_available: bool,
            config: 'user_config.UserConfig',
    ) -> None:
        """Initialize new :class:`~BaseWebApp` instance."""
        super().__init__(
            app_type=constants.AppType.WEB,
            app_name=app_name,
            display_name=display_name,
            description=description,
            image_path=image_path,
            is_available=is_available,
            non_conda=True,
            config=config,
        )

        self.url: 'typing_extensions.Final[str]' = url

    @property
    def tile_definition(self) -> 'api_types.Application':  # noqa: D401
        """Definition of the application tile."""
        result: 'api_types.Application' = super().tile_definition
        result['installed'] = True
        return result

    @classmethod
    def _parse_configuration(
            cls,
            context: parsing_utils.ParsingContext,
            configuration: typing.MutableMapping[str, typing.Any],
            **kwargs: typing.Any,  # app_name, display_name, description, image_path, is_available
    ) -> BaseWebApp:
        """Parse configuration for this particular :class:`~BaseApp`."""
        url: str = validation_utils.pop_mapping_item(configuration, 'url')
        validation_utils.is_str(url, field_name='url')

        validation_utils.mapping_is_empty()(configuration)

        return cls(
            url=url,
            **kwargs,
        )


class BaseInstallableApp(BaseApp, metaclass=abc.ABCMeta, alias=constants.AppType.INSTALLABLE):
    """
    Common base for all external installable applications.

    Most of the params reused from the :class:`~BaseApp`.

    :param detector: Detector of the application location.
    :param process_api: API to spawn new processes (for extensions installation etc.).
    """

    install: typing.ClassVar[InstallFunction]

    def __init__(  # pylint: disable=too-many-arguments
            self,
            app_name: str,
            display_name: str,
            description: str,
            image_path: str,
            detector: 'detectors.Source',
            is_available: bool,
            process_api: 'process.WorkerManager',
            config: 'user_config.UserConfig',
            extra_arguments: typing.Iterable[typing.Any] = (),
    ) -> None:
        """Initialize new :class:`~BaseInstallableApp` instance."""
        super().__init__(
            app_type=constants.AppType.INSTALLABLE,
            app_name=app_name,
            display_name=display_name,
            description=description,
            image_path=image_path,
            is_available=is_available,
            non_conda=True,
            config=config,
        )
        self.extra_arguments: 'typing_extensions.Final[typing.Sequence[typing.Any]]' = tuple(map(str, extra_arguments))
        self._process_api: 'typing_extensions.Final[process.WorkerManager]' = process_api

        context: detectors.DetectorContext = detectors.DetectorContext(
            app_name=app_name,
            user_configuration=config,
        )
        location: 'typing.Optional[detectors.DetectedApplication]'
        for location in detector(context=context):
            if location.complete:
                break
        else:
            location = None
        self.__location: 'typing_extensions.Final[typing.Optional[detectors.DetectedApplication]]' = location

        self.config.set('applications', f'{self.app_name}_path', self.root or '')

    @property
    def tile_definition(self) -> 'api_types.Application':  # noqa: D401
        """Definition of the application tile."""
        result: 'api_types.Application' = super().tile_definition
        result['versions'] = self.versions
        result['installed'] = self.is_installed
        result['command'] = self.executable or ''
        result['extra_arguments'] = self.extra_arguments
        return result

    @property
    def executable(self) -> typing.Optional[str]:  # noqa: D401
        """Command to execute the application."""
        if self.__location is None:
            return None

        from anaconda_navigator.utils import launch  # pylint: disable=import-outside-toplevel

        return launch.safe_argument(self.__location.executable)

    @property
    def is_installation_enabled(self) -> bool:  # noqa: D401
        """Application can be installed."""
        return hasattr(self, 'install')

    @property
    def is_installed(self) -> bool:  # noqa: D401
        """Application is installed in local system."""
        return bool(self.executable)

    @property
    def root(self) -> typing.Optional[str]:  # noqa: D401
        """Directory, in which application is installed."""
        if self.__location is None:
            return None
        return self.__location.root

    @property
    def version(self) -> typing.Optional[str]:  # noqa: D401
        """Version of the installed application."""
        if self.__location is None:
            return None
        return self.__location.version

    @property
    def versions(self) -> typing.Sequence[str]:  # noqa: D401
        """List of available application versions."""
        if self.__location is None:
            return []
        return [self.__location.version]

    @classmethod
    def _parse_configuration(
            cls,
            context: parsing_utils.ParsingContext,
            configuration: typing.MutableMapping[str, typing.Any],
            **kwargs: typing.Any,  # app_name, display_name, description, image_path, is_available, config
    ) -> BaseInstallableApp:
        """Parse configuration for this particular :class:`~BaseApp`."""
        raw_detector: typing.Union[str, typing.Mapping[str, typing.Any]]
        raw_detector = validation_utils.pop_mapping_item(configuration, 'detector')
        with exceptions.ValidationError.with_field('detector'):
            validation_utils.of_type(str, typing.Mapping)(raw_detector)
            detector: detectors.Source = cls.__resolve_detector(raw_detector)

        extra_arguments: typing.Union[None, str, typing.Sequence[str]]
        extra_arguments = validation_utils.pop_mapping_item(configuration, 'extra_arguments', None)
        with exceptions.ValidationError.with_field('extra_arguments'):
            validation_utils.of_type(str, typing.Sequence, allow_none=True)(extra_arguments)
            extra_arguments = cls.__resolve_extra_arguments(extra_arguments)

        result: BaseInstallableApp = cls(
            detector=detector,
            process_api=context.process_api,
            extra_arguments=extra_arguments,
            **kwargs,
        )

        raw_method: typing.Optional[str] = validation_utils.pop_mapping_item(configuration, 'install', None)
        with exceptions.ValidationError.with_field('install'):
            validation_utils.is_str_or_none(raw_method)
            if raw_method is not None:
                setattr(result, 'install', types.MethodType(cls.__resolve_install(raw_method), result))

        method_name: str
        for method_name in ('install_extensions', 'update_config'):
            raw_method = validation_utils.pop_mapping_item(configuration, method_name, None)
            validation_utils.is_str_or_none(raw_method)
            if raw_method is not None:
                setattr(result, method_name, types.MethodType(import_utils.import_callable(raw_method), result))

        validation_utils.mapping_is_empty()(configuration)

        return result

    @staticmethod
    def __resolve_detector(value: typing.Union[str, typing.Mapping[str, typing.Any]]) -> detectors.Source:
        """Validate and resolve value of `detector`."""
        result: detectors.Source
        if isinstance(value, str):
            resolver: typing.Callable[[], detectors.Source] = import_utils.import_callable(value)
            with validation_utils.catch_exception():
                result = resolver()
            validation_utils.of_type(detectors.Source)(result, field_name='result')
            return result

        result = typing.cast(detectors.Source, detectors.Detector.parse_configuration(value))
        validation_utils.of_type(detectors.Source)(result)
        return result

    @staticmethod
    def __resolve_extra_arguments(value: typing.Union[None, str, typing.Sequence[str]]) -> typing.Sequence[str]:
        """Validate and resolve value of `extra_arguments`."""
        if value is None:
            return ()

        if isinstance(value, str):
            result: typing.Sequence[str]
            resolver: typing.Callable[[], typing.Sequence[str]] = import_utils.import_callable(value)
            with validation_utils.catch_exception():
                result = resolver()
            validation_utils.is_str_sequence(result, field_name='result')
            return result

        validation_utils.each_item(validation_utils.is_str)(value)
        return value

    @staticmethod
    def __resolve_install(value: str) -> InstallFunction:
        """Validate and resolve value of `install`."""
        if re.match(r'^[a-z][a-z0-9.+-]*://', value):
            def install(self: 'BaseInstallableApp') -> None:  # pylint: disable=unused-argument
                """Install application"""
                webbrowser.open(value)

            return install

        return import_utils.import_callable(value)

    def install_extensions(self) -> 'process.ProcessWorker':
        """Install app extensions."""
        return self._process_api.create_process_worker(['python', '--version'])

    def update_config(self, prefix: str) -> None:
        """Update user config to use selected Python prefix interpreter."""
