# -*- mode: python; coding: utf-8 -*-
#
##########################################################################
# pyeole.iso - manage EOLE ISO images
# Copyright © 2018 Pôle de compétences EOLE <eole@ac-dijon.fr>
#
# License CeCILL:
#  * in french: http://www.cecill.info/licences/Licence_CeCILL_V2-fr.html
#  * in english http://www.cecill.info/licences/Licence_CeCILL_V2-en.html
##########################################################################

"""Managing EOLE ISO images

The :class:`IsoEOLE` class provided permits for an EOLE release:

* to list all the point release ISO images available
* to get the latest point release version string
* to download the ISO image and check its checksum

SYNOPSYS
========

    import sys

    from pyeole.iso import IsoEOLE
    from pyeole.iso import IsoEOLEError

    try:
        iso = IsoEOLE(release='2.7.0')
        iso_file = iso.download()
    except IsoEOLEError as error
        print("An error occurred: {error}".format(error=error))
        sys.exit(1)

"""


from __future__ import absolute_import, print_function, unicode_literals

import sys

import os
from os.path import join

import hashlib
import logging
import shutil

from subprocess import Popen, PIPE
from packaging.version import parse as version_parse

try:
    # python3
    from html.parser import HTMLParser
except ImportError:
    # python2
    from HTMLParser import HTMLParser

import requests
from requests.exceptions import RequestException

from pyeole.i18n import i18n

ENCODING = sys.stdout.encoding
if ENCODING is None:
    ENCODING = 'UTF-8'

_ = i18n('pyeole-iso')


class IsoEOLEError(Exception):
    """Base exception class

    """
    pass


class IsoEOLEAccessError(IsoEOLEError):
    """An error occurred during request on the EOLE ISO mirror web page

    """
    pass


class IsoEOLENotFound(IsoEOLEError):
    """No point release directory found on the EOLE iso mirror web page for the release

    """
    pass


class IsoEOLEDownloadError(IsoEOLEError):
    """The ISO could not be downloaded

    """
    pass


class IsoEOLECopyError(IsoEOLEDownloadError):
    """The ISO could not be copied from a source

    """
    pass


class IsoEOLECheckError(IsoEOLEError):
    """Base exception class for verification procedures

    """
    pass


class IsoEOLEGPGError(IsoEOLECheckError):
    """The verification of GPG signature of the SHA256 checksum file failed

    """
    pass


class IsoEOLEChecksumNotFound(IsoEOLECheckError):
    """The SHA256 checksum file and its signature is not downloaded

    """
    pass


class IsoEOLEChecksumError(IsoEOLECheckError):
    """The ISO SHA256 checksum does not match the checksum file

    """
    pass


class IsoEOLE(object):
    """Download and verify EOLE ISO images

    """

    def __init__(self,
                 release,
                 download_directory='/var/lib/eole/iso',
                 http_proxy=None,
                 limit_rate='0',
                 base_url='http://eole.ac-dijon.fr/pub/iso'):
        """Initialise the IsoEOLE object

        :param str release: the EOLE release of the ISO
        :param str download_directory: where to store the downloaded files
        :param str http_proxy: URL of an optional HTTP proxy to use for the download
        :param str limit_rate: limit the download speed to this amout of bytes per second, can use suffix like `k` or `m`
        :param str base_url: base URL where to find EOLE ISO images
        """
        self.release = release
        self.download_directory = download_directory
        self.proxies = {'http': http_proxy, 'https': http_proxy}
        self.limit_rate = limit_rate
        self.base_url = base_url
        self._cache = {}

        self.logger = logging.getLogger(__name__)
        self.logger.addHandler(logging.NullHandler())


    @property
    def name(self):
        """The name of the ISO image file

        """
        if self._cache.get('iso_name', None):
            return self._cache['iso_name']

        msg = _("Build EOLE ISO name for release '{release}'")
        text = msg.format(release=self.release)
        self.logger.debug(text)

        if version_parse(self.release) < version_parse('2.9.0'):
            iso_name_format = 'eole-{point_release}-alternate-amd64.iso'
        else:
            iso_name_format = 'eole-{point_release}-amd64.iso'

        self._cache['iso_name'] = iso_name_format.format(point_release=self.last_point_release)
        return self._cache['iso_name']


    @property
    def iso_url(self):
        """The URL of the ISO image

        """
        if self._cache.get('iso_url', None):
            return self._cache['iso_url']

        msg = _("Build EOLE ISO URL for release '{release}'")
        text = msg.format(release=self.release)
        self.logger.debug(text)

        self._cache['iso_url'] = '{base}/{iso_name}'.format(base=self.release_url,
                                                            iso_name=self.name)
        return self._cache['iso_url']


    @property
    def iso_filename(self):
        """Where the ISO file is stored locally

        """
        if self._cache.get('iso_filename', None):
            return self._cache['iso_filename']

        msg = _("Build EOLE ISO download file name for release '{release}'")
        text = msg.format(release=self.release)
        self.logger.debug(text)

        self._cache['iso_filename'] = join(self.download_directory, self.name)

        return self._cache['iso_filename']


    @property
    def release_url(self):
        """The URL of the EOLE release

        """
        if self._cache.get('release_url', None):
            return self._cache['release_url']

        msg = _("Build EOLE ISO release URL for release '{release}'")
        text = msg.format(release=self.release)
        self.logger.debug(text)

        self._cache['release_url'] = '{base}/{last_point_release}'.format(base=self.version_url,
                                                                          last_point_release=self.last_point_release)
        return self._cache['release_url']


    @property
    def version_url(self):
        """The URL of the EOLE version

        """
        if self._cache.get('version_url', None):
            return self._cache['version_url']

        msg = _("Build EOLE ISO version URL for release '{release}'")
        text = msg.format(release=self.release)
        self.logger.debug(text)

        eole_version_str = 'EOLE-{version}'.format(version=self.version)
        self._cache['version_url'] = join(self.base_url, eole_version_str)

        return self._cache['version_url']


    @property
    def version(self):
        """The EOLE version deduced from the release

        """
        if self._cache.get('version', None):
            return self._cache['version']

        msg = _("Build EOLE version for release '{release}'")
        text = msg.format(release=self.release)
        self.logger.debug(text)

        self._cache['version'] = '.'.join(self.release.split('.')[0:2])
        return self._cache['version']


    @property
    def sha256_filename(self):
        """Where the SHA256 checksum file is stored

        """
        return join(self.download_directory, self.name + '.sha256sum')


    @property
    def sha256_url(self):
        """The URL of the SHA256 checksum file

        """
        return join(self.release_url, 'SHA256SUMS')


    @property
    def sha256_gpg_filename(self):
        """Where the SHA256 checksum signature file is stored

        """
        return join(self.download_directory, self.name + '.sha256sum.gpg')


    @property
    def sha256_gpg_url(self):
        """The URL of the SHA256 checksum signature file

        """
        return join(self.release_url, 'SHA256SUMS.gpg')


    @property
    def last_point_release(self):
        """The last point release version string

        """
        if self._cache.get('last_point_release', None):
            return self._cache['last_point_release']

        msg = _("Build EOLE ISO last point release for release '{release}'")
        text = msg.format(release=self.release)
        self.logger.debug(text)

        point_releases = self.point_releases

        self._cache['last_point_release'] = point_releases[-1]
        return self._cache['last_point_release']


    @property
    def point_releases(self):
        """The list of point release version strings available

        """
        if self._cache.get('point_releases', None):
            return self._cache['point_releases']

        msg = _("Build EOLE ISO point releases list for release '{release}'")
        text = msg.format(release=self.release)
        self.logger.debug(text)

        error_msg = _("Unable to list point releases for release '{release}' from '{url}': {error}")
        try:
            response = self._get(self.version_url)
        except RequestException as error:
            text = error_msg.format(release=self.release,
                                    url=self.version_url,
                                    error=error)
            raise IsoEOLEAccessError(text)

        if not response.ok:
            http_error = '{code} - {reason}'.format(code=response.status_code,
                                                    reason=response.reason)

            text = error_msg.format(release=self.release,
                                    url=self.version_url,
                                    error=http_error)
            raise IsoEOLENotFound(text)

        html_parser = ExtractEOLEVersions(self.release)
        html_parser.feed(response.text)

        if not (html_parser.versions
                or html_parser.alpha_versions
                or html_parser.beta_versions
                or html_parser.rc_versions):
            msg = _("No point release found for release '{release}' from '{url}'")
            text = msg.format(release=self.release, url=self.version_url)
            raise IsoEOLENotFound(text)

        versions_levels = ['versions', 'rc_versions', 'beta_versions', 'alpha_versions']
        for level in versions_levels:
            if not getattr(html_parser, level, None):
                # Not version at this level
                continue

            versions = getattr(html_parser, level)
            self._cache['point_releases'] = sorted(set(versions))
            break

        return self._cache['point_releases']


    def download(self, path=None, silent=True, limit_rate=None):
        """Download the ISO image and verify its checksum and signature

        :param str path: path of a source ISO image or its directory
        :param bool silent: display download progress
        :param str limit_rate: rate limit the download speed
        :return str: path to the downloaded ISO image
        """
        self.logger.info(_("Download image ISO for release {release}").format(release=self.release))

        try:
            self.verify()
            # ISO is downloaded and verified
            return self.iso_filename
        except (IsoEOLECheckError, IsoEOLEDownloadError) as error:
            # Try to download a new copy of everything or resume ISO download
            self.logger.warning(error)
            self.cleanup_checksum()

        if path:
            try:
                self.copy_checksum(path)
            except IsoEOLECopyError as error:
                msg = _("Unable to copy SHA256 checksum file and its signature: {error}")
                text = msg.format(source=path, error=error)
                self.logger.warning(text)

            self.copy_iso(path)
        else:
            self.download_checksum()
            self.download_iso(silent=silent, limit_rate=limit_rate)

        if path and not self.is_checksum_ok():
            # Make an extra try to download from the mirror
            self.cleanup_checksum()
            self.download_checksum()

        self.verify()

        msg = _("Successfull download and verification of EOLE ISO image for release '{release}' at '{filename}' ")
        text = msg.format(release=self.release, filename=self.iso_filename)
        self.logger.info(text)

        return self.iso_filename


    def copy_iso(self, path):
        """Copy the ISO image from a source on the local filesystem

        :param str path: path of the source file to copy
        :return str: path to the copied ISO image
        """
        msg = _("Copy ISO image for release '{release}' from '{source}'")
        text = msg.format(release=self.release, source=path)
        self.logger.info(text)

        return self._copy(path, self.iso_filename)


    def download_iso(self, silent=True, limit_rate=None):
        """Download the ISO image

        :param bool silent: display download progress
        :param str limit_rate: rate limit the download speed
        :return str: path to the downloaded ISO image
        :raises IsoEOLEDownloadError: when the ISO could not be downloaded
        """
        msg = _("Download ISO image from release '{release}' from '{url}'")
        text = msg.format(release=self.release, url=self.iso_url)
        self.logger.info(text)

        self._create_target_directory()

        wcmd = ['wget', '-c', '--progress', 'dot:giga']

        if limit_rate is None:
            limit_rate = self.limit_rate

        if limit_rate != '0':
            wcmd.extend(['--limit-rate', limit_rate])

        # Where to download
        wcmd.extend(['-O', self.iso_filename])

        # What to download
        wcmd.append(self.iso_url)

        if self.proxies['http'] or self.proxies['https']:
            env = {'http_proxy': self.proxies['http'],
                   'https_proxy': self.proxies['https']}
        else:
            env = {}

        code, stdout, stderr = self._exec(cmd=wcmd, env=env, silent=silent)
        if code != 0:
            if stderr:
                msg = _("Unable to download the ISO image '{filename}' from '{url}': {error}")
            else:
                msg = _("Unable to download the ISO image '{filename}' from '{url}'")

            text = msg.format(filename=self.iso_filename,
                              url=self.iso_url,
                              error=stderr)

            raise IsoEOLEDownloadError(text)

        msg = _("Successfull download of EOLE ISO image for release '{release}' at '{filename}'")
        text = msg.format(release=self.release, filename=self.iso_filename)
        self.logger.info(text)

        return self.iso_filename


    def verify(self, silent=True):
        """Verify the checksum of the downloaded ISO file

        Check the signature of the checksum file too.

        :param bool silent: display GPG output
        :raises IsoEOLEGPGError: when the verification of GPG signature of the SHA256 checksum file failed
        :raises IsoEOLEDownloadError: when the ISO image is not downloaded
        :raises IsoEOLEChecksumNotFound: when the SHA256 checksum file and its signature is not downloaded
        :raises IsoEOLEChecksumError: when the ISO SHA256 checksum does not match
        """
        self.logger.info(_("Verify ISO image '{filename}'").format(filename=self.iso_filename))
        code, stdout, stderr = self.verify_checksum_signature()
        if code != 0:
            msg = _("GPG verification of SHA256 checksum file failed: {error}")
            text = msg.format(error=stderr)

            raise IsoEOLEGPGError(text)

        if not silent:
            self.logger.info(stderr)

        if not os.path.isfile(self.iso_filename):
            msg = _("The ISO image file is inexistent: {filename}")
            text = msg.format(filename=self.iso_filename)

            raise IsoEOLEDownloadError(text)

        msg = _("Verify the SHA256 checksum of ISO image '{filename}'")
        text = msg.format(filename=self.iso_filename)
        self.logger.info(text)
        checksum = None
        sha256 = hashlib.sha256()

        with open(self.sha256_filename, 'r') as sha_fh:
            for line in sha_fh:
                sha, filename = line.split()
                if filename != '*{iso_name}'.format(iso_name=self.name):
                    continue

                checksum = sha

            if not checksum:
                msg = _("The SHA256 checksum of ISO image '{iso_name}' is not found in '{sha256_filename}'")
                text = msg.format(iso_name=self.name,
                                  sha256_filename=self.sha256_filename)

                raise IsoEOLEChecksumNotFound(text)

            with open(self.iso_filename, 'rb') as iso_fh:
                while True:
                    block = iso_fh.read(2**10)
                    if not block:
                        break

                    sha256.update(block)

        if checksum != sha256.hexdigest():
            msg = _("SHA256 checksum error of ISO image file '{iso_filename}': expecting '{excepted_sha256}' got '{sha256}'")
            text = msg.format(iso_filename=self.iso_filename,
                              excepted_sha256=checksum,
                              sha256=sha256.hexdigest())

            raise IsoEOLEChecksumError(text)

        msg = _("Successfull verification of SHA256 checksum of EOLE ISO image for release '{release}'")
        text = msg.format(release=self.release)
        self.logger.info(text)

        return True


    def copy_checksum(self, path):
        """Copy the SHA256 checksum file and it signature from a path

        The SHA256 checksum file and its signature can be named:

        -  `SHA256SUMS` and `SHA256SUMS.gpg` as when pre-downloaded from the ISO web mirror
        - `<ISO_NAME>.sha256sum` and `<ISO_NAME>.sha256sum.gpgs` from a previous download

        :param str path: source path of the checksum file or its directory
        """
        self.logger.info(_("Copy SHA256 checksum file and its signature"))

        source = None

        if os.path.isfile(path):
            source = os.path.dirname(path)
        else:
            source = path

        if not os.path.isfile(self.sha256_filename):
            if path:
                # The user can copy the first downloaded directory
                # with the new names
                sha256_source = join(source, os.path.basename(self.sha256_filename))

            if sha256_source and not os.path.isfile(sha256_source):
                # Or the user can copy the files from the ISO mirror
                sha256_source = join(source, os.path.basename(self.sha256_url))

            self._copy(sha256_source, self.sha256_filename)
            msg = _("Successfull copy of SHA256 checksum file at '{filename}'")
            text = msg.format(filename=self.sha256_filename)
            self.logger.info(text)

        if not os.path.isfile(self.sha256_gpg_filename):
            if path:
                # The user can copy the first downloaded directory
                # with the new names
                sha256_gpg_source = join(source, os.path.basename(self.sha256_gpg_filename))

            if sha256_gpg_source and not os.path.isfile(sha256_gpg_source):
                # Or the user can copy the files from the ISO mirror
                sha256_gpg_source = join(source, os.path.basename(self.sha256_gpg_url))

            self._copy(sha256_gpg_source, self.sha256_gpg_filename)
            msg = _("Successfull copy of SHA256 checksum file signature at '{filename}'")
            text = msg.format(filename=self.sha256_gpg_filename)
            self.logger.info(text)


    def download_checksum(self):
        """Download by HTTP the SHA256 checksum file and its signature

        """
        self.logger.info(_("Download SHA256 checksum file and signature"))

        if not os.path.isfile(self.sha256_filename):
            self._download(self.sha256_url, self.sha256_filename)

        if not os.path.isfile(self.sha256_gpg_filename):
            self._download(self.sha256_gpg_url, self.sha256_gpg_filename)


    def is_checksum_ok(self):
        """Simple check on the SHA256 checksum signature

        This utility does not require the caller to handle the stdout
        and stderr of `self.verify_checksum_signature`.

        :param bool silent: display GPG output
        :return bool: if the signature is matching
        """
        code, stdout, stderr = self.verify_checksum_signature()
        return code == 0


    def verify_checksum_signature(self):
        """Verify the checksum signature

        :return tuple: the return code, the standard output and the standard error of the GPG command.
        """
        msg = _("Verify the signature of the SHA256 checksum file '{filename}'")
        text = msg.format(filename=self.sha256_filename)
        self.logger.info(text)

        no_file_msg = _("No such file: '{source}'")
        if not os.path.isfile(self.sha256_filename):
            return 1, None, no_file_msg.format(source=self.sha256_filename)

        if not os.path.isfile(self.sha256_gpg_filename):
            return 1, None, no_file_msg.format(source=self.sha256_gpg_filename)

        code, stdout, stderr = self._check_gpg(self.sha256_gpg_filename, self.sha256_filename)
        return code, stdout, stderr


    def cleanup_checksum(self):
        """Remove the checksum files

        """
        self.logger.info(_("Remove the SHA256 checksum file and its signature"))

        if os.path.isfile(self.sha256_filename):
            os.unlink(self.sha256_filename)

        if os.path.isfile(self.sha256_gpg_filename):
            os.unlink(self.sha256_gpg_filename)


    def cleanup_iso(self):
        """Remove the ISO file

        """
        msg = _("Remove the ISO file '{filename}'")
        text = msg.format(filename=self.iso_filename)
        self.logger.info(text)

        if os.path.isfile(self.iso_filename):
            os.unlink(self.iso_filename)


    def _check_gpg(self, gpg_filename, filename, silent=True):
        """Verify the GPG signature

        :param str gpg_filename: filename of the detatched signature
        :param str filename: filename of the signed file
        :return `bool`: `True` if the verification succeed, `False` otherwise.
        """
        msg = _("Verify the GPG signature of '{filename}' with '{signature}'")
        text = msg.format(filename=filename, signature=gpg_filename)
        self.logger.info(text)

        gpg_cmd = ['gpgv', '-q', '--keyring',
                   '/etc/apt/trusted.gpg.d/eole-archive-keyring.gpg',
                   gpg_filename,
                   filename]

        return self._exec(cmd=gpg_cmd, silent=silent)


    def _exec(self, cmd, env=None, silent=True):
        """Simple wrapper around `subprocess.Popen`

        :param list cmd: command to execute
        :param dict env: environment variable to set
        :param bool silent: display the output of the command
        :returns tuple: the return code, the stdandard output and the standard error
        """
        self.logger.debug(_("Execute command '{command}'").format(command=' '.join(cmd)))

        if silent or sys.stdout.mode == 'wb':
            stdout = PIPE
            stderr = PIPE
        else:
            stdout = None
            stderr = None

        process = Popen(cmd, stdin=PIPE, stdout=stdout, stderr=stderr, env=env)
        stdout_output, stderr_output = process.communicate(None)
        return_code = process.returncode

        if stdout_output:
            stdout_output = stdout_output.decode(ENCODING)
        if stderr_output:
            stderr_output = stderr_output.decode(ENCODING)

        return return_code, stdout_output, stderr_output


    def _get(self, url, *args, **kwargs):
        """Simple wrapper around `requests.get` to pass common options

        """
        self.logger.debug(_("Make HTTP GET request to '{url}'").format(url=url))

        return requests.get(url, *args, proxies=self.proxies, **kwargs)


    def _copy(self, source, destination):
        """Simple wrapper around `shutil.copy` to copy a file

        :param str source: the name of the file to copy
        :param str destination: the destination filename
        :return str: the filename of the copied file
        """
        self.logger.debug(_("Copy '{source}' to '{destination}'").format(source=source,
                                                                         destination=destination))

        self._create_target_directory()

        if os.path.isdir(source):
            # Permit to specify a directory as a source
            source_file = join(source, os.path.basename(destination))
            msg = _("Define source of the copy from the directory '{source}' to '{source_file}'")
            text = msg.format(source=source, source_file=source_file)
            self.logger.debug(text)
            source = source_file

        error_msg = None
        if not os.path.isfile(source):
            error_msg = _("No such file: {source}")
        elif not os.access(source, os.R_OK):
            error_msg = _("Read permission denied on '{source}'")

        if error_msg:
            text = error_msg.format(source=source)
            raise IsoEOLECopyError(text)

        shutil.copy(source, destination)

        return destination


    def _download(self, url, filename, *args, **kwargs):
        """Simple wrapper around `requests.get` to download a file

        :param str filename: local filename of the file to download
        :param str url: the URL of the file to download
        :return str: the filename of the downloaded file
        """
        self.logger.debug(_("Download '{url}' to '{filename}'").format(url=url,
                                                                       filename=filename))

        self._create_target_directory()

        try:
            response = self._get(url, *args, stream=True, **kwargs)
        except RequestException as error:
            msg = _("Unable to download '{filename}' from '{url}': {error}")
            text = msg.format(filename=filename, url=url, error=error)
            raise IsoEOLEDownloadError(text)

        with open(filename, 'wb') as file_h:
            shutil.copyfileobj(response.raw, file_h)

        return filename


    def _create_target_directory(self):
        """Create the target directory if it does not exists

        """
        if os.access(self.download_directory, os.W_OK):
            return True

        msg = _("Create download directory '{directory}'")
        text = msg.format(directory=self.download_directory)
        self.logger.debug(text)

        if os.path.isdir(self.download_directory):
            msg = _("Write access denied to download directory '{directory}'")
            text = msg.format(directory=self.download_directory)
            raise IsoEOLEDownloadError(text)

        if not os.access(os.path.dirname(self.download_directory), os.W_OK):
            msg = _("Access denied to create download directory '{directory}'")
            text = msg.format(directory=self.download_directory)
            raise IsoEOLEDownloadError(text)

        return os.makedirs(self.download_directory, mode=0o755)



class ExtractEOLEVersions(HTMLParser):
    """Extrat stable EOLE versions from HTML page

    Gathered versions are stored in ``self.versions``.

    """

    def __init__(self, version):
        HTMLParser.__init__(self)
        self.version = version
        self.versions = []
        self.alpha_versions = []
        self.beta_versions = []
        self.rc_versions = []
        self.process_a = False

    def handle_starttag(self, tag, attrs):
        if tag != 'a':
            self.process_a = False
            return
        self.process_a = True


    def handle_data(self, data):
        if not self.process_a:
            return

        # Strip not matching and pre stable versions
        if data.lower().startswith(self.version):
            if '-' not in data:
                self.versions.append(data.rstrip('/'))
            elif '-a' in data:
                self.alpha_versions.append(data.rstrip('/'))
            elif '-b' in data:
                self.beta_versions.append(data.rstrip('/'))
            elif '-rc' in data:
                self.rc_versions.append(data.rstrip('/'))


if __name__ == '__main__':
    logging.basicConfig(level=logging.DEBUG)
    try:
        ISO = IsoEOLE(release='2.6.2')
        ISO.download(path='/machin/ISO/eole-2.7.0-a8-alternate-amd64.iso')
    except IsoEOLEError as error:
        print("{error}".format(error=error))
        sys.exit(1)
