from argparse import Namespace
from datetime import datetime, timedelta

import functools
import json
import os
import shutil

from tabulate import tabulate
from tinydb.queries import where
import click
import pyperclip
import yaml

from ._compat import FileExistsError
from .credential import split_fullname, make_fullname
from .crypt import Cryptor
from .database import Database
from .importers import find_importer
from .utils import genpass, get_version


__version__ = get_version()

CONFIG_PATH = os.path.expanduser('~/.passpierc')

config_dict = {}
if os.path.exists(CONFIG_PATH):
    with open(CONFIG_PATH) as f:
        config_content = f.read()

    try:
        config_dict = yaml.load(config_content)
        keys = ('path', 'show_password', 'headers', 'colors', 'table_format')
        for k in keys:
            assert k in config_dict
    except (AssertionError, yaml.scanner.ScannerError) as e:
        click.ClickException('Bad configuration file: {}'.format(e))

config = Namespace(
    path=config_dict.get('path', os.path.expanduser("~/.passpie")),
    show_password=config_dict.get('show_password', False),
    short_commands=config_dict.get('short_commands', False),
    table_format=config_dict.get('table_format', "fancy_grid"),
    headers=config_dict.get(
        'headers',
        ["name", "login", "password", "comment"]),
    colors=config_dict.get(
        'colors',
        {"name": "yellow", "login": "green", "password": "magenta"}),
)


class AliasedGroup(click.Group):

    def get_command(self, ctx, cmd_name):
        rv = click.Group.get_command(self, ctx, cmd_name)
        if rv is not None:
            return rv
        matches = [x for x in self.list_commands(ctx)
                   if x.startswith(cmd_name)]
        if not matches:
            return None
        elif len(matches) == 1:
            return click.Group.get_command(self, ctx, matches[0])
        ctx.fail('Too many matches: %s' % ', '.join(sorted(matches)))


class Table(object):
    """Reprensent table outputs"""

    def __init__(self, data):
        self.data = data
        self.tablefmt = config.table_format

    def __getitem__(self, keys):
        if isinstance(keys, str):
            keys = (keys,)
        return [[e[k] for k in keys]
                for e in self.data]

    def render(self, keys):
        return tabulate(self[keys],
                        headers=[k.title() for k in keys],
                        tablefmt=self.tablefmt)


def get_credential_or_abort(db, fullname):
    credential = db.get(where("fullname") == fullname)
    if not credential:
        message = "Credential '{}' not found".format(fullname)
        raise click.ClickException(click.style(message, fg='red'))
    return credential


def ensure_passphrase(db, passphrase):
    try:
        with Cryptor(db._storage.path) as cryptor:
            cryptor.check(passphrase, ensure=True)
        return passphrase
    except ValueError:
        message = 'Wrong passphrase'
        raise click.ClickException(click.style(message, fg='red'))


def print_table(credentials):
    if credentials:
        for credential in credentials:
            credential['password'] = "*****"
            headers = config.headers

        for header, color in config.colors.items():
            for credential in credentials:
                credential[header] = click.style(credential[header], fg=color)

        for credential in [c for c in credentials if 'name' in c.keys()]:
            credential['name'] = click.style(credential['name'], bold=True)

        for credential in [c for c in credentials if 'login' in c.keys()]:
            credential['login'] = click.style(credential['login'], bold=True)

        click.echo(Table(credentials).render(headers))


def ensure_database(func):
    @functools.wraps(func)
    def decorator(*args, **kwargs):
        keys_path = os.path.join(config.path, '.keys')
        if os.path.isdir(config.path) and os.path.isfile(keys_path):
            return func(*args, **kwargs)
        else:
            message = 'Not initialized database at {.path}'.format(config)
            raise click.ClickException(click.style(message, fg='yellow'))
    return decorator


@click.group(cls=AliasedGroup if config.short_commands else click.Group,
             invoke_without_command=True)
@click.option('-D', '--database', help='Alternative database path',
              type=click.Path(dir_okay=True, writable=True, resolve_path=True))
@click.version_option(version=__version__)
@click.pass_context
def cli(ctx, database=config.path):
    if database:
        config.path = database
    if ctx.invoked_subcommand is None:
        db = Database(config.path)
        credentials = sorted(db.all(), key=lambda x: x["name"]+x["login"])
        print_table(credentials)


@cli.command(name='config', help='Show configuration')
def print_config():
    print(yaml.dump(vars(config), default_flow_style=False))


@cli.command(help="Initialize new passpie database")
@click.option('--passphrase', prompt=True, hide_input=True,
              confirmation_prompt=True)
@click.option('--force', is_flag=True, help="force overwrite database")
def init(passphrase, force):
    if force:
        shutil.rmtree(config.path)

    try:
        with Cryptor(config.path) as cryptor:
            cryptor.create_keys(passphrase)
    except FileExistsError:
        message = "Database exists in {}. `--force` to overwrite".format(
            config.path)
        raise click.ClickException(click.style(message, fg='yellow'))
    click.echo("Initialized database in {}".format(config.path))


@cli.command(help="Add new credential")
@click.argument("fullname")
@click.option('-r', '--random', 'password', flag_value=genpass())
@click.password_option(help="Credential password")
@click.option('-c', '--comment', default="", help="Credential comment")
@ensure_database
def add(fullname, password, comment):
    db = Database(config.path)
    try:
        login, name = split_fullname(fullname)
    except ValueError:
        message = 'invalid fullname syntax'
        raise click.ClickException(click.style(message, fg='yellow'))

    found = db.get((where("login") == login) & (where("name") == name))
    if not found:
        with Cryptor(config.path) as cryptor:
            encrypted = cryptor.encrypt(password)

        credential = dict(fullname=fullname,
                          name=name,
                          login=login,
                          password=encrypted,
                          comment=comment,
                          modified=datetime.now())
        db.insert(credential)
    else:
        message = "Credential {} already exists. --force to overwrite".format(
            fullname)
        raise click.ClickException(click.style(message, fg='yellow'))


@cli.command(help="Update credential")
@click.argument("fullname")
@click.option("--name", help="Credential new name")
@click.option("--login", help="Credential new login")
@click.option("--comment", help="Credential new comment")
@click.option("--password", help="Credential new password")
@click.option('--random', 'password', flag_value=genpass(),
              help="Credential new randomly generated password")
def update(fullname, name, login, password, comment):
    db = Database(config.path)
    credential = get_credential_or_abort(db, fullname)
    values = credential.copy()

    if any([name, login, password, comment]):
        values["name"] = name if name else credential["name"]
        values["login"] = login if login else credential["login"]
        values["password"] = password if password else credential["password"]
        values["comment"] = comment if comment else credential["comment"]
    else:
        values["name"] = click.prompt("Name", default=credential["name"])
        values["login"] = click.prompt("Login", default=credential["login"])
        values["password"] = click.prompt("Password",
                                          hide_input=True,
                                          default=credential["password"],
                                          confirmation_prompt=True,
                                          show_default=False,
                                          prompt_suffix=" [*****]: ")
        values["comment"] = click.prompt("Comment",
                                         default=credential["comment"])

    if values != credential:
        values["fullname"] = make_fullname(values["login"], values["name"])
        values["modified"] = datetime.now()
        if values["password"] != credential["password"]:
            with Cryptor(config.path) as cryptor:
                values["password"] = cryptor.encrypt(password)
        db = Database(config.path)
        db.update(values, (where("fullname") == credential["fullname"]))


@cli.command(help="Remove credential")
@click.argument("fullname")
def remove(fullname):
    db = Database(config.path)
    credential = get_credential_or_abort(db, fullname)
    if credential:
        click.confirm(
            'Remove credential: {}'.format(click.style(fullname, 'yellow')),
            abort=True
        )
        db.remove(where('fullname') == credential["fullname"])


@cli.command(help="Copy credential password to clipboard")
@click.argument("fullname")
@click.option("--passphrase", prompt="Passphrase", hide_input=True)
def copy(fullname, passphrase):
    db = Database(config.path)
    credential = get_credential_or_abort(db, fullname)
    ensure_passphrase(db, passphrase)
    with Cryptor(config.path) as cryptor:
        decrypted = cryptor.decrypt(credential["password"],
                                    passphrase=passphrase)
        pyperclip.copy(decrypted)
        click.secho("Password copied to clipboard".format(
            credential["fullname"]), fg="yellow")


@cli.command(help="Search credentials by regular expressions")
@click.argument("regex")
def search(regex):
    db = Database(config.path)
    credentials = db.search(
        where("name").matches(regex) |
        where("login").matches(regex) |
        where("comment").matches(regex))
    credentials = sorted(credentials, key=lambda x: x["name"]+x["login"])
    print_table(credentials)


@cli.command(help="Diagnose database for improvements")
@click.option("--full", is_flag=True, help="Show all entries")
@click.option("--days", default=90, type=int, help="Elapsed days")
@click.option("--passphrase", prompt="Passphrase", hide_input=True)
def status(full, days, passphrase):
    db = Database(config.path)
    ensure_passphrase(db, passphrase)
    credentials = sorted(db.all(), key=lambda x: x["name"]+x["login"])
    with Cryptor(config.path) as cryptor:
        for cred in credentials:
            cred["password"] = cryptor.decrypt(cred["password"], passphrase)

    if credentials:
        # check passwords
        for cred in credentials:
            repeated = [
                c["fullname"] for c in credentials
                if c["password"] == cred["password"] and c != cred
            ]
            if repeated:
                password = "Same as: {}".format(repeated)
                cred["repeated"] = click.style(password, "red")
            else:
                cred["repeated"] = click.style("OK", "green")

        for cred in credentials:
            cred['password'] = cred['repeated']

        # check modified time
        for cred in credentials:
            modified_delta = (datetime.now() - cred["modified"])
            if modified_delta > timedelta(days=days):
                modified_time = "{} days ago".format(modified_delta.days)
                cred["modified"] = click.style(modified_time, "red")

            else:
                cred["modified"] = click.style("OK", "green")

        if not full:
            credentials = [c for c in credentials
                           if c["password"] or c["modified"]]

        table = Table(credentials)
        click.echo(table.render(["name", "login", "password", "modified"]))


@cli.command(name="export", help="Export credentials in plain text")
@click.argument("dbfile", type=click.File("w"))
@click.option("--json", "as_json", is_flag=True, help="Export as JSON")
@click.option("--passphrase", prompt="Passphrase", hide_input=True)
def export_database(dbfile, as_json, passphrase):
    db = Database(config.path)
    ensure_passphrase(db, passphrase)
    credentials = sorted(db.all(), key=lambda x: x["name"]+x["login"])
    with Cryptor(config.path) as cryptor:
        for cred in credentials:
            cred["password"] = cryptor.decrypt(cred["password"], passphrase)

    if as_json:
        for cred in credentials:
            cred["modified"] = str(cred["modified"])
        dict_content = {
            'handler': 'passpie',
            'version': 1.0,
            'credentials': [dict(x) for x in credentials],
        }
        content = json.dumps(dict_content, indent=2)
    else:
        dict_content = {
            'handler': 'passpie',
            'version': 1.0,
            'credentials': [dict(x) for x in credentials],
        }
        content = yaml.dump(dict_content, default_flow_style=False)

    dbfile.write(content)


@cli.command(name="import", help="Import credentials from path")
@click.argument("path", type=click.Path())
def import_database(path):
    importer = find_importer(path)
    if importer:
        credentials = importer.handle(path)
        db = Database(config.path)
        with Cryptor(config.path) as cryptor:
            for cred in credentials:
                encrypted = cryptor.encrypt(cred['password'])
                cred['password'] = encrypted
        db.insert_multiple(credentials)


@cli.command(help='Renew passpie password and re-encrypt credentials')
@click.option("--passphrase", prompt="Passphrase", hide_input=True)
def reset(passphrase):
    db = Database(config.path)
    ensure_passphrase(db, passphrase)
    new_passphrase = click.prompt('New passphrase',
                                  hide_input=True, confirmation_prompt=True)

    credentials = db.all()
    if credentials:
        with Cryptor(config.path) as cryptor:
            # decrypt passwords
            for cred in credentials:
                cred["password"] = cryptor.decrypt(cred["password"],
                                                   passphrase)

            # recreate keys
            cryptor.create_keys(new_passphrase, overwrite=True)

            # encrypt passwords
            for cred in credentials:
                cred["password"] = cryptor.encrypt(cred["password"])

        # remove old and insert re-encrypted credentials
        db.purge()
        db.insert_multiple(credentials)
