# -*- coding: utf-8 -*-
#
# Copyright (C) 2010-2014 Avencall
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>

import contextlib
import hashlib
import logging
import os
from typing import Optional
import requests
from requests.auth import HTTPBasicAuth, HTTPDigestAuth
from binascii import b2a_hex

from xivo_fetchfw.cisco.firmware_download import download_firmware
from xivo_fetchfw.cisco.models import ModelInfo
from xivo_fetchfw.util import FetchfwError

logger = logging.getLogger(__name__)


class DownloadError(FetchfwError):
    pass


class InvalidCredentialsError(DownloadError):
    pass


class CorruptedFileError(DownloadError):
    pass


class AbortedDownloadError(DownloadError):
    pass


class DefaultDownloader(object):
    _TIMEOUT = 15.0

    def __init__(self, session: Optional[requests.Session] = None):
        if session is None or not isinstance(session, requests.Session):
            logger.info("No usable session provided, creating a new one.")
            self._session = requests.Session()
        else:
            logger.info("session is there: %s", type(session))
            self._session = session

    def download(self, url, timeout=_TIMEOUT):
        """
        Open the URL and return a file-like object (response.raw).
        """
        try:
            return self._do_download(url, timeout)
        except requests.exceptions.HTTPError as e:
            logger.warning(
                "HTTPError while downloading '%s': %s", self._get_url(url), e
            )
            if e.response is not None and e.response.status_code == 401:
                raise InvalidCredentialsError(
                    f"unauthorized access to {self._get_url(url)}"
                ) from e
            else:
                raise DownloadError(e)
        except requests.exceptions.RequestException as e:
            logger.warning(
                "RequestException while downloading '%s': %s", self._get_url(url), e
            )
            raise DownloadError(e) from e

    def _get_url(self, url):
        return url

    def _do_download(self, url, timeout):
        response = self._session.get(url, timeout=timeout, stream=True)
        response.raise_for_status()
        # Enable automatic content decoding.
        response.raw.decode_content = True
        return response.raw


class AuthenticatingDownloader(DefaultDownloader):
    def __init__(self, session=None):
        super(AuthenticatingDownloader, self).__init__(session)

    def add_password(self, realm, uri, user, passwd, auth_type="basic"):
        """Set authentication for the session.
        auth_type can be 'basic' or 'digest'.
        """
        if auth_type.lower() == "basic":
            self._session.auth = HTTPBasicAuth(user, passwd)
        elif auth_type.lower() == "digest":
            self._session.auth = HTTPDigestAuth(user, passwd)
        else:
            raise ValueError(f"Unsupported auth type: {auth_type}")


class CiscoDownloader(DefaultDownloader):
    _URI_SCHEME = "cisco:"

    def _do_download(self, url, timeout):
        model_info = self._new_model_info_from_uri(url)
        opener = _OpenerWithTimeout(self._session, timeout)
        return download_firmware(model_info, opener)

    @classmethod
    def _new_model_info_from_uri(cls, uri):
        # Example: cisco:flowid=5964,mdfid=282414110,softwareid=282463187,release=6.1.11
        if not uri.startswith(cls._URI_SCHEME):
            raise ValueError("bad uri scheme; should be %s" % cls._URI_SCHEME)
        uri_data = uri[len(cls._URI_SCHEME) :]
        tokens = [tuple(word.split("=", 1)) for word in uri_data.split(",")]
        flow_id_token = tokens[0]
        if flow_id_token[0] != "flowid":
            raise ValueError("bad uri scheme; first element must be flowid")
        flow_id = flow_id_token[1]
        url_params = tokens[1:]
        return ModelInfo(flow_id, url_params)


class _OpenerWithTimeout(object):
    def __init__(self, session, timeout):
        self._session = session
        self._timeout = timeout

    def open(self, url, data=None):
        response = self._session.get(url, timeout=self._timeout, stream=True)
        response.raise_for_status()
        response.raw.decode_content = True
        return response.raw


class BaseRemoteFile(object):
    """Represents a remote file that can be downloaded."""

    _BLOCK_SIZE = 4096

    def __init__(self, url, downloader, hook_factories=None):
        """:param url: URL or object to pass to the downloader.
        :param downloader: The downloader instance to use.
        :param hook_factories: A list of callable objects that return download hooks.
        """
        self._url = url
        self._downloader = downloader
        if hook_factories is None:
            self._hook_factories = []
        else:
            self._hook_factories = list(hook_factories)

    def download(self, supp_hooks=[]):
        """Download the file and execute hooks in reverse order of their start."""
        logger.debug("Downloading %s", self._url)
        hooks = supp_hooks + [factory() for factory in self._hook_factories]
        last_started_idx = 0
        try:
            while last_started_idx < len(hooks):
                hooks[last_started_idx].start()
                last_started_idx += 1
            with contextlib.closing(self._downloader.download(self._url)) as dlfile:
                while True:
                    data = dlfile.read(self._BLOCK_SIZE)
                    if not data:
                        break
                    for hook in hooks:
                        hook.update(data)
            for hook in reversed(hooks):
                hook.complete()
        except Exception as e:
            try:
                raise
            finally:
                hook_idx = last_started_idx
                while hook_idx:
                    hook_idx -= 1
                    try:
                        hooks[hook_idx].fail(e)
                    except Exception:
                        logger.error("hook.fail raised an exception", exc_info=True)
        finally:
            hook_idx = last_started_idx
            while hook_idx:
                hook_idx -= 1
                try:
                    hooks[hook_idx].stop()
                except Exception:
                    logger.error("hook.stop raised an exception", exc_info=True)


class RemoteFile(object):
    """A BaseRemoteFile with a few extra attributes:
    - size: the remote file size.
    - filename: the name of the downloaded file.
    - path: the full path of the downloaded file.
    - exists: method to check if the file already exists.
    """

    def __init__(self, path, size, base_remote_file):
        """:param path: Path where the file will be saved.
        :param size: File size.
        :param base_remote_file: The BaseRemoteFile instance.
        """
        self.path = path
        self.size = size
        self._base_remote_file = base_remote_file

    @property
    def filename(self):
        return os.path.basename(self.path)

    def exists(self):
        return os.path.isfile(self.path)

    def download(self, supp_hooks=[]):
        self._base_remote_file.download(supp_hooks)

    @classmethod
    def new_remote_file(cls, path, size, url, downloader, hook_factories=[]):
        hook_factories = hook_factories + [WriteToFileHook.create_factory(path)]
        base_remote_file = BaseRemoteFile(url, downloader, hook_factories)
        return cls(path, size, base_remote_file)


class DownloadHook(object):
    """Base class for download hooks."""

    def start(self):
        pass

    def update(self, data):
        pass

    def complete(self):
        pass

    def fail(self, exc_value):
        pass

    def stop(self):
        pass


class WriteToFileHook(DownloadHook):
    """Hook that writes the downloaded data to a file."""

    def __init__(self, filename):
        DownloadHook.__init__(self)
        self._filename = filename
        self._tmp_filename = filename + ".tmp"
        self._fobj = open(self._tmp_filename, "wb")
        self._renamed = False

    def start(self):
        pass

    def update(self, data: bytes):
        self._fobj.write(data)

    def complete(self):
        self._fobj.close()
        os.rename(self._tmp_filename, self._filename)
        self._renamed = True

    def fail(self, exc_value):
        self._fobj.close()
        try:
            pass
        finally:
            filename = self._filename if self._renamed else self._tmp_filename
            try:
                os.remove(filename)
            except OSError as e:
                logger.error("error while removing '%s': %s", filename, e)

    @classmethod
    def create_factory(cls, filename):
        def aux():
            return cls(filename)

        return aux


class SHA1Hook(DownloadHook):
    """Computes the SHA1 sum of the download and verifies it."""

    def __init__(self, sha1sum):
        DownloadHook.__init__(self)
        self._sha1sum = sha1sum
        self._hash = hashlib.sha1()

    def start(self):
        pass

    def update(self, data: bytes):
        self._hash.update(data)

    def complete(self):
        sha1sum = self._hash.digest()
        if sha1sum != self._sha1sum:
            raise CorruptedFileError(
                f"sha1sum mismatch: {b2a_hex(sha1sum)} instead of {b2a_hex(self._sha1sum)}"
            )

    @classmethod
    def create_factory(cls, sha1sum):
        def aux():
            return cls(sha1sum)

        return aux


class ProgressBarHook(DownloadHook):
    """Updates a progress bar according to the download progress."""

    def __init__(self, pbar):
        DownloadHook.__init__(self)
        self._pbar = pbar
        self._size = 0

    def start(self):
        self._pbar.start()

    def update(self, data):
        self._size += len(data)
        self._pbar.update(self._size)

    def complete(self):
        self._pbar.finish()


class AbortHook(DownloadHook):
    """Allows aborting a download by raising an exception during update."""

    def __init__(self):
        DownloadHook.__init__(self)
        self._abort = False

    def update(self, data):
        if self._abort:
            logger.info("explicitly aborting download")
            raise AbortedDownloadError()

    def abort_download(self):
        logger.info("scheduling download abortion")
        self._abort = True


def new_session(proxies=None) -> requests.Session:
    """Creates and returns a requests session configured with proxies if provided."""
    session = requests.Session()
    if proxies:
        session.proxies = proxies
    return session


def new_handlers(proxies=None):
    return new_session(proxies)


def new_downloaders_from_handlers(handlers=None):
    """Returns a dictionary with:
    - 'default': DefaultDownloader
    - 'auth': AuthenticatingDownloader
    - 'cisco': CiscoDownloader
    """
    if isinstance(handlers, requests.Session):
        session: requests.Session = handlers
    else:
        if handlers is not None:
            logger.warning(
                "The passed 'handlers' is not a session but a %s. A new requests session will be created and the list will be ignored.",
                type(handlers),
            )
        session = new_session()
    auth = AuthenticatingDownloader(session)
    default = DefaultDownloader(session)
    cisco = CiscoDownloader(session)
    return {"auth": auth, "default": default, "cisco": cisco}


def new_downloaders(proxies=None):
    return new_downloaders_from_handlers(new_handlers(proxies))
