'''
    Filename: server.py
    Description:
        Simple server that responds to queries from a given client, where
        the query is simply a song writer and the response is all
        songs that the server knows the author has written.
'''

import datetime
import os.path
import pickle
import re
import sys
import time
from socket import *

# Predefined constants pointing server to localhost
HOST, PORT = '127.0.0.1', 25000

# ----------- / Logging


def timestamp():
    return str(datetime.datetime.utcnow())


# Server Log to push output into
log_file = open("server.log", "a")


def log(log_message):
    "Writes a message to the output"
    log_file.write(timestamp() + " : " + log_message + "\n")
    log_file.flush()


def terminate(log_message):
    "Writes message to log file then terminates execution"
    log("Terminating... Reason: " + log_message)
    sys.exit(0)

# ----------- / Song Reading


def song_entries(text_lines):
    "Iterator for each song entry from a text file"
    starts_with_ranking, ends_in_year = re.compile(
        r"^\d{1,3}\s?-"), re.compile(r"\d{4}$")
    line_buffer = ''
    for line in map(str.strip, text_lines):
        if starts_with_ranking.search(line):
            line_buffer += line
        elif line_buffer:
            line_buffer += ('   ' + line)

        if line_buffer and ends_in_year.search(line):
            yield line_buffer
            line_buffer = ''


def load_songs(text_lines):
    "Returns a dictionary of artist-song from a text file"

    songs = {}

    def add_song(artist, title):
        if artist not in songs:
            songs[artist] = []
        songs[artist].append(title)

    def add_multiple_aritst_song(artist_str, title):
        delimiter = "/" if artist_str.find("/") != -1 else "featuring"

        for artist in map(str.strip, artist_str.split(delimiter)):
            add_song(artist, title)

    extract_data_re = re.compile(
        r"\d{1,3}\s?-\s?((?:\S|\S )+)(?:\s{2}\s*|-)((?:\S|\S )+)\s{2}\s*\d{4}")

    for line in song_entries(text_lines):
        match = extract_data_re.match(line)
        if match:
            (song_name, artist_string) = extract_data_re.match(line).groups()

            if artist_string.find("/") != -1 or artist_string.find("featuring") != -1:
                add_multiple_aritst_song(artist_string, song_name)
            else:
                add_song(artist_string, song_name)

    return songs


def load_songs_from_file(song_file_name):
    "Loads songs from a given file"
    if not os.path.isfile(song_file_name):
        terminate("Failed to find the file " + song_file_name)

    with open(song_file_name, "r") as f:
        return load_songs(f.readlines())


Songs = load_songs_from_file("100worst.txt")
# ------------- / Socket Programming
OPERATION_FAILURE = 'Operation-Failed'  # Opaque value to indiciate failure


def safe_execute(failure, operation, return_arity=1):
    '''
        Tries to return result generated by calling operation
        Calls failure callback if operation failures
        Used to simply all the try catch-logic that would
        be required otherwise
    '''
    try:
        return operation()
    except Exception:
        failure()
        if return_arity == 1:
            return OPERATION_FAILURE
        else:
            return (OPERATION_FAILURE,) * return_arity


def accept_artist_name(client_sock, addr):
    '''
        Accepts the server sending an artist name.
        Assumes artist name is not more than 1024 letters long
    '''
    return client_sock.recv(1024).decode()


def listen_on_socket(server_sock):
    '''
        Main server loop that servers the request to clients.
    '''
    server_sock.listen(1)
    # Server should never timeout from lack of clients.
    server_sock.settimeout(None)

    log("Server Started")

    client_sock, addr, time_connected = None, None, None

    def reset_state():
        nonlocal client_sock, addr, time_connected
        client_sock, addr, time_connected = None, None, None

    while True:
        # Listen for connetion
        if client_sock is None:
            client_sock, addr = safe_execute(
                failure=lambda: log(
                    "Failed to receive a connection from the client"),
                operation=lambda: server_sock.accept(),
                return_arity=2)

            if client_sock == OPERATION_FAILURE:
                return
            log("Received a connection from {0}".format(addr))
            time_connected = time.time()

        def report_connection_terminated(reason):
            '''
                Returns function that takes message and logs it
                alongside the duration of the connection
            '''
            def _report():
                connection_length = time.time() - time_connected
                log("Connection terminated. Duration {0}. Reason {1}".format(
                    connection_length, reason))
            return _report

        # Get the artist name from the socket
        artist_name = str(safe_execute(
            failure=report_connection_terminated(
                "Failed to receive an artist from the client"),
            operation=lambda: accept_artist_name(client_sock, addr)))

        if artist_name == OPERATION_FAILURE:
            reset_state()
            break

        # Check if the user wants to quit.
        if artist_name == "quit":
            op_success = safe_execute(
                failure=report_connection_terminated(
                    "Failed to close client socket."),
                operation=lambda: client_sock.close())

            if op_success != OPERATION_FAILURE:
                report_connection_terminated(
                    "User requested to end connection")()

            reset_state()
            continue

        log("Recevied a query request with artist {0}".format(artist_name))

        # Server Response
        songs = Songs.get(artist_name, [])
        if not songs:  # is [] so no songs found
            log("Client requested artist `{0}` that does not exist in our records".format(
                artist_name))

        op_success = safe_execute(
            failure=report_connection_terminated(
                "Connection was closed by client before response was sent"),
            operation=lambda: client_sock.send(pickle.dumps(songs)))

        if op_success == OPERATION_FAILURE:
            reset_state()
            continue

        log("Sent response with {0} songs in.".format(len(songs)))


def launch_server():
    "Starts the server on localhost."
    with socket(AF_INET, SOCK_STREAM) as server_sock:
        # try to bind to port, listening for OSError if port already in use.
        try:
            server_sock.bind((HOST, PORT))
        except OSError:
            log("Failed to bind to host %s on port %d" % (HOST, PORT))
            return
        except Exception as e:
            log("Failed to open server for reason `%s`" % (str(e)))
            return

        listen_on_socket(server_sock)
    log_file.close()


log('Attempting launch server on %s:%d' % (HOST, PORT))
launch_server()
