import ast
from datetime import date, datetime, time, timedelta
from enum import Enum, EnumMeta
from functools import lru_cache
import json
from pathlib import Path
import sys
from typing import Any, Collection, Iterable, List, Mapping, Union


@lru_cache(maxsize=None)
def installed_packages() -> List[str]:
    try:
        from importlib import metadata as importlib_metadata
    except ImportError:  # for Python<3.8
        import importlib_metadata
    installed_distributions = importlib_metadata.distributions()
    return [
        distribution.metadata['Name'].lower()
        for distribution in installed_distributions
        if distribution.metadata['Name'] is not None
    ]


def convert_value(value: Any, to_type: Union[type, Collection[type]]) -> Any:
    """
    convert a value to the specified type

    :param value: Python value
    :param to_type: type to convert to
    :return: converted value

    >>> convert_value(0.55, str)
    '0.55'
    >>> convert_value('0.55', float)
    0.55
    >>> convert_value('0.55', 'float')
    0.55
    >>> convert_value(0.55, int)
    0

    >>> convert_value([1], str)
    '[1]'
    >>> convert_value([1, 2, '3', '4'], [int])
    [1, 2, 3, 4]
    >>> convert_value([1, 2, '3', '4'], (int, str, float, str))
    (1, '2', 3.0, '4')

    >>> convert_value({'a': 2.5, 'b': 4, 3: '18'}, {str: float})
    {'a': 2.5, 'b': 4.0, '3': 18.0}

    >>> convert_value(datetime(2021, 3, 26), str)
    '2021-03-26 00:00:00'
    >>> convert_value('20210326', datetime)
    datetime(2021, 3, 26)
    >>> convert_value('01:13:20:00', timedelta)
    timedelta(days=1, hours=13, minutes=20, seconds=0)
    >>> convert_value(timedelta(hours=1), str)
    '01:00:00.0'
    >>> convert_value(timedelta(hours=1), int)
    3600

    >>> convert_value(CRS.from_epsg(4326), int)
    4326
    >>> convert_value(CRS.from_epsg(4326), str)
    GEOGCRS["WGS 84",ENSEMBLE["World Geodetic System 1984 ensemble",MEMBER["World Geodetic System 1984 (Transit)"],MEMBER["World Geodetic System 1984 (G730)"],MEMBER["World Geodetic System 1984 (G873)"],MEMBER["World Geodetic System 1984 (G1150)"],MEMBER["World Geodetic System 1984 (G1674)"],MEMBER["World Geodetic System 1984 (G1762)"],ELLIPSOID["WGS 84",6378137,298.257223563,LENGTHUNIT["metre",1]],ENSEMBLEACCURACY[2.0]],PRIMEM["Greenwich",0,ANGLEUNIT["degree",0.0174532925199433]],CS[ellipsoidal,2],AXIS["geodetic latitude (Lat)",north,ORDER[1],ANGLEUNIT["degree",0.0174532925199433]],AXIS["geodetic longitude (Lon)",east,ORDER[2],ANGLEUNIT["degree",0.0174532925199433]],USAGE[SCOPE["Horizontal component of 3D system."],AREA["World."],BBOX[-90,-180,90,180]],ID["EPSG",4326]]
    >>> convert_value(4326, CRS)
    CRS.from_epsg(4326)
    """

    if isinstance(to_type, str):
        to_type = getattr(sys.modules['builtins'], to_type)

    if isinstance(value, Enum):
        value = value.name

    to_type = guard_generic_alias(to_type)

    if to_type is None:
        value = None
    elif to_type is Any:
        return value
    elif isinstance(to_type, Collection):
        collection_type = type(to_type)
        if collection_type is not EnumMeta:
            if not issubclass(collection_type, Mapping):
                if value is not None:
                    to_type = list(to_type)
                    if not isinstance(value, Iterable) or isinstance(value, str):
                        try:
                            evaluated_value = ast.literal_eval(value)
                            assert isinstance(evaluated_value, Collection)
                            value = evaluated_value
                        except:
                            if isinstance(value, str) and ',' in value:
                                value = [entry.strip() for entry in value.split(',')]
                            else:
                                value = [value]
                    if len(to_type) == 1:
                        to_type = [to_type[0] for _ in value]
                    elif len(to_type) == len(value):
                        to_type = to_type[: len(value)]
                    else:
                        raise ValueError(
                            f'unable to convert list of values of length {len(value)} '
                            f'to list of types of length {len(to_type)}: '
                            f'{value} -/> {to_type}'
                        )
                    value = collection_type(
                        convert_value(value[index], current_type)
                        for index, current_type in enumerate(to_type)
                    )
                else:
                    value = collection_type()
            elif isinstance(value, str):
                value = json.loads(value)

            elif isinstance(value, Mapping):
                converted_items = []
                key_to_type = list(to_type)[0]
                value_to_type = to_type[key_to_type]
                for key, value in value.items():
                    key = convert_value(key, key_to_type)
                    value = convert_value(value, value_to_type)
                    converted_items.append((key, value))
                value = collection_type(converted_items)
            elif 'pyproj' in installed_packages():
                from pyproj import CRS

                if isinstance(value, CRS):
                    value = value.to_json_dict()
        elif value is not None:
            try:
                value = to_type[value]
            except (KeyError, ValueError):
                try:
                    value = to_type(value)
                except (KeyError, ValueError):
                    raise ValueError(
                        f'unrecognized entry "{value}"; must be one of {list(to_type)}'
                    )
    elif not type(value) is to_type and value is not None:
        if isinstance(value, timedelta):
            if issubclass(to_type, str):
                hours, remainder = divmod(value, timedelta(hours=1))
                minutes, remainder = divmod(remainder, timedelta(minutes=1))
                seconds = remainder / timedelta(seconds=1)
                value = f'{hours:02}:{minutes:02}:{seconds:04.3}'
            else:
                value /= timedelta(seconds=1)
        elif 'pyproj' in installed_packages():
            from pyproj import CRS

            if isinstance(value, CRS):
                if issubclass(to_type, str):
                    value = value.to_wkt()
                elif issubclass(to_type, dict):
                    value = value.to_json_dict()
                elif issubclass(to_type, int):
                    value = value.to_epsg()
        if issubclass(to_type, bool):
            try:
                value = ast.literal_eval(f'{value}')
            except ValueError:
                value = bool(value)
        elif issubclass(to_type, (datetime, date)):
            try:
                from dateutil.parser import parse as parse_date

                value = parse_date(value)
            except (ModuleNotFoundError, TypeError):
                pass
            if (
                issubclass(to_type, datetime)
                and isinstance(value, date)
                and not isinstance(value, datetime)
            ):
                value = datetime.combine(value, time(0, 0, 0))
            elif issubclass(to_type, date) and not issubclass(to_type, datetime):
                value = value.date()

        elif issubclass(to_type, timedelta):
            if isinstance(value, str) and ':' in value:
                parts = [float(part) for part in value.split(':')]
                if len(parts) > 4:
                    raise ValueError(f'unable to parse timedelta from input "{value}"')
                components = {}
                if len(parts) > 3:
                    components['days'] = parts.pop(0)
                if len(parts) > 2:
                    components['hours'] = parts.pop(0)
                components['minutes'] = parts[0]
                components['seconds'] = parts[1]
                value = timedelta(**components)
            else:
                value = timedelta(seconds=float(value))
        elif 'shapely' in installed_packages():
            from shapely import wkb, wkt
            from shapely.geometry import shape as shapely_shape
            from shapely.geometry.base import GEOMETRY_TYPES

            if to_type.__name__ in GEOMETRY_TYPES:
                try:
                    value = wkb.loads(value, hex=True)
                except:
                    try:
                        value = wkt.loads(value)
                    except:
                        try:
                            value = wkb.loads(value)
                        except TypeError:
                            if isinstance(value, str):
                                value = ast.literal_eval(value)
                            try:
                                value = shapely_shape(value)
                            except:
                                value = to_type(value)

        if not isinstance(value, to_type):
            if isinstance(value, (str, bytes)):
                try:
                    value = to_type.from_string(value)
                except AttributeError:
                    value = to_type(value)
            else:
                value = to_type(value)

    return value


def convert_to_json(value: Any) -> Union[str, float, int, dict, list, bool]:
    """
    convert the given value to a JSON-compatible format

    :param value: value to convert
    :return: JSON value

    >>> convert_to_json(5)
    5
    >>> convert_to_json('5')
    '5'

    >>> convert_to_json(datetime(2021, 3, 26))
    '2021-03-26 00:00:00'

    >>> convert_to_json([5, '6', {3: datetime(2021, 3, 27)}])
    [5, '6', {3: '2021-03-27 00:00:00'}]
    >>> convert_to_json({'test': [5, '6', {3: datetime(2021, 3, 27)}]})
    {'test': [5, '6', {3: '2021-03-27 00:00:00'}]}
    """

    if isinstance(value, Path):
        value = value.as_posix()
    elif isinstance(value, Enum):
        value = value.name
    if type(value) not in (float, int, bool, str):
        if isinstance(value, Collection) and not isinstance(value, str):
            if isinstance(value, Mapping):
                value = {
                    convert_to_json(key): convert_to_json(entry)
                    for key, entry in value.items()
                }
            else:
                value = [convert_to_json(entry) for entry in value]
        else:
            try:
                value = convert_value(value, float)
            except:
                try:
                    value = convert_value(value, int)
                except:
                    try:
                        value = convert_value(value, bool)
                    except:
                        value = convert_value(value, str)
    return value


def guard_generic_alias(generic_alias) -> type:
    """
    convert an instance of a subscripted ``typing._GenericAlias`` to a subscripted type

    :param generic_alias: generic alias
    :return: simple type

    >>> from typing import List
    >>> guard_generic_alias(List[str])
    [str]

    >>> from typing import Dict
    >>> guard_generic_alias(Dict[str, float])
    {str: float}

    >>> from typing import Dict, Tuple
    >>> guard_generic_alias({str: (Dict[int, str], str)})
    {str: ({int: str}, str)}

    """

    if (
        hasattr(generic_alias, '__origin__')
        or isinstance(generic_alias, Collection)
        and not isinstance(generic_alias, (EnumMeta, str))
    ):
        if hasattr(generic_alias, '__origin__'):
            type_class = generic_alias.__origin__
            if hasattr(generic_alias, '__args__'):
                members = generic_alias.__args__
                if issubclass(type_class, Mapping):
                    members = [members]
            else:
                members = ()
        elif isinstance(generic_alias, Collection) and not isinstance(
            generic_alias, (EnumMeta, str)
        ):
            type_class = generic_alias.__class__
            if issubclass(type_class, Mapping):
                members = generic_alias.items()
            else:
                members = generic_alias

        members = [guard_generic_alias(member) for member in members]
        if type_class != generic_alias.__class__ or members != generic_alias:
            return type_class(members)
        else:
            return generic_alias
    else:
        return generic_alias
