#  pylint: disable=missing-module-docstring
#
# Copyright (c) 2016--2017 Red Hat, Inc.
#
# This software is licensed to you under the GNU General Public License,
# version 2 (GPLv2). There is NO WARRANTY for this software, express or
# implied, including the implied warranties of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2
# along with this software; if not, see
# http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt.
#
# Red Hat trademarks are not licensed under GPLv2. No permission is
# granted to use or replicate Red Hat trademarks that are incorporated
# in this software or its documentation.
#

import sys
import os.path
from shutil import rmtree
from shutil import copyfile
import solv
import time
import re
import fnmatch
import requests
import logging
from functools import cmp_to_key

from lzreposync.deb_metadata_parser import parse_deb_packages_metadata
from uyuni.common import fileutils
from spacewalk.common.suseLib import get_proxy
from spacewalk.common.rhnConfig import cfg_component
from spacewalk.satellite_tools.download import get_proxies
from spacewalk.satellite_tools.repo_plugins import ContentPackage, CACHE_DIR
from spacewalk.satellite_tools.syncLib import log2
from spacewalk.server import rhnSQL
from spacewalk.common import repo

try:
    #  python 2
    from urllib import unquote, quote
    import urlparse
except ImportError:
    #  python3
    import urllib.parse as urlparse  # pylint: disable=F0401,E0611
    from urllib.parse import unquote, quote

RETRIES = 10
RETRY_DELAY = 1
FORMAT_PRIORITY = [".xz", ".gz", ""]
log = logging.getLogger(__name__)


# pylint: disable-next=missing-class-docstring
class DebPackage:
    def __init__(self, deb_repo=None):
        self.name = None
        self.epoch = None
        self.version = None
        self.release = None
        self.arch = None
        self.relativepath = None
        self.checksum_type = None
        self.checksum = None
        self.solvable = None
        if deb_repo is not None and deb_repo.solv_repo is not None:
            self.solvable = deb_repo.solv_repo.add_solvable()

    def __getitem__(self, key):
        return getattr(self, key)

    def __setitem__(self, key, value):
        setattr(self, key, value)
        if self.solvable is not None:
            if key in ("name", "arch"):
                setattr(self.solvable, key, value)
            if key in ("epoch", "version", "release"):
                self.solvable.evr = self.evr()

    def __getstate__(self):
        state = self.__dict__.copy()
        del state["solvable"]
        return state

    def __setstate__(self, state):
        self.__dict__.update(state)
        self.solvable = None

    def evr(self):
        # The format is: [epoch:]upstream_version[-debian_revision].
        # https://www.debian.org/doc/debian-policy/ch-controlfields.html#version
        evr = ""
        if self.epoch:
            evr = f"{self.epoch}:"
        if self.version:
            evr = f"{evr}{self.version}"
        if self.release:
            evr = f"{evr}-{self.release}"
        return evr

    def evrcmp(self, pkg):
        if self.solvable is None or pkg.solvable is None:
            raise NotImplementedError(
                "Unable to compare versions as libsolv is unavalable"
            )
        return self.solvable.evrcmp(pkg.solvable)

    def nevra(self):
        return f"{self.name}_{self.evr()}_{self.arch}"

    def is_populated(self):
        return all(
            [
                attribute is not None
                for attribute in (
                    self.name,
                    self.epoch,
                    self.version,
                    self.release,
                    self.arch,
                    self.relativepath,
                    self.checksum_type,
                    self.checksum,
                )
            ]
        )


# pylint: disable-next=missing-class-docstring
class DebRepo:
    # url example - http://ftp.debian.org/debian/dists/jessie/main/binary-amd64/
    def __init__(
        self,
        url,
        cache_dir,
        pkg_dir,
        proxy_addr="",
        proxy_user="",
        proxy_pass="",
        gpg_verify=True,
        channel_label=None,
        timeout=None,
    ):
        self.url = url
        parts = url.rsplit("/dists/", 1)
        self.base_url = [parts[0]]
        self.timeout = timeout
        self.url_without_arch = None  # The url without getting to the arch path

        parsed_url = urlparse.urlparse(url)
        query = urlparse.parse_qsl(parsed_url.query)
        new_query = []
        suite = None
        component = None
        arch = None
        for qi in query:
            if qi[0] == "uyuni_suite":
                suite = qi[1]
            elif qi[0] == "uyuni_component":
                component = qi[1]
            elif qi[0] == "uyuni_arch":
                arch = qi[1]
            else:
                new_query.append(qi)
        if suite:
            parsed_url = parsed_url._replace(query=urlparse.urlencode(new_query))
            base_url = urlparse.urlunparse(parsed_url)
            path_list = parsed_url.path.split("/")
            # pylint: disable-next=consider-using-f-string
            log2(0, 0, "Base URL: {}".format(base_url))
            # pylint: disable-next=consider-using-f-string
            log2(0, 0, "Suite: {}".format(suite))
            # pylint: disable-next=consider-using-f-string
            log2(0, 0, "Component: {}".format(component))
            if "/" not in suite:
                path_list.append("dists")
            path_list.extend(suite.split("/"))
            if component:
                path_list.extend(component.split("/"))
            parsed_url_without_arch = parsed_url._replace(path="/".join(path_list))
            self.url_without_arch = urlparse.urlunparse(parsed_url_without_arch)
            if "/" not in suite:
                if arch is None:
                    rhnSQL.initDB()
                    h = rhnSQL.prepare(
                        """
                                       SELECT ca.label AS arch_label
                                       FROM rhnChannel AS c
                                       LEFT JOIN rhnChannelArch AS ca
                                           ON c.channel_arch_id = ca.id
                                       WHERE c.label = :channel_label
                                       """
                    )
                    h.execute(channel_label=channel_label)
                    row = h.fetchone_dict()
                    if row and "arch_label" in row:
                        aspl = row["arch_label"].split("-")
                        if len(aspl) == 3 and aspl[0] == "channel" and aspl[2] == "deb":
                            arch_trans = {
                                "ia32": "i386",
                                "arm": "armhf",
                            }
                            if aspl[1] in arch_trans:
                                arch = arch_trans[aspl[1]]
                            else:
                                arch = aspl[1]
                if arch:
                    # pylint: disable-next=consider-using-f-string
                    log2(0, 0, "Channel architecture: {}".format(arch))
                    # pylint: disable-next=consider-using-f-string
                    path_list.append("binary-{}".format(arch))
            while "" in path_list:
                path_list.remove("")
            parsed_url = parsed_url._replace(path="/".join(path_list))
            self.url = url = urlparse.urlunparse(parsed_url)
            self.base_url = [base_url]

        # Make sure baseurl ends with / and urljoin will work correctly
        if self.base_url[0][-1] != "/":
            self.base_url[0] += "/"
        self.urls = self.base_url
        self.sslclientcert = self.sslclientkey = self.sslcacert = None
        self.proxy = proxy_addr
        self.proxy_username = proxy_user
        self.proxy_password = proxy_pass
        self.gpg_verify = gpg_verify

        self.basecachedir = cache_dir
        # TODO: FIX for some reason this is not working (I might be passing the cache dir in a bad format)
        if not os.path.isdir(self.basecachedir):
            # pylint: disable-next=invalid-name
            with cfg_component(component=None) as CFG:
                fileutils.makedirs(
                    self.basecachedir, user=CFG.httpd_user, group=CFG.httpd_group
                )
        self.includepkgs = []
        self.exclude = []
        self.pkgdir = pkg_dir
        self.http_headers = {}

        self.solv_pool = solv.Pool()
        self.solv_pool.setdisttype(self.solv_pool.DISTTYPE_DEB)
        self.solv_repo = self.solv_pool.add_repo("libsolv")

    def verify(self):
        """
        Verify package index checksum and signature.

        :return:
        """
        dpkg_repo = repo.DpkgRepo(
            self.url, self._get_proxies(), self.gpg_verify, self.timeout
        )
        log.debug("DebRepo.verify() dpkg_repo=%s", dpkg_repo)
        if not dpkg_repo.verify_packages_index():
            raise repo.GeneralRepoException("Package index checksum failure")

    def _get_proxies(self):
        """
        Returns proxies dict for requests with python-requests.
        """
        if self.proxy:
            (_, netloc, _, _, _) = urlparse.urlsplit(self.proxy)
            proxies = {"http": "http://" + netloc, "https": "http://" + netloc}
            if self.proxy_username and self.proxy_password:
                proxies = {
                    "http": "http://"
                    + self.proxy_username
                    + ":"
                    + quote(self.proxy_password, safe="")
                    + "@"
                    + netloc,
                    "https": "http://"
                    + self.proxy_username
                    + ":"
                    + quote(self.proxy_password, safe="")
                    + "@"
                    + netloc,
                }
            return proxies
        else:
            return None

    def _download(self, url):
        if url.startswith("file://"):
            srcpath = unquote(url[len("file://") :])
            if not os.path.exists(srcpath):
                return ""
            filename = self.basecachedir + "/" + os.path.basename(url)
            copyfile(srcpath, filename)
            return filename
        for _ in range(0, RETRIES):
            try:
                data = requests.get(
                    url,
                    proxies=self._get_proxies(),
                    cert=(self.sslclientcert, self.sslclientkey),
                    verify=self.sslcacert,
                    timeout=self.timeout,
                    headers=self.http_headers,
                )
                if not data.ok:
                    return ""
                if not os.path.exists(self.basecachedir):
                    os.makedirs(self.basecachedir)
                filename = os.path.join(
                    self.basecachedir, os.path.basename(urlparse.urlparse(url).path)
                )
                fd = open(filename, "wb")
                try:
                    for chunk in data.iter_content(chunk_size=1024):
                        fd.write(chunk)
                finally:
                    if fd is not None:
                        fd.close()
                return filename
            except requests.exceptions.RequestException as exc:
                print("ERROR: requests.exceptions.RequestException occurred:", exc)
                time.sleep(RETRY_DELAY)

        return ""

    def get_package_list(self):
        decompressed = None
        packages_raw = []
        to_return = []

        for extension in FORMAT_PRIORITY:
            scheme, netloc, path, query, fragid = urlparse.urlsplit(self.url)
            url = urlparse.urlunsplit(
                (
                    scheme,
                    netloc,
                    path
                    + ("/" if not path.endswith("/") else "")
                    + "Packages"
                    + extension,
                    query,
                    fragid,
                )
            )
            filename = self._download(url)
            if filename:
                if query:
                    newfilename = filename.split("?")[0]
                    os.rename(filename, newfilename)
                    filename = newfilename
                decompressed = fileutils.decompress_open(filename)
                break

        if decompressed:
            for pkg in decompressed.read().split("\n\n"):
                packages_raw.append(pkg)
            decompressed.close()
        else:
            print("ERROR: Download of package list failed.")

        # Parse and format package metadata
        for chunk in packages_raw:
            package = DebPackage(self)
            package.epoch = ""
            lines = chunk.split("\n")
            checksums = {}
            for line in lines:
                pair = tuple(p.strip() for p in line.split(" ", 1))
                if pair[0] == "Package:":
                    package.name = pair[1]
                elif pair[0] == "Architecture:":
                    package.arch = pair[1] + "-deb"
                elif pair[0] == "Version:":
                    package["epoch"] = ""
                    version = pair[1]
                    if version.find(":") != -1:
                        package["epoch"], version = version.split(":")
                    if version.find("-") != -1:
                        tmp = version.split("-")
                        package["version"] = "-".join(tmp[:-1])
                        package["release"] = tmp[-1]
                    else:
                        package["version"] = version
                        package["release"] = "X"
                elif pair[0] == "Filename:":
                    package.relativepath = pair[1]
                elif pair[0] == "SHA256:":
                    checksums["sha256"] = pair[1]
                elif pair[0] == "SHA1:":
                    checksums["sha1"] = pair[1]
                elif pair[0] == "MD5sum:":
                    checksums["md5"] = pair[1]

            # Pick best available checksum
            if "sha256" in checksums:
                package.checksum_type = "sha256"
                package.checksum = checksums["sha256"]
            elif "sha1" in checksums:
                package.checksum_type = "sha1"
                package.checksum = checksums["sha1"]
            elif "md5" in checksums:
                package.checksum_type = "md5"
                package.checksum = checksums["md5"]

            if package.is_populated():
                to_return.append(package)
        return to_return

    def get_packages_metadata(self):
        packages_file = self.download_packages_file()
        translation_file = self.download_translation_file()
        if not packages_file:
            print("Error downloading 'Packages' md file. Leaving...")
            return

        base_url = (
            self.base_url[0] if isinstance(self.base_url, list) else self.base_url
        )
        packages = parse_deb_packages_metadata(
            packages_file, translation_file, base_url, self.basecachedir
        )
        yield from packages
        packages_file.close()  # TODO optimize

    def download_packages_file(self):
        """
        Locate and download the 'Packages' (.xz or .gz) metadata file.
        Return the decompressed file
        """
        decompressed = None
        for extension in FORMAT_PRIORITY:
            scheme, netloc, path, query, fragid = urlparse.urlsplit(self.url)

            packages_url = urlparse.urlunsplit(
                (
                    scheme,
                    netloc,
                    path
                    + ("/" if not path.endswith("/") else "")
                    + "Packages"
                    + extension,
                    query,
                    fragid,
                )
            )
            filename = self._download(packages_url)
            if filename:
                if query:
                    newfilename = filename.split("?")[0]
                    os.rename(filename, newfilename)
                    filename = newfilename
                decompressed = fileutils.decompress_open(filename)
            if decompressed:
                return decompressed
            print(f"ERROR: Download of Packages{extension} md file failed.")
        return None

    def download_translation_file(self):
        """
        Locate and download the 'Translation' (.xz or .gz) description file.
        Return the decompressed file
        """
        decompressed = None
        for extension in FORMAT_PRIORITY:
            scheme, netloc, path, query, fragid = urlparse.urlsplit(
                self.url_without_arch
            )
            translation_url = urlparse.urlunsplit(
                (
                    scheme,
                    netloc,
                    path
                    + ("/" if not path.endswith("/") else "")
                    + "/i18n/"
                    + "Translation-en"
                    + extension,
                    query,
                    fragid,
                )
            )
            filename = self._download(translation_url)
            if filename:
                if query:
                    newfilename = filename.split("?")[0]
                    os.rename(filename, newfilename)
                    filename = newfilename
                decompressed = fileutils.decompress_open(filename)
            if decompressed:
                return decompressed
            print(
                f"ERROR: Download of Translation{extension} descriptions file failed."
            )
        return None


# pylint: disable-next=missing-class-docstring
class ContentSource:
    def __init__(
        self,
        url,
        name,
        insecure=False,
        interactive=True,
        yumsrc_conf=None,
        org="1",
        channel_label="",
        no_mirrors=False,
        ca_cert_file=None,
        client_cert_file=None,
        client_key_file=None,
        channel_arch="",
        http_headers=None,
    ):
        # pylint: disable=W0613
        self.url = url
        self.name = name
        if org:
            self.org = org
        else:
            self.org = "NULL"

        # read the proxy configuration in /etc/rhn/rhn.conf
        # pylint: disable-next=invalid-name
        with cfg_component("server.satellite") as CFG:
            self.proxy_addr, self.proxy_user, self.proxy_pass = get_proxy(self.url)
            self.authtoken = None

            # Replace non-valid characters from reponame (only alphanumeric chars allowed)
            self.reponame = "".join([x if x.isalnum() else "_" for x in self.name])
            self.channel_label = channel_label

            # configure network connection
            try:
                # bytes per second
                self.minrate = int(CFG.REPOSYNC_MINRATE)
            except ValueError:
                self.minrate = 1000
            try:
                # seconds
                self.timeout = int(CFG.REPOSYNC_TIMEOUT)
            except ValueError:
                self.timeout = 300

            try:
                # extended reposync nevra filter enable
                # this will filter packages based on full nevra
                # instead of package name only.
                self.nevra_filter = bool(CFG.REPOSYNC_NEVRA_FILTER)
            except (AttributeError, ValueError):
                self.nevra_filter = False

            # SUSE vendor repositories belongs to org = NULL
            # The repository cache root will be "/var/cache/rhn/reposync/REPOSITORY_LABEL/"
            root = os.path.join(CACHE_DIR, str(org or "NULL"), self.reponame)
            self.repo = DebRepo(
                url,
                root,
                os.path.join(CFG.MOUNT_POINT, CFG.PREPENDED_DIR, self.org, "stage"),
                self.proxy_addr,
                self.proxy_user,
                self.proxy_pass,
                gpg_verify=not (insecure),
                channel_label=channel_label,
                timeout=self.timeout,
            )
            self.repo.http_headers = http_headers
            self.repo.verify()

            self.num_packages = 0
            self.num_excluded = 0

            # keep authtokens for mirroring
            # pylint: disable-next=invalid-name,unused-variable
            (_scheme, _netloc, _path, query, _fragid) = urlparse.urlsplit(url)
            if query:
                self.authtoken = query

    def get_md_checksum_type(self):
        pass

    def get_products(self):
        # No products
        return []

    def get_susedata(self):
        # No susedata
        return []

    def get_mediaproducts(self):
        # No mediaproducts data
        return None

    def list_packages(self, filters, latest):
        """list packages"""

        log.debug("ContentSource.list_packages(filters=%s, latest=%s)", filters, latest)
        pkglist = self.repo.get_package_list()
        self.num_packages = len(pkglist)
        if latest:
            latest_pkgs = {}
            for pkg in pkglist:
                ident = f"{pkg.name}.{pkg.arch}"
                if ident not in latest_pkgs or pkg.evrcmp(latest_pkgs[ident]) > 0:
                    latest_pkgs[ident] = pkg
            pkglist = list(latest_pkgs.values())
        pkglist.sort(key=cmp_to_key(self._sort_packages))

        if not filters:
            # if there's no include/exclude filter on command line or in database
            for p in self.repo.includepkgs:
                filters.append(("+", [p]))
            for p in self.repo.exclude:
                filters.append(("-", [p]))

        if filters:
            pkglist = self._filter_packages(
                pkglist, filters, nevra_filter=self.nevra_filter
            )
            self.num_excluded = self.num_packages - len(pkglist)

        to_return = []
        for pack in pkglist:
            new_pack = ContentPackage()
            try:
                new_pack.setNVREA(
                    pack.name, pack.version, pack.release, pack.epoch, pack.arch
                )
            except ValueError as e:
                log.error(
                    "Skipping package %s. Package contains incorrect metadata.\n%s",
                    new_pack,
                    e,
                )
                continue
            new_pack.unique_id = pack
            new_pack.checksum_type = pack.checksum_type
            new_pack.checksum = pack.checksum
            to_return.append(new_pack)
        return to_return

    @staticmethod
    def _sort_packages(pkg1, pkg2):
        """sorts a list of deb package dicts by name"""
        if pkg1.name > pkg2.name:
            return 1
        elif pkg1.name == pkg2.name:
            return 0
        else:
            return -1

    @staticmethod
    def _filter_packages(packages, filters, nevra_filter=False):
        """implement include / exclude logic
        filters are: [ ('+', includelist1), ('-', excludelist1),
                       ('+', includelist2), ... ]
        """
        if filters is None:
            return

        selected = []
        excluded = []
        allmatched_include = []
        allmatched_exclude = []
        if filters[0][0] == "-":
            # first filter is exclude, start with full package list
            # and then exclude from it
            selected = packages
        else:
            excluded = packages

        for filter_item in filters:
            sense, pkg_list = filter_item
            regex = fnmatch.translate(pkg_list[0])
            reobj = re.compile(regex)
            if sense == "+":
                # include
                for excluded_pkg in excluded:
                    if nevra_filter:
                        pkg_name = excluded_pkg.nevra()
                    else:
                        pkg_name = excluded_pkg["name"]
                    if reobj.match(pkg_name):
                        allmatched_include.insert(0, excluded_pkg)
                        selected.insert(0, excluded_pkg)
                for pkg in allmatched_include:
                    if pkg in excluded:
                        excluded.remove(pkg)
            elif sense == "-":
                # exclude
                for selected_pkg in selected:
                    if nevra_filter:
                        pkg_name = selected_pkg.nevra()
                    else:
                        pkg_name = selected_pkg["name"]
                    if reobj.match(pkg_name):
                        allmatched_exclude.insert(0, selected_pkg)
                        excluded.insert(0, selected_pkg)
                for pkg in allmatched_exclude:
                    if pkg in selected:
                        selected.remove(pkg)
                excluded = excluded + allmatched_exclude
            else:
                raise IOError("Filters are malformed")
        return selected

    def clear_cache(self, directory=None):
        if directory is None:
            directory = self.repo.basecachedir
        # remove content in directory
        for item in os.listdir(directory):
            path = os.path.join(directory, item)
            if os.path.isfile(path):
                os.unlink(path)
            elif os.path.isdir(path):
                rmtree(path)

    @staticmethod
    def get_updates():
        # There isn't any update info in the repository
        return "", []

    @staticmethod
    def get_groups():
        pass

    # Get download parameters for threaded downloader
    def set_download_parameters(
        self,
        params,
        relative_path,
        target_file,
        checksum_type=None,
        checksum_value=None,
        bytes_range=None,
    ):
        # Create directories if needed
        target_dir = os.path.dirname(target_file)
        if not os.path.exists(target_dir):
            os.makedirs(target_dir, int("0755", 8))

        params["authtoken"] = self.authtoken
        params["urls"] = self.repo.urls
        params["relative_path"] = relative_path
        params["authtoken"] = self.authtoken
        params["target_file"] = target_file
        params["ssl_ca_cert"] = self.repo.sslcacert
        params["ssl_client_cert"] = self.repo.sslclientcert
        params["ssl_client_key"] = self.repo.sslclientkey
        params["checksum_type"] = checksum_type
        params["checksum"] = checksum_value
        params["bytes_range"] = bytes_range
        params["http_headers"] = tuple(self.repo.http_headers.items())
        params["timeout"] = self.timeout
        params["minrate"] = self.minrate
        params["proxies"] = get_proxies(
            self.repo.proxy, self.repo.proxy_username, self.repo.proxy_password
        )
        # pylint: disable-next=invalid-name
        with cfg_component("server.satellite") as CFG:
            params["urlgrabber_logspec"] = CFG.get("urlgrabber_logspec")

    @staticmethod
    def get_file(path, local_base=None):
        # pylint: disable=W0613
        # Called from import_kickstarts, not working for deb repo
        log2(
            0,
            0,
            # pylint: disable-next=consider-using-f-string
            "Unable to download path %s from deb repo." % path,
            stream=sys.stderr,
        )
