import math
import re
import json
import logging
import secrets
from pathlib import Path
import hashlib
from Crypto.Cipher import AES
from Crypto.PublicKey import RSA
from Crypto.Util import Counter
import os
import random
import binascii
import tempfile
import shutil

import requests
from tenacity import retry, wait_exponential, retry_if_exception_type

from .errors import ValidationError, RequestError
from .crypto import (a32_to_base64, encrypt_key, base64_url_encode,
                     encrypt_attr, base64_to_a32, base64_url_decode,
                     decrypt_attr, a32_to_str, get_chunks, str_to_a32,
                     decrypt_key, mpi_to_int, stringhash, prepare_key, make_id,
                     makebyte, modular_inverse)

logger = logging.getLogger(__name__)


class Mega:
    def __init__(self, options=None):
        self.schema = 'https'
        self.domain = 'mega.co.nz'
        self.timeout = 160  # max secs to wait for resp from api requests
        self.sid = None
        self.sequence_num = random.randint(0, 0xFFFFFFFF)
        self.request_id = make_id(10)
        self._trash_folder_node_id = None

        if options is None:
            options = {}
        self.options = options
        return None

    async def login(self, email=None, password=None):
        if email:
            await self._login_user(email, password)
        else:
            await self.login_anonymous()
        self._trash_folder_node_id = await self.get_node_by_type(4)[0]
        logger.info('Login complete')
        return self

    async def _login_user(self, email, password):
        logger.info('Logging in user...')
        email = email.lower()
        get_user_salt_resp = await self._api_request({'a': 'us0', 'user': email})
        user_salt = None
        try:
            user_salt = await base64_to_a32(await get_user_salt_resp['s'])
        except KeyError:
            # v1 user account
            password_aes = await prepare_key(await str_to_a32(password))
            user_hash = await stringhash(email, password_aes)
        else:
            # v2 user account
            pbkdf2_key = hashlib.pbkdf2_hmac(hash_name='sha512',
                                             password=password.encode(),
                                             salt=await a32_to_str(user_salt),
                                             iterations=100000,
                                             dklen=32)
            password_aes = await str_to_a32(await pbkdf2_key[:16])
            user_hash = await base64_url_encode(await pbkdf2_key[-16:])
        resp = await self._api_request({'a': 'us', 'user': email, 'uh': user_hash})
        if isinstance(resp, int):
            raise RequestError(resp)
        await self._login_process(resp, password_aes)

    async def login_anonymous(self):
        logger.info('Logging in anonymous temporary user...')
        master_key = [random.randint(0, 0xFFFFFFFF)] * 4
        password_key = [random.randint(0, 0xFFFFFFFF)] * 4
        session_self_challenge = [random.randint(0, 0xFFFFFFFF)] * 4

        user = await self._api_request({
            'a':
            'up',
            'k':
            await a32_to_base64(await encrypt_key(master_key, password_key)),
            'ts':
            await base64_url_encode(
                await a32_to_str(session_self_challenge) +
                await a32_to_str(await encrypt_key(session_self_challenge, master_key)))
        })

        resp = await self._api_request({'a': 'us', 'user': user})
        if isinstance(resp, int):
            raise RequestError(resp)
        await self._login_process(resp, password_key)

    async def _login_process(self, resp, password):
        encrypted_master_key = await base64_to_a32(resp['k'])
        self.master_key = await decrypt_key(encrypted_master_key, password)
        if 'tsid' in resp:
            tsid = await base64_url_decode(resp['tsid'])
            key_encrypted = await a32_to_str(
                await encrypt_key(await str_to_a32(tsid[:16]), self.master_key))
            if key_encrypted == tsid[-16:]:
                self.sid = resp['tsid']
        elif 'csid' in resp:
            encrypted_rsa_private_key = await base64_to_a32(resp['privk'])
            rsa_private_key = await decrypt_key(encrypted_rsa_private_key,
                                          self.master_key)

            private_key = await a32_to_str(rsa_private_key)
            # The private_key contains 4 MPI integers concatenated together.
            rsa_private_key = [0, 0, 0, 0]
            for i in range(4):
                # An MPI integer has a 2-byte header which describes the number
                # of bits in the integer.
                bitlength = (private_key[0] * 256) + private_key[1]
                bytelength = math.ceil(bitlength / 8)
                # Add 2 bytes to accommodate the MPI header
                bytelength += 2
                rsa_private_key[i] = await mpi_to_int(private_key[:bytelength])
                private_key = private_key[bytelength:]

            first_factor_p = rsa_private_key[0]
            second_factor_q = rsa_private_key[1]
            private_exponent_d = rsa_private_key[2]
            # In MEGA's webclient javascript, they assign [3] to a variable
            # called u, but I do not see how it corresponds to pycryptodome's
            # RSA.construct and it does not seem to be necessary.
            rsa_modulus_n = first_factor_p * second_factor_q
            phi = (first_factor_p - 1) * (second_factor_q - 1)
            public_exponent_e = await modular_inverse(private_exponent_d, phi)

            rsa_components = (
                rsa_modulus_n,
                public_exponent_e,
                private_exponent_d,
                first_factor_p,
                second_factor_q,
            )
            rsa_decrypter = RSA.construct(rsa_components)

            encrypted_sid = await mpi_to_int(await base64_url_decode(resp['csid']))

            sid = '%x' % rsa_decrypter._decrypt(encrypted_sid)
            sid = binascii.unhexlify('0' + sid if len(sid) % 2 else sid)
            self.sid = await base64_url_encode(sid[:43])

    @retry(retry=retry_if_exception_type(RuntimeError),
           wait=wait_exponential(multiplier=2, min=2, max=60))
    async def _api_request(self, data):
        params = {'id': self.sequence_num}
        self.sequence_num += 1

        if self.sid:
            params.update({'sid': self.sid})

        # ensure input data is a list
        if not isinstance(data, list):
            data = [data]

        url = f'{self.schema}://g.api.{self.domain}/cs'
        response = requests.post(
            url,
            params=params,
            data=json.dumps(data),
            timeout=self.timeout,
        )
        json_resp = json.loads(response.text)
        try:
            if isinstance(json_resp, list):
                int_resp = json_resp[0] if isinstance(json_resp[0],
                                                      int) else None
            elif isinstance(json_resp, int):
                int_resp = json_resp
        except IndexError:
            int_resp = None
        if int_resp is not None:
            if int_resp == 0:
                return int_resp
            if int_resp == -3:
                msg = 'Request failed, retrying'
                logger.info(msg)
                raise RuntimeError(msg)
            raise RequestError(int_resp)
        return json_resp[0]

    async def _parse_url(self, url):
        """Parse file id and key from url."""
        if '/file/' in url:
            # V2 URL structure
            url = url.replace(' ', '')
            file_id = re.findall(r'\W\w\w\w\w\w\w\w\w\W', url)[0][1:-1]
            id_index = re.search(file_id, url).end()
            key = url[id_index + 1:]
            return f'{file_id}!{key}'
        elif '!' in url:
            # V1 URL structure
            match = re.findall(r'/#!(.*)', url)
            path = match[0]
            return path
        else:
            raise RequestError('Url key missing')

    async def _process_file(self, file, shared_keys):
        if file['t'] == 0 or file['t'] == 1:
            keys = dict(
                keypart.split(':', 1) for keypart in file['k'].split('/')
                if ':' in keypart)
            uid = file['u']
            key = None
            # my objects
            if uid in keys:
                key = await decrypt_key(await base64_to_a32(keys[uid]), self.master_key)
            # shared folders
            elif 'su' in file and 'sk' in file and ':' in file['k']:
                shared_key = await decrypt_key(await base64_to_a32(file['sk']),
                                         self.master_key)
                key = await decrypt_key(await base64_to_a32(keys[file['h']]), shared_key)
                if file['su'] not in shared_keys:
                    shared_keys[file['su']] = {}
                shared_keys[file['su']][file['h']] = shared_key
            # shared files
            elif file['u'] and file['u'] in shared_keys:
                for hkey in shared_keys[file['u']]:
                    shared_key = shared_keys[file['u']][hkey]
                    if hkey in keys:
                        key = keys[hkey]
                        key = await decrypt_key(await base64_to_a32(key), shared_key)
                        break
            if file['h'] and file['h'] in shared_keys.get('EXP', ()):
                shared_key = shared_keys['EXP'][file['h']]
                encrypted_key = await str_to_a32(
                    await base64_url_decode(file['k'].split(':')[-1]))
                key = await decrypt_key(encrypted_key, shared_key)
                file['shared_folder_key'] = shared_key
            if key is not None:
                # file
                if file['t'] == 0:
                    k = (key[0] ^ key[4], key[1] ^ key[5], key[2] ^ key[6],
                         key[3] ^ key[7])
                    file['iv'] = key[4:6] + (0, 0)
                    file['meta_mac'] = key[6:8]
                # folder
                else:
                    k = key
                file['key'] = key
                file['k'] = k
                attributes = await base64_url_decode(file['a'])
                attributes = await decrypt_attr(attributes, k)
                file['a'] = attributes
            # other => wrong object
            elif file['k'] == '':
                file['a'] = False
        elif file['t'] == 2:
            self.root_id = file['h']
            file['a'] = {'n': 'Cloud Drive'}
        elif file['t'] == 3:
            self.inbox_id = file['h']
            file['a'] = {'n': 'Inbox'}
        elif file['t'] == 4:
            self.trashbin_id = file['h']
            file['a'] = {'n': 'Rubbish Bin'}
        return file

    async def _init_shared_keys(self, files, shared_keys):
        """
        Init shared key not associated with a user.
        Seems to happen when a folder is shared,
        some files are exchanged and then the
        folder is un-shared.
        Keys are stored in files['s'] and files['ok']
        """
        ok_dict = {}
        for ok_item in files['ok']:
            shared_key = await decrypt_key(await base64_to_a32(ok_item['k']),
                                     self.master_key)
            ok_dict[ok_item['h']] = shared_key
        for s_item in files['s']:
            if s_item['u'] not in shared_keys:
                shared_keys[s_item['u']] = {}
            if s_item['h'] in ok_dict:
                shared_keys[s_item['u']][s_item['h']] = ok_dict[s_item['h']]
        self.shared_keys = shared_keys

    async def find_path_descriptor(self, path, files=()):
        """
        Find descriptor of folder inside a path. i.e.: folder1/folder2/folder3
        Params:
            path, string like folder1/folder2/folder3
        Return:
            Descriptor (str) of folder3 if exists, None otherwise
        """
        paths = path.split('/')

        files = files or (await self.get_files())
        parent_desc = self.root_id
        found = False
        for foldername in paths:
            if foldername != '':
                for file in files.items():
                    if (file[1]['a'] and file[1]['t']
                            and file[1]['a']['n'] == foldername):
                        if parent_desc == file[1]['p']:
                            parent_desc = file[0]
                            found = True
                if found:
                    found = False
                else:
                    return None
        return parent_desc

    async def find(self, filename=None, handle=None, exclude_deleted=False):
        """
        Return file object from given filename
        """
        files = await self.get_files()
        if handle:
            return files[handle]
        path = Path(filename)
        filename = path.name
        parent_dir_name = path.parent.name
        for file in list(files.items()):
            parent_node_id = None
            try:
                if parent_dir_name:
                    parent_node_id = await self.find_path_descriptor(parent_dir_name,
                                                               files=files)
                    if (filename and parent_node_id and file[1]['a']
                            and file[1]['a']['n'] == filename
                            and parent_node_id == file[1]['p']):
                        if (exclude_deleted and self._trash_folder_node_id
                                == file[1]['p']):
                            continue
                        return file
                elif (filename and file[1]['a']
                      and file[1]['a']['n'] == filename):
                    if (exclude_deleted
                            and self._trash_folder_node_id == file[1]['p']):
                        continue
                    return file
            except TypeError:
                continue

    async def get_files(self):
        logger.info('Getting all files...')
        files = await self._api_request({'a': 'f', 'c': 1, 'r': 1})
        files_dict = {}
        shared_keys = {}
        await self._init_shared_keys(files, shared_keys)
        for file in files['f']:
            processed_file = await self._process_file(file, shared_keys)
            # ensure each file has a name before returning
            if processed_file['a']:
                files_dict[file['h']] = processed_file
        return files_dict

    async def get_upload_link(self, file):
        """
        Get a files public link inc. decrypted key
        Requires upload() response as input
        """
        if 'f' in file:
            file = file['f'][0]
            public_handle = await self._api_request({'a': 'l', 'n': file['h']})
            file_key = file['k'][file['k'].index(':') + 1:]
            decrypted_key = await a32_to_base64(
                await decrypt_key(await base64_to_a32(file_key), self.master_key))
            return (f'{self.schema}://{self.domain}'
                    f'/#!{public_handle}!{decrypted_key}')
        else:
            raise ValueError('''Upload() response required as input,
                            use get_link() for regular file input''')

    async def get_link(self, file):
        """
        Get a file public link from given file object
        """
        file = file[1]
        if 'h' in file and 'k' in file:
            public_handle = await self._api_request({'a': 'l', 'n': file['h']})
            if public_handle == -11:
                raise RequestError("Can't get a public link from that file "
                                   "(is this a shared file?)")
            decrypted_key = await a32_to_base64(file['key'])
            return (f'{self.schema}://{self.domain}'
                    f'/#!{public_handle}!{decrypted_key}')
        else:
            raise ValidationError('File id and key must be present')

    async def _node_data(self, node):
        try:
            return node[1]
        except (IndexError, KeyError):
            return node

    async def get_folder_link(self, file):
        try:
            file = file[1]
        except (IndexError, KeyError):
            pass
        if 'h' in file and 'k' in file:
            public_handle = await self._api_request({'a': 'l', 'n': file['h']})
            if public_handle == -11:
                raise RequestError("Can't get a public link from that file "
                                   "(is this a shared file?)")
            decrypted_key = await a32_to_base64(file['shared_folder_key'])
            return (f'{self.schema}://{self.domain}'
                    f'/#F!{public_handle}!{decrypted_key}')
        else:
            raise ValidationError('File id and key must be present')

    async def get_user(self):
        user_data = await self._api_request({'a': 'ug'})
        return user_data

    async def get_node_by_type(self, type):
        """
        Get a node by it's numeric type id, e.g:
        0: file
        1: dir
        2: special: root cloud drive
        3: special: inbox
        4: special trash bin
        """
        nodes = await self.get_files()
        for node in list(nodes.items()):
            if node[1]['t'] == type:
                return node

    async def get_files_in_node(self, target):
        """
        Get all files in a given target, e.g. 4=trash
        """
        if type(target) == int:
            # convert special nodes (e.g. trash)
            node_id = await self.get_node_by_type(target)
        else:
            node_id = [target]

        files = await self._api_request({'a': 'f', 'c': 1})
        files_dict = {}
        shared_keys = {}
        await self._init_shared_keys(files, shared_keys)
        for file in files['f']:
            processed_file = await self._process_file(file, shared_keys)
            if processed_file['a'] and processed_file['p'] == node_id[0]:
                files_dict[file['h']] = processed_file
        return files_dict

    async def get_id_from_public_handle(self, public_handle):
        # get node data
        node_data = await self._api_request({'a': 'f', 'f': 1, 'p': public_handle})
        node_id = await self.get_id_from_obj(node_data)
        return node_id

    async def get_id_from_obj(self, node_data):
        """
        Get node id from a file object
        """
        node_id = None

        for i in node_data['f']:
            if i['h'] != '':
                node_id = i['h']
        return node_id

    async def get_quota(self):
        """
        Get current remaining disk quota in MegaBytes
        """
        json_resp = await self._api_request({
            'a': 'uq',
            'xfer': 1,
            'strg': 1,
            'v': 1
        })
        # convert bytes to megabyes
        return json_resp['mstrg'] / 1048576

    async def get_storage_space(self, giga=False, mega=False, kilo=False):
        """
        Get the current storage space.
        Return a dict containing at least:
          'used' : the used space on the account
          'total' : the maximum space allowed with current plan
        All storage space are in bytes unless asked differently.
        """
        if sum(1 if x else 0 for x in (kilo, mega, giga)) > 1:
            raise ValueError("Only one unit prefix can be specified")
        unit_coef = 1
        if kilo:
            unit_coef = 1024
        if mega:
            unit_coef = 1048576
        if giga:
            unit_coef = 1073741824
        json_resp = await self._api_request({'a': 'uq', 'xfer': 1, 'strg': 1})
        return {
            'used': json_resp['cstrg'] / unit_coef,
            'total': json_resp['mstrg'] / unit_coef,
        }

    async def get_balance(self):
        """
        Get account monetary balance, Pro accounts only
        """
        user_data = await self._api_request({"a": "uq", "pro": 1})
        if 'balance' in user_data:
            return user_data['balance']

    async def delete(self, public_handle):
        """
        Delete a file by its public handle
        """
        return await self.move(public_handle, 4)

    async def delete_url(self, url):
        """
        Delete a file by its url
        """
        path = await self._parse_url(url).split('!')
        public_handle = path[0]
        file_id = await self.get_id_from_public_handle(public_handle)
        return await self.move(file_id, 4)

    async def destroy(self, file_id):
        """
        Destroy a file by its private id
        """
        return await self._api_request({
            'a': 'd',
            'n': file_id,
            'i': self.request_id
        })

    async def destroy_url(self, url):
        """
        Destroy a file by its url
        """
        path = await self._parse_url(url).split('!')
        public_handle = path[0]
        file_id = await self.get_id_from_public_handle(public_handle)
        return await self.destroy(file_id)

    async def empty_trash(self):
        # get list of files in rubbish out
        files = await self.get_files_in_node(4)

        # make a list of json
        if files != {}:
            post_list = []
            for file in files:
                post_list.append({"a": "d", "n": file, "i": self.request_id})
            return await self._api_request(post_list)

    async def download(self, file, dest_path=None, dest_filename=None):
        """
        Download a file by it's file object
        """
        return await self._download_file(file_handle=None,
                                   file_key=None,
                                   file=file[1],
                                   dest_path=dest_path,
                                   dest_filename=dest_filename,
                                   is_public=False)

    async def _export_file(self, node):
        node_data = await self._node_data(node)
        await self._api_request([{
            'a': 'l',
            'n': node_data['h'],
            'i': self.request_id
        }])
        return await self.get_link(node)

    async def export(self, path=None, node_id=None):
        nodes = await self.get_files()
        if node_id:
            node = nodes[node_id]
        else:
            node = await self.find(path)

        node_data = await self._node_data(node)
        is_file_node = node_data['t'] == 0
        if is_file_node:
            return await self._export_file(node)
        if node:
            try:
                # If already exported
                return await self.get_folder_link(node)
            except (RequestError, KeyError):
                pass

        master_key_cipher = AES.new(await a32_to_str(self.master_key), AES.MODE_ECB)
        ha = await base64_url_encode(
            master_key_cipher.encrypt(node_data['h'].encode("utf8") +
                                      node_data['h'].encode("utf8")))

        share_key = secrets.token_bytes(16)
        ok = await base64_url_encode(master_key_cipher.encrypt(share_key))

        share_key_cipher = AES.new(share_key, AES.MODE_ECB)
        node_key = node_data['k']
        encrypted_node_key = await base64_url_encode(
            share_key_cipher.encrypt(await a32_to_str(node_key)))

        node_id = node_data['h']
        request_body = [{
            'a':
            's2',
            'n':
            node_id,
            's': [{
                'u': 'EXP',
                'r': 0
            }],
            'i':
            self.request_id,
            'ok':
            ok,
            'ha':
            ha,
            'cr': [[node_id], [node_id], [0, 0, encrypted_node_key]]
        }]
        await self._api_request(request_body)
        nodes = await self.get_files()
        return await self.get_folder_link(nodes[node_id])

    async def download_url(self, url, dest_path=None, dest_filename=None):
        """
        Download a file by it's public url
        """
        path = await self._parse_url(url).split('!')
        file_id = path[0]
        file_key = path[1]
        return await self._download_file(
            file_handle=file_id,
            file_key=file_key,
            dest_path=dest_path,
            dest_filename=dest_filename,
            is_public=True,
        )

    async def _download_file(self,
                       file_handle,
                       file_key,
                       dest_path=None,
                       dest_filename=None,
                       is_public=False,
                       file=None):
        if file is None:
            if is_public:
                file_key = await base64_to_a32(file_key)
                file_data = await self._api_request({
                    'a': 'g',
                    'g': 1,
                    'p': file_handle
                })
            else:
                file_data = await self._api_request({
                    'a': 'g',
                    'g': 1,
                    'n': file_handle
                })

            k = (file_key[0] ^ file_key[4], file_key[1] ^ file_key[5],
                 file_key[2] ^ file_key[6], file_key[3] ^ file_key[7])
            iv = file_key[4:6] + (0, 0)
            meta_mac = file_key[6:8]
        else:
            file_data = await self._api_request({'a': 'g', 'g': 1, 'n': file['h']})
            k = file['k']
            iv = file['iv']
            meta_mac = file['meta_mac']

        # Seems to happens sometime... When this occurs, files are
        # inaccessible also in the official also in the official web app.
        # Strangely, files can come back later.
        if 'g' not in file_data:
            raise RequestError('File not accessible anymore')
        file_url = file_data['g']
        file_size = file_data['s']
        attribs = await base64_url_decode(file_data['at'])
        attribs = await decrypt_attr(attribs, k)

        if dest_filename is not None:
            file_name = dest_filename
        else:
            file_name = attribs['n']

        input_file = requests.get(file_url, stream=True).raw

        if dest_path is None:
            dest_path = ''
        else:
            dest_path += '/'

        with tempfile.NamedTemporaryFile(mode='w+b',
                                         prefix='megapy_',
                                         delete=False) as temp_output_file:
            k_str = await a32_to_str(k)
            counter = Counter.new(128,
                                  initial_value=((iv[0] << 32) + iv[1]) << 64)
            aes = AES.new(k_str, AES.MODE_CTR, counter=counter)

            mac_str = '\0' * 16
            mac_encryptor = AES.new(k_str, AES.MODE_CBC,
                                    mac_str.encode("utf8"))
            iv_str = await a32_to_str([iv[0], iv[1], iv[0], iv[1]])

            for chunk_start, chunk_size in (await get_chunks(file_size)):
                chunk = input_file.read(chunk_size)
                chunk = aes.decrypt(chunk)
                temp_output_file.write(chunk)

                encryptor = AES.new(k_str, AES.MODE_CBC, iv_str)
                for i in range(0, len(chunk) - 16, 16):
                    block = chunk[i:i + 16]
                    encryptor.encrypt(block)

                # fix for files under 16 bytes failing
                if file_size > 16:
                    i += 16
                else:
                    i = 0

                block = chunk[i:i + 16]
                if len(block) % 16:
                    block += b'\0' * (16 - (len(block) % 16))
                mac_str = mac_encryptor.encrypt(encryptor.encrypt(block))

                file_info = os.stat(temp_output_file.name)
                logger.info('%s of %s downloaded', file_info.st_size,
                            file_size)
            file_mac = await str_to_a32(mac_str)
            # check mac integrity
            if (file_mac[0] ^ file_mac[1],
                    file_mac[2] ^ file_mac[3]) != meta_mac:
                raise ValueError('Mismatched mac')
            output_path = Path(dest_path + file_name)
            shutil.move(temp_output_file.name, output_path)
            return output_path

    async def upload(self, filename, dest=None, dest_filename=None):
        # determine storage node
        if dest is None:
            # if none set, upload to cloud drive node
            if not hasattr(self, 'root_id'):
                await self.get_files()
            dest = self.root_id

        # request upload url, call 'u' method
        with open(filename, 'rb') as input_file:
            file_size = os.path.getsize(filename)
            ul_url = await self._api_request({'a': 'u', 's': file_size})['p']

            # generate random aes key (128) for file
            ul_key = [random.randint(0, 0xFFFFFFFF) for _ in range(6)]
            k_str = await a32_to_str(ul_key[:4])
            count = Counter.new(
                128, initial_value=((ul_key[4] << 32) + ul_key[5]) << 64)
            aes = AES.new(k_str, AES.MODE_CTR, counter=count)

            upload_progress = 0
            completion_file_handle = None

            mac_str = '\0' * 16
            mac_encryptor = AES.new(k_str, AES.MODE_CBC,
                                    mac_str.encode("utf8"))
            iv_str = await a32_to_str([ul_key[4], ul_key[5], ul_key[4], ul_key[5]])
            if file_size > 0:
                for chunk_start, chunk_size in (await get_chunks(file_size)):
                    chunk = input_file.read(chunk_size)
                    upload_progress += len(chunk)

                    encryptor = AES.new(k_str, AES.MODE_CBC, iv_str)
                    for i in range(0, len(chunk) - 16, 16):
                        block = chunk[i:i + 16]
                        encryptor.encrypt(block)

                    # fix for files under 16 bytes failing
                    if file_size > 16:
                        i += 16
                    else:
                        i = 0

                    block = chunk[i:i + 16]
                    if len(block) % 16:
                        block += await makebyte('\0' * (16 - len(block) % 16))
                    mac_str = mac_encryptor.encrypt(encryptor.encrypt(block))

                    # encrypt file and upload
                    chunk = aes.encrypt(chunk)
                    output_file = requests.post(ul_url + "/" +
                                                str(chunk_start),
                                                data=chunk,
                                                timeout=self.timeout)
                    completion_file_handle = output_file.text
                    logger.info('%s of %s uploaded', upload_progress,
                                file_size)
            else:
                output_file = requests.post(ul_url + "/0",
                                            data='',
                                            timeout=self.timeout)
                completion_file_handle = output_file.text

            logger.info('Chunks uploaded')
            logger.info('Setting attributes to complete upload')
            logger.info('Computing attributes')
            file_mac = await str_to_a32(mac_str)

            # determine meta mac
            meta_mac = (file_mac[0] ^ file_mac[1], file_mac[2] ^ file_mac[3])

            dest_filename = dest_filename or os.path.basename(filename)
            attribs = {'n': dest_filename}

            encrypt_attribs = await base64_url_encode(
                await encrypt_attr(attribs, ul_key[:4]))
            key = [
                ul_key[0] ^ ul_key[4], ul_key[1] ^ ul_key[5],
                ul_key[2] ^ meta_mac[0], ul_key[3] ^ meta_mac[1], ul_key[4],
                ul_key[5], meta_mac[0], meta_mac[1]
            ]
            encrypted_key = await a32_to_base64(await encrypt_key(key, self.master_key))
            logger.info('Sending request to update attributes')
            # update attributes
            data = await self._api_request({
                'a':
                'p',
                't':
                dest,
                'i':
                self.request_id,
                'n': [{
                    'h': completion_file_handle,
                    't': 0,
                    'a': encrypt_attribs,
                    'k': encrypted_key
                }]
            })
            logger.info('Upload complete')
            return data

    async def _mkdir(self, name, parent_node_id):
        # generate random aes key (128) for folder
        ul_key = [random.randint(0, 0xFFFFFFFF) for _ in range(6)]

        # encrypt attribs
        attribs = {'n': name}
        encrypt_attribs = await base64_url_encode(await encrypt_attr(attribs, ul_key[:4]))
        encrypted_key = await a32_to_base64(await encrypt_key(ul_key[:4], self.master_key))

        # update attributes
        data = await self._api_request({
            'a':
            'p',
            't':
            parent_node_id,
            'n': [{
                'h': 'xxxxxxxx',
                't': 1,
                'a': encrypt_attribs,
                'k': encrypted_key
            }],
            'i':
            self.request_id
        })
        return data

    async def _root_node_id(self):
        if not hasattr(self, 'root_id'):
            await self.get_files()
        return self.root_id

    async def create_folder(self, name, dest=None):
        dirs = tuple(dir_name for dir_name in str(name).split('/') if dir_name)
        folder_node_ids = {}
        for idx, directory_name in enumerate(dirs):
            existing_node_id = await self.find_path_descriptor(directory_name)
            if existing_node_id:
                folder_node_ids[idx] = existing_node_id
                continue
            if idx == 0:
                if dest is None:
                    parent_node_id = await self._root_node_id()
                else:
                    parent_node_id = dest
            else:
                parent_node_id = folder_node_ids[idx - 1]
            created_node = await self._mkdir(name=directory_name,
                                       parent_node_id=parent_node_id)
            node_id = created_node['f'][0]['h']
            folder_node_ids[idx] = node_id
        return dict(zip(dirs, folder_node_ids.values()))

    async def rename(self, file, new_name):
        file = file[1]
        # create new attribs
        attribs = {'n': new_name}
        # encrypt attribs
        encrypt_attribs = await base64_url_encode(await encrypt_attr(attribs, file['k']))
        encrypted_key = await a32_to_base64(await encrypt_key(file['key'],
                                                  self.master_key))
        # update attributes
        return await self._api_request([{
            'a': 'a',
            'attr': encrypt_attribs,
            'key': encrypted_key,
            'n': file['h'],
            'i': self.request_id
        }])

    async def move(self, file_id, target):
        """
        Move a file to another parent node
        params:
        a : command
        n : node we're moving
        t : id of target parent node, moving to
        i : request id

        targets
        2 : root
        3 : inbox
        4 : trash

        or...
        target's id
        or...
        target's structure returned by find()
        """

        # determine target_node_id
        if type(target) == int:
            target_node_id = str(await self.get_node_by_type(target)[0])
        elif type(target) in (str, ):
            target_node_id = target
        else:
            file = target[1]
            target_node_id = file['h']
        return await self._api_request({
            'a': 'm',
            'n': file_id,
            't': target_node_id,
            'i': self.request_id
        })

#     async def add_contact(self, email):
#         """
#         Add another user to your mega contact list
#         """
#         return await self._edit_contact(email, True)

#     async def remove_contact(self, email):
#         """
#         Remove a user to your mega contact list
#         """
#         return await self._edit_contact(email, False)

#     async def _edit_contact(self, email, add):
#         """
#         Editing contacts
#         """
#         if add is True:
#             l = '1'  # add command
#         elif add is False:
#             l = '0'  # remove command
#         else:
#             raise ValidationError('add parameter must be of type bool')

#         if not re.match(r"[^@]+@[^@]+\.[^@]+", email):
#             ValidationError('add_contact requires a valid email address')
#         else:
#             return await self._api_request({
#                 'a': 'ur',
#                 'u': email,
#                 'l': l,
#                 'i': self.request_id
#             })

    async def get_public_url_info(self, url):
        """
        Get size and name from a public url, dict returned
        """
        file_handle, file_key = await self._parse_url(url).split('!')
        return await self.get_public_file_info(file_handle, file_key)

    async def import_public_url(self, url, dest_node=None, dest_name=None):
        """
        Import the public url into user account
        """
        file_handle, file_key = await self._parse_url(url).split('!')
        return await self.import_public_file(file_handle,
                                       file_key,
                                       dest_node=dest_node,
                                       dest_name=dest_name)

    async def get_public_file_info(self, file_handle, file_key):
        """
        Get size and name of a public file
        """
        data = await self._api_request({'a': 'g', 'p': file_handle, 'ssm': 1})
        if isinstance(data, int):
            raise RequestError(data)

        if 'at' not in data or 's' not in data:
            raise ValueError("Unexpected result", data)

        key = await base64_to_a32(file_key)
        k = (key[0] ^ key[4], key[1] ^ key[5], key[2] ^ key[6],
             key[3] ^ key[7])

        size = data['s']
        unencrypted_attrs = await decrypt_attr(await base64_url_decode(data['at']), k)
        if not unencrypted_attrs:
            return None
        result = {'size': size, 'name': unencrypted_attrs['n']}
        return result

    async def import_public_file(self,
                           file_handle,
                           file_key,
                           dest_node=None,
                           dest_name=None):
        """
        Import the public file into user account
        """
        # Providing dest_node spare an API call to retrieve it.
        if dest_node is None:
            # Get '/Cloud Drive' folder no dest node specified
            dest_node = await self.get_node_by_type(2)[1]

        # Providing dest_name spares an API call to retrieve it.
        if dest_name is None:
            pl_info = await self.get_public_file_info(file_handle, file_key)
            dest_name = pl_info['name']

        key = await base64_to_a32(file_key)
        k = (key[0] ^ key[4], key[1] ^ key[5], key[2] ^ key[6],
             key[3] ^ key[7])

        encrypted_key = await a32_to_base64(await encrypt_key(key, self.master_key))
        encrypted_name = await base64_url_encode(await encrypt_attr({'n': dest_name}, k))
        return await self._api_request({
            'a':
            'p',
            't':
            dest_node['h'],
            'n': [{
                'ph': file_handle,
                't': 0,
                'a': encrypted_name,
                'k': encrypted_key
            }]
        })
