#!/usr/bin/env python3
# -*- mode: Python; indent: 4 -*-
import socket as socket_module
import time
import select
import json
import logging
import sys
import collections
import traceback

from obj_mash import METADATA_MAX_SIZE, MAX_OBJECT_SIZE, NUL
from obj_mash import BusinessObject, hex_from_address
from obj_mash import host_routing_id, is_metadata_received, parse_metadata


SELECT_TIMEOUT = 60

# Data structures and helpers
_client_fields = ["address", "send_buffer", "receive_buffer", "disconnected",
                  "subscriptions", "routing_id"]
_ClientBase = collections.namedtuple("Client", _client_fields)

logger_basename = sys.argv[0].strip('.').strip('/')
def lg(name=None):
    if name is None:
        return logging.getLogger(logger_basename)
    else:
        return logging.getLogger(logger_basename + "." + name)

class Client(_ClientBase):
    def _attr_substituted(self, attr_name, value):
        """
        Implementation is a bit ugly as vars(self) and self._asdict() seem to
        not work like they should on for example Python 3.4 on Mac.
        """
        attrs = {}
        for field in _client_fields:
            attrs[field] = getattr(self, field)
        attrs[attr_name] = value
        return attrs

    def from_address(address):
        return Client(address=address, send_buffer=bytes(), receive_buffer=bytes(),
                      disconnected=False, subscriptions=None, routing_id=hex_from_address(address))

    def with_send_buffer(self, buffer):
        return Client(**self._attr_substituted('send_buffer', buffer))

    def with_receive_buffer(self, buffer):
        return Client(**self._attr_substituted('receive_buffer', buffer))

    def with_subscriptions(self, subscriptions):
        return Client(**self._attr_substituted('subscriptions', subscriptions))

    def as_disconnected(self):
        return Client(**self._attr_substituted('disconnected', True))


def without_disconnected(clients):
    disconnected_routing_ids = []
    result_objects, result_clients = [], {}

    for socket, client in clients.items():
        if client.disconnected is not False:
            disconnected_routing_ids.append(client.routing_id)
        else:
            result_clients[socket] = client

    for routing_id in disconnected_routing_ids:
        for socket, client in result_clients.items():
            result_objects.append(BusinessObject({
                'event': 'routing/disconnect',
                'routing-id': routing_id,
                'to': client.routing_id
                }, None))

    return result_objects, result_clients

def with_closed_sockets(clients):
    logger = lg('close-socket')
    for socket, client in clients.items():
        if client.disconnected:
            logger.info("Disconnecting {0}".format(client))
            socket.close()
    return clients

# Handlers
def handle_reading_sockets(clients, sockets):
    logger = lg('read')
    incoming_objects = []

    for socket in sockets:
        if socket not in clients:
            client_socket, address = socket.accept()
            client_socket.setblocking(False)
            clients[client_socket] = Client.from_address(address)
            logger.info("New client {0}".format(clients[client_socket]))
        else:
            client = clients[socket]
            try:
                i_objs, client = handle_read(socket, client)
                incoming_objects.extend(i_objs)
                clients[socket] = client
            except Exception as e:
                logger.warning("{0}: {1}".format(client, traceback.format_exc()))
                clients[socket] = client.as_disconnected()

    return incoming_objects, clients

def handle_writing_sockets(clients, sockets):
    logger = lg('write')
    for socket in sockets:
        if socket not in clients:
            continue
        client = clients[socket]
        try:
            client = handle_write(socket, client)
            clients[socket] = client
        except Exception as e:
            logger.warning("handle_write, client {0}: {1}".format(client, e))
            clients[socket] = client.as_disconnected()

    return clients

def handle_write(socket, client):
    logger = lg('write')
    sent_bytes = 0
    try:
        sent_bytes = socket.send(client.send_buffer)
        lg('traffic').debug("Sent {0} bytes to {1}".format(sent_bytes, client))
    except socket_module.error as se:
        logger.warning("Received {0} from {1}".format(se, client))
        return client.as_disconnected()
    return client.with_send_buffer(client.send_buffer[sent_bytes:])

def handle_read(socket, client):
    logger = lg('read')

    received = socket.recv(MAX_OBJECT_SIZE)
    if len(received) == 0:
        lg('read').info("{0} disconnected".format(client))
        return [], client.as_disconnected()

    client = client.with_receive_buffer(client.receive_buffer + received)

    metadata_received, first_nul_index = is_metadata_received(client.receive_buffer)
    if not metadata_received and len(client.receive_buffer) >= METADATA_MAX_SIZE:
        logger.warning("No payload within METADATA_MAX_SIZE.")
        return [], client.as_disconnected()

    if not metadata_received:
        return [], client

    # Pump objects out of the receive buffer
    objects = []
    while len(client.receive_buffer) > 0:
        metadata_received, first_nul_index = is_metadata_received(client.receive_buffer)
        if not metadata_received:
            break

        metadata, remaining_buffer = parse_metadata(client.receive_buffer, first_nul_index)

        try:
            payload_length = int(metadata['size'])
        except:
            payload_length = None

        if payload_length is None or payload_length == 0:
            objects.append((socket, BusinessObject(metadata, None)))
            client = client.with_receive_buffer(remaining_buffer)
            continue

        if len(remaining_buffer) < payload_length:
            break

        payload = remaining_buffer[0:payload_length]
        objects.append((socket, BusinessObject(metadata, payload)))
        client = client.with_receive_buffer(remaining_buffer[payload_length:])

    # logger.debug('Received {0} objects'.format(len(objects)))
    return objects, client


def match_hierarchy(matcher, matchable):
    """
    Matches hierarchies of strings such as foo/bar to each other, considering
    asterisk as a wildcard that matches any hierarchy component or subtree.

    >>> match_hierarchy('a/b/c', 'a/b/c')
    True
    >>> match_hierarchy('a/b/c', 'a/b/c/d')
    True
    >>> match_hierarchy('a/b/c/d', 'a/b/c')
    False
    >>> match_hierarchy('a/b/*', 'a/b/c')
    True
    >>> match_hierarchy('a/b/*', 'a/b/c/d')
    True
    >>> match_hierarchy('foo', None)
    False
    """
    if matcher is None or matchable is None:
        return False

    matcher_parts = matcher.split('/')
    matchable_parts = matchable.split('/')

    for index, matcher_part in enumerate(matcher_parts):
        if matcher_part == '*':
            return True

        if index >= len(matchable_parts):
            return False
        matchable_part = matchable_parts[index]
        if matcher_part != matchable_part:
            return False

    return True

def routing_decision(metadata, rules, recipient_routing_id):
    PASS = False # pass written in lowercase is a keyword in Python
    logger = lg('routing-decision')

    for rule in rules:
        is_negative_rule = rule.startswith('!')
        if is_negative_rule:
            rule = rule[1:]

        if rule.startswith('#'):
            rule = rule[1:]
            for nature in metadata.get('natures', []):
                if match_hierarchy(rule, nature): # nature == rule:
                    logger.debug('Matches nature {0}, negative: {1}'.format(nature, is_negative_rule))
                    PASS = not is_negative_rule
                    break

        elif rule.startswith('@'):
            rule = rule[1:]
            event = metadata.get('event', None)
            if event is not None and match_hierarchy(rule, event): # message.event == rule:
                logger.debug('Matches event {0}, negative: {1}'.format(event, is_negative_rule))
                PASS = not is_negative_rule

        elif rule.startswith('?'):
            rule = rule[1:]
            route = metadata.get('route', [])
            if len(route) == 0:
                first_on_route = None
            else:
                first_on_route = route[0]
            if rule == '*' or rule == first_on_route or \
               (rule == 'self' and first_on_route == recipient_routing_id):
                logger.debug('Matches recipient {0}, negative: {1}'.format(rule, is_negative_rule))
                PASS = not is_negative_rule

        else:
            type_value = metadata.get('type', None)
            if isinstance(type_value, str) and ';' in type_value:
                type_value = type_value.split(';')[0]

            if rule == '*' or match_hierarchy(rule, type_value):
                logger.debug('Matches type {0}, negative: {1}'.format(type_value, is_negative_rule))
                PASS = not is_negative_rule

    logger.info('{0}, {1} => {2}'.format(metadata, rules, PASS))

    return PASS

def subscribe_client(clients, client, socket, subscription):
    result_objects = []
    subscriptions = subscription.metadata.get('subscriptions', ['*'])

    metadata = {'event': 'routing/subscribe/reply',
                         'subscriptions': subscriptions,
                         'routing-id': client.routing_id,
                'to': client.routing_id}

    if 'id' in subscription.metadata:
        metadata['in-reply-to'] = subscription.metadata['id']
    result_objects.append(BusinessObject(metadata, None))

    client = client.with_subscriptions(subscriptions)
    result_clients = dict(clients)
    result_clients[socket] = client

    lg('subscribe-client').info("{0} subscribed".format(client))

    notification_metadata = {'event': 'routing/subscribe/notification',
                             'routing-id': client.routing_id}
    for other_socket, other_client in clients.items():
        if other_socket == socket:
            continue

        metadata = dict(notification_metadata)
        metadata['to'] = other_client.routing_id
        # TODO: do we want to target notifications to neighbors with to-field?
        result_objects.append(BusinessObject(metadata, None))

    return result_objects, result_clients, client

def respond_to_ping(client, ping):
    metadata = {'event': 'pong', 'to': client.routing_id}
    if 'id' in ping.metadata:
        metadata['in-reply-to'] = ping.metadata['id']
    return BusinessObject(metadata, None)

def handle_incoming_object(clients, socket_tuple, server_routing_id):
    socket, obj = socket_tuple
    client = clients[socket]
    result_objects = []
    result_clients = dict(clients)

    if client.subscriptions is None:
        event = obj.metadata.get('event', None)
        if event == 'routing/subscribe':
            objects, result_clients, client = subscribe_client(result_clients, client, socket, obj)
            result_objects += objects
            result_clients[socket] = client
        else:
            client = client.as_disconnected()
            lg('handle-object').warning(
                "Unsubscribed client {0} sent something else than a subscription ({1})".format(client, event))
            result_clients[socket] = client
    else:
        event = obj.metadata.get('event', None)
        if event == 'ping':
            pong_object = respond_to_ping(client, obj)
            result_objects.append(pong_object)
        else:
            for other_socket in result_clients.keys():
                other_client = result_clients[other_socket]

                if 'to' in obj.metadata and other_client.routing_id != obj.metadata['to']:
                    continue

                if other_client.subscriptions is None:
                    continue # other client not subscribed

                should_send = routing_decision(obj.metadata, other_client.subscriptions,
                                               other_client.routing_id)

                if should_send is True:
                    if 'route' not in obj.metadata:
                        obj.metadata['route'] = [client.routing_id]
                    obj.metadata['route'].append(server_routing_id)
                    if other_client.routing_id in obj.metadata['route'][1:]:
                        continue # cut cycle

                    other_client = other_client.with_send_buffer(other_client.send_buffer + obj.serialize())
                    result_clients[other_socket] = other_client

    return result_objects, result_clients

def handle_incoming_objects(clients, socket_tuples, server_routing_id):
    result_clients = dict(clients)
    result_objects = []
    for socket_tuple in socket_tuples:
        objects, result_clients = handle_incoming_object(result_clients, socket_tuple, server_routing_id)
        result_objects += objects
    return result_objects, result_clients

def handle_outgoing_object(clients, obj):
    result_clients = dict(clients)

    def get_by_routing_id(routing_id):
        for s, c in result_clients.items():
            if c.routing_id == routing_id:
                return s, c

    if 'to' in obj.metadata:
        socket, client = get_by_routing_id(obj.metadata['to'])

        if client.subscriptions is None:
            should_send = False # client not subscribed
        else:
            should_send = routing_decision(obj.metadata, client.subscriptions, client.routing_id)

        if should_send:
            client = client.with_send_buffer(client.send_buffer + obj.serialize())
            result_clients[socket] = client
    else:
        for socket, client in result_clients.items():
            should_send = routing_decision(obj.metadata, client.subscriptions, client.routing_id)

            if should_send:
                client = client.with_send_buffer(client.send_buffer + obj.serialize())
                result_clients[socket] = client

    return result_clients

def handle_outgoing_objects(clients, objects):
    result_clients = dict(clients)
    for obj in objects:
        clients = handle_outgoing_object(result_clients, obj)
        for k, v in clients.items():
            result_clients[k] = v
    return result_clients

def run_server(host, port, verbose=False, socket_backlog=100):
    # Set up logging
    if verbose is True:
        logging.basicConfig(level=logging.DEBUG)
    else:
        logging.basicConfig(level=logging.INFO)
        lg('routing-decision').setLevel(logging.WARN)
        lg('traffic').setLevel(logging.WARN)
        lg('read').setLevel(logging.WARN)
        # lg('subscribe-client').setLevel(logging.WARN)
        # lg('close-socket').setLevel(logging.WARN)

    server_routing_id = host_routing_id()

    # Open listening socket
    listening_socket = socket_module.socket(socket_module.AF_INET, socket_module.SOCK_STREAM)
    listening_socket.setsockopt(socket_module.SOL_SOCKET, socket_module.SO_REUSEADDR, 1)
    listening_socket.bind((host, port))
    listening_socket.listen(socket_backlog)

    reading_sockets = [listening_socket]
    writing_sockets = []

    initial_clients = {}
    current_clients = initial_clients
    while True:
        # lg('select').debug(repr((len(reading_sockets), len(writing_sockets))))

        try:
            (reading_sockets,
             writing_sockets, _) = select.select(reading_sockets, writing_sockets, [],
                                                 SELECT_TIMEOUT)
        except KeyboardInterrupt as kbi:
            for client_socket in current_clients:
                client_socket.close()
            listening_socket.close()
            raise kbi
        except Exception as e:
            for client_socket in current_clients:
                client_socket.close()
            listening_socket.close()
            raise e

        disco_objs1, current_clients = without_disconnected(with_closed_sockets(current_clients))
        i_objs, current_clients = handle_reading_sockets(current_clients, reading_sockets)
        disco_objs2, current_clients = without_disconnected(with_closed_sockets(current_clients))
        o_objs, current_clients = handle_incoming_objects(current_clients, i_objs, server_routing_id)
        o_objs.extend(disco_objs1)
        o_objs.extend(disco_objs2)
        current_clients = handle_outgoing_objects(current_clients, o_objs)
        current_clients = handle_writing_sockets(current_clients, writing_sockets)

        # Sockets for next round
        reading_sockets = [listening_socket]
        writing_sockets = []
        for client_socket, client in current_clients.items():
            if client.disconnected:
                continue

            reading_sockets.append(client_socket)

            if len(client.send_buffer) > 0:
                writing_sockets.append(client_socket)

if __name__ == '__main__':
    if len(sys.argv) == 2 and sys.argv[1] == 'test':
        import doctest
        doctest.testmod()
    else:
        verbose = '-v' in sys.argv
        run_server("localhost", 7890, verbose=verbose)
