"""cpassdb - Client Protocol Classes"""
__author__ = "Brian Wiborg <baccenfutter@c-base.org>"
__license__ = "GNU/GPLv2"

import os
import sys
import json
import base64
import commands
from twisted.internet import reactor
from twisted.protocols.basic import LineReceiver


class ClientProtocol(LineReceiver):
    """Abstract client protocol base-class

    This class serves as an abstract base-class for all other cpassdb client
    protocols. It provides common methods for all client protocols.
    """

    # This variable holds the request structure.
    request = {}

    # In case the server encounters an error, that error will be saved into
    # this instance attribute.
    error = None

    def connectionMade(self):
        self.send_request(**self.request)

    def dataReceived(self, data):
        """Callback function for incoming data.

        This function should be called from ancestor functions as it respects
        failure responses and handles them accordingly. Additionally, it will
        load the received JSON string into a data structure and return that.
        So there is always a benefit of calling this method.

        :param data: str    - The received line of data.
        """
        if data.startswith('ERROR: '):
            self.error = data
            self.exit_code = 1
            return self.terminate(data)

        try:
            return json.loads(data)

        except ValueError:
            print data
            self.terminate("ERROR: Can not decode JSON.")

    def connectionLost(self, reason=""):
        """Callback function for lost connections.

        For cpassdb clients, a lost connection means that there is no more data
        expected from the server, so the reactor should be stopped.

        :param reason: str  - An optional reason.
        """
        if reactor.running:
            reactor.stop()

    def terminate(self, error=None):
        """Helper function for terminating a connection.

        The provided error message is written to stderr.

        :param error: str   - An optional error message.
        """
        if error is not None:
            self.error = error
            self.exit_code = 1

        if error:
            sys.stderr.write("{}\n".format(error))

        self.transport.loseConnection()

    def gracefully_disconnect(self):
        """Helper function for gracefully terminating a connection.

        The gracefulness comes from sending a termination request to the server
         and having the server terminate the connection.
        """
        self.transport.write("{}\n".format(self.sign_request({"type": "BYE"})))

    def sign_request(self, request):
        """Help function for request signing.

        :param request: struct  - Request structure.
        :return: str            - GPG CLear-Text Armor
        """
        request_string = json.dumps(request)
        status, armor = commands.getstatusoutput("echo '{}' | gpg --clearsign".format(request_string))
        if status:
            return self.terminate("ERROR: Can not load private key.")
        return armor

    def send_request(self, *args, **kwargs):
        """This method must be overloaded by derived classes."""
        raise NotImplementedError("This method must be overloaded by derived classes!")


class MessyClientProtocol(ClientProtocol):
    """Abstract base-class for messy client protocols

    A messy client protocol is one that leaves behind dirty secrets after
    running. So this would be the case for operations such as adding a key to
    or removing it from a recipient group. All secrets that are encrypted for
    this particular recipient group does not match the current state any more;
    it is still decryptable by the old keys or not decryptable by the new ones.
    """

    # This dict can be used by ancestors as a small state-machine.
    state_machine = {
        'requested_dirty_secrets': False,
    }

    def build_request_get_dirty_secrets(self, dirty_secrets):
        print "{} secret(s) need to be cycled, requesting.".format(len(dirty_secrets)),
        return {
            'type': 'GET',
            'names': dirty_secrets,
        }

    def build_request_set_dirty_secrets(self, incoming_dirty_secrets):
        request = {
            'type': 'SET',
            'secrets': [],
        }

        for secret_name in incoming_dirty_secrets:
            secret = json.loads(
                commands.getoutput(
                    "echo '{}' | gpg --decrypt 2>/dev/null".format(
                        incoming_dirty_secrets[secret_name]['armor']
                    )
                )
            )
            ttl = incoming_dirty_secrets[secret_name]['metadata']['ttl']
            recipients = incoming_dirty_secrets[secret_name]['metadata']['recipients']
            secret_object = {
                'name': secret_name,
                'secret': secret,
                'metadata': {
                    'ttl': ttl,
                    'recipients': recipients,
                }
            }
            request['secrets'].append(secret_object)
            print '.',

        print
        return request

    def get_dirty_secrets(self, dirty_secrets):
        request = self.build_request_get_dirty_secrets(dirty_secrets)
        self.transport.write("{}\n".format(self.sign_request(request)))

    def set_dirty_secrets(self, dirty_secrets):
        request = self.build_request_set_dirty_secrets(dirty_secrets)
        self.transport.write("{}\n".format(self.sign_request(request)))

    def handle_dirty_secret_dialog(self, response):
        if self.state_machine['requested_dirty_secrets'] is False:
            self.get_dirty_secrets(response)
            self.state_machine['requested_dirty_secrets'] = True

        elif self.state_machine['requested_dirty_secrets'] is True:
            self.set_dirty_secrets(response)
            self.state_machine['requested_dirty_secrets'] = None

        else:
            print "Cycled {} secret(s).".format(len(response))



class SetSecret(ClientProtocol):
    """cpassdb client protocol class for setting a secret."""


    def send_request(self, name, secret, ttl, recipients):
        """Send write request to server.

        :param name: str        - Name of the secret (incl. categories).
        :param secret: struct   - The secret data structure (usually a dict).
        :param ttl: int         - Time to live (in days past today).
        :param recipients: list - List of all recipients.
        """
        request = {
            "type": "SET",
            "secrets": [{
                "name": name,
                "secret": secret,
                "metadata": {
                    "ttl": ttl,
                    "recipients": recipients,
                }
            }]
        }
        self.transport.write("{}\n".format(self.sign_request(request)))

    def dataReceived(self, data):
        """Callback function for incoming response data.

        This protocol will return a list of written secrets upon successful
        execution or an error message upon failure.

        :param data: str    - The incoming line of data.
        """
        # Parse incoming data into response object.
        response = ClientProtocol.dataReceived(self, data)

        if response:
            print "Written:", ' '.join(response)

        self.gracefully_disconnect()


class GetSecret(ClientProtocol):
    """cpassdb client protocol class for getting secrets."""

    # Set this variable to true if you would like the data to be outputted as
    # a JSON string. The JSON output will always print the full secret object
    # including all metadata.
    as_json = False

    # Set this value to the integer value of line indentation you desire for
    # the JSON output. Defining this attribute only makes sense in combination
    # with the as_json class attribute.
    indent_json = None

    # Set this list to the fields you wish the output the be limited to. Using
    # this attribute only works on non-JSON output format.
    filter_fields = []

    # Store a sorted list of all requested secrets, so they can be outputted in
    # the requested order.
    requested_secrets = []

    def send_request(self, names):
        """Send read request to server.

        :param names: list  - A list of secret-object names.
        """
        request = {
            "type": "GET",
            "names": names,
        }
        self.transport.write("{}\n".format(self.sign_request(request)))

    def decrypt_secret_armor(self, gpg_armor):
        """Helper function for decrypting a GPG encrypted message armor.

        :param gpg_armor: str   - The GPG armor.
        :return: struct         - JSON-loaded secret data structure.
        """
        return json.loads(
            commands.getoutput(
                "echo '{}' | gpg --decrypt 2>/dev/null".format(gpg_armor)
            )
        )

    def dataReceived(self, data):
        """Callback function for incoming data.

        This protocol will return a list of secret object data structures
        containing all metadata in the following format:

            [
                {
                    'name': <secret-object-name>,
                    'secret': <secret-object-data-struct>,
                    'metadata': {
                        'ttl': <ttl>,
                        'recipients': [<recipient>, ...],
                    }
                },
                ...
            ]

        :param data: str    - The line of incoming data.
        """

        # Parse incoming data.
        response = ClientProtocol.dataReceived(self, data)

        if response:
            if self.as_json:
                print json.dumps([{
                    'name': secret_name,
                    'secret': self.decrypt_secret_armor(response[secret_name]['armor']),
                    'metadata': response[secret_name]['metadata'],
                } for secret_name in response], indent=self.indent_json)

            else:
                for secret_name in response:
                    secret = self.decrypt_secret_armor(response[secret_name]['armor'])

                    if self.filter_fields:
                        for field in self.filter_fields:
                            if field in secret:
                                print secret[field]

                    else:
                        print '###', secret_name, '###'
                        for field in secret:
                            print field + ':', secret[field]
                        print

        self.gracefully_disconnect()


class DelSecret(ClientProtocol):
    """cpassdb client protocol class for deleting a secret."""

    def send_request(self, name):
        """Send delete request to server.

        :param name: str    - Name of secret to delete (incl. its category)
        """
        request = {
            "type": "DEL",
            "name": name,
        }
        self.transport.write("{}\n".format(self.sign_request(request)))

    def dataReceived(self, data):
        """Callback function for incoming response data.

        This protocol will reply with a success string upon successful
        execution.

        :param data: str    - The actual line of incoming data.
        """
        response = ClientProtocol.dataReceived(self, data)
        if response:
            print response
        self.gracefully_disconnect()


class ListSecrets(ClientProtocol):
    """cpassdb client protocol class for listing secrets in a given category."""

    def send_request(self, category=None):
        """Send list request to server.

        :param category: str    - Name of category (default: None)
        """
        request = {
            "type": "LIST",
            "path": category,
        }
        self.transport.write("{}\n".format(self.sign_request(request)))

    def dataReceived(self, data):
        """Callback function for incoming response data.

        This protocol will reply with a list of secret-object names upon
        successful execution.

        :param data: str    - Actual line of incoming data.
        """
        response = ClientProtocol.dataReceived(self, data)

        if response:
            if isinstance(response, dict):
                for d in response['dirs']:
                    print d + os.path.sep

                for secret in response['secrets']:
                    print secret

            elif isinstance(response, list):
                for secret in response:
                    print secret

            else:
                raise NotImplementedError

        self.gracefully_disconnect()


class InstallRootKey(ClientProtocol):
    """cpassdb client protocol class for installing the root-key.

    There is a special recipient group that - apart of being a regular
    recipient group - mark all cpassdb admins. Admins are allowed to perform
    key-management operations such as adding keys, deleting keys and
    adding/removing keys from recipient groups.

    The cpassdb admin group is always included as recipient to every secret-object,
    meaning that cpassdb admins can always decrypt every secret. The admin group is
    not stated inside the recipient field in the metadata if not explicitly
    defined for that recipient group.

    Due to this convention it is not possible to use the cpassdb server before at
    least one key-id has been imported and added to the admin group. That's
    what this protocol is for. It is really only used once per server, usually.
    """

    def send_request(self, pubkey_armor):
        """Send root-key installation request to server.

        :param pubkey_armor: str    - The GPG armor of the public key.
        """
        request = {
            'pubkey_armor': pubkey_armor,
        }
        request_string = json.dumps(request)
        self.transport.write("{}\n".format(request_string))

    def dataReceived(self, data):
        """Callback function for incoming response data.

        This protocol usually replies with a success string upon successful
        execution.

        :param data: str    - Actual line of incoming data.
        :return:
        """
        response = ClientProtocol.dataReceived(self, data)
        print response
        self.gracefully_disconnect()


class AddKey(MessyClientProtocol):
    """cpassdb client protocol class for adding a key."""

    def send_request(self, pubkey_armor, groups):
        """Send add-key request to server.

        :param pubkey_armor: str    - GPG armor of public key.
        :param groups: list         - List of group to add this key-id to.
        """
        request = {
            'type': 'ADDKEY',
            'pubkey_armor': base64.b64encode(pubkey_armor),
            'groups': groups,
        }
        self.transport.write("{}\n".format(self.sign_request(request)))

    def dataReceived(self, data):
        """Callback function for incoming response data.

        :param data: str    - Actual line of incoming data.
        """
        response = ClientProtocol.dataReceived(self, data)

        if response:
            self.handle_dirty_secret_dialog(response)

        self.gracefully_disconnect()


class DelKey(MessyClientProtocol):
    """cpassdb client protocol class for deleting a key."""

    def send_request(self, pubkey_id):
        """Send delete-key request to server.

        :param pubkey_id: str   - Key-id of key to delete.
        """
        request = {
            "type": "DELKEY",
            "pubkey_id": pubkey_id,
        }
        self.transport.write("{}\n".format(self.sign_request(request)))

    def dataReceived(self, data):
        """Callback function for incoming response data.

        :param data: str    - Actual line of incoming data.
        :return:
        """
        response = ClientProtocol.dataReceived(self, data)

        if response:
            self.handle_dirty_secret_dialog(response)

        self.gracefully_disconnect()


class ListKeys(ClientProtocol):
    """cpassdb client protocol class for listing all keys in the keyring."""

    def send_request(self, keyid_length=8):
        """Send list-keys request to server.

        :param keyid_length: int    - Length of the key-ids (common are 8 or 16).
        """
        request = {
            "type": "KEYS",
            "keyid_length": keyid_length,
        }
        self.transport.write("{}\n".format(self.sign_request(request)))

    def dataReceived(self, data):
        """Callback function for incoming response data.

        :param data: str    - Actual line of incoming data.
        """
        response = ClientProtocol.dataReceived(self, data)

        if response:
            for key in response:
                print key[0], ' '.join(key[1])

        self.gracefully_disconnect()


class ListGroups(ClientProtocol):
    """cpassdb client protocol class for listing all recipient groups."""

    def send_request(self, pubkey_id=None):
        """Send list-groups request to server.

        If the request is supplied with key-id, only the groups of that key-id
        will be returned.

        :param pubkey_id: str   - Optional key-id.
        """
        request = {
            "type": "GROUPS",
            "pubkey_id": pubkey_id,
        }
        self.transport.write('{}\n'.format(self.sign_request(request)))

    def dataReceived(self, data):
        """Callback function for incoming reponse data.

        :param data: str    - Actual line of incoming data.
        """
        response = ClientProtocol.dataReceived(self, data)

        if response:
            for group in response:
                print group + ':', ' '.join(response[group])

        self.gracefully_disconnect()


class AddGroups(MessyClientProtocol):
    """cpassdb client protocol class for add a key-id to a list of groups.

    When the members of a recipient group change, all secrets of that recipient
    group must be re-encrypted. If the secrets where not re-encrypted they
    would not be readable by the keys they should be in the current state of
    the recipient group. This adds extra ping-pong complexity to this protocol
    that the other client protocols don't have.
    """

    def send_request(self, pubkey_id, groups):
        """Send add-group request to server.

        :param pubkey_id: str   - Key-id of concern.
        :param groups:          - List of groups to add this key-id to.
        """
        request = {
            'type': 'ADDGRP',
            'pubkey_id': pubkey_id,
            'groups': groups,
        }
        self.transport.write("{}\n".format(self.sign_request(request)))

    def dataReceived(self, data):
        """Callback function for incoming response data.

        :param data: str    - Actual line of incoming data.
        :return:
        """
        response = ClientProtocol.dataReceived(self, data)

        if response:
            self.handle_dirty_secret_dialog(response)

        self.gracefully_disconnect()


class DelGroups(AddGroups):
    """cpassdb client protocol class for deleting a key-id from a group."""

    def send_request(self, pubkey_id, groups):
        """Send delete-group request to the server.

        :param pubkey_id: str   - Key-id of concern.
        :param groups:          - List of groups to free from the given key-id.
        """
        request = {
            'type': 'DELGRP',
            'pubkey_id': pubkey_id,
            'groups': groups,
        }
        self.transport.write("{}\n".format(self.sign_request(request)))

    def dataReceived(self, data):
        """Callback function for incoming response data.

        :param data: str    - str   - Actual line of incoming data.
        """
        AddGroups.dataReceived(self, data)
