"""
API arguments and calls

>>> Setup.from_command_line("--save --update --archives-from-proxy --sources bullseye --sources-from-vendor-with-lts Debian --chroots-from-sources --repositories test/test").command_line()
'--save --update --archives-from-proxy --sources bullseye --sources-from-vendor-with-lts Debian --chroots-from-sources --repositories test/test'

>>> Setup.from_sloppy_args(save=True, archives_from_proxy=True, archives_from_apt_sources=False).command_line()
'--save --archives-from-proxy'
"""

import abc
import os
import sys
import inspect
import collections
import logging
import json
import argparse
import re
import glob
import shlex
from contextlib import closing

import debian.debian_support

import mini_buildd.misc
import mini_buildd.net
import mini_buildd.changes
import mini_buildd.package

from mini_buildd.dist import SETUP, CODENAMES, Dist
from mini_buildd.values import OnOff, get_value
from mini_buildd.values import Name as Value

LOG = logging.getLogger(__name__)


class Argument():
    """
    Generic Argument Class

    ``value()`` always provides a non-``None`` value of the specific
    type, either the ``default`` or a ``given`` value.

    ``strvalue()`` always provides a non-``None`` ``str`` value.

    The ``default`` value is given in the constructor. For
    server-specific defaults, this may be function -- then the default
    value will be computed only at run time on the server.

    A ``given`` value can be provided via special ``set()`` method:
      * Empty ``str``, ``list`` or false ``bool`` will yield ``None``.
      * Non-empty ``str`` will be converted to the specific type.
      * Other given values will be used as is.

    ============  ============ ============ =========== =================
    Type          value() type svalue() ex. HTML GET    argparse
    ============  ============ ============ =========== =================
    Str           str          "string"     key=string  --key "string"
    Url           str          "http://.."  key=string  --key "string"
    MultilineStr  str          "long"       key=string  --key "string"
    Choice        str          "c0"         key=string  --key "string"
    Int           int          "17"         key=string  --key "int"
    Bool          bool         "True"       key=True    --key
    List          list of str  "v0,v1,.."   key=v0,v1.. --key "v0" "v1"..
    ============  ============ ============ =========== =================
    """

    #: Validate that values are actually of that type
    VALUE_TYPE = str

    #: Magic string value to use as value when a default callable on the server should be used.
    SERVER_DEFAULT = "<server_default>"

    def __init__(self, id_list, doc="Undocumented", default=None, choices=None, header=None):
        """
        :param id_list: List like '['--with-rollbacks', '-R']' for option or '['distributions']' for positionals; 1st entry always denotes the id.

        >>> Argument(["--long-id", "-s"]).identity
        'long_id'
        >>> Argument(["posi-tional"]).identity
        'posi_tional'
        """
        # Identifiers && doc
        self.id_list = id_list
        self.doc = doc
        self.is_positional = not id_list[0].startswith("--")
        # identity: 1st of id_list with leading '--' removed and hyphens turned to snake case
        self.identity = mini_buildd.misc.Snake(id_list[0][0 if self.is_positional else 2:]).from_kebab()

        # Values
        self._default = default
        self.given = None

        # Choices helper
        self._choices = choices
        self.header = header

    def __str__(self):
        """Be sure not to use value() here"""
        return f"{self.identity}: given={self.given}, _default={self._default}"

    @classmethod
    def s2v(cls, str_value):
        """Convert string to value"""
        return cls.VALUE_TYPE(str_value) if str_value else None

    @classmethod
    def v2s(cls, value):
        """Convert value to string"""
        return "" if value is None else str(value)

    def needs_value(self):
        """If user input is no_value"""
        return self.given is None and self._default is None

    def choices(self):
        return [] if self._choices is None else get_value(self._choices)

    def default(self):
        return get_value(self._default)

    def strdefault(self):
        return self.v2s(self.default())

    def value(self):
        if self.needs_value():
            raise mini_buildd.HTTPBadRequest(f"Missing required argument: {self}")
        return self.default() if self.given is None else self.given

    def strvalue(self):
        return self.v2s(self.value())

    def strgiven(self):
        return self.v2s(self.given)

    def icommand_line_given(self):
        yield self.strgiven()

    def set(self, given):
        if given == self.SERVER_DEFAULT:
            self.given = None
        elif isinstance(given, str):
            self.given = self.s2v(given)
        elif isinstance(given, (list, bool)):
            self.given = given if given else None
        elif isinstance(given, self.VALUE_TYPE):
            self.given = given
        else:
            raise Exception(f"API argument '{self.identity}': Invalid value {given} (type given {type(given)}, needs {self.VALUE_TYPE})")

    def argparse_kvsargs(self):
        """Python 'argparse' support"""
        kvsargs = {"help": self.doc}
        if self._default is not None:
            if isinstance(self._default, Value):
                kvsargs["default"] = self.SERVER_DEFAULT
                kvsargs["help"] += f"\nServer Default: {self._default.name}"
            else:
                kvsargs["default"] = self._default
        return kvsargs


class StrArgument(Argument):
    HTML_TYPE = "text"

    def argparse_kvsargs(self):
        return {**super().argparse_kvsargs(), **{"action": "store"}}


class UrlArgument(StrArgument):
    HTML_TYPE = "url"


class MultilineStrArgument(StrArgument):
    HTML_TYPE = "textarea"


class ChoiceArgument(Argument):
    HTML_TYPE = "text"

    def value(self):
        value = super().value()
        choices = self.choices()
        if value not in choices:
            raise mini_buildd.HTTPBadRequest(f"{self.identity}: Wrong choice argument: '{value}' not in {choices}")
        return value

    def argparse_kvsargs(self):
        kvsargs = super().argparse_kvsargs()
        kvsargs["choices"] = self._choices
        if self.is_positional and self._default is not None:
            kvsargs["nargs"] = "?"  # Allow positional args to be actually optional, if we have a default.
        return kvsargs


class IntArgument(StrArgument):
    VALUE_TYPE = int
    HTML_TYPE = "number"

    def argparse_kvsargs(self):
        return {**super().argparse_kvsargs(), **{"type": int}}


class BoolArgument(ChoiceArgument):
    VALUE_TYPE = bool
    HTML_TYPE = "checkbox"

    def __init__(self, *args, **kwargs):
        super().__init__(*args, choices=[True, False], default=False, **kwargs)

    @classmethod
    def s2v(cls, str_value):
        return str_value == str(True)

    def icommand_line_given(self):
        """Empty generator -- bools are just command line options like ``--with-foo``"""
        yield from []

    def argparse_kvsargs(self):
        kvsargs = Argument.argparse_kvsargs(self)
        kvsargs["action"] = "store_true"
        return kvsargs


class ListArgument(StrArgument):
    VALUE_TYPE = list
    SEPARATOR = ","

    @classmethod
    def s2v(cls, str_value):
        return str_value.split(cls.SEPARATOR) if str_value else None

    @classmethod
    def v2s(cls, value):
        return "" if value is None else cls.SEPARATOR.join(value)

    def icommand_line_given(self):
        if self.given is not None:
            yield from self.given

    def argparse_kvsargs(self):
        kvsargs = super().argparse_kvsargs()
        kvsargs["nargs"] = "+"
        if self._choices is not None and not isinstance(self._choices, Value):
            kvsargs["choices"] = self._choices
        kvsargs["metavar"] = mini_buildd.misc.singularize(self.identity.upper())
        return kvsargs


#: Specialized argument classes
class Repositories(ListArgument):
    def __init__(self, id_list, **kwargs):
        super().__init__(
            id_list,
            choices=Value.ALL_REPOSITORIES,
            doc="Repository identities (like 'test', the default sandbox repo identity)",
            **kwargs,
        )

    def objects(self):
        return mini_buildd.mdls().repository.Repository.objects.filter(identity__in=self.value())


class Codenames(ListArgument):
    def __init__(self, id_list, **kwargs):
        super().__init__(
            id_list,
            choices=Value.ALL_CODENAMES,
            doc="Codenames (like 'buster', 'bullseye')",
            **kwargs,
        )


def diststr2repository(diststr):
    return mini_buildd.mdls().repository.get(Dist(diststr).repository)


class Distribution(StrArgument):
    def __init__(self, id_list, choices=Value.ACTIVE_DISTRIBUTIONS, extra_doc="", **kwargs):
        super().__init__(id_list, choices=choices, doc="Distribution name (<codename>-<id>-<suite>[-rollback<n>])" + extra_doc, **kwargs)


class Distributions(ListArgument):
    def __init__(self, id_list, choices=Value.ACTIVE_DISTRIBUTIONS, **kwargs):
        super().__init__(id_list, choices=choices, doc="Distribution names", **kwargs)


class Source(StrArgument):
    def __init__(self, id_list, extra_doc="", **kwargs):
        super().__init__(id_list, choices=Value.LAST_SOURCES, doc="Source package name" + extra_doc, **kwargs)


class UploadOptions(StrArgument):
    def __init__(self, id_list, **kwargs):
        super().__init__(id_list,
                         default="lintian-mode=ignore",
                         doc=("List of upload options, separated by '|':\n"
                              "\n"
                              f"{mini_buildd.changes.Upload.Options.usage()}"
                              ),
                         **kwargs)


class Call():
    AUTH = mini_buildd.config.AUTH_NONE
    NEEDS_RUNNING_DAEMON = False
    CONFIRM = False

    PLAIN = "__plain__"

    @classmethod
    def name(cls):
        return mini_buildd.misc.Snake(cls.__name__).from_camel()

    @classmethod
    def doc(cls):
        return inspect.getdoc(cls)

    @classmethod
    def doc_title(cls):
        return cls.doc().partition("\n")[0]

    @classmethod
    def doc_body(cls):
        return cls.doc().partition("\n")[2][1:]

    # Category support -- purely for structuring documentation.
    CATEGORIES = ["Consumer", "Developer", "Administrator"]

    @classmethod
    def category(cls):
        if cls.AUTH in [mini_buildd.config.AUTH_NONE, mini_buildd.config.AUTH_LOGIN]:
            return cls.CATEGORIES[0]
        if cls.AUTH in [mini_buildd.config.AUTH_STAFF]:
            return cls.CATEGORIES[1]
        return cls.CATEGORIES[2]

    @classmethod
    def uri(cls):
        return mini_buildd.config.URIS["api"]["view"].join(cls.name() + "/")

    @classmethod
    def iarguments(cls):
        yield from []

    @classmethod
    def from_sloppy_args(cls, **kwargs):
        """Construct ignoring any unknown arguments given"""
        keys = [a.identity for a in cls.iarguments()]
        return cls(**{key: value for key, value in kwargs.items() if key in keys})

    def __init__(self, **kwargs):
        self.args = {arg.identity: arg for arg in self.iarguments()}

        LOG.debug(f"{self.name()}: Given args: {kwargs}")
        for key, value in kwargs.items():
            if key not in self.args:
                raise mini_buildd.HTTPBadRequest(f"API call '{self.name()}': Unknown argument '{key}'")
            self.args[key].set(value)
            LOG.debug(f"{self.name()}: Adding given arg: {key}={value}")

        for key in (key for key in self.args if key in kwargs):
            self.args[key].set(kwargs.get(key))

        self.request = None
        self.result = {}  # json result

    @classmethod
    def from_command_line(cls, command_line):
        return cls(**cls.parse_command_line(command_line))

    def set_request(self, request):
        self.request = request

    @classmethod
    def json_pretty(cls, result):
        return json.dumps(result, indent=2)

    @classmethod
    def get_plain(cls, result):
        return result.get(cls.PLAIN, cls.json_pretty(result))

    @classmethod
    def parse_command_line(cls, command_line):
        class RaisingArgumentParser(argparse.ArgumentParser):
            """Make argparse raise only (not exit) on error. See https://bugs.python.org/issue41255"""

            def error(self, message):
                raise Exception(message)

        parser = RaisingArgumentParser()
        for argument in cls.iarguments():
            parser.add_argument(*argument.id_list, **argument.argparse_kvsargs())
        return vars(parser.parse_args(args=shlex.split(command_line)))

    def icommand_line(self, full=False, with_user=False, user=None):
        if full:
            yield "mini-buildd-api"
            yield self.name()
            yield mini_buildd.http_endpoint().geturl(with_user=with_user, user=user)
        for arg in self.args.values():
            if arg.given is not None:
                if arg.is_positional:
                    yield from arg.icommand_line_given()
                else:
                    yield arg.id_list[0]
                    yield from arg.icommand_line_given()

    def command_line(self, full=False, with_user=False, user=None):
        return mini_buildd.PyCompat.shlex_join(self.icommand_line(full=full, with_user=with_user, user=user))

    def command_line_full(self):
        user = self.request.user if self.AUTH is not mini_buildd.config.AUTH_NONE and self.request is not None and self.request.user.is_authenticated else None
        return self.command_line(full=True, with_user=user is not None, user=user)

    def http_args(self, with_confirm=False):
        http_args = {"confirm": self.name()} if with_confirm else {}
        for arg in self.args.values():
            if arg.given is not None:
                http_args[arg.identity] = arg.strgiven()
        return http_args

    def plain(self):
        return self.get_plain(self.result)

    @abc.abstractmethod
    def run(self):
        pass


def _pimpdoc(result_doc=None):
    def pimp(cls):
        indent = "    "  # Be sure to keep the same indent like a top level class doc (4 chars) for all lines, so inspect.getdoc() will properly unindent.
        if result_doc:
            cls.__doc__ += "\n" + "\n".join([f"{indent}{line}" for line in result_doc]) + "\n"
        cls.__doc__ += f"\n{indent}Authorization: {cls.AUTH}\n"
        return cls
    return pimp


#: Option shortcuts (use as mixin)
class _Admin:
    AUTH = mini_buildd.config.AUTH_ADMIN


class _Staff:
    AUTH = mini_buildd.config.AUTH_STAFF


class _Login:
    AUTH = mini_buildd.config.AUTH_LOGIN


class _Running:
    NEEDS_RUNNING_DAEMON = True


class _Confirm:
    CONFIRM = True


#: API calls
@_pimpdoc()
class Status(Call):
    """
    Get the status of this instance

    JSON Result:

    * version     : mini-buildd's version.
    * identity    : Instance identity.
    * url         : Instance URL (HTTP).
    * incoming_url: Incoming URL (currently FTP).
    * load        : Instance's (0 =< load <= 1). If negative, the instance is not powered on.
    * chroots     : Active chroots.
    * remotes     : Active or auto-reactivatable remotes.
    """

    def run(self):
        self.result = {
            "version": mini_buildd.__version__,
            "identity": mini_buildd.get_daemon().model.identity,
            "url": mini_buildd.http_endpoint().geturl(),
            "incoming_url": mini_buildd.get_daemon().model.mbd_get_ftp_endpoint().geturl(),
            "load": mini_buildd.get_daemon().builder.load() if mini_buildd.get_daemon().is_alive() else -1.0,
            "chroots": [c.mbd_key() for c in mini_buildd.mdls().chroot.Chroot.mbd_get_active()],
            "remotes": [r.mbd_url() for r in mini_buildd.mdls().gnupg.Remote.mbd_get_active_or_auto_reactivate()],
        }


@_pimpdoc()
class PubKey(Call):
    """
    Get public key

    Get ASCII-armored GnuPG public key of this instance.

    Used to sign the apt repositories (apt key) and for authorization across instances.
    """

    def run(self):
        self.result[self.PLAIN] = mini_buildd.get_daemon().gnupg.pub_key


@_pimpdoc()
class DputConf(Call):
    """
    Get recommended dput config snippet

    Usually, this is for integration in your personal ~/.dput.cf.
    """

    def run(self):
        self.result[self.PLAIN] = mini_buildd.get_daemon().model.mbd_get_dput_conf()


@_pimpdoc()
class SourcesList(Call):
    """
    Get sources.list (apt lines)

    Usually, this output is put to a file like '/etc/sources.list.d/mini-buildd-xyz.list'.
    """

    @classmethod
    def iarguments(cls):
        yield Codenames(["--codenames", "-C"], default=Value.ALL_CODENAMES)
        yield Repositories(["--repositories", "-R"], default=Value.ALL_REPOSITORIES)
        yield ListArgument(["--suites", "-S"], default=Value.ALL_SUITES, choices=Value.ALL_SUITES, doc="Suite names (like 'stable', 'unstable')")
        yield ListArgument(["--types", "-T"], default=["deb"], choices=["deb", "deb-src"], doc="Types of apt lines")
        yield StrArgument(["--options", "-O"], default="", doc="Apt line options ('deb[-src] [<options>] ...'). See 'man 5 source.list'")
        yield IntArgument(["--rollbacks", "-r"], default=0, doc="Also list n rollback sources (0=no rollbacks)")
        yield StrArgument(["--snapshot", "-P"], default="", doc="Select a repository snapshot")
        yield StrArgument(["--mirror", "-M"], default="", doc="URL of a mirror to use (instead of mini-buildd's native URL)")
        yield BoolArgument(["--with-extra", "-X"], doc="Also list extra sources needed")
        yield BoolArgument(["--with-comment", "-D"], doc="Add comment line above apt line")

    def run(self):
        sources_list = mini_buildd.files.SourcesList()

        for r in self.args["repositories"].objects():
            for d in r.distributions.all().filter(base_source__codename__in=self.args["codenames"].value()):
                if self.args["with_extra"].value():
                    for e in d.extra_sources.all():
                        sources_list.append(e.source.mbd_get_apt_line())

                for s in r.layout.suiteoption_set.filter(suite__name__in=self.args["suites"].value()):
                    sources_list.append(r.mbd_get_apt_line(d, s, snapshot=self.args["snapshot"].value()))
                    if self.args["rollbacks"].value():
                        for rollback in range(0, self.args["rollbacks"].value()):
                            sources_list.append(r.mbd_get_apt_line(d, s, snapshot=self.args["snapshot"].value(), rollback=rollback))

        self.result[self.PLAIN] = sources_list.get(self.args["types"].value(),
                                                   mirror=self.args["mirror"].value(),
                                                   options=self.args["options"].value(),
                                                   with_comment=self.args["with_comment"].value())


@_pimpdoc()
class Ls(Call):
    """
    Show source package
    """

    @classmethod
    def iarguments(cls):
        yield Source(["source"])
        yield Repositories(["--repositories", "-R"], default=Value.ALL_REPOSITORIES)
        yield Codenames(["--codenames", "-C"], default=Value.ALL_CODENAMES)
        yield StrArgument(["--version", "-V"], default="", doc="Limit to exactly this version")
        yield StrArgument(["--minimal-version", "-M"], default="", doc="Limit to this version or greater")

    def run(self):
        # Shortcuts (and avoid value being possibly evaluated multiple times).
        version = self.args["version"].value()
        minimal_version = self.args["minimal_version"].value()

        for repository in self.args["repositories"].objects():
            ls = list(repository.mbd_reprepro.ils(self.args["source"].value()))
            for r in ls:
                dist = Dist(r["distribution"])
                if dist.codename in self.args["codenames"].value() and (not version or r["version"] == version) and (not minimal_version or debian.debian_support.Version(r["version"]) >= debian.debian_support.Version(minimal_version)):
                    dist_dict = self.result.setdefault(dist.get(rollback=False), {})

                    distribution, suite = repository.mbd_parse_dist(dist)
                    _component, dsc_path = repository.mbd_find_dsc_path(distribution, r["source"], r["version"], components=[r["component"]])
                    r["dsc_path"] = dsc_path
                    r["uploadable"] = suite.uploadable

                    if dist.is_rollback:
                        dist_dict.setdefault("rollbacks", []).append(r)
                    else:
                        if suite.migrates_to:
                            r["migrates_to"] = repository.mbd_get_distribution_string(distribution, suite.migrates_to)
                            r["is_migrated"] = bool(repository.mbd_reprepro.find_in(ls, distribution=r["migrates_to"], version=r["version"]))

                        for k, v in r.items():
                            dist_dict[k] = v


@_pimpdoc()
class Find(Ls):
    """
    Find source package

    Like 'ls', but call will fail on no results.
    """

    def run(self):
        super().run()
        if not self.result:
            raise mini_buildd.HTTPBadRequest(f"No source package matches criteria: {self.args['source'].value()}")


@_pimpdoc()
class List(_Login, Call):
    """
    List packages (deb or dsc)

    Unlike 'ls', this will also look for debs. This may be a very
    expensive operation - pick <package> and <distributions> wisely.
    """

    @classmethod
    def iarguments(cls):
        yield StrArgument(["package"], choices=Value.LAST_SOURCES, doc="Package name (deb or src). You may use glob-like patterns here")
        yield Distributions(["--distributions", "-D"], default=Value.ALL_DISTRIBUTIONS)
        yield IntArgument(["--max", "-M"], default=50, doc="Max list size per distribution")

    def run(self):
        for diststr in self.args["distributions"].value():
            repository = diststr2repository(diststr)
            self.result[repository.identity] = repository.mbd_reprepro.list(self.args["package"].value(), diststr, list_max=self.args["max"].value())


MULT_VERSIONS_PER_DIST_NOTE = "Use as safeguard, or for rare cases of multiple version of the same package in one distribution (in different components)"


@_pimpdoc()
class Migrate(_Staff, _Confirm, Call):
    """
    Migrate source package

    Migrates a source package along with all its binary packages. If
    run for a rollback distribution, this will perform a rollback
    restore.

    JSON Result (dict):
      __plain__: string: textual migration log.

    """

    @classmethod
    def iarguments(cls):
        yield Source(["source"])
        yield Distribution(["distribution"], choices=Value.MIGRATABLE_DISTRIBUTIONS)
        yield BoolArgument(["--full", "-F"], doc="migrate all 'migrates_to' suites up (f.e. unstable->testing->stable)")
        yield StrArgument(["--version", "-V"], default="", doc=f"Migrate exactly this version. {MULT_VERSIONS_PER_DIST_NOTE}")

    def run(self):
        dist, repository, distribution, suite = mini_buildd.mdls().repository.parse_diststr(self.args["distribution"].value())
        version = self.args["version"].value()
        self.result[self.PLAIN] = repository.mbd_package_migrate(self.args["source"].value(),
                                                                 distribution,
                                                                 suite,
                                                                 full=self.args["full"].value(),
                                                                 rollback=dist.rollback_no,
                                                                 version=version if version else None)


@_pimpdoc()
class Remove(_Admin, _Confirm, Call):
    """
    Remove source package

    Removes a source package along with all its binary packages.

    JSON Result (dict):
      __plain__: string: textual removal log.
    """

    @classmethod
    def iarguments(cls):
        yield Source(["source"])
        yield Distribution(["distribution"])
        yield BoolArgument(["--without-rollback"], doc="don't copy to rollback distribution")
        yield StrArgument(["--version", "-V"], default="", doc=f"Remove exactly this version. {MULT_VERSIONS_PER_DIST_NOTE}")

    def run(self):
        dist, repository, distribution, suite = mini_buildd.mdls().repository.parse_diststr(self.args["distribution"].value())
        version = self.args["version"].value()
        self.result[self.PLAIN] = repository.mbd_package_remove(self.args["source"].value(),
                                                                distribution,
                                                                suite,
                                                                rollback=dist.rollback_no,
                                                                version=version if version else None,
                                                                without_rollback=self.args["without_rollback"].value())


PORT_RESULT_DOC = (
    "JSON Result (dict):",
    "  uploaded: list of triples of all items uploaded",
)


@_pimpdoc(PORT_RESULT_DOC)
class Port(_Staff, _Running, _Confirm, Call):
    """
    Port internal source package

    An internal 'port' is a no-changes (i.e., only the changelog
    will be adapted) rebuild of the given locally-installed
    package.

    When from-distribution equals to_distribution, a rebuild will be done.
    """

    @classmethod
    def iarguments(cls):
        yield Source(["source"])
        yield Distribution(["from_distribution"])
        yield Distributions(["to_distributions"], choices=Value.ACTIVE_UPLOADABLE_DISTRIBUTIONS)
        yield StrArgument(["--version", "-V"], default="", doc=f"Port exactly this version. {MULT_VERSIONS_PER_DIST_NOTE}")
        yield UploadOptions(["--options", "-O"])

    def run(self):
        self.result["uploaded"] = []
        version = self.args["version"].value()
        for to_distribution in self.args["to_distributions"].value():
            self.result["uploaded"].append(mini_buildd.package.port(self.args["source"].value(),
                                                                    self.args["from_distribution"].value(),
                                                                    to_distribution,
                                                                    version=version if version else None,
                                                                    options=self.args["options"].value().split("|")))


@_pimpdoc(PORT_RESULT_DOC)
class PortExt(_Staff, _Running, _Confirm, Call):
    """
    Port external source package

    An external 'port' is a no-changes (i.e., only the changelog
    will be adapted) rebuild of any given source package.
    """

    @classmethod
    def iarguments(cls):
        yield UrlArgument(["dsc"], doc="URL of any Debian source package (dsc) to port")
        yield BoolArgument(["--allow-unauthenticated", "-u"], doc="Don't verify downloaded DSC against Debian keyrings (see ``man dget``)")
        yield Distributions(["distributions"], choices=Value.ACTIVE_UPLOADABLE_DISTRIBUTIONS)
        yield UploadOptions(["--options", "-O"])

    def run(self):
        self.result["uploaded"] = []
        for d in self.args["distributions"].value():
            self.result["uploaded"].append(mini_buildd.package.port_ext(self.args["dsc"].value(),
                                                                        d,
                                                                        options=self.args["options"].value().split("|"),
                                                                        allow_unauthenticated=self.args["allow_unauthenticated"].value()))


@_pimpdoc()
class Retry(_Staff, _Running, _Confirm, Call):
    """
    Retry a previously failed source package

    JSON Result:

    * changes: Changes file that has been re-uploaded.
    """

    BKEY_FORMAT = "<source>/<version>/<timecode>/source[ <arch>]"
    BKEY_REGEX = re.compile(r"[^/]+/[^/]+/[^/]+/[^/]+")

    @classmethod
    def iarguments(cls):
        yield StrArgument(["bkey"], choices=Value.LAST_FAILED_BKEYS, doc=f"Package bkey ('{cls.BKEY_FORMAT}') of a past packaging try; 'extra.bkey' of a FAILED packaging event")

    def run(self):
        bkey = self.args["bkey"].value()
        if not self.BKEY_REGEX.match(bkey):
            raise mini_buildd.HTTPBadRequest(f"Wrong bkey format. Should be '{self.BKEY_FORMAT}'")

        events_path = mini_buildd.config.ROUTES["events"].path.new_sub([bkey])
        if not os.path.exists(events_path.full):
            raise mini_buildd.HTTPBadRequest(f"No such event path: {bkey}")

        # Check that there is exactly one failed event in the given path
        failed_events = glob.glob(events_path.join("*_FAILED.json"))
        if len(failed_events) != 1:
            raise mini_buildd.HTTPBadRequest(f"{len(failed_events)} failed events found (we need exactly 1). Check your bkey")
        failed_event = mini_buildd.events.Event.load(failed_events[0])
        LOG.info(f"Retry: Found valid FAILED event: {failed_event}")

        # Find all changes in that path
        changes_files = glob.glob(events_path.join("*.changes"))
        if len(changes_files) != 1:
            raise mini_buildd.HTTPBadRequest(f"{len(changes_files)} changes found (we need exactly 1). Check your bkey")
        changes = changes_files[0]
        LOG.info(f"Retry: Found solitaire changes file: {changes}")

        # Should be save now to re-upload the changes files found in path
        mini_buildd.changes.Base(changes).upload(mini_buildd.get_daemon().model.mbd_get_ftp_endpoint(), force=True)
        self.result["changes"] = os.path.basename(changes)


@_pimpdoc()
class Cancel(_Staff, _Running, _Confirm, Call):
    """Cancel an ongoing package build"""

    @classmethod
    def iarguments(cls):
        yield StrArgument(["bkey"], choices=Value.CURRENT_BUILDS, doc="build key ('<source>/<version>/<timecode>/<arch>' of ongoing builds; 'extra.bkey' in events)")

    def run(self):
        mini_buildd.get_daemon().builder.cancel(self.args["bkey"].value())
        self.result["cancelled"] = self.args["bkey"].value()


@_pimpdoc()
class SetUserKey(_Login, _Confirm, Call):
    """
    Set a user's GnuPG public key
    """

    @classmethod
    def category(cls):
        return cls.CATEGORIES[1]

    @classmethod
    def iarguments(cls):
        yield MultilineStrArgument(["key"], doc="GnuPG public key; multiline inputs will be handled as ascii armored full key, one-liners as key ids")

    def run(self):
        uploader = self.request.user.uploader
        uploader.Admin.mbd_remove(uploader)
        key = self.args["key"].value()

        if "\n" in key:
            LOG.info("Using given key argument as full ascii-armored GPG key")
            uploader.key_id = ""
            uploader.key = key
        else:
            LOG.info("Using given key argument as key ID")
            uploader.key_id = key
            uploader.key = ""

        uploader.Admin.mbd_prepare(uploader)
        uploader.Admin.mbd_check(uploader)
        LOG.info(f"Uploader profile changed: {uploader}")
        LOG.warning("Your uploader profile must be (re-)activated by the mini-buildd staff before you can actually use it")


@_pimpdoc()
class Subscribe(_Login, Call):
    """
    Subscribe to (email) notifications
    """

    @classmethod
    def iarguments(cls):
        yield Source(["source"], default="", extra_doc="\n\nLeave empty for all source packages")
        yield Distribution(["distribution"], default="", choices=Value.PREPARED_DISTRIBUTIONS, extra_doc="\n\nLeave empty for all distributions")

    def __init__(self, **kwargs):
        super().__init__(**kwargs)

        self.source = self.args["source"].value()
        self.sourcestr = "" if self.source is None else self.source
        self.distribution = self.args["distribution"].value()
        self.distributionstr = "" if self.distribution is None else self.distribution

    def run(self):
        s, created = mini_buildd.mdls().subscription.Subscription.objects.get_or_create(
            subscriber=self.request.user,
            package=self.sourcestr,
            distribution=self.distributionstr,
        )
        self.result["subscribed"] = {"created": created, "source": s.package, "distribution": s.distribution}


@_pimpdoc()
class Unsubscribe(Subscribe):
    """
    Unsubscribe from (email) notifications
    """

    def run(self):
        self.result["unsubscribed"] = []
        for s in mini_buildd.mdls().subscription.Subscription.objects.filter(subscriber=self.request.user):
            if (self.sourcestr == s.package) and (self.distributionstr == s.distribution):
                s.delete()
                self.result["unsubscribed"].append({"source": s.package, "distribution": s.distribution})


@_pimpdoc()
class Setup(_Admin, _Confirm, Call):
    """
    Create, update or inspect your setup

    Daemon will be stopped while running, and started when finished (i.e., even if it was not running before).
    """

    @classmethod
    def iarguments(cls):
        yield BoolArgument(["--save"], doc="Save command line from this (successful) run as default for future runs", header="Run Options")
        yield BoolArgument(["--update"], doc="Update existing instances")

        yield StrArgument(["--identity"], default=Value.DEFAULT_IDENTITY, doc="instance identity (for keyring package names, dput config, ...)", header="Daemon")
        yield StrArgument(["--ftp-endpoint"], default=Value.DEFAULT_FTP_ENDPOINT, doc="ftp (incoming) network endpoint")

        yield ListArgument(["--archives", "-A"], default=[], doc="add arbitrary archives", header="Archives")
        yield ListArgument(["--archives-from-vendor"], default=[], choices=SETUP["vendor"].keys(), doc="add original archives from these vendors")
        yield BoolArgument(["--archives-from-apt-sources"], doc="add archives guessed from your local sources.list")
        yield BoolArgument(["--archives-from-proxy"], doc="add archives guessed from a locally running apt-cacher-ng")

        yield ListArgument(["--sources"], default=[], choices=CODENAMES.keys(), doc="manually select codenames to setup sources for", header="Sources")
        yield ListArgument(["--sources-from-vendor"], default=[], choices=SETUP["vendor"].keys(), doc="add 'supported' (as per ``distro-info``) set of source codenames from these vendors")
        yield ListArgument(["--sources-from-vendor-with-lts"], default=[], choices=SETUP["vendor"].keys(), doc="add 'supported' plus 'lts' (as per ``distro-info`` -- adds 'lts', 'elts' for Debian and 'esm' for Ubuntu) set of source codenames from these vendors")
        yield ListArgument(["--sources-from-vendor-with-all"], default=[], choices=SETUP["vendor"].keys(), doc="add all source codenames with a working setup from these vendors (i.e., includes very old ones)")

        yield ListArgument(["--chroots"], default=[], choices=CODENAMES.keys(), doc="codenames to setup chroots for", header="Chroots")
        yield BoolArgument(["--chroots-from-sources"], doc="add chroots for all base sources found")
        yield ChoiceArgument(["--chroots-backend", "-C"], default=Value.DEFAULT_CHROOT_BACKEND, choices=["Dir", "File", "LVM", "LoopLVM", "BtrfsSnapshot"], doc="chroot backend to use, or empty string to not create chroots")

        yield ListArgument(["--remotes"], default=[], doc=f"remotes to add. {inspect.getdoc(mini_buildd.net.ClientEndpoint)}", header="Remotes")

        yield ListArgument(["--repositories"], default=[], doc=f"repositories to setup \'<repo_id>[/({'|'.join(SETUP['repository'].keys())})]\'", header="Repositories")

    def x_or_create(self, cls, defaults=None, **kwargs):
        if self.args["update"].value():
            obj, created = cls.objects.update_or_create(**kwargs, defaults=defaults)
        else:
            obj, created = cls.objects.get_or_create(**kwargs, defaults=defaults)

        class_info = self.result.setdefault(cls.__name__, {})
        info = class_info.setdefault(str(obj), {})

        if created:
            info["status"] = "created"
        elif self.args["update"].value():
            info["status"] = "updated"
        else:
            info["status"] = "exists"

        # Add diff convenience; be sure we don't care if it fails, as code is somewhat whacky.
        try:
            info["diff"] = cls.mbd_diff(obj, **kwargs, **(defaults if defaults is not None else {}))
        except BaseException as e:
            mini_buildd.log_exception(LOG, f"Diff convenience feature failed for {cls}", e)
            info["diff"] = None

        return obj, created

    @classmethod
    def ilocal_archive_urls(cls):
        try:
            import aptsources.sourceslist
            for src in (src for src in aptsources.sourceslist.SourcesList() if not src.invalid and not src.disabled):
                # These URLs come from the user. 'normalize' the uri first to have exactly one trailing slash.
                yield src.uri.rstrip("/") + "/"
        except BaseException as e:
            mini_buildd.log_exception(LOG, "Failed to scan local sources.lists for default mirrors ('python-apt' not installed?)", e)

    @classmethod
    def iapt_cacher_archive_urls(cls):
        url = mini_buildd.net.detect_apt_cacher_ng(url=f"http://{mini_buildd.config.HOSTNAME_FQDN}:3142")
        if url:
            LOG.info(f"Local apt-cacher-ng detected: {url}")
            for setup in SETUP["vendor"].values():
                for path in setup.get("archive_paths", []):
                    LOG.info(f"Local proxy archive: '{url}/{path}/'")
                    yield f"{url}/{path}/"

    def setup_daemon(self):
        defaults = {
            "identity": self.args["identity"].value(),
            "ftpd_bind": self.args["ftp_endpoint"].value(),
        }

        self.x_or_create(mini_buildd.mdls().daemon.Daemon, defaults=defaults)

        mini_buildd.mdls().daemon.Daemon.Admin.mbd_pca_all(self.request)

    def setup_archives(self):
        archive_urls = []
        archive_urls += self.args["archives"].value()
        if self.args["archives_from_apt_sources"].value():
            archive_urls += self.ilocal_archive_urls()
        if self.args["archives_from_proxy"].value():
            archive_urls += self.iapt_cacher_archive_urls()
        for v in self.args["archives_from_vendor"].value():
            archive_urls += SETUP["vendor"][v]["archive"]

        for url in archive_urls:
            self.x_or_create(mini_buildd.mdls().source.Archive, url=url)

    def setup_sources(self):
        codenames = []
        codenames += self.args["sources"].value()
        for v in self.args["sources_from_vendor"].value():
            codenames += CODENAMES.get_supported(v)
        for v in self.args["sources_from_vendor_with_lts"].value():
            codenames += CODENAMES.get_supported_plus_lts(v)
        for v in self.args["sources_from_vendor_with_all"].value():
            codenames += CODENAMES.get_working(v)

        for codename in set(codenames):
            for s in CODENAMES[codename]["sources"]:
                obj, created = self.x_or_create(mini_buildd.mdls().source.Source, origin=s.origin, codename=s.codename, defaults={"extra_options": s.extra_options})
                if created or self.args["update"].value():
                    # apt_keys (m2m) need to be updated manually, with save() (x_or_create can't be used)
                    obj.apt_keys.clear()
                    for long_key_id in s.apt_keys:
                        matching_keys = mini_buildd.mdls().gnupg.AptKey.mbd_filter_key(long_key_id)
                        if matching_keys:
                            apt_key = matching_keys[0]
                        else:
                            apt_key, _created = self.x_or_create(mini_buildd.mdls().gnupg.AptKey, key_id=long_key_id)
                        obj.apt_keys.add(apt_key)
                    obj.save()

                # Smartly add prio source: Security gets prio=500, others prio=1 (opt-in).
                self.x_or_create(mini_buildd.mdls().source.PrioritySource, source=obj, priority=500 if CODENAMES.is_security(obj.origin, obj.codename) else 1)

        mini_buildd.mdls().source.Source.Admin.mbd_pca_all(self.request)

    def setup_chroots(self):
        codenames = []
        codenames += self.args["chroots"].value()
        if self.args["chroots_from_sources"].value():
            codenames += [s.codename for s in mini_buildd.mdls().source.Source.Admin.mbd_filter_active_base_sources()]

        # Setup chroots
        for codename in codenames:
            s = mini_buildd.mdls().source.Source.Admin.mbd_filter_active_base_sources().get(codename=codename)
            cb_class = getattr(mini_buildd.mdls().chroot, f"{self.args['chroots_backend'].value()}Chroot")

            archs = mini_buildd.mdls().source.Architecture.mbd_supported_architectures()
            LOG.debug(f"Host supports {' '.join(archs)}")

            for a in mini_buildd.mdls().source.Architecture.objects.filter(name__regex=rf"^({'|'.join(archs)})$"):
                try:
                    defaults = {}
                    if CODENAMES[s.codename].get("needs_uname_26", False):
                        defaults["extra_options"] = "Debootstrap-Command: /usr/sbin/mini-buildd-debootstrap-uname-2.6\n"

                    self.x_or_create(cb_class, source=s, architecture=a, defaults=defaults)
                except BaseException as e:
                    LOG.warning(f"{s.codename}/{a.name}: Skipping customized chroot (on another backend, or not on default values) [{e}]")

            cb_class.Admin.mbd_pca_all(self.request)

    def setup_remotes(self):
        for ep in self.args["remotes"].value():
            self.x_or_create(mini_buildd.mdls().gnupg.Remote, http=ep)
        mini_buildd.mdls().gnupg.Remote.Admin.mbd_pca_all(self.request)

    def setup_repositories(self):
        # Create default layouts and suites.
        for layout_name, layout_setup in SETUP["layout"].items():
            layout, layout_created = self.x_or_create(
                mini_buildd.mdls().distribution.Layout,
                name=layout_name,
                defaults=layout_setup.get("options", {}))
            if layout_created or self.args["update"].value():
                for suite_name, suite_setup in layout_setup["suites"].items():
                    suite, _suite_created = self.x_or_create(mini_buildd.mdls().distribution.Suite, name=suite_name)

                    defaults = suite_setup.get("options", {})
                    defaults["extra_options"] = f"Rollback: {suite_setup.get('rollback', 0)}\n" if layout_setup.get("with_rollbacks", False) else ""
                    if suite_setup.get("migrates_to"):
                        defaults["migrates_to"] = mini_buildd.mdls().distribution.SuiteOption.objects.get(layout=layout, suite=mini_buildd.mdls().distribution.Suite.objects.get(name=suite_setup.get("migrates_to")))

                    self.x_or_create(mini_buildd.mdls().distribution.SuiteOption, layout=layout, suite=suite, defaults=defaults)

        # Add default distribution objects for all base sources found.
        for s in mini_buildd.mdls().source.Source.Admin.mbd_filter_active_base_sources():
            codename_setup = CODENAMES[s.codename]
            defaults = {
                "apt_allow_unauthenticated": codename_setup.get("apt_allow_unauthenticated", False),
                "extra_options": mini_buildd.sbuild.CONFIG_BLOCKS.default() + mini_buildd.sbuild.SETUP_BLOCKS.default(),
            }
            defaults["extra_options"] += codename_setup.get("extra_options", "")

            distribution, distribution_created = self.x_or_create(mini_buildd.mdls().distribution.Distribution, base_source=s, defaults=defaults)

            if distribution_created or self.args["update"].value():
                # Auto-add known default components or Origin
                distribution.components.set(mini_buildd.mdls().source.Component.objects.filter(name__in=SETUP["vendor"][s.origin]["default_components"]))

                # Auto-add extra (prio) sources that start with our base_source's codename
                distribution.extra_sources.set(mini_buildd.mdls().source.PrioritySource.objects.filter(source__codename__regex=fr"^{s.codename}[-/]"))

                # Auto-add all locally supported archs
                archall_set = False  # Use first non-optional arch to build arch "all"
                for arch in mini_buildd.mdls().source.Architecture.mbd_supported_architectures():
                    optional = arch in codename_setup.get("arch_optional", [])
                    architecture_option, _dummy = self.x_or_create(
                        mini_buildd.mdls().distribution.ArchitectureOption,
                        architecture=mini_buildd.mdls().source.Architecture.objects.get(name__exact=arch),
                        distribution=distribution,
                        defaults={
                            "build_architecture_all": not archall_set and not optional,
                            "optional": optional,
                        })
                    if architecture_option.build_architecture_all:
                        archall_set = True

                # Save changes to Distribution model
                distribution.save()

        # Add repositories
        for repo_preset in self.args["repositories"].value():
            repo, dummy, preset = repo_preset.partition("/")
            preset = preset if preset else "default"
            repository_setup = SETUP["repository"][preset]
            defaults = repository_setup.get("options", {})
            defaults["layout"] = mini_buildd.mdls().distribution.Layout.objects.get(name__exact=repository_setup["layout"])

            repository, repository_created = self.x_or_create(mini_buildd.mdls().repository.Repository, identity=repo, defaults=defaults)

            if repository_created:
                for d in mini_buildd.mdls().distribution.Distribution.objects.filter(**repository_setup["distribution_filter"]):
                    repository.distributions.add(d)

        mini_buildd.mdls().repository.Repository.Admin.mbd_pca_all(self.request)

    def run(self):
        with mini_buildd.daemon.Stopped(force_start=True):
            self.setup_daemon()
            self.setup_archives()
            self.setup_sources()
            self.setup_chroots()
            self.setup_remotes()
            if self.args["repositories"].value():
                self.setup_repositories()

            if self.args["save"].value():
                mini_buildd.get_daemon().model.mbd_setup_save(self.command_line())

    @classmethod
    def _preset(cls, **kwargs):
        return Setup(update=True, chroots_from_sources=True, **kwargs)

    @classmethod
    def preset_debian(cls, **kwargs):
        return cls._preset(archives_from_vendor="Debian", sources_from_vendor="Debian", **kwargs)

    @classmethod
    def preset_ubuntu(cls, **kwargs):
        return cls._preset(archives_from_vendor="Ubuntu", sources_from_vendor="Ubuntu", **kwargs)

    @classmethod
    def preset_bootstrap(cls, **kwargs):
        return cls.preset_debian(repositories="test/test", **kwargs)

    @classmethod
    def preset_saved_or_bootstrap(cls):
        try:
            cl = mini_buildd.get_daemon().model.mbd_setup_get()
            if not cl:
                raise Exception("No saved setup")
            return {"desc": "Update with saved options", "setup": Setup.from_command_line(cl)}
        except BaseException as e:
            mini_buildd.log_exception(LOG, "Can't load saved setup (using empty setup)", e, level=logging.INFO)
            return {"desc": "Bootstrap (adds sandbox repo 'test' by default)", "setup": Setup.preset_bootstrap()}


@_pimpdoc()
class RemakeChroots(_Admin, _Confirm, Call):
    """
    Remake chroots

    Run actions 'remove', 'prepare', 'check' and 'activate'.

    Daemon will be stopped while running.
    """

    @classmethod
    def iarguments(cls):
        yield ListArgument(["--keys"], default=Value.ALL_CHROOTS, choices=Value.ALL_CHROOTS, doc="chroot keys (<codename>:<arch>)")

    def run(self):
        with mini_buildd.daemon.Stopped():
            chroots = mini_buildd.mdls().chroot.Chroot.objects.none()
            for key in self.args["keys"].value():
                codename, arch = key.split(":")
                chroots |= mini_buildd.mdls().chroot.Chroot.objects.filter(source__codename=codename, architecture__name=arch)
            mini_buildd.mdls().chroot.Chroot.Admin.mbd_actions(self.request, chroots, ["remove", "prepare", "check", "activate"])


@_pimpdoc()
class Power(_Admin, _Confirm, Call):
    """
    Power Daemon on or off (toggles by default)

    This essentially stops accepting incoming, and forcibly stops any possibly running builds.

    This state is *not persisted*. Please *deactivate* the Daemon instance via :mbdpage:`setup` to make the state persist over *mini-buildd service* restarts.
    """

    @classmethod
    def iarguments(cls):
        yield ChoiceArgument(["to_state"], choices=[o.name for o in OnOff] + [Argument.SERVER_DEFAULT], default=Value.POWER_TOGGLE, doc="power state to set to")

    def run(self):
        self.result["state_pre"] = OnOff(mini_buildd.get_daemon().is_alive()).name
        {OnOff.ON.name: mini_buildd.get_daemon().mbd_start, OnOff.OFF.name: mini_buildd.get_daemon().mbd_stop}[self.args["to_state"].value()]()
        self.result["state"] = OnOff(mini_buildd.get_daemon().is_alive()).name


@_pimpdoc()
class Wake(_Admin, _Confirm, Call):
    """
    Wake a remote instance
    """

    @classmethod
    def iarguments(cls):
        yield StrArgument(["--remote", "-r"], choices=Value.ALL_REMOTES)
        yield IntArgument(["--sleep", "-s"], default=5, doc="Sleep between wake attempts")
        yield IntArgument(["--attempts", "-a"], default=3, doc="Max number attempts")

    def run(self):
        remote = mini_buildd.mdls().gnupg.Remote.objects.get(http=self.args["remote"].value())
        self.result = remote.mbd_get_status(wake=True, wake_sleep=self.args["sleep"].value(), wake_attempts=self.args["attempts"].value())


@_pimpdoc()
class Handshake(Call):
    """
    Check if signed message matches a remote, reply our signed message on success

    This is for internal use only.
    """

    @classmethod
    def category(cls):
        return cls.CATEGORIES[2]

    @classmethod
    def iarguments(cls):
        yield MultilineStrArgument(["--signed-message", "-S"])

    def run(self):
        signed_message = self.args["signed_message"].value()
        for r in mini_buildd.mdls().gnupg.Remote.objects.all():
            try:
                r.mbd_verify(signed_message)
                self.result[self.PLAIN] = mini_buildd.get_daemon().handshake_message()
                LOG.info(f"Remote handshake ok: '{r}': {r.key_long_id}: {r.key_name}")
                return
            except Exception as e:
                LOG.debug(f"Remote handshake failed for '{r}': {e}")
        raise mini_buildd.HTTPBadRequest(f"GnuPG handshake failed: No remote for public key on {mini_buildd.http_endpoint()}")


@_pimpdoc()
class Cronjob(_Admin, _Confirm, Call):
    """
    Run a cron job now (out of schedule)
    """

    @classmethod
    def iarguments(cls):
        yield StrArgument(["id"], choices=Value.ALL_CRONJOBS)

    def run(self):
        job = mini_buildd.get_daemon().crontab.get(self.args["id"].value())
        self.result[job.id()] = job.run()


@_pimpdoc()
class Uploaders(_Admin, _Running, Call):
    """
    Get upload permissions for repositories
    """

    @classmethod
    def iarguments(cls):
        yield Repositories(["--repositories", "-R"], default=Value.ALL_REPOSITORIES)

    def run(self):
        for r in self.args["repositories"].objects():
            self.result[r.identity] = {"allow_unauthenticated_uploads": r.allow_unauthenticated_uploads}
            with closing(mini_buildd.daemon.UploadersKeyring(r.identity)) as gpg:
                self.result[r.identity]["uploaders"] = gpg.get_pub_keys_infos()


@_pimpdoc()
class SnapshotLs(_Running, Call):
    """
    Get list of repository snapshots for a distribution

    JSON Result (dict):
      dict: <distribution>: [snasphot,...]: List of snapshots for the given distribution.
    """

    @classmethod
    def iarguments(cls):
        yield Distribution(["distribution"])

    def run(self):
        diststr = self.args["distribution"].value()
        self.result = {diststr: diststr2repository(diststr).mbd_reprepro.get_snapshots(diststr)}


@_pimpdoc()
class SnapshotCreate(_Admin, _Confirm, SnapshotLs):
    """
    Create a repository snapshot
    """

    @classmethod
    def iarguments(cls):
        yield from SnapshotLs.iarguments()
        yield StrArgument(["name"], doc="snapshot name")

    def run(self):
        diststr = self.args["distribution"].value()
        diststr2repository(diststr).mbd_reprepro.gen_snapshot(diststr, self.args["name"].value())


class SnapshotDelete(SnapshotCreate):
    """
    Delete a repository snapshot
    """

    def run(self):
        diststr = self.args["distribution"].value()
        diststr2repository(diststr).mbd_reprepro.del_snapshot(diststr, self.args["name"].value())


@_pimpdoc(PORT_RESULT_DOC)
class KeyringPackages(_Admin, _Running, _Confirm, Call):
    """
    Build keyring packages
    """

    @classmethod
    def iarguments(cls):
        yield Distributions(["--distributions", "-D"], default=Value.ACTIVE_KEYRING_DISTRIBUTIONS)
        yield BoolArgument(["--without-migration", "-M"], doc="migrate packages")

    def run(self):
        self.result["uploaded"] = []
        events = mini_buildd.events.Attach(mini_buildd.get_daemon().events)

        for d in self.args["distributions"].value():
            _dist, repository, distribution, suite = mini_buildd.mdls().repository.parse_diststr(d)
            if not suite.build_keyring_package:
                raise mini_buildd.HTTPBadRequest(f"Keyring package to non-keyring suite requested (see 'build_keyring_package' flag): '{d}'")
            self.result["uploaded"].append(mini_buildd.package.upload_template_package(mini_buildd.package.KeyringPackage(), d))

        uploaded = set(self.result["uploaded"])
        while uploaded:
            event = events.get()
            for dist, source, version in uploaded.copy():
                if event.match(types=[mini_buildd.events.Type.INSTALLED, mini_buildd.events.Type.FAILED, mini_buildd.events.Type.REJECTED], distribution=dist, source=source, version=version):
                    LOG.info(f"Keyring package result: {event}")
                    uploaded.remove((dist, source, version))
                    if (event.type == mini_buildd.events.Type.INSTALLED) and not self.args["without_migration"].value():
                        _dist, repository, distribution, suite = mini_buildd.mdls().repository.parse_diststr(dist)
                        repository.mbd_package_migrate(source, distribution, suite, full=True, version=version)


@_pimpdoc(PORT_RESULT_DOC)
class TestPackages(_Admin, _Running, _Confirm, Call):
    """
    Build test packages
    """

    __TEMPLATES = ["mbd-test-archall", "mbd-test-cpp", "mbd-test-ftbfs"]

    @classmethod
    def iarguments(cls):
        yield ListArgument(["--sources", "-S"], default=cls.__TEMPLATES, choices=cls.__TEMPLATES, doc="test source packages to use")
        yield Distributions(["--distributions", "-D"], default=Value.ACTIVE_EXPERIMENTAL_DISTRIBUTIONS, choices=Value.ACTIVE_UPLOADABLE_DISTRIBUTIONS)
        yield Distributions(["--auto-ports", "-A"], default=[], choices=Value.ACTIVE_UPLOADABLE_DISTRIBUTIONS)
        yield BoolArgument(["--with-check", "-c"], doc="Check for correct packager results")

    def run(self):
        self.result["uploaded"] = []
        events = mini_buildd.events.Attach(mini_buildd.get_daemon().events)

        for p in self.args["sources"].value():
            for d in self.args["distributions"].value():
                self.result["uploaded"].append(mini_buildd.package.upload_template_package(mini_buildd.package.TestPackage(p, auto_ports=self.args["auto_ports"].value()), d))

        if self.args["with_check"].value():
            uploaded = set(self.result["uploaded"])
            while uploaded:
                event = events.get()
                for dist, source, version in uploaded.copy():
                    if event.match(types=[mini_buildd.events.Type.INSTALLED, mini_buildd.events.Type.FAILED, mini_buildd.events.Type.REJECTED], distribution=dist, source=source, version=version):
                        uploaded.remove((dist, source, version))
                        if (event.type == mini_buildd.events.Type.FAILED and source in ["mbd-test-archall", "mbd-test-cpp"]) or \
                           (event.type == mini_buildd.events.Type.INSTALLED and source in ["mbd-test-ftbfs"]):
                            raise mini_buildd.HTTPBadRequest(f"Test package failed: {event}")
                        LOG.info(f"Test package result: {event}")


class Calls(collections.OrderedDict):
    """Automatically collect all calls defined in this module, and make them accessible"""

    def __init__(self):
        super().__init__({c.name(): c for c in sys.modules[__name__].__dict__.values() if inspect.isclass(c) and issubclass(c, Call) and c != Call})


CALLS = Calls()
