from __future__ import annotations

import functools
import hashlib
import re
import struct
from typing import Callable, List, Optional

from jinsi.exceptions import NoMergePossible


def hash_complex(
        value, *, algo="sha256",
        _prefix: Optional[bytes] = None,
        _none=bytes([3]),
        _true=bytes([5]),
        _false=bytes([7]),
        _zero=bytes([11]),
        _int_neg=bytes([13]),
        _int_pos=bytes([17]),
        _double=bytes([19]),
        _string=bytes([23]),
        _seq=bytes([29]),
        _map=bytes([31]),
        _set=bytes([37]),
        _arbitrary=bytes([41]),
        _custom=bytes([43]),
) -> bytes:
    if isinstance(value, str):
        md = hashlib.new(algo)
        md.update(_string)
        md.update(value.encode('utf8'))
        return md.digest()
    if isinstance(value, bool):
        md = hashlib.new(algo)
        if value:
            md.update(_true)
        else:
            md.update(_false)
        return md.digest()
    if isinstance(value, int):
        md = hashlib.new(algo)
        if value == 0:
            md.update(_zero)
        elif value < 0:
            value *= -1
            md.update(_int_neg)
        else:
            md.update(_int_pos)
        while value > 0:
            md.update(bytes([value % 256]))
            value //= 256
        return md.digest()
    if isinstance(value, float):
        md = hashlib.new(algo)
        md.update(_double)
        md.update(struct.pack("d", value))
        return md.digest()
    if isinstance(value, (list, tuple)):
        md = hashlib.new(algo)
        md.update(_seq)
        for item in value:
            md.update(hash_complex(item, algo=algo))
        return md.digest()
    if isinstance(value, (set, frozenset)):
        md = hashlib.new(algo)
        md.update(_set)
        hashes = []
        for item in value:
            hashes.append(hash_complex(item, algo=algo))
        hashes.sort()
        for h in hashes:
            md.update(h)
        return md.digest()
    if isinstance(value, dict):
        md = hashlib.new(algo)
        if _prefix is None:
            md.update(_map)
        else:
            md.update(_prefix)
        _seq = []
        for key, value in value.items():
            _seq.append((hash_complex(key, algo=algo), hash_complex(value, algo=algo)))
        _seq.sort()
        for key, value in _seq:
            md.update(key)
            md.update(value)
        return md.digest()
    if isinstance(value, (bytes, bytearray)):
        md = hashlib.new(algo)
        md.update(_arbitrary)
        md.update(value)
        return md.digest()
    if value is None:
        md = hashlib.new(algo)
        md.update(_none)
        return md.digest()
    if hasattr(value, '__class__'):
        prefix = bytearray()
        prefix.extend(_custom)
        prefix.extend(hash_complex(value.__class__.__module__, algo=algo))
        prefix.extend(hash_complex(value.__class__.__name__, algo=algo))
        return hash_complex(value.__dict__, algo=algo, _prefix=prefix)
    raise ValueError(value)


def cached_function(func):
    _empty_args_hash = hash_complex(((), {}), algo='sha1').hex()
    _cache = {}

    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        if not args and not kwargs:
            args_hash = _empty_args_hash
        else:
            args_hash = hash_complex((args, kwargs), algo='sha1').hex()
        try:
            result = _cache[args_hash]
        except KeyError:
            result = func(*args, **kwargs)
            _cache[args_hash] = result
        return result

    return wrapper


def cached_method(func):
    _empty_args_hash = hash_complex(((), {}), algo='sha1').hex()

    @functools.wraps(func)
    def wrapper(self, *args, **kwargs):
        try:
            cache = self.__method_cache__
        except AttributeError:
            self.__method_cache__ = {}
            cache = self.__method_cache__
        if not args and not kwargs:
            args_hash = _empty_args_hash
        else:
            args_hash = hash_complex((args, kwargs), algo='sha1').hex()
        name = f"{func.__name__}:{args_hash}"
        try:
            result = cache[name]
        except KeyError:
            result = func(self, *args, **kwargs)
            cache[name] = result
        return result

    return wrapper


class Singleton(type):
    _instances = {}

    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
        return cls._instances[cls]


def select(obj, *path, fallback=None):
    current = obj
    for element in path:
        try:
            current = current[element]
        except (KeyError, TypeError):
            return fallback
    return current


def substitute(thing, callback: Callable[[str], str]):
    if isinstance(thing, (type(None), bool, int, float)):
        return thing
    if isinstance(thing, str):
        fs = re.split(r"<<(\$?[a-zA-Z0-9-_]+)>>", thing)
        result = []
        is_even = True
        for f in fs:
            is_even = not is_even
            if is_even:
                result.append(callback(f))
            else:
                result.append(f)
        return "".join(result)
    elif isinstance(thing, list):
        result = []
        for item in thing:
            result.append(substitute(item, callback))
        return result
    else:
        result = {}
        for key, value in thing.items():
            key = substitute(key, callback)
            value = substitute(value, callback)
            result[key] = value
        return result


def merge(*objects):
    if all(isinstance(item, list) for item in objects):
        result = []
        for ls in objects:
            for item in ls:
                result.append(item)
        return result
    if not all(isinstance(item, dict) for item in objects):
        raise NoMergePossible()
    result = {}
    for obj in objects:
        for key, value in obj.items():
            if key not in result:
                result[key] = value
                continue
            if isinstance(value, list):
                if not isinstance(result[key], list):
                    result[key] = [result[key]]
            if isinstance(result[key], list):
                if isinstance(value, list):
                    for item in value:
                        result[key].append(item)
                    continue
                result[key].append(value)
                continue
            if isinstance(result[key], dict):
                if not isinstance(value, dict):
                    raise NoMergePossible()
                for item_key, item_value in value.items():
                    result[key][item_key] = item_value
                continue
            result[key] = value
    return result


def parse_name(name) -> List[str]:
    parts = []
    for p1 in str(name).split("-"):
        for part in p1.split("_"):
            current_part = []
            last_was_upper = False
            for char in part:
                if char.isupper() and not last_was_upper:
                    if current_part:
                        parts.append("".join(current_part).lower())
                    current_part = []
                elif char.islower() and last_was_upper:
                    last = current_part.pop()
                    if current_part:
                        parts.append("".join(current_part).lower())
                    current_part = [last]
                current_part.append(char)
                last_was_upper = char.isupper()
            if current_part:
                parts.append("".join(current_part).lower())
    return parts


def empty(value) -> bool:
    if isinstance(value, (bool, list, dict, str)):
        return not value
    return value is None
