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

import debian.debian_support

from mini_buildd import call, config, daemon, files, util

LOG = logging.getLogger(__name__)


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():
    #: Set of "building" event types (package not (necessarily) in instance's repository)
    BUILDING = {Type.BUILDING, Type.BUILT}

    #: Set of "packaging" event types (package in instances's repository)
    PACKAGING = set(Type) - BUILDING

    def __init__(self, type_, distribution, source, version, extra):
        self.type = type_
        self.distribution = distribution
        self.source = source
        self.version = version
        self.timestamp = util.Datetime.now()
        self.extra = extra

    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.extra.get('architecture')})", 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"] = util.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):
        os.makedirs(os.path.dirname(file_path), exist_ok=True)
        with util.fopen(file_path, "w") as f:
            json.dump(self.to_json(), f)

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

    def json_file_name(self):
        return 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 util.fopen(file_path) as f:
            return Event.from_json(json.load(f))

    def ongoing(self):
        """Get ongoing status (bool) -- convenience for template access"""
        try:
            return self.extra["bkey"] in daemon.get().events.ongoing
        except Exception:
            return False

    def isummary(self):
        """Produce most noteworthy/usable textual information from event (for example, for an email body)"""
        if self.type in [Type.REJECTED]:
            pass
        elif self.type in [Type.BUILDING]:
            yield "Live buildlog", self.extra.get("buildlog_building")
        elif self.type in [Type.BUILT]:
            yield "Buildlog", self.extra.get("buildlog_installed")
        elif self.type in [Type.PACKAGING, Type.INSTALLED, Type.FAILED]:
            changes = self.extra.get("changes")
            if changes is not None:
                yield "Changes", config.URIS["events"]["dir"].url_join(changes).replace(" ", "%20")

            buildresults = self.extra.get("buildresults")
            if buildresults is not None:
                for arch, bres in self.extra["buildresults"].items():
                    yield f"Buildlog({arch})", bres["buildlog_installed"]

            buildrequests = self.extra.get("buildrequests")
            if buildrequests is not None:
                for arch, bres in self.extra["buildrequests"].items():
                    yield f"Live buildlog({arch})", bres["buildlog_building"]

            auto_ports = self.extra.get("auto_ports")
            if auto_ports is not None:
                for d, port in auto_ports.items():
                    yield f"Autoport({d})", port[1]["dsc_url"] if port[0] else port[1]
        elif self.type in [Type.MIGRATED]:
            pass
        elif self.type in [Type.REMOVED]:
            pass

    def summary(self):
        s = dict(self.isummary())
        ml = max((len(name) for name in s.keys()), default=0)  # "max length" formatting helper
        return "\n".join(f"{name:<{ml}}: {info}" for name, info in self.isummary())


class Queue(collections.deque):
    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 = util.HTTPInternal(changes.cget("Internal-Error"))

        if exception is not None:
            util.log_exception(LOG, "Event exception", exception)

        event = Event.from_changes(typ, changes, exception, extra)
        file_path = changes.get_events_path(event.json_file_name())
        event.save_as(file_path)
        LOG.debug("Logging event: %s (%s)", event, file_path)
        with self._lock:
            self.appendleft(event)
            util.attempt(daemon.get_model().mbd_notify_event, event)

            for _key, (weakref_obj, q) in self._clients.items():
                if weakref_obj() is not None:
                    LOG.debug("Queuing event for object '%s'", weakref_obj)
                    q.put(event)

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

    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]:
                del self._clients[key]
                LOG.debug("Events queue client detached (%s/%s): %s", len(self._clients), config.MAX_EVENTS_QUEUE_CLIENTS, obj)

            if id(obj) not in self._clients:
                if len(self._clients) >= config.MAX_EVENTS_QUEUE_CLIENTS:
                    raise util.HTTPUnavailable(f"Max client limit ({config.MAX_EVENTS_QUEUE_CLIENTS}) exceeded")
                self._clients[id(obj)] = (weakref.ref(obj), queue.Queue())
                LOG.debug("Events queue client attached (%s/%s): %s", len(self._clients), config.MAX_EVENTS_QUEUE_CLIENTS, obj)

                # Optionally put past events
                if after is not None:
                    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(config.SHUTDOWN)
                except Exception as e:
                    LOG.warning("Events queue: Ignoring failure to hint shutdown to client %s: %s", 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)

    @classmethod
    def load(cls, maxlen):
        return Queue(load(maxlen=maxlen), maxlen=maxlen)


def get(events):
    event = events.get()
    if event is config.SHUTDOWN:
        raise util.HTTPShutdown
    return event


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

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


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 util.HTTPBadRequest(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, maxlen=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 config.ROUTES["events"].path.ifiles(path, ".json", after=after, before=before)], key=operator.attrgetter("timestamp"), reverse=True)[0:maxlen]
