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

import dateparser
import debian.debian_support
import django
import django.core.exceptions
import packaging.version
import twisted

from mini_buildd import config, net, version

LOG = logging.getLogger(__name__)


__version__ = version.__version__


class Versions(dict):
    """Main software component's version support (for compat code, informational)"""

    def __init__(self):
        self.version = __version__
        self["django"] = django.get_version()
        self["twisted"] = twisted.version.public()

    def __str__(self):
        return f"mini-buildd {self.version} ({', '.join([f'{c} {v}' for c, v in self.items()])})"

    def has(self, component, version_string):
        return packaging.version.Version(version_string) <= packaging.version.Version(self[component])


VERSIONS = Versions()


def http_endpoint(number=0):
    return net.ServerEndpoint(config.HTTP_ENDPOINTS[number], protocol="http")


def daemon():
    """
    Import ``daemon`` module and return ``Daemon`` singleton (sort-of dependency injection)

    Use this where you need the ``models`` module but can't do a 'proper import' -- as django needs to be
    configured first.
    """
    from mini_buildd.daemon import Daemon
    return Daemon()


def models():
    """
    Import and return ``models`` module (sort-of dependency injection)

    Use this where you need the ``models`` module but can't do a 'proper import' -- as django needs to be
    configured first.
    """
    from mini_buildd import models as models_module
    return models_module


#: For use in fstrings
NEWLINE = "\n"


def fopen(path, mode="r", **kwargs):
    """
    Text file open with our fixed char encoding (UTF-8)

    UTF-8 may become default for open at some point, but not just yet.

    See https://www.python.org/dev/peps/pep-0597/
    """
    return open(path, mode, encoding=config.CHAR_ENCODING, **kwargs)


class Rfc7807:
    def __init__(self, status, detail=None):
        self.status = status if isinstance(status, http.HTTPStatus) else http.HTTPStatus(status)
        self.detail = "No public details (see service log)" if detail is None else detail

    def __str__(self):
        return f"{self.detail} (HTTP {self.status.value} {self.status.description})"

    def to_json(self):
        return {
            "type": "about:blank",  # No special semantics. Status code is HTTP status code, title is HTTP status message
            "status": self.status.value,
            "title": self.status.description,
            "detail": self.detail,
            "instance": "about:blank",
        }

    @classmethod
    def from_json(cls, data):
        return Rfc7807(data["status"], data["detail"])


class HTTPError(Exception):
    """Public (HTTP) exception -- raise this if the exception string is ok for user consumption"""

    def __init__(self, status, detail=None):
        self.rfc7807 = Rfc7807(status, detail)
        self.status = self.rfc7807.status.value
        self.detail = self.rfc7807.detail
        super().__init__(str(self.rfc7807))

    def __str__(self):
        return str(self.rfc7807)


class HTTPOk(HTTPError):
    def __init__(self, detail=None):
        super().__init__(http.HTTPStatus.OK, detail)


class HTTPNotFound(HTTPError):
    def __init__(self, detail=None):
        super().__init__(http.HTTPStatus.NOT_FOUND, detail)


class HTTPBadRequest(HTTPError):
    def __init__(self, detail=None):
        super().__init__(http.HTTPStatus.BAD_REQUEST, detail)


class HTTPUnauthorized(HTTPError):
    def __init__(self, detail=None):
        super().__init__(http.HTTPStatus.UNAUTHORIZED, detail)


class HTTPUnavailable(HTTPError):
    def __init__(self, detail=None):
        super().__init__(http.HTTPStatus.SERVICE_UNAVAILABLE, detail)


HTTPShutdown = HTTPUnavailable("Server Shutdown")


class HTTPInternal(HTTPError):
    def __init__(self, detail=None):
        super().__init__(http.HTTPStatus.INTERNAL_SERVER_ERROR, detail)


def raised_info(exception):
    """Try to get filename:lineno from last raise (i.e., to log even in 'production mode'/exc_info=False)"""
    try:
        tb = exception.__traceback__
        while tb.tb_next is not None:
            tb = tb.tb_next
        return f"{os.path.basename(tb.tb_frame.f_code.co_filename)}:{tb.tb_lineno}"
    except:  # noqa (pep8 E722)  # pylint: disable=bare-except
        return ""


def log_exception(log, message, exception, level=logging.WARNING):
    log.log(level, "%s: %s [raised in: %s]", message, exception, raised_info(exception), exc_info=log.isEnabledFor(logging.DEBUG))


def log_stack(log, level=logging.INFO):
    """Log stack (for debugging)"""
    log.log(level, "".join(traceback.format_stack()))


def e2http(exception, status=http.HTTPStatus.INTERNAL_SERVER_ERROR):
    if isinstance(exception, django.core.exceptions.ValidationError):
        return HTTPBadRequest(" ".join(exception.messages))
    return exception if isinstance(exception, HTTPError) else HTTPError(status)


def rrpes(func, *args, **kwargs):
    """Run ``func``. On exception, return public error str"""
    try:
        return func(*args, **kwargs)
    except BaseException as e:
        log_exception(LOG, f"'{func}' failed (error str returned)", e, level=logging.INFO)
        return str(e2http(e))


def check_program(path, deb=None):
    if not os.path.exists(path):
        raise HTTPUnavailable(f"Program not found: {path} (please install Debian package {deb})")


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


def esplit(s, sep):
    """Unlike python ``str.strip(sep)``, this delivers an empty list for the empty string"""
    return [] if s == "" else s.split(sep)


def uniq(iterable):
    """
    Make list unique

    Sometimes, a ``list`` should be unique, but you don't want to use ``set`` (for example, as it does not keep order or
    can't be JSON-serialized).

    There does not seem to be a simple builtin for this. Since ``python 3.7``, ``dict`` is guaranteed to be ordered, so
    we can use this approach.
    """
    return list(dict.fromkeys(iterable).keys())


def mdget(d, keys, default=None):
    """
    Get nested value from multidimensional dict, return default if any 'path part' is missing

    Rather use this instead of code like ``d.get(foo, {}).get(bar, {}).get(mykey, mydefaultvalue)``.

    >>> d = {"foo": {"bar": {"mykey": "myvalue"}}}
    >>> mdget(d, ["foo", "bar", "mykey"], "default_value")
    'myvalue'
    >>> mdget(d, ["fpp", "bar", "mykey"], "default_value")
    'default_value'
    >>> mdget(d, ["foo", "bsr", "mykey"], "default_value")
    'default_value'
    >>> mdget(d, ["foo", "bar", "mykex"], "default_value")
    'default_value'
    """
    _d = d
    for k in keys:
        _d = _d.get(k)
        if _d is None:
            return default
    return _d


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 HTTPBadRequest("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 HTTPBadRequest(f"Date parse failed on: '{date_string}' ({Datetime.parse.__doc__})")
        if Datetime.is_naive(dt):
            dt = dt.astimezone()
            LOG.debug("Naive timestamp converted: %s => %s", 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 = 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 config.DEBUG:
            LOG.warning("DEBUG MODE('keep'): Skipping removal of temporary dir '%s'", self.tmpdir)
        else:
            shutil.rmtree(self.tmpdir, onerror=lambda function, path, excinfo: LOG.warning("Could not remove temporary '%s': %s", 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:
        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("%s: %s seconds", func, time.process_time() - start)
    return result


def strip_epoch(version_str):
    """Strip the epoch from a version string"""
    return version_str.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("Directory removed recursively: %s", path)


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