# Source code of the Ubuntu-it website
# Copyright (C) 2016  Pietro Albini <pietroalbini@ubuntu.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; witout even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import hashlib
import json
import toml
import os
import random
import collections

import flask
import itsdangerous
import requests

from . import cache
from . import launchpad

CONFIG_FILE = "/data/downloads.toml"
CACHE_FILE = "download-cache.json"
CACHE_FILE_VERSION = 1


class Downloads:
    def __init__(self, data_path):
        # Load the configuration
        path = os.path.dirname(os.path.abspath(__file__)) + CONFIG_FILE
        self.config = toml.load(
            path,  # _dict=collections.OrderedDict,
        )

        self._strip_non_lts_releases()

        # Save the hash of the configuration
        with open(path, "rb") as raw:
            self._config_hash = "sha1=%s" % hashlib.sha1(raw.read()).hexdigest()
        self._cache_file = os.path.join(data_path, CACHE_FILE)

    @property
    def _cache(self):  # Cacheception
        """Get the cache from the data directory"""
        if not hasattr(self, "_cache_cache"):  # Naming things is hard
            self._cache_cache = self._cache_content()
        return self._cache_cache

    @property
    def mirrors(self):
        """Get a list of CD mirrors needed by the website"""
        if not hasattr(self, "_mirrors"):
            if self._cache is not None:
                self._mirrors = self._cache["mirrors"]
            else:
                self._mirrors = {}
                for distro in self.config["mirrors"]["for"]:
                    found_mirrors = list(
                        sorted(
                            launchpad.get_cdimage_mirrors(
                                distro, self.config["mirrors"]["country"]
                            )
                        )
                    )
                    if found_mirrors:
                        self._mirrors[distro] = found_mirrors
                if not self._mirrors:
                    raise ValueError(
                        "No available mirrors found for %(for)s in %(country)s"
                        % self.config["mirrors"]
                    )
        return self._mirrors

    @property
    def md5sums(self):
        """Get a list of all the MD5SUMS"""
        if not hasattr(self, "_md5sums"):
            if self._cache is not None:
                self._md5sums = self._cache["md5sums"]
            else:
                self._md5sums = self._fetch_md5sums()
        return self._md5sums

    def _strip_non_lts_releases(self):
        """Process the lts-only distro configuration"""
        for config in self.config["distros"].values():
            if not config["lts-only"]:
                continue

            for release in config["releases"][:]:
                if not self.config["releases"][release]["lts"]:
                    config["releases"].remove(release)

    def _fetch_md5sums(self):
        """Fetch all the needed MD5SUMS"""
        result = {}
        files_content = {}

        for distro, config in self.config["distros"].items():
            for release in config["releases"]:
                if config["lts-only"] and not self.config["releases"][release]["lts"]:
                    continue

                for arch in config["archs"].keys():
                    url = self.url_for(distro, release, arch, use_random=False)
                    path, file = url.rsplit("/", 1)

                    # Fetch the file from the remote if it wasn't done already
                    if path not in files_content:
                        md5s = {}

                        response = requests.get("%s/SHA256SUMS" % path)
                        if response.status_code == 404:
                            raise RuntimeError(
                                "Missing {} {} on {}".format(distro, release, url),
                            )

                        for line in response.text.split("\n"):
                            if line.strip() == "":
                                continue

                            hash, name = line.split(" ", 1)
                            if name.startswith("*"):
                                name = name[1:]
                            md5s[name] = hash
                        files_content[path] = md5s

                    # Add the MD5 to the result if it was found
                    if file in files_content[path]:
                        key = "%s:%s:%s" % (distro, release, arch)
                        result[key] = files_content[path][file]
                    else:
                        print(files_content[path])
                        raise RuntimeError("Missing MD5 for {}".format(file))

        return result

    def _cache_content(self):
        """Get the content of the cache in the data directory"""
        # The cache is returned if the file exists, the download configuration
        # didn't change, the file is valid JSON and the version is correct
        if not os.path.exists(self._cache_file):
            return None
        try:
            with open(self._cache_file) as f:
                content = json.load(
                    f,
                    object_pairs_hook=collections.OrderedDict,
                )
        except ValueError:
            return None
        if content["config-hash"] != self._config_hash:
            return None
        if content["version"] != CACHE_FILE_VERSION:
            return None
        return content["content"]

    def store_cache_file(self):
        """Store the cache file in the data directory"""
        # Store only if the cache isn't available
        if self._cache is None:
            with open(self._cache_file, "w") as f:
                json.dump(
                    {
                        "config-hash": self._config_hash,
                        "version": CACHE_FILE_VERSION,
                        "content": {
                            "mirrors": self.mirrors,
                            "md5sums": self.md5sums,
                        },
                    },
                    f,
                    separators=(",", ":"),
                )

            # Invalidate the cache in this instance
            delattr(self, "_cache_cache")

    def url_for(self, distro, release, arch, torrent=False, use_random=True):
        """Get the URL of a download file"""
        # Build the dict of keys to replace
        replaces = {
            "distro": distro,
            "release": release,
            "arch": arch,
            "version": self.config["releases"][release]["version"],
            "codename": self.config["releases"][release]["codename"],
        }

        # Add mirrors to the replaces key
        for name, choices in self.mirrors.items():
            if use_random:
                replaces["mirror:%s" % name] = random.choice(choices)
            else:
                replaces["mirror:%s" % name] = choices[0]

        # Get the right source
        source_name = self.config["distros"][distro]["archs"][arch]
        source = self.config["sources"][source_name]

        # Download URLs are different for torrent and HTTP
        if torrent:
            url = source["torrent"]
        else:
            url = source["http"]

        for key, value in replaces.items():
            url = url.replace("{%s}" % key, value)

        return url

    def prepare_blueprint(self, app):
        """Prepare a blueprint with the download pages"""
        signer = itsdangerous.URLSafeSerializer(app.secret_key)

        bp = flask.Blueprint("download", __name__)

        @bp.route("/")
        @cache.enable
        def index():
            return flask.render_template(
                "download/index.html",
                distros=self.config["distros"],
            )

        @bp.route("/<distro>")
        @cache.enable
        def landing(distro):
            if distro not in self.config["distros"]:
                flask.abort(404)

            return flask.render_template(
                "download/landing.html",
                distro_name=distro,
                distro=self.config["distros"][distro],
                releases=self.config["releases"],
                archs=self.config["archs"],
            )

        @bp.route("/+redirect", methods=["POST"])
        def redirect():
            # Return a Bad Request if not all the fields are present
            try:
                distro = flask.request.form["distro"]
                release = flask.request.form["release"]
                arch = flask.request.form["arch"]
            except KeyError:
                flask.abort(400)

            # Validate the input
            if distro not in self.config["distros"]:
                flask.abort(400)
            if release not in self.config["distros"][distro]["releases"]:
                flask.abort(400)
            if arch not in self.config["distros"][distro]["archs"]:
                flask.abort(400)

            torrent = "torrent" in flask.request.form

            payload = signer.dumps(
                {
                    "url": self.url_for(distro, release, arch, torrent),
                    "md5": self.md5sums["%s:%s:%s" % (distro, release, arch)],
                    "name": "%s %s%s"
                            % (
                                self.config["distros"][distro]["name"],
                                self.config["releases"][release]["version"],
                                " LTS" if self.config["releases"][release]["lts"] else "",
                            ),
                    "theme": distro,
                }
            )
            return flask.redirect(flask.url_for(".thanks", payload=payload))

        @bp.route("/grazie/<payload>")
        def thanks(payload):
            try:
                content = signer.loads(payload)
            except itsdangerous.BadSignature:
                return flask.redirect(flask.url_for("pages.download_index"))

            return flask.render_template("download/thanks.html", **content)

        return bp

    def generate_navbar(self):
        """Generate the sub-navbar for /download"""
        result = []
        for name, distro in self.config["distros"].items():
            result.append(
                {
                    "name": distro["name"],
                    "endpoint": "download.landing",
                    "endpoint-args": {"distro": name},
                }
            )
        return result
