import os
import glob
import logging
import ftplib
import re

import debian.deb822

import mini_buildd.config
import mini_buildd.misc
import mini_buildd.files
import mini_buildd.sbuild
import mini_buildd.dist
import mini_buildd.net

LOG = logging.getLogger(__name__)


class Base(debian.deb822.Changes):
    def __init__(self, initial_value):
        self.file_path = None
        if isinstance(initial_value, str):
            self.file_path = initial_value
            with mini_buildd.fopen(initial_value) as cf:
                super().__init__(cf)
        else:
            super().__init__(initial_value)

        self.dfn = mini_buildd.files.DebianName(self["Source"], self["Version"])

    @property
    def file_name(self):
        return os.path.basename(self.file_path) if self.file_path is not None else None

    def dsc_file_path(self):
        return os.path.join(os.path.dirname(self.file_path), self.dfn.dsc())

    def get_files(self, key=None):
        return [f[key] if key else f for f in self.get("Files", [])]

    def add_file(self, file_name):
        self.setdefault("Files", [])
        self["Files"].append({"md5sum": mini_buildd.misc.Hash(file_name).md5(),
                              "size": os.path.getsize(file_name),
                              "section": "mini-buildd",
                              "priority": "extra",
                              "name": os.path.basename(file_name)})

    def save_as(self, file_path, gnupg=None):
        """
        Write to file (optionally signed).
        """
        LOG.info(f"Saving changes: {file_path}")
        os.makedirs(os.path.dirname(file_path), exist_ok=True)  # Be sure base dir is always available
        with open(file_path, "w+", encoding=mini_buildd.config.CHAR_ENCODING) as f:
            f.write(self.dump())
        if gnupg is not None:
            LOG.info(f"Signing changes: {file_path}")
            gnupg.sign(file_path)
        self.file_path = file_path

    def _upload(self, ftp_cls, endpoint, force=False):
        pre_upload_timestamp = mini_buildd.misc.Datetime.now()

        upload = os.path.splitext(self.file_path)[0] + ".upload"
        if not force and os.path.exists(upload):
            with mini_buildd.fopen(upload) as uf:
                LOG.warning(f"FTP: '{upload}' exists (already uploaded to '{uf.read()}')")
        else:
            with ftp_cls() as ftp:
                ftp.connect(endpoint.options.get("host"), int(endpoint.options.get("port")))
                if isinstance(ftp, ftplib.FTP_TLS):
                    ftp.auth()
                    ftp.prot_p()
                ftp.login()
                ftp.cwd("/incoming")
                for fd in self.get_files() + [{"name": self.file_name}]:
                    f = fd["name"]
                    LOG.debug(f"FTP: Uploading file: '{f}'")
                    with open(os.path.join(os.path.dirname(self.file_path), f), "rb") as fi:
                        ftp.storbinary(f"STOR {f}", fi)
                with mini_buildd.fopen(upload, "w") as fi:
                    fi.write(f"{endpoint.geturl()}")
                LOG.info(f"FTP: '{self.file_name}' uploaded to '{endpoint.geturl()}'.")

        return pre_upload_timestamp  # for events --after=

    def upload(self, endpoint, force=False):
        return self._upload(ftplib.FTP_TLS if endpoint.is_ssl() else ftplib.FTP, endpoint, force=force)

    def tar(self):
        files = [os.path.join(os.path.dirname(self.file_path), f) for f in self.get_files("name")] + [self.file_path]
        return mini_buildd.files.Tar().add(files)

    def untar(self, dir_path):
        mini_buildd.files.Tar.extract(self.file_path + ".tar", dir_path)


class Changes(Base):
    FILE_APX = ""
    #: <key>: <public_hint>
    MANDATORY = {"Distribution": "", "Source": "", "Version": "", "Architecture": ""}

    @classmethod
    def match(cls, file_path):
        return re.compile(f"^.+{mini_buildd.files.DebianName.uext(cls.FILE_APX)}_[^_]+.changes$").match(file_path)

    def __init__(self, initial_value, create_events=False, create_builds=False):
        super().__init__(initial_value)

        for key, hint in self.MANDATORY.items():
            if key not in self:
                hint_apx = f" ({hint})" if hint else ""
                raise mini_buildd.HTTPBadRequest(f"Changes missing mandatory key: {key}{hint_apx}")

        self._timecode = mini_buildd.misc.Datetime.timecode()

        #: Key to identify if a 'Package' (Upload, Buildrequest, Buildresult).
        self.key = os.path.join(self["Source"], self["Version"], self.timecode)
        #: Key to identify a 'Build' (Buildrequest, Buildresult).
        self.bkey = os.path.join(self.key, self["Architecture"])

        self.events_path = mini_buildd.config.ROUTES["events"].path.new_sub([self.bkey], create=create_events)
        self.builds_path = mini_buildd.config.ROUTES["builds"].path.new_sub([self.bkey], create=create_builds)

    @property
    def timecode(self):
        return self.cget("Timecode", self._timecode)

    def cget(self, field, default=None):
        return self.get(mini_buildd.misc.CField(field).fullname, default=default)

    def cset(self, field, value):
        self[mini_buildd.misc.CField(field).fullname] = value

    def fields(self):
        return {mini_buildd.misc.Field(field): value for field, value in self.items()}

    def to_event_json(self):
        return {
            **{
                "timecode": self.timecode,
                "key": self.key,
                "bkey": self.bkey,
                "changes": os.path.join(self.bkey, self.file_name) if self.file_name else None,
            },
            **{field.snake_name: value for field, value in self.fields().items() if field.is_cfield or field.snake_name in ["architecture", "maintainer", "changed_by"]},
        }

    def save_to(self, path, tar=None):
        file_path = path.join(self.dfn.changes(self["Architecture"], self.FILE_APX))
        if tar is not None:
            tar_path = file_path + ".tar"
            tar.save_as(tar_path)
            self.add_file(tar_path)
        self.save_as(file_path, mini_buildd.get_daemon().model.mbd_gnupg())

    def move_to(self, path):
        LOG.info(f"Moving changes: '{self.file_path}'->'{path.full}'")
        path.create()
        for fd in [{"name": self.file_name}] + self.get_files():
            f = fd["name"]
            f_abs = os.path.join(os.path.dirname(self.file_path), f)
            LOG.debug(f"Moving '{f}' to '{path.full}'")
            os.rename(f_abs, os.path.join(path.full, f))


class Upload(Changes):
    class Options():
        """
        Uploader options in changes.

        >>> mini_buildd.config.ROUTES = mini_buildd.config.Routes("./test-data/home")   # We need ROUTES setup

        >>> f"{Upload.Options(Base('test-data/changes.options'))}"
        "auto-ports=['jessie-test-unstable', 'squeeze-test-snasphot'], autopkgtest-mode=errfail, internal-apt-priority=543, lintian-mode=errfail, piuparts-mode=errfail"

        >>> f"{Upload.Options(Base('test-data/changes.options_1.0'))}"
        "auto-ports=['jessie-test-unstable', 'squeeze-test-snasphot'], internal-apt-priority=543, lintian-mode=errfail, lintian-mode[i386]=disabled"

        >>> f"{Upload.Options(Base('test-data/changes.options_0.8'))}"
        "auto-ports=['jessie-test-unstable', 'squeeze-test-snasphot'], lintian-mode=ignore"
        """

        class Bool():
            _TRUE = ["true", "1"]
            _FALSE = ["false", "0"]
            _VALID = _TRUE + _FALSE

            def __init__(self, raw_value):
                if raw_value.lower() not in self._VALID:
                    raise Exception(f"Bool value must be one of {','.join(self._VALID)}")
                self.value = raw_value.lower() in self._TRUE

        class Int():
            def __init__(self, raw_value):
                self.value = int(raw_value)

        class Str():
            def __init__(self, raw_value):
                self.value = str(raw_value)

        class CSV():
            def __init__(self, raw_value):
                self.value = raw_value.split(",")

        KEYWORD = "MINI_BUILDD_OPTION"

        _OPTIONS_DEPRECATED = {
            "ignore-lintian": Bool,
            "run-lintian": Bool,
        }

        _OPTIONS = {**{
            "lintian-mode": Str,
            "piuparts-mode": Str,
            "autopkgtest-mode": Str,
            "internal-apt-priority": Int,
            "auto-ports": CSV,
            "deb-build-options": Str,
            "deb-build-profiles": Str,
        }, **_OPTIONS_DEPRECATED}

        @classmethod
        def usage(cls):
            return ("(lintian|piuparts|autopkgtest)-mode[<arch>]=<mode>\n"
                    f"  {mini_buildd.dist.SbuildCheck.usage()}\n"
                    "internal-apt-priority[<arch>]=<prio>\n"
                    "  Apt prio for our repo packages when building (use 500 to always prefer).\n"
                    "auto-ports=<dist>,...\n"
                    "  List of distributions (comma-separated) to automatically run ports for after successful install.\n"
                    "deb-build-options[<arch>]=<option> <option>...\n"
                    "  Contents of DEB_BUILD_OPTIONS environment for building (see https://www.debian.org/doc/debian-policy/ch-source.html#debian-rules-and-deb-build-options).\n"
                    "deb-build-profiles[<arch>]=<profile> <profile>...\n"
                    "  Contents of DEB_BUILD_PROFILES environment for building (see https://wiki.debian.org/BuildProfileSpec).\n"
                    )

        @classmethod
        def _get_top_changes(cls, upload_changes):
            """
            Filter only the first block from the changes (changelog) entry.

            Upload changes may include multiple version blocks from
            the changelog (internal porting does it, for example),
            but we must only consider values from the top one.
            """
            result = ""
            header_found = False
            for line in upload_changes.get("Changes", "").splitlines(True):
                if re.match(r"^ [a-z0-9]+", line):
                    if header_found:
                        break
                    header_found = True
                result += line
            return result

        def _parse_ancient(self):
            """Compat parse support for ancient 'magic' options."""
            def warning(ancient, option):
                LOG.warning(f"Ancient user option \"{ancient}\" found. Please use option \"{option}\" instead (see user manual).")

            ancient_auto_backports = re.search(r"\*\s*MINI_BUILDD:\s*AUTO_BACKPORTS:\s*([^*.\[\]]+)", self._top_changes)
            if ancient_auto_backports:
                warning("AUTO_BACKPORTS", "auto-ports")
                self._set("auto-ports", ancient_auto_backports.group(1))

            ancient_backport_mode = re.search(r"\*\s*MINI_BUILDD:\s*BACKPORT_MODE", self._top_changes)
            if ancient_backport_mode:
                warning("BACKPORT_MODE", "lintian-mode")
                self._set("lintian-mode", "ignore")

        def __init__(self, upload_changes):
            self._top_changes = self._get_top_changes(upload_changes)
            self._options = {}
            matches = re.findall(fr"\*\s*{self.KEYWORD}:\s*([^*.]+)=([^*.]+)", self._top_changes)
            for m in matches:
                self._set(m[0], m[1])

            self._parse_ancient()

        def __str__(self):
            return ", ".join(f"{key}={value}" for key, value in sorted(self._options.items()))

        def _set(self, key, raw_value):
            key_part = key.partition("[")
            base_key = key_part[0]
            value = re.sub(r"\s+", "", raw_value)

            # Validity check for key
            if base_key not in list(self._OPTIONS):
                raise mini_buildd.HTTPBadRequest(f"Unknown upload option: {key}")

            # Duplicity check
            if key in list(self._options):
                raise mini_buildd.HTTPBadRequest(f"Duplicate upload option: {key}")

            # Value conversion check
            converted_value = None
            try:
                converted_value = self._OPTIONS[base_key](value)
            except BaseException as e:
                raise mini_buildd.HTTPBadRequest(f"Invalid upload option value: {key}=\"{value}\"") from e

            # Handle deprecated options
            if base_key in list(self._OPTIONS_DEPRECATED):
                oldkey = key
                oldvalue = converted_value.value
                alt = key_part[1] + key_part[2]
                if base_key == "ignore-lintian":
                    key = f"lintian-mode{alt}"
                    converted_value.value = "ignore" if oldvalue else "errfail"
                elif base_key == "run-lintian":
                    key = f"lintian-mode{alt}"
                    converted_value.value = "errfail" if oldvalue else "disabled"
                LOG.warning(f"User Option: Converted DEPRECATED {oldkey}={oldvalue} to {key}={converted_value.value}")

            self._options[key] = converted_value.value

            LOG.debug(f"Upload option set: {key}=\"{value}\"")

        def get(self, key, alt=None, default=None):
            """Get first existing option value in this order: key[a], key, default."""
            # Validity check for key
            if key not in list(self._OPTIONS):
                raise mini_buildd.HTTPInternal(f"Unknown key used for get(): {key}")

            if alt:
                m_key = f"{key}[{alt}]"
                if m_key in self._options:
                    return self._options.get(m_key, default)
            return self._options.get(key, default)

    MANDATORY = {**Changes.MANDATORY, **{"Changed-By": "Maybe the changelog entry was not finalised?"}}

    def __init__(self, initial_value, **kwargs):
        super().__init__(initial_value, **kwargs)
        self.options = self.Options(self)
        # (Optionally map and) check distribution string integrity
        self.dist = mini_buildd.dist.Dist(mini_buildd.models.repository.map_distribution(self["Distribution"]))

    def check_mode(self, checker, alt, fallback_mode_int):
        """Get check mode from upload options, with fallback."""
        options_mode_str = self.options.get(f"{checker}-mode", alt=alt)
        fallback_mode_str = mini_buildd.dist.SbuildCheck.Mode(fallback_mode_int).name
        return mini_buildd.dist.SbuildCheck(checker, options_mode_str if options_mode_str is not None else fallback_mode_str)

    def request_builds(self, repository, distribution, suite_option):
        """
        Build buildrequest files for all architectures.
        """
        # Extra check on all DSC/source package files
        # - Check md5 against possible pool files.
        # - Add missing from pool (i.e., orig.tar.gz).
        # - make sure all files from dsc are actually available
        files_from_pool = []
        with mini_buildd.fopen(self.dsc_file_path()) as dsc_file:
            dsc = debian.deb822.Dsc(dsc_file)

        for f in dsc["Files"]:
            in_changes = f["name"] in self.get_files(key="name")
            from_pool = False
            for p in glob.glob(os.path.join(repository.mbd_path, "pool", "*", "*", self["Source"], f["name"])):
                if f["md5sum"] == mini_buildd.misc.Hash(p).md5():
                    if not in_changes:
                        files_from_pool.append(p)
                        from_pool = True
                        LOG.info(f"Buildrequest: File added from pool: {p}")
                else:
                    raise mini_buildd.HTTPBadRequest(f"MD5 mismatch in uploaded dsc vs. pool: {f['name']}")

            # Check that this file is available
            if not in_changes and not from_pool:
                raise mini_buildd.HTTPBadRequest(f"Missing file '{f['name']}' neither in upload, nor in pool (use '-sa' for uploads with new upstream)")

        buildrequest_dict = {}
        for ao in distribution.architectureoption_set.all():
            path = self.events_path.new_sub([ao.architecture.name], create=True)   # Usually: <source>/<version>/<timecode>/source/<arch>/

            buildrequest = Buildrequest({
                "Distribution": self.dist.get(),
                "Source": self["Source"],
                "Version": self["Version"],
                "Architecture": ao.architecture.name,
                mini_buildd.misc.CField("Timecode").fullname: self.timecode,
            })

            # Generate config files
            config_path = path.new_sub([mini_buildd.sbuild.CONFIG_DIR], create=True)
            config_files = {
                mini_buildd.sbuild.CONFIG_APT_SOURCES_LIST: repository.mbd_get_apt_build_sources_list(distribution, suite_option).get(),
                mini_buildd.sbuild.CONFIG_APT_PREFERENCES: repository.mbd_get_apt_build_preferences(distribution, suite_option, self.options.get("internal-apt-priority")),
                mini_buildd.sbuild.CONFIG_APT_KEYS: repository.mbd_get_apt_keys(distribution),
                mini_buildd.sbuild.CONFIG_SSL_CERT: mini_buildd.http_endpoint().get_certificate(),
                mini_buildd.sbuild.CONFIG_CHROOT_SETUP_SCRIPT: distribution.chroot_setup_script,
                mini_buildd.sbuild.CONFIG_SBUILDRC_SNIPPET: distribution.sbuildrc_snippet,
            }
            for file_name, content in config_files.items():
                with mini_buildd.fopen(config_path.join(file_name), "w") as f:
                    f.write(content)

            # Generate tar
            tar = mini_buildd.files.Tar()
            tar.add([config_path.join(file_name) for file_name in config_files], arcdir=mini_buildd.sbuild.CONFIG_DIR)
            tar.add([self.dsc_file_path()])
            tar.add([os.path.join(os.path.dirname(self.dsc_file_path()), f["name"]) for f in dsc.get("Files", [])])
            tar.add(files_from_pool)

            buildrequest.cset("Upload-To", mini_buildd.get_daemon().model.mbd_get_ftp_endpoint().geturl())
            buildrequest.cset("Packager", mini_buildd.http_endpoint().geturl())
            if ao.build_architecture_all:
                buildrequest.cset("Arch-All", "Yes")
            buildrequest.cset("Build-Dep-Resolver", distribution.get_build_dep_resolver_display())
            buildrequest.cset("Apt-Allow-Unauthenticated", "1" if distribution.apt_allow_unauthenticated else "0")

            for blocks_option in mini_buildd.sbuild.CONFIG_BLOCKS.extra_options() + mini_buildd.sbuild.SETUP_BLOCKS.extra_options():
                buildrequest.cset(blocks_option, distribution.mbd_get_extra_option(blocks_option, ""))

            buildrequest.cset("Deb-Build-Options", self.options.get("deb-build-options", ao.architecture.name, distribution.mbd_get_extra_option("Deb-Build-Options", "")))
            buildrequest.cset("Deb-Build-Profiles", self.options.get("deb-build-profiles", ao.architecture.name, distribution.mbd_get_extra_option("Deb-Build-Profiles", "")))

            buildrequest.cset("Lintian-Mode", self.check_mode("lintian", ao.architecture.name, distribution.lintian_mode).mode.name)
            buildrequest.cset("Lintian-Extra-Options", distribution.lintian_extra_options)
            buildrequest.cset("Autopkgtest-Mode", self.check_mode("autopkgtest", ao.architecture.name, int(distribution.mbd_get_extra_option("Autopkgtest-Mode", "0"))).mode.name)
            buildrequest.cset("Piuparts-Mode", self.check_mode("piuparts", ao.architecture.name, distribution.piuparts_mode).mode.name)

            buildrequest.cset("Buildlog", os.path.join(buildrequest.bkey, self.dfn.buildlog(ao.architecture.name)))

            builders = mini_buildd.mdls().gnupg.Builders(check=True).get(self.dist.codename, ao.architecture.name)
            for _load, builder in sorted(builders.items()):
                try:
                    buildrequest.cset("Builder", builder["url"])
                    buildrequest.save_to(self.events_path.new_sub([ao.architecture.name], create=True), tar)
                    buildrequest.upload(mini_buildd.net.ClientEndpoint(builder["incoming_url"]))
                    buildrequest_dict[ao.architecture.name] = buildrequest
                    break
                except BaseException as e:
                    mini_buildd.log_exception(LOG, f"Uploading to '{builder['incoming_url']}' failed", e)
            if ao.architecture.name not in buildrequest_dict:
                raise mini_buildd.HTTPUnavailable(f"No working builder found for {ao.architecture.name}/{self.dist.codename}")
        return buildrequest_dict


class Buildrequest(Changes):
    FILE_APX = "mini-buildd-buildrequest"

    def __init__(self, initial_value, **kwargs):
        super().__init__(initial_value, **kwargs)
        self.dist = mini_buildd.dist.Dist(self["Distribution"])

    def check_mode(self, checker):
        """Get check mode from checker (lintian, piuparts, autopkgtest)."""
        return mini_buildd.dist.SbuildCheck(checker, self.cget(f"{checker.capitalize()}-Mode", mini_buildd.dist.SbuildCheck.Mode.DISABLED.name))

    def check_extra_options(self, checker):
        """Get check mode from checker (lintian, piuparts, autopkgtest)."""
        return self.cget(f"{checker.capitalize()}-Extra-Options", "")

    def gen_buildresult(self):
        bres_path = self.dfn.changes(self["Architecture"], mini_buildd.changes.Buildresult.FILE_APX)
        if os.path.exists(bres_path):
            return Buildresult(bres_path)
        return Buildresult({k: self[k] for k in ["Distribution", "Source", "Version", "Architecture"] + [mini_buildd.misc.CField(cfield).fullname for cfield in ["Timecode", "Builder", "Buildlog"]]})

    def schroot_name(self):
        """
        Schroot name w/o namespace (see ``schroot --list``).

        Must produce same as :func:`~mini_buildd.models.chroot.Chroot.mbd_schroot_name`.
        """
        return f"mini-buildd-{self.dist.codename}-{self['Architecture']}"


class Buildresult(Changes):
    FILE_APX = "mini-buildd-buildresult"

    def needs_build(self):
        return self.file_name is None

    def needs_upload(self):
        upload_path = self.builds_path.join(self.dfn.upload(self["Architecture"]))
        return not os.path.exists(upload_path)

    def success(self, upload, distribution, ignore_checks):
        arch = self["Architecture"]
        status = self.cget("Sbuild-Status")
        lintian = self.cget("Sbuild-Lintian")
        piuparts = self.cget("Sbuild-Piuparts")
        autopkgtest = self.cget("Sbuild-Autopkgtest")

        def check(arch, status, checker, mode):
            """
            Check if arch is allowed to succeed based on check results.

            Succeeds if
            - the actual check run has no errors or warnings
            - the distribution is configured to ignore check results
            - upload says to ignore check results
            - suite is experimental (we deliberately ignore check in that case)
            - warnings are present, but check mode is not warnfail
            """
            check_mode = upload.check_mode(checker, arch, mode)
            return check_mode.check(status, ignore=ignore_checks)

        def check_all():
            return \
                check(arch, lintian, "lintian", distribution.lintian_mode) and \
                check(arch, piuparts, "piuparts", distribution.piuparts_mode) and \
                check(arch, autopkgtest, "autopkgtest", int(distribution.mbd_get_extra_option("Autopkgtest-Mode", "0")))

        return check_all() if status == "successful" else status == "skipped"


def select(file_path):
    if Buildresult.match(file_path):
        return Buildresult(file_path)
    if Buildrequest.match(file_path):
        return Buildrequest(file_path)
    return Upload(file_path)
