import contextlib
import datetime
import hashlib
import http
import json
import logging
import multiprocessing
import os
import platform
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["python"] = platform.python_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 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  # pylint: disable=import-outside-toplevel
    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)


class HTTPShutdown(HTTPUnavailable):
    def __init__(self, detail=None):
        super().__init__("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 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:
            if VERSIONS.has("python", "3.12"):
                shutil.rmtree(self.tmpdir, onexc=lambda function, path, excinfo: LOG.warning("Could not remove temporary '%s': %s", path, excinfo))  # pylint: disable=unexpected-keyword-arg
            else:
                shutil.rmtree(self.tmpdir, onerror=lambda function, path, excinfo: LOG.warning("Could not remove temporary '%s': %s", path, excinfo[1]))  # pylint: disable=deprecated-argument


@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)
