#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Safe
  a commandline password manager using AES encryption, written in python.

:: author   Erick Daniszewski
:: date     05 December 2015
"""
import json
import getpass
import argparse
import struct

from os import mkdir, remove
from os.path import join as join_path
from os.path import isdir, isfile, expanduser, getsize
from random import randint

from Crypto.Cipher import AES
from hashlib import md5, sha256

# Safe Constants
SAFE_ROOT   = join_path(expanduser('~'), '.safe')
SAFE_CONFIG = join_path(SAFE_ROOT, 'config.json')
SAFE_META   = join_path(SAFE_ROOT, 'meta.json')
SAFES_PATH  = join_path(SAFE_ROOT, 'safes')
ALL_SAFES   = '__all'

# Define the argument parser
parser = argparse.ArgumentParser(description='Safe :: A minimalistic commandline password manager')

parser.add_argument('-i', '--init', nargs='*', help='initialize a new safe.')
parser.add_argument('-o', '--open', nargs='*', help='open the specified safe. this is needed for both read and write operations.')
parser.add_argument('-c', '--close', nargs='?', const=ALL_SAFES, help='close the specified safe. if no safe is specified, all open safes '
                                                                      'are closed. safes will not close automatically - it is up to the user '
                                                                      'to close their safes.')
parser.add_argument('-a', '--add', nargs='*', help='add a new entry to the safe. add can take 0..3 arguments, where the user will be prompted '
                                                   'to fill in any missing arguments. the arguments are positional. the order is as follows: '
                                                   '(1) entry name, (2) username/id, (3) password')
parser.add_argument('-d', '--delete', help='remove an entry from the safe.')
parser.add_argument('--default', help='set the default safe. the default safe is used by commands, such as open, to determine which safe '
                                      'to use if none is specified.')
parser.add_argument('-D', '--delete_safe', help='remove an existing safe. this removes the safe and all of its entries permanently.')
parser.add_argument('-m', '--modify', nargs='?', const=False, help='modify an existing entry for the open safe. this can be used '
                                                                   'to change username/password information.')
parser.add_argument('-M', '--modify_safe', nargs='*', help='modify an existing safe. this should be used if one wants to change the '
                                                           'master password to a safe, without losing the safe contents.')
parser.add_argument('-u', '--username', action='store_true', help='a flag which, when present, will include username info in an entry\'s output.')
parser.add_argument('-w', '--whole', action='store_true', help='a flag which, when present, will show the full entry (all data).')
parser.add_argument('-e', '--entries', action='store_true', help='show all entries (by name) which exist in the open safe.')
parser.add_argument('-s', '--safes', action='store_true', help='show all safes (by name) which exist.')
parser.add_argument('-f', '--force', action='store_true', help='force an action. typically, this is used with deletes in order to suppress '
                                                               'the verification prompt.')
parser.add_argument('-v', '--verbose', action='store_true', help='a flag which toggles the verbosity of safe. if set to true, additional messages '
                                                                 'will be output, such as verification of action success.')
parser.add_argument('entry', nargs='?', help='the name of the entry in the safe for which the stored information will be retrieved.')


# Define 'clean' state of configuration and metadata files
default_cfg  = dict(default_safe=None, verbose=False)
default_meta = dict(safes=[])

# By default, set the verbosity to False. This will get updated based on the value
# stored in the Safe configuration file at runtime.
is_verbose = False


# ========================================================================
# Convenience Methods
# ========================================================================

def get_meta():
    """ Get the Safe metadata from the metadata file.

    :return: A dictionary containing the metadata stored in the metadata file.
    :rtype: dict
    """
    with open(SAFE_META, 'r') as f:
        meta = json.load(f)
    return meta


def set_meta(data):
    """ Write a metadata to the metadata file.

    This will overwrite any existing metadata which may exist in the file.

    :param data: The metadata to write to the metadata file.
    :type data: dict
    :return: None
    """
    with open(SAFE_META, 'w') as f:
        json.dump(data, f)


def get_config_value(key):
    """ Get a value from the config file.

    :param key: The key to search for in the config file.
    :return: The value found in the config file, if exists. Otherwise None.
    """
    with open(SAFE_CONFIG, 'r') as f:
        cfg_data = json.load(f)
    if key in cfg_data:
        return cfg_data[key]
    return None


def overwrite_config(**kwargs):
    """ Update the config file based on the specified kwargs.

    If a key specified in the kwargs does not exist in the config file, that
    key:value pair will be skipped (will not be added to the config file, but
    also will not fail).

    :param kwargs: The entries in the config JSON to update.
    :return: None
    """
    with open(SAFE_CONFIG, 'r+') as f:
        cfg_data = json.load(f)
        for k, v in kwargs.items():
            if k in cfg_data:
                cfg_data[k] = v
        f.seek(0)
        json.dump(cfg_data, f)
        f.truncate()


def toggle_config_value(to_toggle):
    """ Toggle values in the config file based on the specified kwargs.

    The only values which may be toggled are boolean values. Attempts to toggle
    any other type will be ignored.

    :param to_toggle: The entry in the config JSON to toggle.
    :return: The new value of the updated config field.
    :rtype: bool
    """
    result = None
    with open(SAFE_CONFIG, 'r+') as f:
        cfg_data = json.load(f)
        if to_toggle in cfg_data and isinstance(cfg_data[to_toggle], bool):
            cfg_data[to_toggle] = not cfg_data[to_toggle]
            result = cfg_data[to_toggle]
        f.seek(0)
        json.dump(cfg_data, f)
        f.truncate()
    return result


def get_open_safe():
    """ Get the name of the Safe that is currently open, if it exists.

    There should never be more than one Safe open at a time. If multiple Safes
    are found to be open, this will indiscriminately close all of them to prevent
    erroneous writes.

    :return: The name of the open safe, if any. Otherwise None
    """
    meta = get_meta()

    unlocked = []
    for safe in meta['safes']:
        if safe['is_open']:
            unlocked.append(safe['name'])
    if not unlocked:
        return None
    if len(unlocked) > 1:
        info('more than one safe is open. closing all safes.')
        close_safe()
        return None
    else:
        return unlocked[0]


def get_safe_file_paths(safe_name):
    """ Get the path for the open and closed Safe files.

    :param safe_name: Name of the Safe.
    :return: A tuple which contains the path of the open safe file and the path
        of the closed safe file.
    """
    return join_path(SAFES_PATH, safe_name + '.open'), join_path(SAFES_PATH, safe_name)


def fail(message):
    """ A convenience method to exit with a failure message.

    :param message: The message to output.
    :type message: str
    :return: None
    """
    exit('\n[FAILED] - {}'.format(message))


def info(message):
    """ A convenience method to print out info messages to console.

    :param message: The message to output.
    :type message: str
    :return: None
    """
    print '>> {}'.format(message)


def exit_info(message):
    """ A convenience method to print out an info message to console and exit.

    :param message: The message to output.
    :type message: str
    :return: None
    """
    exit('>> {}'.format(message))


def prompt(message):
    """ A convenience method to prompt the used for information.

    :param message: The message to output for the prompt.
    :type message: str
    :return: The value given by the user.
    """
    return raw_input(':: {}: '.format(message))


# ========================================================================
# Encryption/Hashing Methods
# ========================================================================

def get_md5_hash(to_hash):
    """ Generate an MD5 hash of the given value.

    :param to_hash: Value to crate an MD5 hash of.
    :type to_hash: str
    :return: The hexadecimal representation of the MD5 hash.
    """
    return md5(to_hash).hexdigest()


def encrypt_file(password, in_file, out_file):
    """ Encrypt the contents of the given file.

    Encrypts the contents of the in_file into the out_file.

    :param password: The password for the file being encrypted.
    :type password: str
    :param in_file: The name of the file to encrypt.
    :type in_file: str
    :param out_file: The name of the file to create, which contains the encrypted data.
    :type out_file: str
    :return: None
    """
    bs = AES.block_size
    chunk_size = bs * 1024
    key = sha256(password).digest()

    iv = ''.join(chr(randint(0, 0xff)) for _ in range(16))
    cipher = AES.new(key, AES.MODE_CBC, iv)
    file_size = getsize(in_file)

    with open(in_file, 'rb') as in_f, open(out_file, 'wb') as out_f:
        out_f.write(struct.pack('<Q', file_size))
        out_f.write(iv)

        while True:
            chunk = in_f.read(chunk_size)
            if len(chunk) == 0:
                break
            elif len(chunk) % 16 != 0:
                chunk += ' ' * (16 - len(chunk) % 16)
            out_f.write(cipher.encrypt(chunk))


def decrypt_file(password, in_file, out_file):
    """ Decrypt the contents of the given file.

    Decrypts the contents of the in_file into the out_file.

    :param password: The password for the file being decrypted.
    :param in_file: The name of the file to decrypt.
    :param out_file: The name of the file to create, which contains the decrypted
        data.
    :return: None
    """
    bs = AES.block_size
    chunk_size = bs * 1024
    key = sha256(password).digest()

    with open(in_file, 'rb') as in_f, open(out_file, 'wb') as out_f:
        orig_size = struct.unpack('<Q', in_f.read(struct.calcsize('Q')))[0]
        iv = in_f.read(16)
        cipher = AES.new(key, AES.MODE_CBC, iv)

        while True:
            chunk = in_f.read(chunk_size)
            if len(chunk) == 0:
                break
            out_f.write(cipher.decrypt(chunk))
        out_f.truncate(orig_size)


# ========================================================================
# Safe Methods
# ========================================================================

def initialize():
    """ Initialize Safe, ensuring the Safe directory is created.

    This should:
     * create the Safe root dir, if it does not exist
     * create the safes dir to hold all user Safes, if it doesnt exist
     * create the base config and metadata files, if they do not exist

    :return: None
    """
    if not isdir(SAFE_ROOT):
        mkdir(SAFE_ROOT)
    if not isdir(SAFES_PATH):
        mkdir(SAFES_PATH)
    if not isfile(SAFE_CONFIG):
        with open(SAFE_CONFIG, 'w') as conf:
            json.dump(default_cfg, conf)
    if not isfile(SAFE_META):
        with open(SAFE_META, 'w') as meta:
            json.dump(default_meta, meta)


def initialize_safe(name=None, password=None):
    """ Create a new Safe.

    Creates a new Safe given a name and password. If no name and password are
    provided via commandline args, the user will be prompted for both.

    Safes cannot be overwritten. If a user desires to overwrite an existing
    Safe with a clean Safe of the same name, the Safe should first be deleted
    (using the -R or --remove-safe option) and a new safe initialized.

    :param name: The name of the Safe to initialize.
    :param password: The password for the new Safe.
    :return: None
    """
    if name is None:
        name = prompt('set safe name')
        if not name:
            name = 'safe'

    if name == ALL_SAFES:
        fail('cannot create safe with name "{}". this is a reserved name.'.format(name))

    if password is None:
        password = getpass.getpass(':: set password for safe "{}": '.format(name))
        re_password = getpass.getpass(':: re-enter password: ')
        if password != re_password:
                fail('entered passwords do not match.')

    # load in the data from the meta file
    meta = get_meta()

    # check if a safe with that name already exists
    for safe in meta['safes']:
        if safe['name'] == name:
            fail('a safe with name "{}" already exists.'.format(name))

    # hash the password
    password_hash = get_md5_hash(password)

    # close the open safe, if there is one
    active_safe = get_open_safe()
    if active_safe:
        set_meta(meta)
        close_safe(active_safe)
        meta = get_meta()

    # create an entry in the meta file
    new_safe = {
        'name': name,
        'hash': password_hash,
        'is_open': True
    }
    meta['safes'].append(new_safe)
    set_meta(meta)

    # create a safe file for this safe. note that the default behavior for the init
    # is to leave that safe open. otherwise, we would encrypt and close.
    with open(join_path(SAFES_PATH, name + '.open'), 'w+') as f:
        json.dump({'password': password, 'entries': []}, f)

    if is_verbose:
        info('created safe "{}"'.format(name))


def open_safe(name=None, password=None):
    """ Open an existing Safe.

    If no Safe is specified, the default Safe will be used. If no default Safe
    is specified, the user will be notified of failure and safe will exit. Opening
    a Safe will close any other Safe that is open, to prevent erroneous writes.

    :param name: Name of the safe to open. If no name is provided, the user will
        be prompted for a name.
    :param password: Password for the safe to open. If no password is provided, the
        user will be prompted for a password.
    :return: None
    """
    if name is None:
        name = get_config_value('default_safe')
        if not name:
            fail('no safe name provided, and no default safe found.')
        info('no safe provided; choosing default ({})'.format(name))

    if password is None:
        password = getpass.getpass(':: password: ')

    # close the open safe, if any
    active_safe = get_open_safe()
    if active_safe:
        close_safe(active_safe)

    meta = get_meta()
    for safe in meta['safes']:
        if safe['name'] == name:
            if safe['hash'] == get_md5_hash(password):
                if not safe['is_open']:
                    safe_paths = get_safe_file_paths(name)
                    decrypt_file(password, safe_paths[1], safe_paths[0])
                    remove(safe_paths[1])
                    safe['is_open'] = True
                else:
                    info('the safe is already open.')
            else:
                fail('password incorrect for safe')
        elif safe['is_open']:
            close_safe(safe['name'])

    set_meta(meta)

    if is_verbose:
        info('opened safe "{}"'.format(name))


def close_safe(name=None):
    """ Close an existing open Safe.

    Closes the specified Safe. All Safes will be closed if no specific Safe is
    specified. If a specified Safe does not exist, a message may be logged,
    otherwise nothing will happen, as a non-existent Safe can technically be
    considered a closed Safe.

    :param name: The name of the Safe to close.
    :return: None
    """
    meta = get_meta()

    def encrypt_safe(safe_name):
        safe_paths = get_safe_file_paths(safe_name)
        if isfile(safe_paths[0]):
            with open(safe_paths[0], 'r') as s:
                data = json.load(s)
            password = data['password']

            encrypt_file(password, safe_paths[0], safe_paths[1])
            remove(safe_paths[0])

    if name is None:
        for safe in meta['safes']:
            if safe['is_open']:
                encrypt_safe(safe['name'])
                safe['is_open'] = False
    else:
        for safe in meta['safes']:
            if safe['name'] == name:
                encrypt_safe(name)
                safe['is_open'] = False
                break

    set_meta(meta)

    if is_verbose:
        info('closed safe "{}"'.format(name))


def set_default(safe_name):
    """ Set the default Safe.

    This updates the 'default_safe' field in the Safe config file.

    :param safe_name: The name of the safe to be the default.
    :return: None
    """
    meta = get_meta()
    safe_exists = False
    for safe in meta['safes']:
        if safe['name'] == safe_name:
            safe_exists = True