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

import packaging.version

import dateparser

import django
import django.core.exceptions

import twisted

import debian.debian_support

from mini_buildd import config, version, net

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 PyCompat:
    """Misc helpers to stay compatible with python <= 3.6 (see ``debian/control``)"""

    @staticmethod
    def removeprefix(s, prefix):
        """
        .. attention:: **compat** (``python < 3.9``): ``str.removeprefix``
        """
        if s.startswith(prefix):
            return s[len(prefix):]
        return s

    @staticmethod
    def shlex_join(split_command):
        """
        .. attention:: **compat** (``python < 3.8``): ``shlex.join``
        """
        return " ".join(shlex.quote(arg) for arg in split_command)


class Rfc7807:
    def __init__(self, status, detail=None):
        self.status = status if isinstance(status, http.HTTPStatus) else http.HTTPStatus(status)
        self.detail = f"No public details (see <a href='{config.URIS['log']['view'].uri}daemon.log'>daemon.log</a>)" 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 log_exception(log, message, exception, level=logging.WARNING):
    log.log(level, f"{message}: {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 systemcert_workaround():
    """
    Set environment ``SSL_CERT_FILE`` to point to system's certificate store (ca-certificates) when not using ``libssl3``

    .. tip:: **compat** (``< libssl3``): ``python`` fails for (self-signed) certificate even though it's enrolled with system's ``ca-certificates``?

        You will get some error like ``certificate verify failed: self signed certificate``.

        This seems to occur with ``libssl1``, while it seems fine with ``libssl3`` (even though :debbug:`805646` never got closed).

        Check the package deps on ``libpythonX.Y-minimal`` to see if it was build against ``libssl1`` or ``libssl3`` (or mini-buildd's log for warning message).

        Simplest workaround is to set ``SSL_CERT_FILE`` environment, for example like so:

        .. code:: bash

          export SSL_CERT_FILE="/etc/ssl/certs/ca-certificates.crt"

    """
    if ssl.OPENSSL_VERSION_INFO[0] < 3:
        os.environ["SSL_CERT_FILE"] = "/etc/ssl/certs/ca-certificates.crt"
        LOG.warning("Setting environment SSL_CERT_FILE='/etc/ssl/certs/ca-certificates.crt' for openssl < 3 (use system's cert store)")


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(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 = 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(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:
        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_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(f"Directory removed recursively: {path}")


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