#!/usr/bin/env python3.4
# -*- coding: utf-8 -*-
"""
NINJAM <=> Chat gateway


Usage:
    python3.4 bot.py

"""

__author__ = 'tea <Ikkei.Shimomura at gmail dot com>'
__version__ = '0.4.0'

import io
import os
import re
import sys
import hashlib
import logging
from cmd import Cmd
from argparse import ArgumentParser
from collections import namedtuple
from struct import Struct, pack, unpack
from socket import socket
from threading import Thread
from multiprocessing import Process, JoinableQueue as Queue
from configparser import ConfigParser

from util import (
  ws_build_msg, ws_parse_msg, queue_loop,
  untuple, normalize, is_topic_message)


Logger = logging.getLogger(__file__)


# Exit code for launch shell script will restart
EXIT_RESTART = 3


##
# NetMsg structure header
# 1 byte for message type
# 4 bytes unsigned int32 for a length of following message payload.
# n bytes data follow as the message payload. (could be zero for keep-alive)

NetMsg = Struct("<BL")


UserInfo = namedtuple(
    "UserInfo",
    "active,index,volume,pan,flags,username,channel")


class NINJAMConnection:
    def __init__(self, host, port, username, password, encoding, **config):
        sock = socket()
        sock.connect((host, int(port)))

        self._sock = sock
        self._stream = sock.makefile("rb")
        self.username = username
        self.password = password
        self.encoding = encoding
        self.config = config
        self.topic = None # NOTE: belong main message loop, don't update in client
        self.users = {}

    def message_loop(self):
        read = self._stream.read
        for header in iter(lambda: read(5), b''):
            if len(header) < 5:
                if __debug__:
                    Logger.debug("NINJAM Connection lost")
                os._exit(EXIT_RESTART)
                break
            msgtype, msglen = NetMsg.unpack(header)
            msgbody = read(msglen) if msglen > 0 else b''
            yield msgtype, msgbody

    def sendchatmsg(self, msg):
        msg = "MSG\x00{}\x00".format(msg).encode(self.encoding, 'ignore')
        self.sendmsg(0xc0, msg)

    def sendprivmsg(self, username, msg):
        msg = "PRIVMSG\x00{}\x00{}\x00".format(
          username, msg).encode(self.encoding, 'ignore')
        self.sendmsg(0xc0, msg)

    def sendmsg(self, msgtype, msg):
        if __debug__:
            Logger.debug("NINJAM>{:02X} {}".format(msgtype, msg))
        try:
            self._sock.sendall(NetMsg.pack(msgtype, len(msg)) + msg)
        except:
            import traceback
            traceback.print_exc()
            os._exit(EXIT_RESTART)

    @staticmethod
    def _parse_user_info(data, offset=0):
        assert isinstance(data, bytes)
        view = memoryview(data)
        stream = io.BytesIO(data)

        while len(data) > offset:
            params = unpack("<BBhBB", view[offset:offset+6])
            active, index, volume, pan, flags = params
            offset += 6
            stream.seek(offset, io.SEEK_SET)

            offset = data.find(b"\x00", offset + 1)
            assert offset >= 0
            username = stream.read(offset - stream.tell())
            stream.seek(1, io.SEEK_CUR)

            offset = data.find(b"\x00", offset + 1)
            assert offset >= 0
            channel = stream.read(offset - stream.tell())
            stream.seek(1, io.SEEK_CUR)
            offset += 1

            yield UserInfo(active, index, volume, pan, flags,
                           username, channel)

    @classmethod
    def parse_user_info(cls, data):
        return list(cls._parse_user_info(data))


class IRCConnection:
    def __init__(self, host, port, nick, channel, encoding, **config):
        sock = socket()
        sock.connect((host, int(port)))

        self._sock = sock
        self._stream = sock.makefile("rb")
        self.nick = nick
        self.channel = channel
        self.encoding = encoding
        self.config = config

    def readlines(self):
        while True:
            yield self._stream.readline().decode(self.encoding, "ignore")

    def sendline(self, line):
        line = line.lstrip("\r\n").encode(self.encoding, "ignore")
        if __debug__:
            Logger.debug("IRC> {}".format(line))
        try:
            self._sock.sendall(line + b"\r\n")
        except:
            import traceback
            traceback.print_exc()
            os._exit(EXIT_RESTART)


# XXX: separate "irc" argument, remove the dependency.
# XXX: assign ninjam.users dictionary is temporary code.
def ninjam_bot(Q, ninjam, irc):
    """
    https://github.com/wahjam/wahjam/wiki/Ninjam-Protocol
    """

    for msgtype, msgbody in ninjam.message_loop():
        if __debug__:
            Logger.debug("{:02X} {}".format(msgtype, msgbody))

        if msgtype == 0x00:  # SERVER AUTH CHALLENGE
            username = ninjam.username.encode("latin-1")
            password = ninjam.password.encode("latin-1")
            challenge, servercaps, protover = unpack("<8sLL", msgbody[:16])
            if __debug__:
                Logger.debug("{} {:08x} {:08x}".format(
                    challenge, servercaps, protover))
            if 0x0002ffff >= protover >= 0x00020000 and servercaps & 1:

                # keep-alive (minimam keep-alive is 3)
                keep_alive_interval = max((servercaps >> 8) & 0xff, 3)
                ninjam_start_keep_alive_timer(Q, keep_alive_interval)
                print("{:02X}".format(servercaps))

                passhash = hashlib.sha1(username + b":" + password).digest()
                passhash = hashlib.sha1(passhash + challenge).digest()
                # CLIENT AUTH USER
                x = pack("<LL", servercaps, protover)
                chunk = passhash + username + b"\x00" + x
                Q.put(("NINJAM", 0x80, chunk))
                del x, chunk
            else:
                break
        elif msgtype == 0x01:  # SERVER AUTH REPLY
            # TODO: error or userlist
            if __debug__:
                Logger.debug(msgbody)
        elif msgtype == 0x02:  # SERVER CONFIG CHANGE NOTIFY
            bpm, bpi = unpack("<HH", msgbody)
            pass
        elif msgtype == 0x03:  # SERVER USERINFO CHANGE NOTIFY
            for info in ninjam.parse_user_info(msgbody):
                ninjam.users[info.username.decode("latin-1")] = 1
        elif msgtype == 0xc0:  # CHAT
            params = msgbody.split(b'\x00')
            assert len(params) == 6
            mode, sender, message, _, _, _ = params
            sender = sender.decode('latin-1')
            if mode == b"MSG":
                # NOTE: skip self message
                username = sender.split("@", 1)[0]
                username = username if username else 'BOT' # XXX: ws.config
                message = normalize(message.decode(ninjam.encoding, 'ignore'))
                if __debug__:
                    Logger.debug("{} {}".format(username, ninjam.username))
                if username != ninjam.username.split(":")[-1]:
                    if irc:
                        for line in message.splitlines():
                            msg = "{}> {}".format(sender, line)
                            Q.put(("IRC", "PRIVMSG {} :{}".format(irc.channel, msg)))
                    Q.put(("GUI", "add_line", message))
                    Q.put((">WS", "chat", username, message))
                    if is_topic_message(message):
                        Q.put(("NINJAM-SET-TOPIC", message))
                        Q.put((">WS", "topic", "", "{topic:}"))
                    elif message.startswith("88888"):
                        Q.put(("NINJAM-SET-TOPIC", ""))
                        Q.put((">WS", "topic", "", ""))
            elif mode == b"JOIN":
                ninjam.users[sender] = 1
                msg = ninjam.config["join_msg"].format(username=sender)
                if irc:
                    Q.put(("IRC", "PRIVMSG {} :{}".format(irc.channel, msg)))
                    Logger.info(msg)
                Q.put(("GUI", "add_line", msg))
                Q.put((">WS", "join", sender))
                Q.put(("NINJAM-PRIVMSG-TOPIC", sender, "{topic:}"))

            elif mode == b"PART":
                del ninjam.users[sender]
                msg = ninjam.config["part_msg"].format(username=sender)
                if irc:
                    Q.put(("IRC", "PRIVMSG {} :{}".format(irc.channel, msg)))
                    Logger.info(msg)
                Q.put(("GUI", "add_line", msg))
                Q.put((">WS", "part", sender))
            del params, mode, sender, message
        elif msgtype == 0xfd:  # KEEP-ALIVE
            # TODO: check expire here
            pass
        else:
            break


def irc_bot(Q, irc):
    """
    http://tools.ietf.org/html/rfc2812
    """
    sender_name = lambda x: x.lstrip(":").split("!", 1)[0]  # XXX

    for line in irc.readlines():
        if __debug__:
            Logger.debug(repr(line))
        if line.startswith(":"):
            sender, msgtype, rest = line.split(" ", 2)
            username = sender_name(sender)
            if "End of MOTD command." in rest:  # XXX
                # /MOTD or MOTD
                # or capture ':name ODE name :args'
                irc.connected = True
                Q.put(("IRC", "JOIN {}".format(irc.channel)))
            elif msgtype == "PRIVMSG":
                # NOTE: channel or private ?
                Logger.debug(rest)
                msg = rest.split(" ", 1)[-1]
                message = normalize(msg.lstrip(":").strip())
                Q.put(("NINJAM-MSG", "{}: {}".format(username, message)))
                Q.put(("GUI", "add_line",
                       "{}> {}".format(username, message)))
                Q.put((">WS", "chat", username, message))
                del msg, message
            elif msgtype == "JOIN" or msgtype == "PART":
                key = "{}_msg".format(msgtype.lower())
                msg = irc.config[key].format(username=username)
                Q.put(("NINJAM-MSG", msg))
                Q.put(("GUI", "add_line", msg))
                Q.put((">WS", msgtype.lower(), username))
                Logger.info(msg)
                del key, msg
            else:
                pass
        else:
            msgtype, *rest = line.split(" ", 1)
            if msgtype == "PING":
                Q.put(("IRC", "PONG {}".format(*rest)))
            else:
                pass


def message_loop(queue, bot):
    # XXX: Just wrap tk frame is closed
    try:
        _message_loop(queue, bot)
    except EOFError:
        os._exit(0)


def _message_loop(queue, bot):
    """
    TODO: Observer pattern for multi notification
    """

    # Timing issue, this message_loop()
    # must be called at the end of threads initializations.
    ws = bot.ws
    gui = bot.gui
    irc = bot.irc
    ninjam = bot.ninjam

    for target, *rest in iter(queue.get, None):
        if __debug__:
            Logger.debug("QUEUE: {}".format(target))

        if "<WS" == target:
            # XXX: bad for extensions.
            packet = ws_parse_msg(rest[0])
            if 'flag' in packet:  # avoid echo back
                continue
            msgtype = packet.get('type')
            username = normalize(packet.get('user', 'anon'))
            text = normalize(packet.get('text', ''))
            msg = None
            if msgtype == 'join' or msgtype == 'part':
                msg = "{} {}".format(username, msgtype)
            elif msgtype == 'chat':
                msg = "{}> {}".format(username, text)

            if irc:
                bot.send_irc_chat_msg(msg)

            if ninjam:
                bot.send_ninjam_chat_msg(msg)
                if msgtype == 'join' and ninjam.topic:
                    queue.put((">WS", "topic", "", "{topic:}"))

            if gui and username != "bot.py":  # XXX: gui username
                gui.add_line(msg)

            del packet, msgtype, username, text
            continue
        elif target == ">WS" and ws:
            msgtype = rest[0]
            if msgtype == 'topic' and ninjam.topic:
                assert len(rest) == 3
                msgtype, sender, msg = rest
                rest = (msgtype, sender, msg.format(topic=ninjam.topic))
            bot.send_websocket_chat_msg(*rest)
            continue

        if target == "NINJAM" and ninjam:
            ninjam.sendmsg(*rest)
        elif target == "NINJAM-MSG" and ninjam:
            ninjam.sendchatmsg(*rest)
        elif target == "NINJAM-SET-TOPIC" and ninjam:
            ninjam.topic = rest[0]
        elif target == "NINJAM-PRIVMSG-TOPIC" and ninjam:
            assert len(rest) == 2
            if ninjam.topic:
                sender, message = rest
                ninjam.sendprivmsg(sender, message.format(topic=ninjam.topic))
        elif target == "IRC" and irc:
            irc.sendline(*rest)
        elif target == "GUI" and gui:
            action, args = untuple(*rest)
            method = getattr(gui, action, None)
            if method:
                method(*args)


def start_web_server(app, config):
    """
    XXX: limitation, This function is called in a child thread,
    so may not able to use a database which assume to be run in
    main thread.
    httpd should have it own process, but this is a sample,
    I will go on this quick way.
    """
    from wsgiref.simple_server import make_server

    assert callable(app)
    assert isinstance(config, dict)

    # NOTE: host can be blank or 127.0.0.1 for local, 0.0.0.0 for public
    # recommend setting is bind to local address, and use another httpd
    # as frontend, and mapping proxy for the port.
    httpd = make_server(config['host'], int(config['port']), app)
    httpd.serve_forever()


class AdminShell(Cmd):
    completekey = 'tab'

    def __init__(self, bot=None):
        super(AdminShell, self).__init__()
        self.bot = bot

    def do_quit(self, *args):
        return True


class AdminGui:
    # TODO: redirect stdout logging into tk's console

    def __init__(self, queue=None, shell=None, bot=None):
        self._queue = queue  # XXX: no used now
        self._shell = shell  # XXX: no used now
        self._bot = bot
        self._initialize_components()

    def _initialize_components(self):
        from tkinter import Tk, StringVar
        from tkinter.ttk import Entry
        from tkinter.scrolledtext import ScrolledText as TextArea
        from tkinter.constants import BOTH, BOTTOM

        root = self.root = Tk()
        root.title("bot.py admin console")
        line = self.line = StringVar(root)
        entry = self.entry = Entry(root, textvariable=line)
        entry.pack(side=BOTTOM, fill=BOTH)
        entry.bind('<Return>', self.on_return)
        textarea = self.textarea = TextArea()
        textarea.pack(fill=BOTH, expand=1)

        root.after(100, entry.focus)

    def on_return(self, evt):
        try:
            text = normalize(self.line.get())
            if text == "/quit":
                self.root.after(100, self.root.quit)
            elif text.startswith("/"):
                self._shell.onecmd(text.lstrip("/"))
            else:
                sender_name = "bot.py"
                bot = self._bot
                bot.send_irc_chat_msg(text)
                bot.send_ninjam_chat_msg(text)
                bot.send_websocket_chat_msg('chat', sender_name, text)
                self.add_line("{}> {}".format(sender_name, text))
        finally:
            self.line.set("")

    def add_line(self, line):
        line = "{}\n".format(line.rstrip())  # ensure line break
        self.root.after(100, lambda: self.textarea.insert('end', line))

    def mainloop(self):
        self.root.mainloop()


class AdminWeb:
    """
    @see WSGI Application
    """
    def __init__(self, bot):
        self.bot = bot

    def __call__(self, environ, start_response):
        start_response('200 Ok', [('Content-type', 'text/html')])
        path_info = environ['PATH_INFO']
        if path_info == "/list":
            yield b"<ul>"
            # XXX: too long dot chain
            for item in self.bot.ninjam.users.keys():
                yield "<li>{}</li>".format(item).encode("utf-8")
            yield b"</ul>"
        else:
            yield b"""<a href="/list">List</a>"""


class Bot:
    def __init__(self, irc=None, ninjam=None, gui=None, ws=None, queue=None):
        self.ws = ws
        self.gui = gui
        self.irc = irc
        self.ninjam = ninjam
        self.queue = queue

    def send_websocket_chat_msg(self, *args):
        if not self.ws:
            return
        self.ws.put(ws_build_msg(*args))

    def send_ninjam_chat_msg(self, msg):
        if not self.ninjam:
            return
        chunk = "MSG\x00{}\x00".format(msg)
        self.queue.put(("NINJAM", 0xc0, chunk.encode(self.ninjam.encoding, "ignore")))

    def send_irc_chat_msg(self, msg):
        if not self.irc:
            return
        line = "PRIVMSG {} :{}".format(self.irc.channel, msg)
        self.queue.put(("IRC", line.strip()))


def ninjam_start_keep_alive_timer(Q, interval):
    thread = make_daemon_thread(
                target=ninjam_keep_alive_timer,
                args=(Q, interval))
    thread.start()
    Logger.info("KeepAlive timer started (interval {})".format(interval))
    return thread


def ninjam_keep_alive_timer(Q, interval):
    import time
    while 1:
        time.sleep(interval)
        Q.put(("NINJAM", 0xfd, b""))


def make_daemon_thread(**kw):
    thread = Thread(**kw)
    thread.setDaemon(True)
    return thread


def main():
    # init logging
    LOG_FORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
    formatter = logging.Formatter(LOG_FORMAT)
    handler = logging.StreamHandler()
    handler.setFormatter(formatter)
    Logger.setLevel(logging.DEBUG)
    Logger.addHandler(handler)

    # Init config dictionary
    config = ConfigParser()
    config.read("bot.cfg")
    ninjam_config = config['ninjam']
    irc_config = config['irc']
    httpd_config = config['httpd']
    ws_config = config['ws']
    config_enable = config['enable']

    # Init command line arguments parser
    parser = ArgumentParser()
    parser.add_argument(
        '--tk',
        action='store_true',
        help='show admin GUI console')
    parser.add_argument(
        '--httpd',
        type=int,
        default=0,
        help='enable httpd')
    parser.add_argument(
        '--ws',
        action='store_true',
        help='enable websocket connection')
    parser.add_argument(
        '-v',
        '--version',
        action='version',
        version='NINJAM Bot v{}'.format(__version__))
    args = parser.parse_args()

    # Override config by command line arguments.
    if args.tk:
        config_enable['tk'] = "1"
    if args.ws:
        config_enable['ws'] = "1"
    if args.httpd > 0:
        config_enable['httpd'] = "1"
        httpd_config['port'] = str(args.httpd)

    # XXX: being deep dependency each classes.
    queue = Queue()
    bot = Bot(queue=queue)
    shell = AdminShell(bot=bot)

    if int(config_enable['tk']):
        gui = bot.gui = AdminGui(queue=queue, shell=shell, bot=bot)
    else:
        gui = None

    if int(config_enable['irc']):
        nick = irc_config['nick']
        irc = bot.irc = IRCConnection(**irc_config)
        irc.sendline("NICK {}".format(nick))
        irc.sendline("USER {0} * * :{0}".format(nick))
        irc_thread = make_daemon_thread(target=irc_bot, args=(queue, irc))
        irc_thread.start()
    else:
        irc = None

    if int(config_enable['ninjam']):
        ninjam = bot.ninjam = NINJAMConnection(**ninjam_config)
        ninjam_thread = make_daemon_thread(
            target=ninjam_bot, args=(queue, ninjam, irc))
        ninjam_thread.start()
    else:
        ninjam = None

    if int(config_enable['httpd']):
        app = AdminWeb(bot)
        httpd_thread = make_daemon_thread(
            target=start_web_server, args=(app, dict(httpd_config)))
        httpd_thread.start()

    if int(config_enable['ws']):
        import ws_chat_client
        ws_send_queue = bot.ws = Queue()
        ws_process = Process(
            target=ws_chat_client.worker,
            daemon=True,
            args=(queue, ws_send_queue, dict(ws_config)))
        ws_process.start()

    if 1:  # Worker thread should always be enable
        worker_thread = make_daemon_thread(
            target=message_loop, args=(queue, bot))
        worker_thread.start()

    if gui:
        gui.mainloop()
    else:
        shell.cmdloop()


if __name__ == "__main__":
    main()
