import os
import time
import shutil
import multiprocessing
import datetime
import tempfile
import hashlib
import json
import re
import logging
import contextlib

import dateparser

import debian.debian_support

import mini_buildd.config

LOG = logging.getLogger(__name__)


def singularize(s):
    """Singularize some english nouns from plural."""
    if s:
        is_upper = s[-1].isupper()
        for plural, singular in [("ies", "y"), ("es", "e"), ("s", "")]:
            plural = plural.upper() if is_upper else plural
            singular = singular.upper() if is_upper else singular
            if plural == s[-len(plural):]:
                return s[0:-len(plural)] + singular
    return s


class Datetime:
    """Datetime tools -- always use same/comparable (aware && UTC) datetime objects."""

    EPOCH = datetime.datetime.fromtimestamp(0, tz=datetime.timezone.utc)
    SINCE_OPTIONS = ["1 hour ago", "1 day ago", "1 week ago", "1 month ago", "1 year ago", "epoch"]

    @classmethod
    def now(cls):
        """Shortcut to be used for all internal UTC stamps."""
        return datetime.datetime.now(datetime.timezone.utc)

    @classmethod
    def from_stamp(cls, stamp):
        return datetime.datetime.fromtimestamp(stamp, tz=datetime.timezone.utc)

    @classmethod
    def from_iso(cls, isostr):
        return datetime.datetime.fromisoformat(isostr).replace(tzinfo=datetime.timezone.utc)

    @classmethod
    def from_path(cls, path):
        return cls.from_stamp(os.path.getmtime(path))

    @classmethod
    def timecode(cls, stamp=None):
        """Timecode string from current timestamp."""
        s = cls.now() if stamp is None else stamp
        return s.strftime("%Y%m%d:%H%M%S:%f")

    @classmethod
    def is_naive(cls, date_obj):
        # https://docs.python.org/3/library/datetime.html#determining-if-an-object-is-aware-or-naive
        return date_obj.tzinfo is None or date_obj.tzinfo.utcoffset(date_obj) is None

    @classmethod
    def check_aware(cls, date_obj):
        if date_obj and cls.is_naive(date_obj):
            raise Exception("Timestamp not time zone aware")
        return date_obj

    @classmethod
    def parse(cls, date_string, default=None):
        """Use keyword 'epoch' for a big-bang timestamp. Otherwise most formats should work (like '2 weeks ago')."""
        if date_string is None:
            return default
        if date_string == "epoch":
            return cls.EPOCH
        dt = dateparser.parse(date_string)
        if dt is None:
            raise mini_buildd.HTTPBadRequest(f"Date parse failed on: '{date_string}' ({Datetime.parse.__doc__})")
        if mini_buildd.misc.Datetime.is_naive(dt):
            dt = dt.astimezone()
            LOG.debug(f"Naive timestamp converted: {date_string} => {dt}")
        return dt


class Snake():
    """
    Case style conversion to (lowercase) snake.

    >>> Snake("CamelCase").from_camel()
    'camel_case'
    >>> Snake("Kebab-Case").from_kebab()
    'kebab_case'
    """

    __CAMEL2SNAKE = re.compile(r"(?<!^)(?=[A-Z])")

    def __init__(self, name):
        self.name = name

    def from_camel(self):
        return self.__CAMEL2SNAKE.sub("_", self.name).lower()

    def from_kebab(self):
        return self.name.replace('-', '_').lower()


class Field():
    """Changes field name handling (custom prefix && (snake) name conversion)."""

    CPREFIX = "X-Mini-Buildd-"

    def __init__(self, field):
        self.name, self.fullname, self.is_cfield = field, field, False
        if field.startswith(self.CPREFIX):
            self.name = field[len(self.CPREFIX):]
            self.is_cfield = True
        self.snake_name, self.snake_fullname = Snake(self.name).from_kebab(), Snake(self.fullname).from_kebab()


class CField(Field):
    def __init__(self, field):
        super().__init__(self.CPREFIX + field)


class StopWatch():
    def __init__(self):
        self.start = Datetime.now()
        self.stop = None

    def __str__(self):
        return f"{self.start.isoformat(timespec='seconds')} -> {self.start.isoformat(timespec='seconds') if self.stop else 'ongoing'} ({round(self.delta().total_seconds())} seconds)"

    def close(self):
        self.stop = Datetime.now()

    def delta(self):
        return (Datetime.now() if self.stop is None else self.stop) - self.start

    def to_json(self):
        return {"start": self.start.isoformat(),
                "stop": self.stop.isoformat() if self.stop else None,
                "delta": self.delta().total_seconds()}


class Singleton(type):
    __INSTANCES = {}

    def __call__(cls, *args, **kwargs):  # noqa (pep8 N805)
        if cls not in cls.__INSTANCES:
            cls.__INSTANCES[cls] = super(Singleton, cls).__call__(*args, **kwargs)
        return cls.__INSTANCES[cls]

    def destroy(cls):  # noqa (pep8 N805)
        if cls in cls.__INSTANCES:
            del cls.__INSTANCES[cls]


class TmpDir():
    """Temporary dir class (use with ``with contextlib.closing()...``, or as mixin class)"""

    def __init__(self, **kwargs):
        tmp_route = mini_buildd.config.ROUTES.get("tmp")
        self.tmpdir = tempfile.mkdtemp(dir=None if tmp_route is None else tmp_route.path.join(), **kwargs)

    def close(self):
        if "keep" in mini_buildd.config.DEBUG:
            LOG.warning(f"DEBUG MODE('keep'): Skipping removal of temporary dir '{self.tmpdir}'")
        else:
            shutil.rmtree(self.tmpdir, onerror=lambda function, path, excinfo: LOG.warning(f"Could not remove temporary '{path}': {excinfo[1]}"))


@contextlib.contextmanager
def tmp_dir(**kwargs):
    """Temporary dir context manager (use with ``with``)"""
    tmpdir = TmpDir(**kwargs)
    try:
        yield tmpdir.tmpdir
    finally:
        tmpdir.close()


def nop(*_args, **_kwargs):
    pass


def attempt(func, *args, retval_on_failure=None, **kwargs):
    """
    Run function, warn-log exception on error, but continue.

    >>> attempt(lambda x: x, "ypsilon")
    'ypsilon'
    >>> attempt(lambda x: x, "ypsilon", retval_on_failure="xylophon", unknown_arg="xanthippe")
    'xylophon'
    >>> attempt(lambda x: x/0, "ypsilon", retval_on_failure="xylophon")
    'xylophon'
    """
    try:
        return func(*args, **kwargs)
    except BaseException as e:
        mini_buildd.log_exception(LOG, f"Attempting '{func}' failed (ignoring)", e, level=logging.WARNING)
        return retval_on_failure


def measure(func, *args, **kwargs):
    start = time.process_time()
    result = func(*args, **kwargs)
    LOG.debug(f"{func}: {time.process_time() - start} seconds")
    return result


def strip_epoch(version):
    """Strip the epoch from a version string."""
    return version.rpartition(":")[2]


def guess_default_dirchroot_backend(overlay, aufs):
    try:
        release = os.uname()[2]
        # linux 3.18-1~exp1 in Debian removed aufs in favor of overlay
        if debian.debian_support.Version(release) < debian.debian_support.Version("3.18"):
            return aufs
    except BaseException:
        pass

    return overlay


def subst_placeholders(template, placeholders):
    """
    Substitute placeholders in string from a dict.

    >>> subst_placeholders("Repoversionstring: %IDENTITY%%CODEVERSION%", { "IDENTITY": "test", "CODEVERSION": "60" })
    'Repoversionstring: test60'
    """
    for key, value in list(placeholders.items()):
        template = template.replace(f"%{key}%", value)
    return template


class Hash():
    """
    Shortcut to get hashsums from file.

    >>> Hash("test-data/unix.txt").md5()
    'cc3d5ed5fda53dfa81ea6aa951d7e1fe'
    >>> Hash("test-data/unix.txt").sha1()
    '8c84f6f36dd2230d3e9c954fa436e5fda90b1957'
    """

    def __init__(self, path):
        self.path = path

    def get(self, hash_type="md5"):
        """Get any hash from file contents."""
        hash_func = hashlib.new(hash_type)
        with open(self.path, "rb") as f:
            while True:
                data = f.read(128)
                if not data:
                    break
                hash_func.update(data)
        return hash_func.hexdigest()

    def md5(self):
        return self.get(hash_type="md5")

    def sha1(self):
        return self.get(hash_type="sha1")


def get_cpus():
    try:
        return multiprocessing.cpu_count()
    except BaseException:
        return 1


def list_get(list_, index, default=None):
    try:
        return list_[index]
    except IndexError:
        return default


def rmdirs(path):
    """Remove path recursively. Succeed even if it does not exist in the first place."""
    if os.path.exists(path):
        shutil.rmtree(path)
        LOG.info(f"Directory removed recursively: {path}")


def json_pretty(json_obj):
    return json.dumps(json_obj, indent=2)
