# -*- coding: utf-8 -*-
"""opvault library"""
# Any code, applications, scripts, templates, proofs of concept, documentation
# and other items provided by OBLCC under this SOW are 'OBLCC Content' as
# defined in the Agreement, and are provided for illustration purposes only.
# All such OBLCC Content is provided solely at the option of OBLCC, and is
# subject to the terms of the Addendum and the Agreement. Customer is solely
# responsible for using, deploying, testing, and supporting any code and
# applications provided by OBLCC under this SOW.
#
# (c) 2018 Oblivion Cloud Control
# Author: S. Huizinga <steyn@oblcc.com>
# Author: S. Huizinga <steyn@oblcc.com>
#
# See https://support.1password.com/opvault-design/ for OPVault
# Design documentation
# See https://cache.agilebits.com/security-kb/ for sample data
# Inspired by https://github.com/sethvoltz/one_pass


from __future__ import print_function

import base64
import glob
import hashlib
import hmac
import json
import os
import platform
import struct
from collections import defaultdict

from Crypto.Cipher import AES

from .exceptions import OpvaultException


class OnePass():
    # pylint: disable=R0902
    """Opvault class"""

    def __init__(self, path=None, profile='default'):
        self._path = path
        if not self._path:
            home_dir = os.getenv('HOME')
            self._path = '{0}/1Password.opvault'.format(home_dir)

        self._profile = profile
        self._profile_path = '{0}/{1}/profile.js'.format(
            self._path, self._profile)

        self._master_key = self._master_mac_key = None
        self._overview_key = self._overview_mac_key = None

        self._items = None
        self._item_index = defaultdict(list)

        self._validate_vault()
        self._validate_profile()

    def get_items(self):
        """Get all items from index"""
        return self._item_index

    def __del__(self):
        self.lock()

    def _validate_vault(self):
        if not os.path.isdir(self._path) and not os.path.islink(self._path):
            except_msg = 'Vault not found in {0}'.format(self._path)
            raise OpvaultException('VaultNotFound', except_msg)

        if not os.access(self._path, os.R_OK):
            except_msg = 'Vault not readable {0}'.format(self._path)
            raise OpvaultException('VaultNotFound', except_msg)

        return True

    def _validate_profile(self):
        if not os.path.isfile(self._profile_path):
            except_msg = 'Profile not readable {0}'.format(self._profile_path)
            raise OpvaultException('ProfileNotFound', except_msg)

        try:
            with open(self._profile_path, 'r') as profile_file:
                profile_content = profile_file.read().strip()
        except (IOError, AttributeError):
            except_msg = 'Cannot open profile file {0}'.format(
                self._profile_path)
            raise OpvaultException('ProfileNotFound', except_msg)

        if not profile_content.startswith('var profile=') or \
                not profile_content.endswith(';'):
            except_msg = 'Invalid syntax in {0}'.format(self._profile_path)
            raise OpvaultException('ProfileNotFound', except_msg)

        try:
            self._profile_json = json.loads(profile_content[12:-1])
        except ValueError as profile_error:
            except_msg = 'Cannot parse profile {0}'.format(str(profile_error))
            raise OpvaultException('ProfileNotFound', except_msg)

        return True

    @staticmethod
    def is_python31_or_newer():
        """Check if python version >= 3.1"""
        major, minor, _patch = platform.python_version_tuple()
        return bool((major == 3 and minor > 1) or major > 3)

    def decode_function(self, *args, **kwargs):
        """Decode b64 encoded data"""
        if self.is_python31_or_newer():
            # pylint: disable=E1101
            return base64.decodebytes(*args, **kwargs)
        # pylint: disable=W1505
        return base64.decodestring(*args, **kwargs)

    def unlock(self, master_password):
        """Unlock the opvault vault with password"""
        salt = bytes(self.decode_function(self._profile_json['salt'].encode()))
        iterations = self._profile_json['iterations']

        key, mac_key = self._derive_keys(master_password.encode(),
                                         salt, iterations)

        try:
            self._master_key, self._master_mac_key = self.master_keys(
                key, mac_key)
            self._overview_key, self._overview_mac_key = self.overview_keys(
                key, mac_key)
        except OpvaultException as opvault_exception:
            except_msg = 'Incorrect password: "{0}"'.format(
                str(opvault_exception))
            raise OpvaultException('DecryptError', except_msg)

        return True

    def lock(self):
        """Lock the vault by resetting master key information"""
        self._master_key = self._master_mac_key = None

        return True

    def is_unlocked(self):
        """Is master password information present"""
        return bool(self._master_key and self._overview_key)

    @staticmethod
    def _derive_keys(master_password, salt, iterations):
        derived_key = hashlib.pbkdf2_hmac(
            'sha512', master_password, salt, iterations)
        key = derived_key[:32]
        hmac_key = derived_key[32:64]

        return key, hmac_key

    def master_keys(self, derived_key, derived_mac_key):
        """Get the master keys from vault"""
        encrypted = self.decode_function(
            self._profile_json['masterKey'].encode())

        return self.decrypt_keys(encrypted, derived_key, derived_mac_key)

    def overview_keys(self, derived_key, derived_mac_key):
        """List all keys"""
        encrypted = self.decode_function(
            self._profile_json['overviewKey'].encode())

        return self.decrypt_keys(encrypted, derived_key, derived_mac_key)

    def decrypt_keys(self, encrypted_key, derived_key, derived_mac_key):
        """Decrypt all encrypted keys"""
        key_base = self.decrypt_opdata(
            encrypted_key, derived_key, derived_mac_key)

        keys = hashlib.sha512(bytes(key_base))
        digest = keys.digest()

        key_from_digest = digest[:32]
        hmac_from_digest = digest[32:64]

        return key_from_digest, hmac_from_digest

    def decrypt_opdata(self, cipher_text, cipher_key, cipher_mac_key):
        """Decrypt opvault data"""
        key_data = cipher_text[:-32]
        mac_data = cipher_text[-32:]

        self.check_hmac(key_data, cipher_mac_key, mac_data)

        plaintext = self.decrypt_data(
            cipher_key, key_data[16:32], key_data[32:])
        plaintext_size = int(struct.unpack('Q', key_data[8:16])[0])

        plaintext_start = plaintext_size*-1
        opdata = plaintext[plaintext_start:]

        return opdata

    @staticmethod
    def check_hmac(data, hmac_key, desired_hmac):
        """Check if hmac matches"""
        computed_hmac = hmac.new(
            hmac_key, msg=data, digestmod=hashlib.sha256).digest()

        if bytes(computed_hmac) != bytes(desired_hmac):
            except_msg = 'Error checking HMAC'
            raise OpvaultException('DecodeError', except_msg)

        return True

    def load_items(self, exclude_trashed=False):
        """Load all items from json files"""
        file_glob = os.path.join(self._path, self._profile, 'band_*.js')

        self._items = {}
        for item in glob.glob(file_glob):
            with open(item, 'r') as file_descriptor:
                content = file_descriptor.read()[3:-2]
                try:
                    band = json.loads(content)
                    self._items.update(band)
                except ValueError:
                    pass

        self._item_index = defaultdict(list)
        for uuid, item in self._items.items():
            overview = self.item_overview(item)
            if 'title' in overview:
                if exclude_trashed and 'trashed' in item and item['trashed']:
                    continue
                self._item_index[overview['title']].append(uuid)

        return self._items

    def item_keys(self, item):
        """Get all keys for item"""
        item_key = self.decode_function(item['k'].encode())
        key_data = item_key[:-32]
        key_hmac = item_key[-32:]

        self.check_hmac(key_data, self._master_mac_key, key_hmac)
        plaintext = self.decrypt_data(
            self._master_key, key_data[0:16], key_data[16:])

        decrypted_key = plaintext[0:32]
        decrypted_hmac = plaintext[32:64]

        return decrypted_key, decrypted_hmac

    def item_overview(self, item):
        """Overview the item"""
        overview_data = self.decode_function(item['o'].encode())

        try:
            overview = self.decrypt_opdata(
                overview_data, self._overview_key, self._overview_mac_key)
            item_data = json.loads(overview)

        except OpvaultException as opvault_exception:
            except_msg = 'Cannot decrypt item: {0}, error: "{1}"'.format(
                item['uuid'], opvault_exception.error)
            raise OpvaultException('DecodeError', except_msg)

        except ValueError as value_error:
            except_msg = 'Cannot parse item: {0}, error: "{1}"'.format(
                item['uuid'], str(value_error))
            raise OpvaultException('DecodeError', except_msg)

        item_data.update({u'uuid': item['uuid']})

        return item_data

    def item_detail(self, item):
        """Get item details"""
        data = self.decode_function(item['d'].encode())

        try:
            item_key, item_mac_key = self.item_keys(item)
            detail = self.decrypt_opdata(data, item_key, item_mac_key)
            item_detail = json.loads(detail)

        except OpvaultException as opvault_exception:
            except_msg = 'Cannot decrypt item: {0}, error: "{1}"'.format(
                item['uuid'], opvault_exception.error)
            raise OpvaultException('DecodeError', except_msg)

        except ValueError as value_error:
            except_msg = 'Cannot parse item: {0}, error: "{1}"'.format(
                item['uuid'], str(value_error))
            raise OpvaultException('DecodeError', except_msg)

        return item_detail

    def get_item(self, title):
        """Get item from vault"""
        try:
            uuids = self._item_index[title]
            items = [self._items[uuid] for uuid in uuids]
        except KeyError:
            except_msg = 'Item with title {0} does not exists'.format(title)
            raise OpvaultException('ItemNotFound', except_msg)

        return [(self.item_overview(item), self.item_detail(item))
                for item in items]

    @staticmethod
    def decrypt_data(key, initialization_vector, data):
        """Decrypt data"""
        cipher = AES.new(key, AES.MODE_CBC, initialization_vector)
        return cipher.decrypt(data)
