# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4; coding:utf-8 -*-
#
# Copyright 2013 jno
# Copyright 2016 Dmitry Nezhevenko
#
# Version: 0.3
#
# 0. You can make me happy with https://www.dropbox.com/referrals/NTE2ODA0Mzg5
# 1. Most of the code was taken from cli_client.py. The ftpsbackend.py was used as a template
# 2. DPBX & dpbx are used because the use of the actual name is prohibited
#
# This file is part of duplicity.
#
# Duplicity 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 2 of the License, or (at your
# option) any later version.
#
# Duplicity 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 duplicity; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA

"""
Dropbox backend.
"""

import io
import os
import re
import sys
import time
import traceback
import urllib.error
import urllib.parse
import urllib.request

from requests.exceptions import ConnectionError  # pylint: disable=redefined-builtin

import duplicity.backend
from duplicity import config
from duplicity import errors
from duplicity import log
from duplicity import progress
from duplicity.errors import BackendException

# This is chunk size for upload using Dpbx chumked API v2. It doesn't
# make sense to make it much large since Dpbx SDK uses connection pool
# internally. So multiple chunks will sent using same keep-alive socket
# Plus in case of network problems we most likely will be able to retry
# only failed chunk
DPBX_UPLOAD_CHUNK_SIZE = 16 * 1024 * 1024

# Download internal buffer size. Files are downloaded using one request.
DPBX_DOWNLOAD_BUF_SIZE = 512 * 1024

DPBX_AUTORENAMED_FILE_RE = re.compile(r" \([0-9]+\)\.[^\.]+$")


def log_exception(e):
    log.Error(f"Exception [{e}]:")
    f = io.StringIO()
    traceback.print_exc(file=f)
    f.seek(0)
    for s in f.readlines():
        log.Error(f"| {s.rstrip()}")
    f.close()


def command(login_required=True):  # pylint: disable=unused-argument
    """
    a decorator for handling authentication and exceptions
    """

    def decorate(f):
        def wrapper(self, *args):
            try:
                return f(self, *args)
            except ApiError as e:
                log_exception(e)
                raise BackendException(f'dpbx api error "{e}"')
            except Exception as e:
                log_exception(e)
                log.Error(f'dpbx code error "{e}"', log.ErrorCode.backend_code_error)
                raise

        wrapper.__doc__ = f.__doc__
        return wrapper

    return decorate


class DPBXBackend(duplicity.backend.Backend):
    """
    Connect to remote store using Dr*pB*x service
    """

    def __init__(self, parsed_url):
        """
        Initialize the Dropbox backend.

        :param ParsedUrl parsed_url: The parsed URL.
        """
        duplicity.backend.Backend.__init__(self, parsed_url)

        global Dropbox
        global AuthError, BadInputError, ApiError
        global UploadSessionCursor, CommitInfo
        global WriteMode, GetMetadataError
        global DeleteError, UploadSessionLookupError
        global ListFolderError
        global DropboxOAuth2FlowNoRedirect
        try:
            from dropbox import Dropbox
            from dropbox.exceptions import AuthError
            from dropbox.exceptions import BadInputError
            from dropbox.exceptions import ApiError
            from dropbox.files import UploadSessionCursor
            from dropbox.files import CommitInfo
            from dropbox.files import WriteMode
            from dropbox.files import GetMetadataError
            from dropbox.files import DeleteError
            from dropbox.files import UploadSessionLookupError
            from dropbox.files import ListFolderError
            from dropbox.oauth import DropboxOAuth2FlowNoRedirect
        except ImportError as e:
            raise BackendException(f"""This backend requires the dropbox package version 6.9.0
To install use "sudo pip install dropbox==6.9.0"
Exception: {str(e)}""")

        self.api_account = None
        self.api_client = None
        self.auth_flow = None

        self.login()

    def user_authenticated(self):
        try:
            account = self.api_client.users_get_current_account()
            log.Debug(f"User authenticated as ,{account}")
            return True
        except Exception as e:
            log.Debug("User not authenticated")
            return False

    def load_access_token(self):
        return os.environ.get("DPBX_ACCESS_TOKEN", None)

    def save_access_token(self, access_token):
        raise BackendException(f'dpbx: Please set DPBX_ACCESS_TOKEN="{access_token}" environment variable')

    def obtain_access_token(self):
        log.Info("dpbx: trying to obtain access token")
        for env_var in ["DPBX_APP_KEY", "DPBX_APP_SECRET"]:
            if env_var not in os.environ:
                raise BackendException(f"dpbx: {env_var} environment variable not set")

        app_key = os.environ["DPBX_APP_KEY"]
        app_secret = os.environ["DPBX_APP_SECRET"]

        if not sys.stdout.isatty() or not sys.stdin.isatty():
            raise errors.FatalError(
                "dpbx error: cannot interact, but need human attention",
                log.ErrorCode.backend_command_error,
            )

        auth_flow = DropboxOAuth2FlowNoRedirect(app_key, app_secret)
        log.Debug("dpbx,auth_flow.start()")
        authorize_url = auth_flow.start()
        print()
        print("-" * 72)
        print(f"1. Go to: {authorize_url}")
        print('2. Click "Allow" (you might have to log in first).')
        print("3. Copy the authorization code.")
        print("-" * 72)
        auth_code = input("Enter the authorization code here: ").strip()
        try:
            log.Debug(f"dpbx,auth_flow.finish({auth_code})")
            authresult = auth_flow.finish(auth_code)
        except Exception as e:
            raise BackendException(f"dpbx: Unable to obtain access token: {e}")
        log.Info("dpbx: Authentication successfull")
        self.save_access_token(authresult.access_token)

    def login(self):
        if self.load_access_token() is None:
            self.obtain_access_token()

        self.api_client = Dropbox(self.load_access_token())
        self.api_account = None
        try:
            log.Debug("dpbx,users_get_current_account([token])")
            self.api_account = self.api_client.users_get_current_account()
            log.Debug(f"dpbx,{self.api_account}")

        except (BadInputError, AuthError) as e:
            log.Debug(f"dpbx,exception: {e}")
            log.Info("dpbx: Authentication failed. Trying to obtain new access token")

            self.obtain_access_token()

            # We're assuming obtain_access_token will throw exception.
            # So this line should not be reached
            raise BackendException("dpbx: Please update DPBX_ACCESS_TOKEN and try again")

        log.Info(f"dpbx: Successfully authenticated as {self.api_account.name.display_name}")

    def _error_code(self, operation, e):  # pylint: disable=unused-argument
        if isinstance(e, ApiError):
            err = e.error

            if isinstance(err, GetMetadataError) and err.is_path():
                if err.get_path().is_not_found():
                    return log.ErrorCode.backend_not_found
            elif isinstance(err, DeleteError) and err.is_path_lookup():
                lookup = e.error.get_path_lookup()
                if lookup.is_not_found():
                    return log.ErrorCode.backend_not_found

    @command()
    def _put(self, source_path, remote_filename):
        remote_dir = urllib.parse.unquote(self.parsed_url.path.lstrip("/"))
        remote_path = f"/{os.path.join(remote_dir, remote_filename.decode()).rstrip()}"

        file_size = os.path.getsize(source_path.name)
        progress.report_transfer(0, file_size)

        if file_size < DPBX_UPLOAD_CHUNK_SIZE:
            # Upload whole file at once to avoid extra server request
            res_metadata = self.put_file_small(source_path, remote_path)
        else:
            res_metadata = self.put_file_chunked(source_path, remote_path)

        # A few sanity checks
        if res_metadata.path_display != remote_path:
            raise BackendException(f"dpbx: result path mismatch: {res_metadata.path_display} (expected: {remote_path})")
        if res_metadata.size != file_size:
            raise BackendException(f"dpbx: result size mismatch: {res_metadata.size} (expected: {file_size})")

    def put_file_small(self, source_path, remote_path):
        if not self.user_authenticated():
            self.login()

        file_size = os.path.getsize(source_path.name)
        f = source_path.open("rb")
        try:
            log.Debug(f"dpbx,files_upload({remote_path}, [{int(file_size)} bytes])")

            res_metadata = self.api_client.files_upload(
                f.read(),
                remote_path,
                mode=WriteMode.overwrite,
                autorename=False,
                client_modified=None,
                mute=True,
            )
            log.Debug(f"dpbx,files_upload(): {res_metadata}")
            progress.report_transfer(file_size, file_size)
            return res_metadata
        finally:
            f.close()

    def put_file_chunked(self, source_path, remote_path):
        if not self.user_authenticated():
            self.login()

        file_size = os.path.getsize(source_path.name)
        f = source_path.open("rb")
        try:
            buf = f.read(DPBX_UPLOAD_CHUNK_SIZE)
            log.Debug(f"dpbx,files_upload_session_start([{len(buf)} bytes]), total: {int(file_size)}")
            upload_sid = self.api_client.files_upload_session_start(buf)
            log.Debug(f"dpbx,files_upload_session_start(): {upload_sid}")
            upload_cursor = UploadSessionCursor(upload_sid.session_id, f.tell())
            commit_info = CommitInfo(
                remote_path,
                mode=WriteMode.overwrite,
                autorename=False,
                client_modified=None,
                mute=True,
            )
            res_metadata = None
            progress.report_transfer(f.tell(), file_size)

            requested_offset = None
            current_chunk_size = DPBX_UPLOAD_CHUNK_SIZE
            retry_number = config.num_retries
            is_eof = False

            # We're doing our own error handling and retrying logic because
            # we can benefit from Dpbx chunked upload and retry only failed
            # chunk
            while not is_eof or not res_metadata:
                try:
                    if requested_offset is not None:
                        upload_cursor.offset = requested_offset

                    if f.tell() != upload_cursor.offset:
                        f.seek(upload_cursor.offset)
                    buf = f.read(current_chunk_size)

                    is_eof = f.tell() >= file_size
                    if not is_eof and len(buf) == 0:
                        continue

                    # reset temporary status variables
                    requested_offset = None
                    current_chunk_size = DPBX_UPLOAD_CHUNK_SIZE
                    retry_number = config.num_retries

                    if not is_eof:
                        assert len(buf) != 0, "buffer should not be empty"
                        log.Debug(
                            f"dpbx,files_upload_sesssion_append([{len(buf)} bytes], "
                            f"offset={int(upload_cursor.offset)})"
                        )
                        self.api_client.files_upload_session_append(buf, upload_cursor.session_id, upload_cursor.offset)
                    else:
                        log.Debug(
                            f"dpbx,files_upload_sesssion_finish([{len(buf)} bytes], "
                            f"offset={int(upload_cursor.offset)})"
                        )
                        res_metadata = self.api_client.files_upload_session_finish(buf, upload_cursor, commit_info)

                    upload_cursor.offset = f.tell()
                    log.Debug(f"progress: {int(upload_cursor.offset)} of {int(file_size)}")
                    progress.report_transfer(upload_cursor.offset, file_size)
                except ApiError as e:
                    error = e.error
                    if isinstance(error, UploadSessionLookupError) and error.is_incorrect_offset():
                        # Server reports that we should send another chunk.
                        # Most likely this is caused by network error during
                        # previous upload attempt. In such case we'll get
                        # expected offset from server and it's enough to just
                        # seek() and retry again
                        new_offset = error.get_incorrect_offset().correct_offset
                        log.Debug(
                            f"dpbx,files_upload_session_append: incorrect offset: {int(upload_cursor.offset)} "
                            f"(expected: {new_offset})"
                        )
                        if requested_offset is not None:
                            # chunk failed even after seek attempt. Something
                            # strange and no safe way to recover
                            raise BackendException("dpbx: unable to chunk upload")
                        else:
                            # will seek and retry
                            requested_offset = new_offset
                        continue
                    raise
                except ConnectionError as e:
                    log.Debug(f"dpbx,files_upload_session_append: {e}")

                    retry_number -= 1

                    if not self.user_authenticated():
                        self.login()

                    if retry_number == 0:
                        raise

                    # We don't know for sure, was partial upload successful or
                    # not. So it's better to retry smaller amount to avoid extra
                    # reupload
                    log.Info("dpbx: sleeping a bit before chunk retry")
                    time.sleep(30)
                    current_chunk_size = DPBX_UPLOAD_CHUNK_SIZE / 5
                    requested_offset = None
                    continue

            if f.tell() != file_size:
                raise BackendException("dpbx: something wrong")

            log.Debug(f"dpbx,files_upload_sesssion_finish(): {res_metadata}")
            progress.report_transfer(f.tell(), file_size)

            return res_metadata

        finally:
            f.close()

    @command()
    def _get(self, remote_filename, local_path):
        if not self.user_authenticated():
            self.login()

        remote_dir = urllib.parse.unquote(self.parsed_url.path.lstrip("/"))
        remote_path = f"/{os.path.join(remote_dir, remote_filename.decode()).rstrip()}"

        log.Debug(f"dpbx,files_download({remote_path})")
        res_metadata, http_fd = self.api_client.files_download(remote_path)
        log.Debug(f"dpbx,files_download({remote_path}): {res_metadata}, {http_fd}")
        file_size = res_metadata.size
        to_fd = None
        progress.report_transfer(0, file_size)
        try:
            to_fd = local_path.open("wb")
            for c in http_fd.iter_content(DPBX_DOWNLOAD_BUF_SIZE):
                to_fd.write(c)
                progress.report_transfer(to_fd.tell(), file_size)

        finally:
            if to_fd:
                to_fd.close()
            http_fd.close()

        # It's different from _query() check because we're not querying metadata
        # again. Since this check is free, it's better to have it here
        local_size = os.path.getsize(local_path.name)
        if local_size != file_size:
            raise BackendException(f"dpbx: wrong file size: {int(local_size)} (expected: {int(file_size)})")

        local_path.setdata()

    @command()
    def _list(self):
        """
        List files in the remote Dropbox folder.

        :returns: A list of filenames as byte strings.
        :rtype: list
        """
        # Do a long listing to avoid connection reset
        if not self.user_authenticated():
            self.login()
        remote_dir = f"/{urllib.parse.unquote(self.parsed_url.path.lstrip('/')).rstrip()}"

        log.Debug(f"dpbx.files_list_folder({remote_dir})")
        res = []
        try:
            resp = self.api_client.files_list_folder(remote_dir)
            log.Debug(f"dpbx.list({remote_dir}): {resp}")

            while True:
                res.extend([entry.name for entry in resp.entries])
                if not resp.has_more:
                    break
                resp = self.api_client.files_list_folder_continue(resp.cursor)
        except ApiError as e:
            if isinstance(e.error, ListFolderError) and e.error.is_path() and e.error.get_path().is_not_found():
                log.Debug(f"dpbx.list({remote_dir}): ignore missing folder ({e})")
            else:
                raise

        # Warn users of old version dpbx about automatically renamed files
        self.check_renamed_files(res)

        return res

    @command()
    def _delete(self, filename):
        """
        Delete a file from Dropbox.

        :param str filename: The name of the file to delete.
        """
        if not self.user_authenticated():
            self.login()

        remote_dir = urllib.parse.unquote(self.parsed_url.path.lstrip("/"))
        remote_path = f"/{os.path.join(remote_dir, filename.decode()).rstrip()}"

        log.Debug(f"dpbx.files_delete({remote_path})")
        self.api_client.files_delete(remote_path)

        # files_permanently_delete seems to be better for backup purpose
        # but it's only available for Business accounts
        # self.api_client.files_permanently_delete(remote_path)

    @command()
    def _close(self):
        """
        close backend session? no! just "flush" the data
        """
        log.Debug("dpbx.close():")

    @command()
    def _query(self, filename):
        """
        Get metadata for a file on Dropbox.

        :param str filename: The name of the file to query.
        :returns: A dictionary containing metadata (e.g., 'size').
        :rtype: dict
        """
        if not self.user_authenticated():
            self.login()
        remote_dir = urllib.parse.unquote(self.parsed_url.path.lstrip("/"))
        remote_path = f"/{os.path.join(remote_dir, filename.decode()).rstrip()}"

        log.Debug(f"dpbx.files_get_metadata({remote_path})")
        info = self.api_client.files_get_metadata(remote_path)
        log.Debug(f"dpbx.files_get_metadata({remote_path}): {info}")
        return {"size": info.size}

    def check_renamed_files(self, file_list):
        if not self.user_authenticated():
            self.login()
        bad_list = [x for x in file_list if DPBX_AUTORENAMED_FILE_RE.search(x) is not None]
        if len(bad_list) == 0:
            return
        log.Warn("-" * 72)
        log.Warn("Warning! It looks like there are automatically renamed files on backend")
        log.Warn("They were probably created when using older version of duplicity.")
        log.Warn("")
        log.Warn("Please check your backup consistency. Most likely you will need to choose")
        log.Warn("largest file from duplicity-* (number).gpg and remove brackets from its name.")
        log.Warn("")
        log.Warn("These files are not managed by duplicity at all and will not be")
        log.Warn("removed/rotated automatically.")
        log.Warn("")
        log.Warn("Affected files:")
        for x in bad_list:
            log.Warn(f"\t{x}")
        log.Warn("")
        log.Warn("In any case it's better to create full backup.")
        log.Warn("-" * 72)


duplicity.backend.register_backend("dpbx", DPBXBackend)
