import os.path
import enum
import queue
import operator
import datetime
import glob
import json
import threading
import collections
import functools
import weakref
import logging

import mini_buildd.config

import debian.debian_support

LOG = logging.getLogger(__name__)


class _NoLock():
    """
    Debug/temporary only: Experienced dead locks -- not reliably reproducible for now.

    May-be-related to our locking here. Use this to exclude our locking once we can
    reproduce the lockdown.
    """

    def acquire(self):
        pass

    def release(self):
        pass

    def __enter__(self):
        pass

    def __exit__(self, _type, _value, _traceback):
        pass


class Type(enum.Enum):
    REJECTED = enum.auto()

    BUILDING = enum.auto()
    BUILT = enum.auto()

    PACKAGING = enum.auto()
    INSTALLED = enum.auto()
    FAILED = enum.auto()

    MIGRATED = enum.auto()
    REMOVED = enum.auto()


#: Human-readable semantics of all event types.
DESC = {
    Type.REJECTED: "User upload was rejected",

    Type.BUILDING: "Building on one of our chroots has started",
    Type.BUILT: "Building on one of our chroots has finished (does not imply a successful build result)",

    Type.PACKAGING: "Packaging to one of our repositories has started",
    Type.INSTALLED: "Packaging to one of our repositories was successful",
    Type.FAILED: "Packaging to one of our repositories has failed",

    Type.MIGRATED: "A package in one of our repositories has been migrated",
    Type.REMOVED: "A package in one of our repositories has been removed",
}


class Event():
    def __init__(self, type_, distribution, source, version, extra):
        self.type = type_
        self.distribution = distribution
        self.source = source
        self.version = version
        self.timestamp = mini_buildd.misc.Datetime.now()
        self.extra = extra

        #: Special run-time only flag (see Events.log()). For events outside Events(), this will always be false.
        self.ongoing = False

    def strerror(self):
        """Public human-readable one-liner string from ``extra.error``."""
        error = self.extra.get("error")  # Rfc7807 dict
        return "" if error is None else error.get("detail", "No details")

    def desc(self):
        return DESC[self.type]

    def __str__(self):
        return ": ".join(filter(None, [f"{self.type.name}({self.distribution}): {self.source}-{self.version}", self.strerror()]))

    def match(self, types=None, distribution=None, source=None, version=None, minimal_version=None):
        def match(match, value):
            return match is None or match == value

        return \
            (types is None or self.type in types) and \
            match(distribution, self.distribution) and \
            match(source, self.source) and \
            match(version, self.version) and \
            (minimal_version is None or debian.debian_support.Version(self.version) >= debian.debian_support.Version(minimal_version))

    def to_json(self):
        return {
            "type": self.type.name,
            "distribution": self.distribution,
            "source": self.source,
            "version": self.version,
            "timestamp": self.timestamp.isoformat(),
            "extra": self.extra,
        }

    @classmethod
    def from_json(cls, data):
        event = Event(Type[data["type"]],
                      data["distribution"],
                      data["source"],
                      data["version"],
                      data["extra"])
        event.timestamp = datetime.datetime.fromisoformat(data["timestamp"])
        return event

    @classmethod
    def from_changes(cls, typ, changes, exception=None, extra=None):
        xtra = {}
        xtra.update(changes.to_event_json())
        if exception is not None:
            xtra["error"] = mini_buildd.e2http(exception).rfc7807.to_json()
        if extra is not None:
            xtra.update(extra)

        return Event(typ,
                     distribution=changes.get("distribution"),
                     source=changes.get("source"),
                     version=changes.get("version"),
                     extra=xtra)

    def save_as(self, file_path):
        with mini_buildd.fopen(file_path, "w") as f:
            json.dump(self.to_json(), f)

        hook_pattern = "[!_.]*[!~]"
        for hook in sorted(glob.glob(mini_buildd.config.ROUTES["event_hooks"].path.join(hook_pattern))):
            if os.path.isfile(hook) and os.access(hook, os.X_OK):
                try:
                    mini_buildd.call.Call([hook, file_path]).check()
                except BaseException as e:
                    LOG.warning(f"Custom Event Hook failed: {hook}: {e}")

    def json_file_name(self):
        return mini_buildd.files.DebianName(self.source, self.version).json(self.type.name, self.extra["architecture"] if self.type in [Type.BUILT, Type.BUILDING] else None)

    @classmethod
    @functools.lru_cache(maxsize=512)
    def load(cls, file_path):
        with mini_buildd.fopen(file_path) as f:
            return Event.from_json(json.load(f))


class Queue(collections.deque):
    MAX_CLIENTS = 100

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._clients = {}
        self._lock = threading.Lock()
        self.ongoing = collections.OrderedDict()

    def log(self, typ, changes, exception=None, extra=None):
        if exception is None and changes.cget("Internal-Error"):
            exception = mini_buildd.HTTPInternal(changes.cget("Internal-Error"))

        event = Event.from_changes(typ, changes, exception, extra)
        file_path = changes.events_path.join(event.json_file_name())
        event.save_as(file_path)
        LOG.debug(f"Logging event: {event} ({file_path})")
        with self._lock:
            self.appendleft(event)
            mini_buildd.get_daemon().model.mbd_notify_event(event)

            for _key, (weakref_obj, q) in self._clients.items():
                if weakref_obj() is not None:
                    LOG.info(f"Queuing event for object '{weakref_obj}'")
                    q.put(event)

            # Handle "ongoing" dict
            if event.type in [Type.PACKAGING, Type.BUILDING]:
                LOG.debug(f"Adding event to 'ongoing': {changes.bkey}={event}")
                self.ongoing[changes.bkey] = event
                event.ongoing = True
            elif event.type in [Type.BUILT, Type.INSTALLED, Type.FAILED]:
                LOG.debug(f"Deleting event from 'ongoing': {changes.bkey} from {event}")
                ongoing_event = self.ongoing.pop(changes.bkey, None)   # None: Should rather not raise exception here if key is not there for some reason
                if ongoing_event is not None:
                    ongoing_event.ongoing = False

    def attach(self, obj, after=None):
        with self._lock:
            # Garbage collection
            for key in [key for key, (weakref_obj, _q) in self._clients.items() if weakref_obj() is None]:
                LOG.info(f"Detach event queue from object '{key}': Dead.")
                del self._clients[key]

            if id(obj) not in self._clients:
                if len(self._clients) >= self.MAX_CLIENTS:
                    raise mini_buildd.HTTPUnavailable(f"Max client limit ({self.MAX_CLIENTS}) exceeded")
                LOG.info(f"Events queue: Attaching client: {obj}")
                self._clients[id(obj)] = (weakref.ref(obj), queue.Queue())
                if after:
                    for event in reversed(self):
                        if event.timestamp > after:
                            self._clients[id(obj)][1].put(event)

        return self._clients[id(obj)][1]

    def shutdown(self):
        """Hint shutdown to all client (queues). Essentially makes blocking get() in httpd.py continue so httpd can shutdown."""
        with self._lock:
            for client in self._clients.values():
                try:
                    client[1].put_nowait(mini_buildd.config.SHUTDOWN)
                except Exception as e:
                    LOG.info(f"Events queue: Ignoring failure to hint shutdown to client {client[0]}: {e}")

    def to_json(self):
        return [event.to_json() for event in self]

    @classmethod
    def from_json(cls, events, maxlen):
        return Queue([Event.from_json(event) for event in events], maxlen=maxlen)

    def mbd_snapshot(self, maxlen=None):
        """Return triple: <list of newest events>, <total number of events in queue>, <max number of events in queue>, <number of ongoing>."""
        with self._lock:
            cp = []
            i = 0
            for e in self:
                if maxlen is not None and i >= maxlen:
                    break
                cp.append(e)
                i += 1
            return cp, len(self), self.maxlen, len(self.ongoing)


class Attach():
    def __init__(self, events, *args, **kwargs):
        self.events = events.attach(self, *args, **kwargs)

    def get(self):
        return self.events.get()


def ifilter(ievents, types=None, distribution=None, source=None, version=None, minimal_version=None, exit_on=None, fail_on=None):
    for event in ievents():
        if event.match(types=types, distribution=distribution, source=source, version=version, minimal_version=minimal_version):
            yield event
            if fail_on is not None and event.type in fail_on:
                raise Exception(f"Failed on event: {event}")
            if exit_on is not None and event.type in exit_on:
                break


def load(path="", after=None, before=None):
    """Load events (as normal list) from events path, ordered as issued using event's timestamp."""
    return sorted([Event.load(entry.path) for entry in mini_buildd.config.ROUTES["events"].path.ifiles(path, ".json", after=after, before=before)], key=operator.attrgetter("timestamp"), reverse=True)
