import os
import stat
import glob
import fnmatch
import logging
import datetime
import tarfile
import socket
import ftplib
import urllib.parse
import re
import contextlib

import debian.deb822

import mini_buildd.config
import mini_buildd.misc
import mini_buildd.net

LOG = logging.getLogger(__name__)


class Changes(debian.deb822.Changes):  # pylint: disable=too-many-ancestors
    def _spool_hash_from_file(self):
        return None if not os.path.exists(self._file_path) else mini_buildd.misc.sha1_of_file(self._file_path)

    def __init__(self, file_path):
        self._file_path = file_path
        self._file_name = os.path.basename(file_path)
        self._new = not os.path.exists(file_path)
        # Instance might be produced from a temporary file, so we need to save the hash now.
        self._spool_hash = self._spool_hash_from_file()

        if self._new:
            super().__init__([])
        else:
            with mini_buildd.misc.open_utf8(file_path) as cf:
                super().__init__(cf)

        # Be sure base dir is always available
        os.makedirs(os.path.dirname(file_path), exist_ok=True)

    @classmethod
    def gen_changes_file_name(cls, package, version, arch, apx=""):
        """
        Gen any changes file name.

        Always strip epoch from version, and handle special
        mini-buildd types.

        >>> Changes.gen_changes_file_name("mypkg", "1.2.3-1", "mips")
        'mypkg_1.2.3-1_mips.changes'
        >>> Changes.gen_changes_file_name("mypkg", "7:1.2.3-1", "mips")
        'mypkg_1.2.3-1_mips.changes'
        >>> Changes.gen_changes_file_name("mypkg", "7:1.2.3-1", "mips", apx=Buildrequest.FILE_APX)
        'mypkg_1.2.3-1_mini-buildd-buildrequest_mips.changes'
        >>> Changes.gen_changes_file_name("mypkg", "7:1.2.3-1", "mips", apx=Buildresult.FILE_APX)
        'mypkg_1.2.3-1_mini-buildd-buildresult_mips.changes'
        """
        return f"{package}_{mini_buildd.misc.strip_epoch(version)}{apx}_{arch}.changes"

    def gen_file_name(self, arch, apx):
        return self.gen_changes_file_name(self["Source"], self["Version"], arch, apx)

    @classmethod
    def gen_dsc_file_name(cls, package, version):
        return f"{package}_{mini_buildd.misc.strip_epoch(version)}.dsc"

    @property
    def dsc_name(self):
        return self.gen_dsc_file_name(self["Source"], self["Version"])

    @property
    def dsc_file_name(self):
        return os.path.join(os.path.dirname(self._file_path), self.dsc_name)

    @property
    def file_name(self):
        return self._file_name

    @property
    def file_path(self):
        return self._file_path

    @property
    def buildlog_name(self):
        return f"{self['Source']}_{self['Version']}_{self['Architecture']}.buildlog"

    @property
    def live_buildlog_name(self):
        return f"{self.get_spool_id()}.buildlog"

    def get_live_buildlog_loc(self):
        return f"/mini_buildd/live-buildlogs/{self.live_buildlog_name}"

    def get_live_buildlog_url(self, base_url):
        return urllib.parse.urljoin(base_url, self.get_live_buildlog_loc())

    def get_pkglog_dir(self, installed, relative=True):
        """
        Get package log dir.

        Package log path for this changes file: REPOID/[_failed]/PACKAGE/VERSION/ARCH

        In case the changes is bogus (i.e, cannot produce a
        valid path for us, like a wrong distribution), None is
        returned.
        """
        try:
            import mini_buildd.models.repository  # pylint: disable=redefined-outer-name
            return mini_buildd.misc.PkgLog.get_path(mini_buildd.misc.Distribution(mini_buildd.models.repository.map_incoming_distribution(self["Distribution"])).repository,
                                                    installed,
                                                    self["Source"],
                                                    self["Version"],
                                                    architecture=self["Architecture"],
                                                    relative=relative)
        except BaseException as e:
            mini_buildd.config.log_exception(LOG, f"No package log dir for bogus changes: {self.file_name}", e, logging.DEBUG)

    def get_buildlog_path(self, installed):
        return os.path.join(self.get_pkglog_dir(installed=installed), self.buildlog_name)

    def is_new(self):
        return self._new

    def get_spool_id(self):
        return f"{type(self).__name__}-{self._spool_hash}"

    def get_spool_dir(self):
        return os.path.join(mini_buildd.config.SPOOL_DIR, self.get_spool_id())

    def get_pkg_id(self, with_arch=False, arch_separator=":"):
        pkg_id = f"{self['Source']}_{self['Version']}"
        if with_arch:
            pkg_id += f"{arch_separator}{self['Architecture']}"
        return pkg_id

    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.md5_of_file(file_name),
                              "size": os.path.getsize(file_name),
                              "section": "mini-buildd",
                              "priority": "extra",
                              "name": os.path.basename(file_name)})

    def save(self, gnupg=None):
        """
        Write to file (optionally signed).

        >>> import tempfile
        >>> t = tempfile.NamedTemporaryFile()
        >>> c = Changes(t.name)
        >>> c["key"] = "ASCII value"
        >>> c.save(None)
        >>> c["key"] = "Ünicöde «value»"
        >>> c.save(None)
        """
        try:
            LOG.info(f"Saving changes: {self._file_path}")
            with open(self._file_path, "w+", encoding=mini_buildd.config.CHAR_ENCODING) as f:
                f.write(self.dump())

            LOG.info(f"Signing changes: {self._file_path}")
            if gnupg:
                gnupg.sign(self._file_path)
            self._spool_hash = self._spool_hash_from_file()
        except BaseException:
            # Existence of the file name is used as flag
            if os.path.exists(self._file_path):
                os.remove(self._file_path)
            raise

    def _upload(self, FTP, endpoint, force=False):  # pylint: disable=invalid-name  # noqa (pep8 N802)
        pre_upload_timestamp = datetime.datetime.now(datetime.timezone.utc)

        upload = os.path.splitext(self._file_path)[0] + ".upload"
        if not force and os.path.exists(upload):
            with mini_buildd.misc.open_utf8(upload) as uf:
                LOG.warning(f"FTP: '{upload}' exists (already uploaded to '{uf.read()}')")
        else:
            with FTP() 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.misc.open_utf8(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 --since=

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

    def upload_retry_tls(self, endpoint, force=False):
        try:
            return self._upload(ftplib.FTP, endpoint, force=force)
        except ftplib.error_perm as e:
            LOG.warning(f"Permission error ({e}): Retrying w/ 'ftps' (RFC 4217)...")
            return self._upload(ftplib.FTP_TLS, endpoint, force=force)

    def tar(self, tar_path, add_files=None, exclude_globs=None):
        exclude_globs = exclude_globs if exclude_globs else []

        def exclude(file_name):
            for e in exclude_globs:
                if fnmatch.fnmatch(file_name, e):
                    return True
            return False

        with contextlib.closing(tarfile.open(tar_path, "w")) as tar:
            def tar_add(file_name):
                if exclude(file_name):
                    LOG.info(f"Excluding \"{file_name}\" from tar archive \"{tar_path}\".")
                else:
                    tar.add(file_name, arcname=os.path.basename(file_name))

            tar_add(self._file_path)
            for f in self.get_files():
                tar_add(os.path.join(os.path.dirname(self._file_path), f["name"]))
            if add_files:
                for f in add_files:
                    tar_add(f)

    def untar(self, path):
        tar_file = self._file_path + ".tar"
        if os.path.exists(tar_file):
            with contextlib.closing(tarfile.open(tar_file, "r")) as tar:
                tar.extractall(path=path)
        else:
            LOG.info(f"No tar file (skipping): {tar_file}")

    def move_to_pkglog(self, installed, rejected=False):
        logdir = None if rejected else self.get_pkglog_dir(installed, relative=False)

        if logdir and not os.path.exists(logdir):
            os.makedirs(logdir)

        LOG.info(f"Moving changes to package log: '{self._file_path}'->'{logdir}'")
        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)
            # If not installed, just move all files to log dir.
            # If installed, only save buildlogs and changes.
            if logdir and (not installed or re.match(r"(.*\.buildlog$|.*changes$)", f)):
                LOG.info(f"Moving '{f}' to '{logdir}'")
                os.rename(f_abs, os.path.join(logdir, f))
            else:
                LOG.info(f"Removing '{f}'")
                mini_buildd.misc.skip_if_keep_in_debug(os.remove, f_abs)

    def remove(self):
        LOG.info(f"Removing changes: '{self._file_path}'")
        for fd in [{"name": self._file_name}] + self.get_files():
            f = os.path.join(os.path.dirname(self._file_path), fd["name"])
            LOG.debug(f"Removing: '{fd['name']}'")
            os.remove(f)


class Upload(Changes):  # pylint: disable=too-many-ancestors
    class Options():
        """
        Uploader options in changes.

        >>> f"{Upload('test-data/changes.options').options}"
        "auto-ports=['jessie-test-unstable', 'squeeze-test-snasphot'], ignore-lintian=True, ignore-lintian[i386]=False, internal-apt-priority=543, run-lintian=True, run-lintian[i386]=False"

        >>> f"{Upload('test-data/changes.magic').options}"
        "auto-ports=['jessie-test-unstable', 'squeeze-test-snasphot'], ignore-lintian=True"
        """

        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 CSV():
            def __init__(self, raw_value):
                self.value = raw_value.split(",")

        KEYWORD = "MINI_BUILDD_OPTION"
        _OPTIONS = {"ignore-lintian": Bool,
                    "run-lintian": Bool,
                    "internal-apt-priority": Int,
                    "auto-ports": CSV}

        @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 _compat_parse_magic(self):
            """Compat parse support for old style "magic" options."""
            def warning(magic, option):
                LOG.warning(f"Deprecated \"magic\" option \"{magic}\" found. Please use new-style option \"{option}\" instead (see user manual).")

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

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

        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._compat_parse_magic()

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

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

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

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

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

            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.keys()):
                raise Exception(f"Internal error: Upload Options: 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)

    def __init__(self, file_path):
        super().__init__(file_path)
        self._options = None

    def __str__(self):
        return f"User upload: {self.get_pkg_id()}"

    @property
    def options(self):
        """note:: We can't parse this in constructor currently: Upload option error handling won't work properly, exceptions triggered too early in packager.py."""
        if not self._options:
            self._options = self.Options(self)
        return self._options

    def gen_buildrequests(self, daemon, repository, dist, suite_option):
        """
        Build buildrequest files for all architectures.

        .. todo:: **IDEA**: gen_buildrequests(): Instead of tar'ing ourselves (uploaded changes)
                  with exceptions (.deb, .buildinfo, .changes), add the *.dsc* and its files only!
        """
        # 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 open(self.dsc_file_name) 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_get_path(), "pool", "*", "*", self["Source"], f["name"])):
                if f["md5sum"] == mini_buildd.misc.md5_of_file(p):
                    if not in_changes:
                        files_from_pool.append(p)
                        from_pool = True
                        LOG.info(f"Buildrequest: File added from pool: {p}")
                else:
                    raise Exception(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 Exception(f"Missing file '{f['name']}' neither in upload, nor in pool (use '-sa' for uploads with new upstream)")

        breq_dict = {}
        for ao in dist.architectureoption_set.all():
            path = os.path.join(self.get_spool_dir(), ao.architecture.name)

            breq = Buildrequest(os.path.join(path, self.gen_file_name(ao.architecture.name, Buildrequest.FILE_APX)))

            if breq.is_new():
                import mini_buildd.models.repository  # pylint: disable=redefined-outer-name
                distribution = mini_buildd.misc.Distribution(mini_buildd.models.repository.map_incoming_distribution(self["Distribution"]))
                breq["Distribution"] = distribution.get()
                for v in ["Source", "Version"]:
                    breq[v] = self[v]

                # Generate files
                chroot_setup_script = os.path.join(path, "chroot_setup_script")
                with mini_buildd.misc.open_utf8(os.path.join(path, "apt_sources.list"), "w") as asl, \
                     mini_buildd.misc.open_utf8(os.path.join(path, "apt_preferences"), "w") as ap, \
                     mini_buildd.misc.open_utf8(os.path.join(path, "apt_keys"), "w") as ak, \
                     mini_buildd.misc.open_utf8(os.path.join(path, "ssl_cert"), "w") as ssl_cert, \
                     mini_buildd.misc.open_utf8(chroot_setup_script, "w") as css, \
                     mini_buildd.misc.open_utf8(os.path.join(path, "sbuildrc_snippet"), "w") as src:
                    asl.write(dist.mbd_get_apt_sources_list(repository, suite_option))
                    ap.write(dist.mbd_get_apt_preferences(repository, suite_option, self.options.get("internal-apt-priority")))
                    ak.write(repository.mbd_get_apt_keys(dist))
                    ssl_cert.write(mini_buildd.config.primary_http_endpoint().get_certificate())
                    css.write(mini_buildd.misc.fromdos(dist.chroot_setup_script))  # Note: For some reason (python, django sqlite, browser?) the text field may be in DOS mode.
                    os.chmod(chroot_setup_script, stat.S_IRWXU)
                    src.write(dist.mbd_get_sbuildrc_snippet(ao.architecture.name))

                # Generate tar from original changes
                self.tar(tar_path=breq.file_path + ".tar",
                         add_files=[os.path.join(path, "apt_sources.list"),
                                    os.path.join(path, "apt_preferences"),
                                    os.path.join(path, "apt_keys"),
                                    os.path.join(path, "ssl_cert"),
                                    chroot_setup_script,
                                    os.path.join(path, "sbuildrc_snippet")] + files_from_pool,
                         exclude_globs=["*.deb", "*.changes", "*.buildinfo"])
                breq.add_file(breq.file_path + ".tar")

                breq["Upload-Result-To"] = daemon.mbd_get_ftp_endpoint().geturl()
                breq["Base-Distribution"] = dist.base_source.codename
                breq["Architecture"] = ao.architecture.name
                if ao.build_architecture_all:
                    breq["Arch-All"] = "Yes"
                breq["Build-Dep-Resolver"] = dist.get_build_dep_resolver_display()
                breq["Apt-Allow-Unauthenticated"] = "1" if dist.apt_allow_unauthenticated else "0"
                if dist.lintian_mode != dist.LINTIAN_DISABLED and self.options.get("run-lintian", alt=ao.architecture.name, default=True):
                    # Generate lintian options
                    modeargs = {
                        dist.LINTIAN_DISABLED: "",
                        dist.LINTIAN_RUN_ONLY: "",
                        dist.LINTIAN_FAIL_ON_ERROR: "",
                        dist.LINTIAN_FAIL_ON_WARNING: "--fail-on-warning"}
                    breq["Run-Lintian"] = modeargs[dist.lintian_mode] + " " + dist.lintian_extra_options
                breq["Deb-Build-Options"] = dist.mbd_get_extra_option("Deb-Build-Options", "")

                breq.save(daemon.mbd_gnupg)
            else:
                LOG.info(f"Re-using existing buildrequest: {breq.file_name}")
            breq_dict[ao.architecture.name] = breq

        return breq_dict

    def extra(self, bresults, installed):
        return {"log": os.path.dirname(self.get_pkglog_dir(installed=True)),
                "archs": {bres.get("Architecture"): {"sbuild": bres.get("Sbuild-Status"),
                                                     "lintian": bres.get("Sbuild-Lintian"),
                                                     "buildlog": bres.get_buildlog_path(installed=installed)} for bres in bresults}}


class Buildrequest(Changes):  # pylint: disable=too-many-ancestors
    FILE_APX = "_mini-buildd-buildrequest"
    FILE_REGEX = re.compile(f"^.+{FILE_APX}_[^_]+.changes$")

    def __init__(self, file_path):
        super().__init__(file_path)

        # This is just for stat/display purposes
        self.remote_http_url = None
        self.live_buildlog_url = None

    def __str__(self):
        return f"Buildrequest from '{self.get('Upload-Result-To')}': {self.get_pkg_id(with_arch=True)}"

    def upload_buildrequest(self, local_endpoint):
        import mini_buildd.models.gnupg  # pylint: disable=redefined-outer-name

        arch = self["Architecture"]
        codename = self["Base-Distribution"]

        remotes = {}

        def add_remote(remote):
            status = remote.mbd_get_status()
            status["url"] = remote.mbd_url()  # Not cool: Monkey patching status for url
            if status["running"] and mini_buildd.api.Status.has_chroot(status, codename, arch):
                remotes[status["load"]] = status
                LOG.debug(f"Remote[{status['load']}]={remote}")

        # Always add our own instance as pseudo remote first.
        add_remote(mini_buildd.models.gnupg.Remote(http=local_endpoint.geturl()))

        # Check all active or auto-deactivated remotes.
        for r in mini_buildd.models.gnupg.Remote.mbd_get_active_or_auto_reactivate():
            try:
                mini_buildd.models.gnupg.Remote.Admin.mbd_check(None, r, force=True)
                add_remote(r)
            except BaseException as e:
                mini_buildd.config.log_exception(LOG, "Builder check failed", e, logging.WARNING)

        if not remotes:
            raise Exception(f"No builder found for {codename}/{arch}")

        for _load, remote in sorted(remotes.items()):
            try:
                self.upload(mini_buildd.net.ClientEndpoint(remote["ftp"]))
                self.remote_http_url = remote["url"]
                self.live_buildlog_url = self.get_live_buildlog_url(base_url=self.remote_http_url)
                return
            except BaseException as e:
                mini_buildd.config.log_exception(LOG, f"Uploading to '{remote['ftp']}' failed", e, logging.WARNING)

        raise Exception(f"Buildrequest upload failed for {arch}/{codename}")

    def gen_buildresult(self, path=None):
        if not path:
            path = self.get_spool_dir()

        bres = Buildresult(os.path.join(path, self.gen_file_name(self["Architecture"], Buildresult.FILE_APX)))

        for v in ["Distribution", "Source", "Version", "Architecture"]:
            bres[v] = self[v]

        return bres

    def upload_failed_buildresult(self, gnupg, endpoint, retval, status, exception):
        with contextlib.closing(mini_buildd.misc.TmpDir()) as t:
            bres = self.gen_buildresult(path=t.tmpdir)

            bres["Sbuildretval"] = str(retval)
            bres["Sbuild-Status"] = status
            buildlog = os.path.join(t.tmpdir, self.buildlog_name)
            with mini_buildd.misc.open_utf8(buildlog, "w+") as log_file:
                log_file.write(f"""
Host: {socket.getfqdn()}
Build request failed: {retval} ({status}): {exception}
""")
            bres.add_file(buildlog)
            bres.save(gnupg)
            bres.upload(endpoint)


class Buildresult(Changes):  # pylint: disable=too-many-ancestors
    FILE_APX = "_mini-buildd-buildresult"
    FILE_REGEX = re.compile(f"^.+{FILE_APX}_[^_]+.changes$")

    def __str__(self):
        return f"Buildresult from '{self.get('Built-By')}': {self.get_pkg_id(with_arch=True)}"

    @property
    def bres_stat(self):
        return f"Build={self.get('Sbuild-Status')}, Lintian={self.get('Sbuild-Lintian')}"


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