# This file is part of Gajim.
#
# Gajim 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; version 3 only.
#
# Gajim 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 Gajim.  If not, see <http://www.gnu.org/licenses/>.

# XEP-0363: HTTP File Upload


import os
import io
from urllib.parse import urlparse
import mimetypes

from nbxmpp.namespaces import Namespace
from nbxmpp.errors import StanzaError
from nbxmpp.errors import MalformedStanzaError
from nbxmpp.errors import HTTPUploadStanzaError
from nbxmpp.util import convert_tls_error_flags
from gi.repository import GLib
from gi.repository import Soup

from gajim.common import app
from gajim.common.i18n import _
from gajim.common.helpers import get_tls_error_phrase
from gajim.common.helpers import get_user_proxy
from gajim.common.const import FTState
from gajim.common.filetransfer import FileTransfer
from gajim.common.modules.base import BaseModule
from gajim.common.exceptions import FileError


class HTTPUpload(BaseModule):

    _nbxmpp_extends = 'HTTPUpload'

    def __init__(self, con):
        BaseModule.__init__(self, con)

        self.available = False
        self.component = None
        self.httpupload_namespace = None
        self.max_file_size = None  # maximum file size in bytes

        self._proxy_resolver = None
        self._queued_messages = {}
        self._session = Soup.Session()
        self._session.props.ssl_strict = False
        self._session.props.user_agent = 'Gajim %s' % app.version

    def _set_proxy_if_available(self):
        proxy = get_user_proxy(self._account)
        if proxy is None:
            self._proxy_resolver = None
            self._session.props.proxy_resolver = None
        else:
            self._proxy_resolver = proxy.get_resolver()
            self._session.props.proxy_resolver = self._proxy_resolver

    def pass_disco(self, info):
        if not info.has_httpupload:
            return

        self.available = True
        self.httpupload_namespace = Namespace.HTTPUPLOAD_0
        self.component = info.jid
        self.max_file_size = info.httpupload_max_file_size

        self._log.info('Discovered component: %s', info.jid)

        if self.max_file_size is None:
            self._log.warning('Component does not provide maximum file size')
        else:
            size = GLib.format_size_full(self.max_file_size,
                                         GLib.FormatSizeFlags.IEC_UNITS)
            self._log.info('Component has a maximum file size of: %s', size)

        for ctrl in app.interface.msg_win_mgr.get_controls(acct=self._account):
            ctrl.update_actions()

    def make_transfer(self, path, encryption, contact, groupchat=False):
        if not path or not os.path.exists(path):
            raise FileError(_('Could not access file'))

        invalid_file = False
        stat = os.stat(path)

        if os.path.isfile(path):
            if stat[6] == 0:
                invalid_file = True
                msg = _('File is empty')
        else:
            invalid_file = True
            msg = _('File does not exist')

        if self.max_file_size is not None and \
                stat.st_size > self.max_file_size:
            invalid_file = True
            size = GLib.format_size_full(self.max_file_size,
                                         GLib.FormatSizeFlags.IEC_UNITS)
            msg = _('File is too large, '
                    'maximum allowed file size is: %s') % size

        if invalid_file:
            raise FileError(msg)

        mime = mimetypes.MimeTypes().guess_type(path)[0]
        if not mime:
            mime = 'application/octet-stream'  # fallback mime type
        self._log.info("Detected MIME type of file: %s", mime)

        return HTTPFileTransfer(self._account,
                                path,
                                contact,
                                mime,
                                encryption,
                                groupchat)

    def cancel_transfer(self, transfer):
        transfer.set_cancelled()
        message = self._queued_messages.get(id(transfer))
        if message is None:
            return

        self._session.cancel_message(message, Soup.Status.CANCELLED)

    def start_transfer(self, transfer):
        if transfer.encryption is not None and not transfer.is_encrypted:
            transfer.set_encrypting()
            plugin = app.plugin_manager.encryption_plugins[transfer.encryption]
            if hasattr(plugin, 'encrypt_file'):
                plugin.encrypt_file(transfer,
                                    self._account,
                                    self.start_transfer)
            else:
                transfer.set_error('encryption-not-available')

            return

        transfer.set_preparing()
        self._log.info('Sending request for slot')
        self._nbxmpp('HTTPUpload').request_slot(
            jid=self.component,
            filename=transfer.filename,
            size=transfer.size,
            content_type=transfer.mime,
            callback=self._received_slot,
            user_data=transfer)

    def _received_slot(self, task):
        transfer = task.get_user_data()

        try:
            result = task.finish()
        except (StanzaError,
                HTTPUploadStanzaError,
                MalformedStanzaError) as error:

            if error.app_condition == 'file-too-large':
                size_text = GLib.format_size_full(
                    error.get_max_file_size(),
                    GLib.FormatSizeFlags.IEC_UNITS)

                error_text = _('File is too large, '
                               'maximum allowed file size is: %s' % size_text)
                transfer.set_error('file-too-large', error_text)

            else:
                transfer.set_error('misc', str(error))

            return

        transfer.process_result(result)

        if (urlparse(transfer.put_uri).scheme != 'https' or
                urlparse(transfer.get_uri).scheme != 'https'):
            transfer.set_error('unsecure')
            return

        self._log.info('Uploading file to %s', transfer.put_uri)
        self._log.info('Please download from %s', transfer.get_uri)

        self._upload_file(transfer)

    def _upload_file(self, transfer):
        transfer.set_started()

        message = Soup.Message.new('PUT', transfer.put_uri)
        message.connect('starting', self._check_certificate, transfer)

        # Set CAN_REBUILD so chunks get discarded after they have been
        # written to the network
        message.set_flags(Soup.MessageFlags.CAN_REBUILD |
                          Soup.MessageFlags.NO_REDIRECT)

        message.props.request_body.set_accumulate(False)

        message.props.request_headers.set_content_type(transfer.mime, None)
        message.props.request_headers.set_content_length(transfer.size)
        for name, value in transfer.headers.items():
            message.props.request_headers.append(name, value)

        message.connect('wrote-headers', self._on_wrote_headers, transfer)
        message.connect('wrote-chunk', self._on_wrote_chunk, transfer)

        self._queued_messages[id(transfer)] = message
        self._set_proxy_if_available()
        self._session.queue_message(message, self._on_finish, transfer)

    def _check_certificate(self, message, transfer):
        https_used, tls_certificate, tls_errors = message.get_https_status()
        if not https_used:
            self._log.warning('HTTPS was not used for upload')
            transfer.set_error('unsecure')
            self._session.cancel_message(message, Soup.Status.CANCELLED)
            return

        tls_errors = convert_tls_error_flags(tls_errors)
        if app.cert_store.verify(tls_certificate, tls_errors):
            return

        for error in tls_errors:
            phrase = get_tls_error_phrase(error)
            self._log.warning('TLS verification failed: %s', phrase)

        transfer.set_error('tls-verification-failed', phrase)
        self._session.cancel_message(message, Soup.Status.CANCELLED)

    def _on_finish(self, _session, message, transfer):
        self._queued_messages.pop(id(transfer), None)

        if message.props.status_code == Soup.Status.CANCELLED:
            self._log.info('Upload cancelled')
            return

        if message.props.status_code in (Soup.Status.OK, Soup.Status.CREATED):
            self._log.info('Upload completed successfully')
            transfer.set_finished()


        else:
            phrase = Soup.Status.get_phrase(message.props.status_code)
            self._log.error('Got unexpected http upload response code: %s',
                            phrase)
            transfer.set_error('http-response', phrase)

    def _on_wrote_chunk(self, message, transfer):
        transfer.update_progress()
        if transfer.is_complete:
            message.props.request_body.complete()
            return

        bytes_ = transfer.get_chunk()
        self._session.pause_message(message)
        GLib.idle_add(self._append, message, bytes_)

    def _append(self, message, bytes_):
        if message.props.status_code == Soup.Status.CANCELLED:
            return
        self._session.unpause_message(message)
        message.props.request_body.append(bytes_)

    @staticmethod
    def _on_wrote_headers(message, transfer):
        message.props.request_body.append(transfer.get_chunk())


class HTTPFileTransfer(FileTransfer):

    _state_descriptions = {
        FTState.ENCRYPTING: _('Encrypting file…'),
        FTState.PREPARING: _('Requesting HTTP File Upload Slot…'),
        FTState.STARTED: _('Uploading via HTTP File Upload…'),
    }

    _errors = {
        'unsecure': _('The server returned an insecure transport (HTTP).'),
        'encryption-not-available': _('There is no encryption method available '
                                      'for the chosen encryption.')
    }

    def __init__(self,
                 account,
                 path,
                 contact,
                 mime,
                 encryption,
                 groupchat):

        FileTransfer.__init__(self, account)

        self._path = path
        self._encryption = encryption
        self._groupchat = groupchat
        self._contact = contact
        self._mime = mime

        self.size = os.stat(path).st_size
        self.put_uri = None
        self.get_uri = None
        self._uri_transform_func = None

        self._stream = None
        self._data = None
        self._headers = {}

        self._is_encrypted = False

    @property
    def mime(self):
        return self._mime

    @property
    def contact(self):
        return self._contact

    @property
    def is_groupchat(self):
        return self._groupchat

    @property
    def encryption(self):
        return self._encryption

    @property
    def headers(self):
        return self._headers

    @property
    def path(self):
        return self._path

    @property
    def is_encrypted(self):
        return self._is_encrypted

    def get_transformed_uri(self):
        if self._uri_transform_func is not None:
            return self._uri_transform_func(self.get_uri)
        return self.get_uri

    def set_uri_transform_func(self, func):
        self._uri_transform_func = func

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

    def set_error(self, domain, text=''):
        if not text:
            text = self._errors[domain]

        self._close()
        super().set_error(domain, text)

    def set_finished(self):
        self._close()
        super().set_finished()

    def set_encrypted_data(self, data):
        self._data = data
        self._is_encrypted = True

    def _close(self):
        if self._stream is not None:
            self._stream.close()

    def get_chunk(self):
        if self._stream is None:
            if self._encryption is None:
                self._stream = open(self._path, 'rb')
            else:
                self._stream = io.BytesIO(self._data)

        data = self._stream.read(16384)
        if not data:
            self._close()
            return None
        self._seen += len(data)
        if self.is_complete:
            self._close()
        return data

    def get_data(self):
        with open(self._path, 'rb') as file:
            data = file.read()
        return data

    def process_result(self, result):
        self.put_uri = result.put_uri
        self.get_uri = result.get_uri
        self._headers = result.headers


def get_instance(*args, **kwargs):
    return HTTPUpload(*args, **kwargs), 'HTTPUpload'
