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

"""Additional utility functions to use with pycharm."""

from __future__ import annotations

__all__ = ['jetbrains_is_available', 'JetBrainsVersionChecker', 'JetBrainsDetector']

import abc
import collections.abc
import contextlib
import json
import os
import re
import typing

import attrs

from anaconda_navigator import config as navigator_config

from .. import detectors
from ..detectors import utilities as detector_utilities
from .. import validation
from . import detector_utils

if typing.TYPE_CHECKING:
    from typing_extensions import Self


def jetbrains_is_available() -> bool:
    """Check if PyCharm should be available on a platform."""
    return navigator_config.BITS_64


class JetBrainsVersionChecker(detectors.Filter):  # pylint: disable=too-few-public-methods
    """
    Detect version of a JetBrains application.

    :param product_code: acceptable product code(s)

                         Known options:

                         - PY: PyCharm Pro
                         - PC: PyCharm Community
                         - PE: PyCharm Edu
                         - DS: DataSpell
    :param product_info_path: Path to the `product-info.json` file relative to the root of the application.
    :param build_path: Path to the `build.txt` file relative to the root of the application.
    """

    __slots__ = ('__build_path', '__product_code', '__product_info_path')

    BUILD_PATTERN: typing.Final[typing.Pattern[str]] = re.compile(
        r'(?P<product>[a-zA-Z0-9]+)-(?P<build>[0-9.]+)',
    )
    KNOWN_BUILDS: typing.Final[typing.Mapping[str, str]] = {
        '101.15': '1.1.1',
        '105.58': '1.2.1',
        '107.756': '1.5.4',
        '111.291': '2.0.2',
        '117.663': '2.5.2',
        '121.378': '2.6.3',
        '125.57': '2.7',
        '125.92': '2.7.1',
        '129.314': '2.7.2',
        '129.782': '2.7.3',
        '129.1566': '2.7.4',
        '131.19': '3.0',
        '131.339': '3.0.1',
        '131.618': '3.0.2',
        '131.849': '3.0.3',
        '133.804': '3.1',
        '133.881': '3.1.1',
        '133.1229': '3.1.2',
        '133.1347': '3.1.3',
        '133.1884': '3.1.4',
        '135.973': '3.4',
        '135.1057': '3.4.1',
        '135.1317': '3.4.2',
        '135.1318': '3.4.3',
        '135.1357': '3.4.4',
        '139.487': '4.0',
        '139.574': '4.0.1',
        '139.711': '4.0.2',
        '139.781': '4.0.3',
        '139.1001': '4.0.4',
        '139.1547': '4.0.5',
        '139.1659': '4.0.6',
        '139.1803': '4.0.7',
        '141.1116': '4.5',
        '141.1245': '4.5.1',
        '141.158': '4.5.2',
        '141.1899': '4.5.3',
        '141.2569': '4.5.4',
        '141.3058': '4.5.5',
        '143.589': '5.0',
        '143.595': '5.0.1',
        '143.1184': '5.0.2',
        '143.1559': '5.0.3',
        '143.1919': '5.0.4',
        '143.2370': '5.0.5',
        '143.2371': '5.0.6',
        '145.26': '2016.1',
        '145.598': '2016.1.1',
        '145.844': '2016.1.2',
        '145.971': '2016.1.3',
        '145.1504': '2016.1.4',
        '145.2073.10': '2016.1.5',
        '162.1237.1': '2016.2',
        '162.1628.8': '2016.2.1',
        '162.1812.1': '2016.2.2',
        '162.1967.10': '2016.2.3',
        '163.8233.8': '2016.3',
        '163.9735.8': '2016.3.1',
        '163.10154.50': '2016.3.2',
        '163.15188.4': '2016.3.3',
        '163.15529.21': '2016.3.4',
        '163.15529.24': '2016.3.5',
        '163.15529.25': '2016.3.6',
        '171.3780.115': '2017.1',
        '171.4249.47': '2017.1.2',
        '171.4424.42': '2017.1.3',
        '171.4694.38': '2017.1.4',
        '171.4694.67': '2017.1.5',
        '171.4694.79': '2017.1.6',
        '171.4694.87': '2017.1.7',
        '171.4694.94': '2017.1.8',
        '172.3317.103': '2017.2',
        '172.3544.46': '2017.2.1',
        '172.3757.67': '2017.2.2',
        '172.3968.37': '2017.2.3',
        '172.4343.24': '2017.2.4',
        '172.4574.27': '2017.2.5',
        '172.4574.33': '2017.2.6',
        '172.4574.37': '2017.2.7',
        '173.3727.137': '2017.3',
        '173.3942.36': '2017.3.1',
        '173.4127.16': '2017.3.2',
        '173.4301.16': '2017.3.3',
        '173.4674.37': '2017.3.4',
        '173.4674.54': '2017.3.5',
        '173.4674.57': '2017.3.6',
        '173.4674.62': '2017.3.7',
        '181.4203.547': '2018.1',
        '181.4445.76': '2018.1.1',
        '181.4668.75': '2018.1.2',
        '181.4892.64': '2018.1.3',
        '181.5087.37': '2018.1.4',
        '181.5540.17': '2018.1.5',
        '181.5540.34': '2018.1.6',
        '182.3684.100': '2018.2',
        '182.3911.33': '2018.2.1',
        '182.4129.34': '2018.2.2',
        '182.4323.49': '2018.2.3',
        '182.4505.26': '2018.2.4',
        '182.5107.22': '2018.2.5',
        '182.5107.44': '2018.2.6',
        '182.5107.56': '2018.2.7',
        '182.5262.4': '2018.2.8',
        '183.4284.139': '2018.3',
        '183.4588.64': '2018.3.1',
        '183.4886.43': '2018.3.2',
        '183.5153.39': '2018.3.3',
        '183.5429.31': '2018.3.4',
        '183.5912.18': '2018.3.5',
        '183.6156.13': '2018.3.6',
        '183.6156.16': '2018.3.7',
        '191.6183.50': '2019.1',
        '191.6605.12': '2019.1.1',
        '191.7141.48': '2019.1.2',
        '191.7479.30': '2019.1.3',
        '191.8026.44': '2019.1.4',
        '192.5728.105': '2019.2',
        '192.6262.63': '2019.2.1',
        '192.6603.34': '2019.2.2',
        '192.6817.19': '2019.2.3',
        '192.7142.42': '2019.2.4',
        '192.7142.56': '2019.2.5',
        '192.7142.79': '2019.2.6',
        '193.5233.109': '2019.3',
        '193.5662.61': '2019.3.1',
        '193.6015.41': '2019.3.2',
        '193.6494.30': '2019.3.3',
        '193.6911.25': '2019.3.4',
        '193.7288.30': '2019.3.5',
        '201.6668.115': '2020.1',
        '201.7223.92': '2020.1.1',
        '201.7846.77': '2020.1.2',
        '201.8538.36': '2020.1.3',
        '201.8743.11': '2020.1.4',
        '201.8743.20': '2020.1.5',
        '202.6397.98': '2020.2',
        '202.6948.78': '2020.2.1',
        '202.7319.64': '2020.2.2',
        '202.7660.27': '2020.2.3',
        '202.8194.15': '2020.2.4',
        '202.8194.22': '2020.2.5',
        '203.5981.165': '2020.3',
        '203.6682.86': '2020.3.1',
        '203.6682.179': '2020.3.2',
        '203.7148.72': '2020.3.3',
        '203.7717.65': '2020.3.4',
        '203.7717.81': '2020.3.5',
        '211.6693.115': '2021.1',
        '211.7142.13': '2021.1.1',
        '211.7442.45': '2021.1.2',
        '211.7628.24': '2021.1.3',
        '212.4746.96': '2021.2',
        '212.5080.64': '2021.2.1',
        '212.5284.44': '2021.2.2',
        '212.5457.59': '2021.2.3',
    }

    def __init__(
            self,
            product_code: typing.Union[str, typing.Iterable[str]],
            product_info_path: str = 'product-info.json',
            build_path: str = 'build.txt',
    ) -> None:
        """Initialize new :class:`~JetBrainsVersionChecker` instance."""
        super().__init__()

        if isinstance(product_code, str):
            product_code = (product_code,)
        else:
            product_code = tuple(product_code)

        self.__product_code: typing.Final[typing.Tuple[str, ...]] = product_code
        self.__product_info_path: typing.Final[str] = product_info_path
        self.__build_path: typing.Final[str] = build_path

    @classmethod
    def _parse_configuration(cls, *args: typing.Any, **kwargs: typing.Any) -> detectors.Detector:
        """Parse configuration for this particular :class:`~Detector`."""
        validation.OfLength(0)(args, field='args')

        product_code: typing.Tuple[str, ...] = validation.ItemPopper('product_code').convert(
            validation.sequence_of(str),
        )(kwargs)

        product_info_path: str = validation.ItemPopper('product_info_path', default='product-info.json').convert(
            detector_utilities.parse_and_join,
        )(kwargs) or ''

        build_path: str = validation.ItemPopper('build_path', default='build.txt').convert(
            detector_utilities.parse_and_join,
        )(kwargs) or ''

        validation.IsEmptyMapping()(kwargs)

        with validation.catch_exception():
            return cls(product_code=product_code, product_info_path=product_info_path, build_path=build_path)

    def __parse_product_info(self, root: str) -> typing.Optional[str]:
        """Parse `product-info.json` file for application version."""
        with contextlib.suppress(BaseException):
            stream: typing.TextIO
            data: typing.Mapping[str, typing.Any]
            with open(os.path.join(root, self.__product_info_path), 'rt', encoding='utf-8') as stream:
                data = json.load(stream)

            if data['productCode'] in self.__product_code:
                return data['version']

        return None

    def __parse_build(self, root: str) -> typing.Optional[str]:
        """Parse `build.txt` file for application version."""
        with contextlib.suppress(BaseException):
            stream: typing.TextIO
            match: typing.Optional[typing.Match[str]]
            with open(os.path.join(root, self.__build_path), 'rt', encoding='utf-8') as stream:
                match = self.BUILD_PATTERN.fullmatch(stream.read().strip())

            if match and (match.group('product') in self.__product_code):
                build: str = match.group('build')
                return self.KNOWN_BUILDS.get(build, f'build {build}')

        return None

    def __call__(
            self,
            parent: typing.Iterator[detectors.DetectedApplication],
            *,
            context: detectors.DetectorContext,
    ) -> typing.Iterator[detectors.DetectedApplication]:
        """Iterate through detected applications."""
        application: detectors.DetectedApplication
        for application in parent:
            if not application.root:
                continue

            version: typing.Optional[str] = self.__parse_product_info(application.root)
            if version is None:
                version = self.__parse_build(application.root)

            if version is not None:
                yield application.replace(version=version)


def pop_string_sequence(data: dict[str, typing.Any], name: str) -> collections.abc.Sequence[str]:
    """Extract string sequence from :code:`data[name]`."""
    return validation.ItemPopper(name, default=()).convert(validation.sequence_of(str))(data)


@attrs.frozen
class JetBrainsCommonDetector(metaclass=abc.ABCMeta):
    """Base for JetBrains product partial detectors."""

    product_code: collections.abc.Sequence[str]

    @classmethod
    @abc.abstractmethod
    def parse(cls, data: dict[str, typing.Any]) -> Self:
        """Parse detector configuration."""

    @abc.abstractmethod
    def build(self) -> detectors.Detector:
        """Prepare a detector for a JetBrains product."""


@attrs.frozen
class JetBrainsLinuxDetector(JetBrainsCommonDetector):
    """Detector of JetBrains products on Linux."""

    directory_prefix: collections.abc.Sequence[str]
    executable: collections.abc.Sequence[str]
    snap_directory: collections.abc.Sequence[str]
    toolbox_1_directory: collections.abc.Sequence[str]
    toolbox_2_directory: collections.abc.Sequence[str]

    @classmethod
    def parse(cls, data: dict[str, typing.Any]) -> Self:
        """Parse detector configuration."""
        return cls(
            directory_prefix=pop_string_sequence(data, 'directory_prefix'),
            executable=pop_string_sequence(data, 'executable'),
            product_code=pop_string_sequence(data, 'product_code'),
            snap_directory=pop_string_sequence(data, 'snap_directory'),
            toolbox_1_directory=pop_string_sequence(data, 'toolbox_1_directory'),
            toolbox_2_directory=pop_string_sequence(data, 'toolbox_2_directory'),
        )

    def build(self) -> detectors.Detector:
        """Prepare a detector for a JetBrains product."""
        toolbox_root: typing.Optional[str] = detectors.join(
            detectors.FOLDERS['linux/home'], '.local', 'share', 'JetBrains', 'Toolbox', 'apps',
        )
        return detectors.LinuxOnly(
            detectors.Group(
                detectors.CheckConfiguredRoots(),

                detectors.Group(
                    detectors.CheckKnownRoots(
                        detectors.join(detectors.FOLDERS['linux/root'], 'opt'),
                    ),
                    detectors.StepIntoRoot(starts_with=self.directory_prefix, reverse=True),
                ),

                detectors.Group(
                    detectors.CheckKnownRoots(*(
                        detectors.join(toolbox_root, toolbox_name)
                        for toolbox_name in self.toolbox_1_directory
                    )),
                    detectors.StepIntoRoot(starts_with='ch-'),
                    detectors.StepIntoRoot(reverse=True),
                ),

                detectors.Group(
                    detectors.CheckKnownRoots(toolbox_root),
                    detectors.StepIntoRoot(matches=[
                        fr'{re.escape(toolbox_name)}(?:-\d+)?'
                        for toolbox_name in self.toolbox_2_directory
                    ]),
                ),

                detectors.CheckKnownRoots(*(
                    detectors.join(snap_root, snap_name, 'current')
                    for snap_name in self.snap_directory
                    for snap_root in (
                        detectors.FOLDERS['linux/snap_primary'],
                        detectors.FOLDERS['linux/snap_secondary'],
                    )
                )),

                detectors.AppendExecutable(*(
                    detectors.join('bin', exec_filename)
                    for exec_filename in self.executable
                )),
            ),

            detectors.Group(
                detectors.CheckPATH(self.executable),
                detectors.AppendRoot(level=1),
            ),

            JetBrainsVersionChecker(
                product_code=self.product_code,
            ),
        )


@attrs.frozen
class JetBrainsMacOsDetector(JetBrainsCommonDetector):
    """Detector of JetBrains products on macOS."""

    application_name: collections.abc.Sequence[str]
    application_prefix: collections.abc.Sequence[str]
    executable: collections.abc.Sequence[str]
    toolbox_1_directory: collections.abc.Sequence[str]

    @classmethod
    def parse(cls, data: dict[str, typing.Any]) -> Self:
        """Parse detector configuration."""
        return cls(
            application_name=pop_string_sequence(data, 'application_name'),
            application_prefix=pop_string_sequence(data, 'application_prefix'),
            executable=pop_string_sequence(data, 'executable'),
            product_code=pop_string_sequence(data, 'product_code'),
            toolbox_1_directory=pop_string_sequence(data, 'toolbox_1_directory'),
        )

    def build(self) -> detectors.Detector:
        """Prepare a detector for a JetBrains product."""
        toolbox_root: typing.Optional[str] = detectors.join(
            detectors.FOLDERS['osx/home'], 'Library', 'Application Support', 'JetBrains', 'Toolbox', 'apps',
        )
        return detectors.OsXOnly(
            detectors.Group(
                detectors.CheckConfiguredRoots(),

                detectors.CheckKnownOsXRoots(self.application_name),

                detectors.Group(
                    detectors.CheckKnownRoots(
                        detectors.FOLDERS['osx/user_applications'],
                        detectors.FOLDERS['osx/applications'],
                    ),
                    detectors.StepIntoRoot(starts_with=self.application_prefix),
                ),

                detectors.Group(
                    detectors.CheckKnownRoots(*(
                        detectors.join(toolbox_root, toolbox_name)
                        for toolbox_name in self.toolbox_1_directory
                    )),
                    detectors.StepIntoRoot(starts_with='ch-'),
                    detectors.StepIntoRoot(reverse=True),
                    detectors.StepIntoRoot(equals=self.application_name),
                ),

                detectors.AppendExecutable(*(
                    detectors.join('Contents', 'MacOS', exec_filename)
                    for exec_filename in self.executable
                )),
            ),

            detectors.Group(
                detectors.CheckPATH(self.executable),
                detectors.AppendRoot(level=2),
            ),

            JetBrainsVersionChecker(
                product_code=self.product_code,
                product_info_path=detectors.join('Contents', 'Resources', 'product-info.json'),
                build_path=detectors.join('Contents', 'Resources', 'build.txt'),
            ),
        )


@attrs.frozen
class JetBrainsWindowsDetector(JetBrainsCommonDetector):
    """Detector of JetBrains products on Windows."""

    directory_prefix: collections.abc.Sequence[str]
    executable: collections.abc.Sequence[str]
    toolbox_1_directory: collections.abc.Sequence[str]

    @classmethod
    def parse(cls, data: dict[str, typing.Any]) -> Self:
        """Parse detector configuration."""
        return cls(
            directory_prefix=pop_string_sequence(data, 'directory_prefix'),
            executable=pop_string_sequence(data, 'executable'),
            product_code=pop_string_sequence(data, 'product_code'),
            toolbox_1_directory=pop_string_sequence(data, 'toolbox_1_directory'),
        )

    def build(self) -> detectors.Detector:
        """Prepare a detector for a JetBrains product."""
        toolbox_root: typing.Optional[str] = detectors.join(
            detectors.FOLDERS['windows/local_app_data'], 'JetBrains', 'Toolbox', 'apps',
        )
        return detectors.WindowsOnly(
            detectors.Group(
                detectors.CheckConfiguredRoots(),

                detectors.Group(
                    detectors.CheckKnownRoots(
                        detectors.join(detectors.FOLDERS['windows/program_files_x64'], 'JetBrains'),
                        detectors.join(detectors.FOLDERS['windows/program_files_x86'], 'JetBrains'),
                    ),
                    detectors.StepIntoRoot(starts_with=self.directory_prefix, reverse=True),
                ),

                detectors.Group(
                    detectors.CheckKnownRoots(detectors.FOLDERS['windows/user_program_files']),
                    detectors.StepIntoRoot(starts_with=self.directory_prefix),
                ),

                detectors.Group(
                    detectors.CheckKnownRoots(*(
                        detectors.join(toolbox_root, toolbox_name)
                        for toolbox_name in self.toolbox_1_directory
                    )),
                    detectors.StepIntoRoot(starts_with='ch-'),
                    detectors.StepIntoRoot(reverse=True),
                ),

                detectors.AppendExecutable(*(
                    detectors.join('bin', exec_filename)
                    for exec_filename in self.executable
                )),
            ),

            detectors.Group(
                detectors.CheckPATH(self.executable),
                detectors.AppendRoot(level=1),
            ),

            detectors.Group(
                detectors.CheckExecutableInWindowsRegistry(*(
                    detectors.RegistryKey(
                        root='HKEY_CLASSES_ROOT',
                        key=f'Applications\\{exec_file_name}\\shell\\open\\command',
                        converter=detector_utils.extract_app_from_command,
                    )
                    for exec_file_name in self.executable
                )),
                detectors.AppendRoot(level=1),
            ),

            JetBrainsVersionChecker(
                product_code=self.product_code,
            ),
        )


class JetBrainsDetector(detectors.Group):  # pylint: disable=too-few-public-methods
    """Detector for Jetbrains products."""

    __slots__ = ()

    def __init__(self, *args: JetBrainsCommonDetector) -> None:
        """Initialize new :class:`~JetBrainsDetector` instance."""
        super().__init__(*(arg.build() for arg in args))

    @classmethod
    def _parse_configuration(cls, *args: typing.Any, **kwargs: typing.Any) -> detectors.Detector:
        """Parse configuration for this particular :class:`~Detector`."""
        validation.OfLength(0)(args, field='args')

        common: dict[str, typing.Any] = validation.ItemPopper('common', default={}).convert(validation.IS_MAP)(kwargs)
        result: list[JetBrainsCommonDetector] = []

        detector_options: collections.abc.Mapping[str, type[JetBrainsCommonDetector]] = {
            'linux': JetBrainsLinuxDetector,
            'macos': JetBrainsMacOsDetector,
            'windows': JetBrainsWindowsDetector,
        }

        field: str
        detector: type[JetBrainsCommonDetector]
        for field, detector in detector_options.items():
            part: dict[str, typing.Any] = validation.ItemPopper(field, default={}).convert(validation.IS_MAP)(kwargs)
            if part:
                with validation.exceptions.ValidationError.with_field(field):
                    result.append(detector.parse({**common, **part}))

        validation.IsEmptyMapping()(kwargs)

        with validation.catch_exception():
            return cls(*result)
