#!/usr/bin/python3
"""A virtual IRC server and a Malíček client.

This module provides an IRC interface for Malíček.  The connected
user is the only real user on the server, while everyone else is
virtual, as reported by the backend.

Periodically fetches new messages for joined channels.  Simulates
actions from other users.

Strives for full RFC 2812 compatibility.
"""

import argparse
import configparser
import enum
import json
import logging
import os
import random
import re
import selectors
import socket
import string
import sys
import threading
import time
import types

import requests

# Version
VERSION = '0.2.0'
# User agent for requests
AGENT = 'mlck/{}'.format(VERSION)
# Startup timestamp
STARTED = time.strftime('%a %b %d %Y at %H:%M:%S UTC', time.gmtime())

logging.basicConfig(format='%(asctime)s : %(levelname)s : %(funcName)s: %(message)s')

# Global config
config = dict()
# Global logger
logger = logging.getLogger(__name__)
# Global selector
sel = selectors.DefaultSelector()

# Caching user IDs for use in system messages
cache = types.SimpleNamespace(
    ids=dict(),
    links=dict()
)

class IRC(enum.Enum):
    """Provides IRC response code mappings.

    An enum class, it holds response codes for each defined IRC
    keyword response.  Helps with readability.  Only the actual
    codes are sent, however.

    Based on RFC 2812.
    """
    RPL_WELCOME = '001'
    RPL_YOURHOST = '002'
    RPL_CREATED = '003'
    RPL_MYINFO = '004'
    RPL_BOUNCE = '005'
    RPL_TRACELINK = '200'
    RPL_TRACECONNECTING = '201'
    RPL_TRACEHANDSHAKE = '202'
    RPL_TRACEUNKNOWN = '203'
    RPL_TRACEOPERATOR = '204'
    RPL_TRACEUSER = '205'
    RPL_TRACESERVER = '206'
    RPL_TRACESERVICE = '207'
    RPL_TRACENEWTYPE = '208'
    RPL_TRACECLASS = '209'
    RPL_TRACERECONNECT = '210'
    RPL_STATSLINKINFO = '211'
    RPL_STATSCOMMANDS = '212'
    RPL_ENDOFSTATS = '219'
    RPL_STATSUPTIME = '242'
    RPL_STATSONLINE = '243'
    RPL_UMODEIS = '221'
    RPL_SERVLIST = '234'
    RPL_SERVLISTEND = '235'
    RPL_LUSERCLIENT = '251'
    RPL_LUSEROP = '252'
    RPL_LUSERUNKNOWN = '253'
    RPL_LUSERCHANNELS = '254'
    RPL_LUSERME = '255'
    RPL_ADMINME = '236'
    RPL_ADMINLOC1 = '257'
    RPL_ADMINLOC2 = '258'
    RPL_ADMINEMAIL = '259'
    RPL_TRACELOG = '261'
    RPL_TRACEEND = '262'
    RPL_TRYAGAIN = '263'
    RPL_AWAY = '301'
    RPL_USERHOST = '302'
    RPL_ISON = '303'
    RPL_UNAWAY = '305'
    RPL_NOWAWAY = '306'
    RPL_WHOISUSER = '311'
    RPL_WHOISSERVER = '312'
    RPL_WHOISOPERATOR = '313'
    RPL_WHOWASUSER = '314'
    RPL_ENDOFWHO = '315'
    RPL_WHOISIDLE = '317'
    RPL_ENDOFWHOIS = '318'
    RPL_WHOISCHANNELS = '319'
    RPL_LISTSTART = '321'
    RPL_LIST = '322'
    RPL_LISTEND = '323'
    RPL_CHANNELMODEIS = '324'
    RPL_UNIQOPIS = '325'
    RPL_NOTOPIC = '331'
    RPL_TOPIC = '332'
    RPL_INVITING = '341'
    RPL_SUMMONING = '342'
    RPL_INVITELIST = '346'
    RPL_ENDOFINVITELIST = '347'
    RPL_EXCEPTLIST = '348'
    RPL_ENDOFEXCEPTLIST = '349'
    RPL_VERSION = '351'
    RPL_WHOREPLY = '352'
    RPL_NAMEREPLY = '353'
    RPL_LINKS = '364'
    RPL_ENDOFLINKS = '365'
    RPL_ENDOFNAMES = '366'
    RPL_BANLIST = '367'
    RPL_ENDOFBANLIST = '368'
    RPL_ENDOFWHOWAS = '369'
    RPL_INFO = '371'
    RPL_MOTD = '372'
    RPL_ENDOFINFO = '374'
    RPL_MOTDSTART = '375'
    RPL_ENDOFMOTD = '376'
    RPL_YOUREOPER = '381'
    RPL_REHASHING = '382'
    RPL_YOURESERVICE = '383'
    RPL_TIME = '391'
    RPL_USERSSTART = '392'
    RPL_USERS = '393'
    RPL_ENDOFUSERS = '394'
    RPL_NOUSERS = '395'
    ERR_NOSUCHNICK = '401'
    ERR_NOSUCHSERVER = '402'
    ERR_NOSUCHCHANNEL = '403'
    ERR_CANNOTSENDTOCHAN = '404'
    ERR_TOOMANYCHANNELS = '405'
    ERR_WASNOSUCHNICK = '406'
    ERR_TOOMANYTARGETS = '407'
    ERR_NOSUCHSERVICE = '408'
    ERR_NOORIGIN = '409'
    ERR_NORECIPIENT = '411'
    ERR_NOTEXTTOSEND = '412'
    ERR_NOTOPLEVEL = '413'
    ERR_WILDTOPLEVEL = '414'
    ERR_BADMASK = '415'
    ERR_UNKNOWNCOMMAND = '421'
    ERR_NOMOTD = '422'
    ERR_NOADMININFO = '423'
    ERR_FILEERROR = '424'
    ERR_NONICKNAMEGIVEN = '431'
    ERR_ERRONEUSNICKNAME = '432'
    ERR_NICKNAMEINUSE = '433'
    ERR_NICKCOLLISION = '436'
    ERR_UNAVAILRESOURCE = '437'
    ERR_USERNOTINCHANNEL = '441'
    ERR_NOTONCHANNEL = '442'
    ERR_USERONCHANNEL = '443'
    ERR_NOLOGIN = '444'
    ERR_SUMMONDISABLED = '445'
    ERR_USERSDISABLED = '446'
    ERR_NOTREGISTERED = '451'
    ERR_NEEDMOREPARAMS = '461'
    ERR_ALREADYREGISTERED = '462'
    ERR_NOPERMFORHOST = '463'
    ERR_PASSWDMISMATCH = '464'
    ERR_YOUREBANNEDCREEP = '465'
    ERR_YOUWILLBEBANNED = '466'
    ERR_KEYSET = '467'
    ERR_CHANNELISFULL = '471'
    ERR_UNKNOWNMODE = '472'
    ERR_INVITEONLYCHAN = '473'
    ERR_BANNEDFROMCHAN = '474'
    ERR_BADCHANNELKEY = '475'
    ERR_BADCHANMASK = '476'
    ERR_NOCHANMODES = '477'
    ERR_BANLISTFULL = '478'
    ERR_NOPRIVILEGES = '481'
    ERR_CHANOPRIVSNEEDED = '482'
    ERR_CANTKILLSERVER = '483'
    ERR_RESTRICTED = '484'
    ERR_UNIQOPPRIVSNEEDED = '485'
    ERR_NOOPERHOST = '491'
    ERR_UMODEUNKNOWNFLAG = '501'
    ERR_USERSDONTMATCH = '502'

class Client:
    """Provides an abstraction of the connection session.

    Instances represent each individual connection, hold its client
    socket, Malíček session, and various collected metadata.
    """
    def __init__(self, sock, peer):
        """Class constructor.

        Initializes instance variables; these include the socket
        reference, the endpoint, the Malíček session reference, the user
        nickname, their password, the channels they've joined,
        information about other nicks they've collected, the automatic
        periodic thread reference, and thread locks for shared
        resources.

        Requires the socket reference.

        The default nickname is '*', as per RFC 2812.

        :param sock: The socket reference of this connection
        :returns: A Client instance
        """
        self.socket = sock
        self.peer = peer
        self.session = None
        self.user = '*'
        self.password = None
        self.userstring = None
        self.modes = types.SimpleNamespace(
            # Away
            a=False,
            # FIXME: Actually respect this
            # Convert to emojis (non-standard)
            e=True,
            # Invisible
            i=False,
            # Server operator
            o=False,
            # Local server operator
            O=False,
            # Restricted, no nick change
            r=True,
            # Receives server notices
            s=True,
            # Receives wallops
            w=True,
        )
        # Dict of channels we're in
        # Values are another dict, with `seen` and `activity` as keys
        self.channels = dict()
        # All users we know about, userful for user IDs in JOIN & PART,
        # and potentially also for WHO, WHOIS, NAMES and ISON
        self.nicks = dict()
        # Our auto thread for fetching messages and other updates
        self.auto = threading.Thread(target=self.periodic, daemon=True)
        # Locks for commonly accessed shared resources
        self.lock = {
            x: threading.Lock() for x in (
                'channels',
                'nicks',
            )
        }

    def __del__(self):
        """Destroys the session instance.

        Attempts to log off, properly destroys the session and clears
        the Client.periodic() thread.  Called automatically by GC.
        """
        if self.registered():
            self.logout()
            self.session = None
            self.auto.join()

    def setup(self):
        """Creates an instance of the session.

        Automatically logs in and maintains the session cookie.  Clears
        the session upon failure not to confuse Client.registered().

        :returns: True on success, False otherwise
        """
        self.session = requests.Session()
        self.session.headers.update({
            'User-Agent': AGENT,
        })
        if self.login():
            self.auto.start()
            return True
        self.session = None
        return False

    # FIXME: Consider join/parts/kicks without system messages
    def handle(self, msg):
        """Consume messages from the socket.

        The class entry point.  Messages extracted from the socket
        stream are passed to this method as a byte sequence.

        Decodes the input, splits it into commands and arguments, and
        directs the data to respective handlers.

        Provides very basic checking for sanity.

        :param msg: Byte sequence representing message input
        """
        if not msg:
            return
        try:
            msg = msg.decode(config['encoding'])
        except ValueError:
            logger.exception('failed to decode input')
            self.do_error(
                'That does not look like {encoding}, I am confused.'.format(
                    encoding=config['encoding'],
                ),
            )
            return
        if msg.startswith(':') or not msg:
            pos = msg.find(' ')
            if pos == -1:
                return
            msg = msg[pos + 1:]
        pos = msg.find(' ')
        if pos == -1:
            cmd = msg
            args = None
        else:
            cmd = msg[0:pos]
            args = msg[pos + 1:]
        if not cmd:
            return
        cmd = str.upper(cmd)
        if cmd == 'PASS':
            logger.debug(
                '%s, %s, %s, ***',
                self.peer,
                self.user,
                cmd,
            )
        else:
            logger.debug('%s, %s, %s, %s',
                self.peer,
                self.user,
                cmd,
                args,
            )
        if self.registered() and cmd in ('NICK', 'USER', 'PASS'):
            self.error(IRC.ERR_ALREADYREGISTERED)
            return
        if not self.registered() and cmd not in ('NICK', 'USER', 'PASS'):
            self.error(IRC.ERR_NOTREGISTERED)
            return
        if cmd == 'PASS':
            self.handle_pass(args)
        elif cmd == 'USER':
            self.handle_user(args)
        elif cmd == 'NICK':
            self.handle_nick(args)
        elif cmd == 'OPER':
            self.handle_oper(args)
        elif cmd == 'MODE':
            self.handle_mode(args)
        elif cmd == 'SERVICE':
            self.handle_service(args)
        elif cmd == 'QUIT':
            self.handle_quit(args)
        elif cmd == 'SQUIT':
            self.handle_squit(args)
        elif cmd == 'JOIN':
            self.handle_join(args)
        elif cmd == 'PART':
            self.handle_part(args)
        elif cmd == 'TOPIC':
            self.handle_topic(args)
        elif cmd == 'NAMES':
            self.handle_names(args)
        elif cmd == 'LIST':
            self.handle_list(args)
        elif cmd == 'INVITE':
            self.handle_invite(args)
        elif cmd == 'KICK':
            self.handle_kick(args)
        elif cmd == 'PRIVMSG':
            self.handle_privmsg(args)
        elif cmd == 'NOTICE':
            self.handle_notice(args)
        elif cmd == 'MOTD':
            self.handle_motd(args)
        elif cmd == 'LUSERS':
            self.handle_lusers(args)
        elif cmd == 'VERSION':
            self.handle_version(args)
        elif cmd == 'STATS':
            self.handle_stats(args)
        elif cmd == 'LINKS':
            self.handle_links(args)
        elif cmd == 'TIME':
            self.handle_time(args)
        elif cmd == 'CONNECT':
            self.handle_connect(args)
        elif cmd == 'TRACE':
            self.handle_trace(args)
        elif cmd == 'ADMIN':
            self.handle_admin(args)
        elif cmd == 'INFO':
            self.handle_info(args)
        elif cmd == 'SERVLIST':
            self.handle_servlist(args)
        elif cmd == 'SQUERY':
            self.handle_squery(args)
        elif cmd == 'WHO':
            self.handle_who(args)
        elif cmd == 'WHOIS':
            self.handle_whois(args)
        elif cmd == 'WHOWAS':
            self.handle_whowas(args)
        elif cmd == 'KILL':
            self.handle_kill(args)
        elif cmd == 'PING':
            self.handle_ping(args)
        elif cmd == 'PONG':
            self.handle_pong(args)
        elif cmd == 'ERROR':
            self.handle_error(args)
        elif cmd == 'AWAY':
            self.handle_away(args)
        elif cmd == 'REHASH':
            self.handle_rehash(args)
        elif cmd == 'DIE':
            self.handle_die(args)
        elif cmd == 'RESTART':
            self.handle_restart(args)
        elif cmd == 'SUMMON':
            self.handle_summon(args)
        elif cmd == 'USERS':
            self.handle_users(args)
        elif cmd == 'WALLOPS':
            self.handle_wallops(args)
        elif cmd == 'USERHOST':
            self.handle_userhost(args)
        elif cmd == 'ISON':
            self.handle_ison(args)
        else:
            self.error(IRC.ERR_UNKNOWNCOMMAND)
            logger.warning('unknown command: %s, args: %s', cmd, args)

    def send(self, msg, server=None, user=None, names=None, sep=' ', **kwargs):
        """Send a formatted message to our socket.

        This is the only point through which all other methods should
        send their messages.  Accepts the message string that will be
        formatted with **kwargs.

        If a user's nickname is to be included in the message, it should
        be passed as 'user'.  This is typically the sender or the
        recipient of the message.

        If any other nicknames are to be included, they should be listed
        in the 'names' list.  The list will be sanitized and joined with
        'sep', which is a space by default.

        If the server name is to be included, it should be passed as
        'server'.

        If the user is ommitted, it defaults to the session user's name.

        If the server is omitted, it defaults to the mlck server name.

        Finally, {server}, {user} and {names} are substituted in the
        message.

        :param msg: The message to be formatted
        :param server: Server name, sanitized
        :param user: User name to sanitize, typically the user's own
        :param names: A list of other names to be sanitized
        :param sep: The names separator in the joined string
        :param **kwargs: Keyword arguments for formatting
        """
        if not user:
            user = self.user
        user = ircencode(user)
        if not server:
            server = config['server']
        server = ircencode(server)
        if names is not None:
            names = sep.join(map(ircencode, names))
        data = msg.format(
            server=server,
            user=user,
            names=names,
            **kwargs,
        )
        logger.debug(
            '%s, %s, %s',
            self.peer,
            self.user,
            data,
        )
        try:
            self.socket.sendall(
                data.encode(config['encoding']) + \
                b'\r\n',
            )
        except socket.error:
            logger.error('%s, could not send data, terminating', self.peer)
            self.terminate()
        except Exception:
            logger.exception('%s, unhandled exception', self.peer)

    def terminate(self):
        """Terminate the client connection.

        Sometimes we may encounter errors we can't recover from and the
        only sensible thing to do is to tell the client to go away.
        Handle any error reporting outside of this method, then call
        terminate to destroy the session, unregister the selector and
        close the link.
        """
        try:
            sel.unregister(self.socket)
        except Exception:
            pass
        if self.registered():
            self.session = None
            self.auto.join()
        try:
            self.socket.close()
        except socket.error:
            pass
        except Exception:
            self.exception('unhandled exception')

    def reply(self, msg, code=None, **kwargs):
        """Send a standard reply in response to a command.

        Only so slightly simplifies sending code responses back to the
        client.  The method only prefixes each message with the server
        name.

        Unfortunately, as each message has a different format, this is
        the best we can currently offer.

        The message must include everything to be sent: the code, the
        possible arguments, additional human readable messages and more.

        :param msg: The message to send
        :param code: IRC response code enum
        :param **kwargs: Additional formatting keywords
        """
        if code:
            code = code.value
        self.send(
            ':{{server}} {msg}'.format(
                msg=msg,
            ),
            code=code,
            **kwargs,
        )

    def error(self, code, response=None, **kwargs):
        """Send an error reply in response to a command.

        Simplifies error responses.  The response message is optional
        and standard messages for the given error codes are used, if
        known.

        If not, the default error message is the error code constant.

        This is nott he same as the ERROR command.  For that one, refer
        to Client.do_error().

        :param err: The error code, an enum of the IRC class
        :param response: Optional error message
        :param kwargs: Additional formatting keywords
        """
        if code == IRC.ERR_NEEDMOREPARAMS:
            response = response or 'Not enough parameters given.'
        elif code == IRC.ERR_UNKNOWNCOMMAND:
            response = response or 'Command not implemented.'
        elif code == IRC.ERR_PASSWDMISMATCH:
            response = response or 'Authentication failed, sorry.'
        elif code == IRC.ERR_ALREADYREGISTERED:
            response = response or 'You are already logged in.'
        elif code == IRC.ERR_NOTREGISTERED:
            response = response or 'You must log in to continue.'
        elif code == IRC.ERR_NOSUCHNICK:
            respose = respose or 'Private messaging is unsupported.'
        elif code == IRC.ERR_NOTEXTTOSEND:
            response = response or 'No message specified.'
        elif code in (IRC.ERR_CANNOTSENDTOCHAN, IRC.ERR_NOTONCHANNEL):
            response = response or 'You must join the channel first.'
        elif code == IRC.ERR_NOPRIVILEGES:
            response = response or 'You are not allowed to do that here.'
        elif code == IRC.ERR_NORECIPIENT:
            response = response or 'No recipient specified.'
        elif code == IRC.ERR_SUMMONDISABLED:
            response = response or 'You think you are a summoner, eh?'
        elif code == IRC.ERR_USERSDISABLED:
            response = response or 'It is just the two of us.'
        elif code == IRC.ERR_NOSUCHSERVICE:
            response = response or 'There are no services here.'
        elif code == IRC.ERR_RESTRICTED:
            response = response or 'Your nick is restricted to {}.'.format(
                self.user,
            )
        response = response or code
        self.reply(
            msg='{code} {user} :{response}',
            code=code,
            response=response,
            **kwargs,
        )

    def periodic(self):
        """Automatic periodic check of active channels.

        Periodically checks all channels the user has joined, querying
        for new messages, users joining or parting, mode changes and
        more.

        Triggers relevant IRC actions to simulate the activity.
        Maintains presence via keepalive calls, whenever required.

        The backbone of mlck.  Runs in an idependent thread.

        Note this holds the global channels lock.
        """
        while self.registered():
            self.lock['channels'].acquire()
            for chan in self.channels:
                logger.debug(
                    '%s, %s, %s',
                    self.peer,
                    self.user,
                    chan
                )
                res = self.get('rooms/{}'.format(chan[1:]))
                if not res:
                    continue
                # FIXME: Handle these
                if res.code != requests.codes.ok:
                    # Send wallops or privmsg
                    # Malicek codes:
                    # - 4: kicked for inactivity
                    # - 5: you left
                    # - 6: room doesn't exist
                    # - 8: blocked by admin
                    # - 9: locked
                    # - 11: kicked out (and blocked?)
                    if res.code == 401:
                        # Session dead, kill everything, close link
                        pass
                    if res.data['alik'] == 4:
                        pass
                    elif res.data['alik'] == 5:
                        pass
                    elif res.data['alik'] == 6:
                        pass
                    elif res.data['alik'] == 8:
                        pass
                    elif res.data['alik'] == 9:
                        pass
                    elif res.data['alik'] == 11:
                        pass
                messages = list()
                for msg in res.data['messages']:
                    if msg == self.channels[chan]['seen']:
                        break
                    messages.append(msg)
                messages.reverse()
                if messages:
                    self.channels[chan]['seen'] = messages[-1]
                ctime = int(time.time())
                if ctime - self.channels[chan]['activity'] > 15 * 60:
                    if self.keepalive(chan):
                        self.channels[chan]['activity'] = ctime
                topic = '{}{}'.format(
                    res.data['name'],
                    ' | {}'.format(res.data['topic']) \
                        if res.data['topic'] \
                        else '',
                )
                if self.channels[chan]['topic'] != topic:
                    self.do_topic(chan, topic)
                self.channels[chan]['topic'] = topic
                for usr in res.data['users']:
                    if usr['name'] not in cache.ids and usr['id']:
                        cache.ids[usr['name']] = usr['id']
                    if usr['name'] not in cache.links and usr['link']:
                        cache.links[usr['name']] = usr['link']
                for msg in messages:
                    if msg['from'] == self.user:
                        continue
                    if msg['type'] == 'system':
                        if msg['event']['type'] == 'join':
                            self.do_join(
                                msg['event']['source'],
                                chan,
                            )
                            continue
                        if msg['event']['type'] == 'part':
                            if msg['event']['source'] \
                                in self.channels[chan]['ops']:
                                self.channels[chan]['ops'].remove(
                                    msg['event']['source']
                                )
                            self.do_part(
                                msg['event']['source'],
                                chan,
                                msg['message'],
                            )
                            continue
                        if msg['event']['type'] == 'kick':
                            if msg['event']['source'] \
                                in self.channels[chan]['ops']:
                                self.channels[chan]['ops'].remove(
                                    msg['event']['source']
                                )
                            self.do_kick(
                                msg['event']['source'] or config['server'],
                                msg['event']['target'],
                                chan,
                                msg['message'],
                            )
                            continue
                    self.do_privmsg(
                        msg['from'] or config['server'],
                        chan,
                        emoji(msg['message']) \
                            if not msg['to'] \
                            else '\x02[{}, pst!] {}\x0f'.format(
                                msg['to'],
                                emoji(msg['message'])
                            )
                    )
                for usr in res.data['users']:
                    mode = None
                    if usr['admin']:
                        if ('rooms' in usr['admin']
                            or 'master' in usr['admin']
                            or 'guru' in usr['admin']) \
                            and usr['name'] not in self.channels[chan]['ops']:
                            self.channels[chan]['ops'].add(usr['name'])
                            mode = 'o'
                        elif 'chat' in usr['admin'] \
                            and usr['name'] not in self.channels[chan]['voices']:
                            self.channels[chan]['voices'].add(usr['name'])
                            mode = 'v'
                        if mode:
                            mode = '+{}'.format(mode)
                    else:
                        if usr['name'] in self.channels[chan]['ops']:
                            self.channels[chan]['ops'].remove(usr['name'])
                            mode = 'o'
                        elif usr['name'] in self.channels[chan]['voices']:
                            self.channels[chan]['voices'].remove(usr['name'])
                            mode = 'v'
                        if mode:
                            mode = '-{}'.format(mode)
                    if mode:
                        self.do_mode(config['server'], chan,
                            '{} {}'.format(mode, ircencode(usr['name'])))
                if res.data['allowed'] != self.channels[chan]['allowed']:
                    self.channels[chan]['allowed'] = res.data['allowed']
                    if res.data['allowed'] == 'all':
                        self.do_mode(config['server'], chan, '-iI')
                    elif res.data['allowed'] == 'none':
                        self.do_mode(config['server'], chan, '+i-I')
                    else:
                        self.do_mode(config['server'], chan, '+iI {}'.format(
                            res.data['allowed']))
            self.lock['channels'].release()
            time.sleep(config['refresh'])

    def keepalive(self, chan):
        """Send a keepalive message.

        Generates a simple, hopefully unique keepalive message and sends
        it as a broken private message to the channel of choice.

        Prevents idling out even when inactive.

        The message takes form of "[ malíček/keepalive <16 bytes of hex>
        ]".

        :param chan: Channel name
        """
        logger.debug(
            '%s, %s, %s',
            self.peer,
            self.user,
            chan
        )
        msg = '[ malíček/keepalive {} ]'.format(
            str.lower(
                ''.join(random.choice(string.hexdigits) for i in range(16))
            )
        )
        return self.post(
            'rooms/{}'.format(chan[1:]),
            data={
                'action': 'post',
                'to': -1,
                'message': msg,
                'color': self.channels[chan]['color'],
            },
        )

    def register(self):
        """Register a connection.

        Attempt to register the connection if all required data has been
        provided.  Called from handle_nick() and handle_user().
        """
        if self.user == '*' or not self.password or not self.userstring:
            return
        if self.setup():
            self.reply(
                msg='{code} {user} :Welcome to Malíček',
                code=IRC.RPL_WELCOME,
            )
            self.reply(
                msg=('{code} {user} :Your host is {server}, '
                    'running version mlck-{version}.'),
                code=IRC.RPL_YOURHOST,
                version=VERSION,
            )
            self.reply(
                msg='{code} {user} :This server was created on {when}',
                code=IRC.RPL_CREATED,
                when=STARTED,
            )
            self.reply(
                msg=('{code} {user} :{server} mlck-{version} '
                    'aeioOrsw bhiIntovZ'),
                code=IRC.RPL_MYINFO,
                version=VERSION,
            )
            self.handle_motd(None)
            self.error(IRC.ERR_RESTRICTED)
        else:
            self.error(IRC.ERR_PASSWDMISMATCH)
            self.terminate()

    def registered(self):
        """Check if our connection has been registered.

        Most of our functionality depends on having a registered session.
        This method checks whether we have one.

        :returns: True if we have a working session, False otherwise
        """
        if self.session:
            return True
        return False

    def process_response(self, response):
        """Distills request responses only to essentials

        We don't need to keep the entire response objects arounds.
        Basically never.  This method decodes Malíček responses
        correctly and returns a simple object with code and data
        properties.

        The code is the original status code and can be checked against
        requests.code.  The data is decoded JSON.

        :param response: requests response object to process
        :returns: a simple object with code and data properties
        """
        response.encoding = 'utf-8'
        try:
            data = response.json()
        except json.decoder.JSONDecodeError:
            data = None
        except Exception:
            logger.exception('failed handling malicek response')
        return types.SimpleNamespace(
            code=response.status_code,
            headers=response.headers,
            data=data,
        )

    def get(self, resource, params=None):
        """Fetch Malíček resources using GET.

        A simple wrapper for fetching backend resources.  Returns raw
        request response objects.  Callers may check the status code or
        extract the data using the json() method.

        :param resource: API resource, appended to the endpoint
        :returns: Request response object
        """
        try:
            response = self.session.get(
                '{}/{}'.format(config['endpoint'], resource),
                timeout=config['timeout'],
                params=params,
            )
            return self.process_response(response)
        except requests.exceptions.Timeout:
            logger.warning('timeout while processing %s, params: %s',
                resource, params)
            return None
        except requests.exceptions.ConnectionError:
            logger.error('cannot connect to Malíček, closing connection')
            self.do_error('Cannot connect to Malíček, terminating.')
            self.terminate()
        except Exception:
            logger.exception('unhandled exception')

    def post(self, resource, params=None, data=None):
        """Fetch Malíček resources using POST

        A simple wrapper for fetching backend resources.  Returns raw
        request response objects.  Callers may check the status code or
        extract the data using the json() method.

        Optionally, a data structure can be passed representing the data
        to send.

        :param resource: API resource, appended to the endpoint
        :param data: Data to send, if any
        :returns: Request response object
        """
        try:
            response = self.session.post(
                '{}/{}'.format(config['endpoint'], resource),
                timeout=config['timeout'],
                params=params,
                json=data,
            )
            return self.process_response(response)
        except requests.exceptions.Timeout:
            logger.warning('timeout while processing %s, params: %s',
                resource, params)
            return None
        except requests.exceptions.ConnectionError:
            logger.error('cannot connect to Malíček, closing connection')
            self.do_error('Cannot connect to Malíček, terminating.')
            self.terminate()
        except Exception:
            logger.exception('unhandled exception')

    def login(self):
        """Initiate the Malíček session.

        Calls the /login resource, creating the session.  Having one is
        generally required for almost everything else.  Only works after
        PASS and NICK.  Should be called after USER.

        Clears the password from memory.

        :returns: True if we managed to log in, False otherwise
        """
        logger.debug(
            '%s, %s',
            self.peer,
            self.user,
        )
        r = self.post(
            'login',
            data={
                'user': self.user,
                'pass': self.password
            }
        )
        self.password = None
        return r and r.code == requests.codes.ok

    def logout(self):
        """Terminate the Malíček session.

        Calls the /logout resource.  The only real use is a cleanup, so
        this gets called from the destructor.
        """
        logger.debug(
            '%s, %s',
            self.peer,
            self.user,
        )
        self.get('logout')

    def rooms(self):
        """Fetch the /rooms resource.

        This is a shorthand for fetching the list of rooms.

        :returns: Request response
        """
        rooms = self.get('rooms/')
        if rooms:
            if rooms.code == requests.codes.ok:
                cache.links.update({
                    u['name']: u['link'] \
                    for r in rooms.data \
                    for u in r['users']
                })
                return rooms.data
            else:
                # FIXME: Unauthorized? Terminate.
                pass
        return None

    def do_mode(self, who, target, modes):
        """Send the MODE command.

        Emit a mode setting message.  This can be to response to the
        user's own MODE commands or produced by periodic() as things
        happen in channels.

        :param who: Who's changing the mode, typically *
        :param target: The affected channel or user
        :param modes: Full modes, including arguments and +/-
        """
        self.send(
            msg=':{user} MODE {target} {modes}',
            user=who,
            target=target,
            modes=modes,
        )

    def do_privmsg(self, sender, target, message):
        """Send the PRIVMSG command.

        Sends a PRIVMSG message on behalf of the sender.

        The sender is the sender's nickname, which is translated into a
        virtual username formed from the link ID + @alik.cz.  If the
        link ID is unavailable, *@alik.cz is used instead.

        In the rare event of sending PART of behalf of *, don't do any
        user mangling.

        :param sender: The messaging person, their NICK
        :param target: The target, typically a channel
        :param message: The message to send
        """
        if sender != config['server']:
            sender = '{}!{}@alik.cz'.format(sender,
                cache.links[sender] if sender in cache.links else '*')
        self.send(
            msg=':{user} PRIVMSG {target} :{message}',
            user=sender,
            target=target,
            message=message,
        )

    def do_notice(self, sender, target, message):
        """Send the NOTICE command.

        Sends a NOTICE message on behalf of the sender.

        The sender is the sender's nickname, which is translated into a
        virtual username formed from the link ID + @alik.cz.  If the
        link ID is unavailable, *@alik.cz is used instead.

        In the rare event of sending PART of behalf of *, don't do any
        user mangling.

        :param sender: The messaging person, their NICK
        :param target: The target, typically a channel
        :param message: The message to send
        """
        if sender != config['server']:
            sender = '{}!{}@alik.cz'.format(sender,
                cache.links[sender] if sender in cache.links else '*')
        self.send(
            msg=':{user} NOTICE {target} :{message}',
            user=sender,
            target=target,
            message=message,
        )

    def do_join(self, sender, channel):
        """Send the JOIN command.

        Sends JOIN to the channel on behalf of the sender.

        The sender is the sender's nickname, which is translated into a
        virtual username formed from the link ID + @alik.cz.  If the
        link ID is unavailable, *@alik.cz is used instead.

        In the rare event of sending PART of behalf of *, don't do any
        user mangling.

        :param sender: The joining person, their NICK
        :param channel: The channel they are joining
        """
        if sender != config['server']:
            sender = '{}!{}@alik.cz'.format(sender,
                cache.links[sender] if sender in cache.links else '*')
        self.send(
            msg=':{user} JOIN {channel}',
            user=sender,
            channel=channel,
        )

    def do_part(self, sender, channel, msg=None):
        """Send the PART command.

        Sends PART to the channel on behalf of the sender, with an
        optional parting message.

        The sender is the sender's nickname, which is translated into a
        virtual username formed from the link ID + @alik.cz.  If the
        link ID is unavailable, *@alik.cz is used instead.

        In the rare event of sending PART of behalf of *, don't do any
        user mangling.

        :param sender: The leaving person, their NICK
        :param channel: The channel they are leaving
        :param msg: Optional parting message
        """
        if sender != config['server']:
            sender = '{}!{}@alik.cz'.format(sender,
                cache.links[sender] if sender in cache.links else '*')
        self.send(
            msg=':{user} PART {channel} :{message}',
            user=sender,
            channel=channel,
            message=msg or '',
        )

    def do_topic(self, channel, topic):
        """Send the TOPIC command.

        Send TOPIC to the channel.  We don't normally know who did this
        so we don't care who the sender is and always send it as the
        server.

        :param channel: Channel changing its topic
        :param topic: The new topic
        """
        self.send(
            msg=':{server} TOPIC {channel} :{topic}',
            channel=channel,
            topic=topic,
        )

    def do_kick(self, who, whom, chan, msg):
        """Send the KICK command.

        ...

        :param who: Who is doing the kicking
        :param whom: Who is being kicked out
        :param chan: What channel is this happening on
        :param msg: Kick message
        """
        if who != config['server']:
            who = '{}!{}@alik.cz'.format(who,
                cache.links[sender] if sender in cache.links else '*')
        self.send(
            msg=':{user} KICK {channel} {names} :{message}',
            user=who,
            channel=chan,
            names=(whom,),
            message=msg,
        )

    def do_error(self, message=None):
        """Send the ERROR command.

        These are emitted by the server under special circumstances and
        they always target our only user, self.user.

        Not to be used for ERR replies.  See Client.error() for those.

        If no message is specified, "An error is an error." is sent.

        :param message: The error message to send
        """
        message = message or 'An error is an error.'
        self.send(
            msg=':{server} ERROR :{message}',
            message=message,
        )

    def handle_pass(self, args):
        """Handle PASS commands.

        Required for registering a session with us.  This needs to
        preceed the USER command which itself finishes the registration.

        :param args: <password>
        """
        if not args:
            self.error(IRC.ERR_NEEDMOREPARAMS)
            return
        self.password = args

    def handle_nick(self, args):
        """Handle NICK commands.

        Set the nickname and use it Alík's username.  It doesn't make
        sense to change it later as it's your immutable remote
        identifier.

        Attempts to register the connection.

        :param args: <nick>
        """
        if not args:
            self.error(IRC.ERR_NEEDMOREPARAMS)
            return
        self.user = args
        self.register()

    # FIXME: Parse the user mask and set +i (& 8), +w (& 4)
    def handle_user(self, args):
        """Handle USER commands.

        Finishes the registration.  We don't actually care about the
        arguments as all, so we ignore them all.  No one will see you.

        The arguments are effectively ignored but stored.

        Attempts to register the connection.

        :param args: <USER string>
        """
        if not args:
            self.error(IRC.ERR_NEEDMOREPARAMS)
            return
        self.userstring = args
        self.register()

    def handle_oper(self, args):
        """Handle OPER commands.

        Requires username + password but we don't really care.  We don't
        support server administration, so just reject everything.

        :param args: <username> <password>
        """
        if not args or args.find(' ') == -1:
            self.error(IRC.ERR_NEEDMOREPARAMS)
            return
        self.error(IRC.ERR_PASSWDMISMATCH)

    def handle_mode(self, args):
        """Handle MODE commands.

        Sets user or channel modes.  With no modes, just queries the
        current modes.

        Most standard modes are supported within reason, even though
        many don't have any real effect.

            User mode a: Away; set via /away, not here, unused
            User mode e: Use emoji, non-standard, default
            User mode i: Invisible, unused
            User mode o: Server operator, unused
            User mode O: Local server operator, unused
            User mode r: Restricted, no nick changes, default
            User mode s: Receives server notices, default
            User mode w: Receives wallops, default

            Channel mode b: Bans nicks from all chatrooms
            Channel mode h: Denotes channel halfop, chat admin
            Channel mode i: Invite only, channel locking
            Channel mode I: Invite list, friends, boys, girls
            Channel mode n: Must be a member to message
            Channel mode o: Denotes the channel operator, rooms+
            Channel mode O: Query the channel creator, unused
            Channel mode t: Must be the channel op to change toppic
            Channel mode v: Denotes channel voice, other admin
            Channel mode Z: Special, long commands (clear, destroy)

        Users can toggle 'e', 'i', 's' and 'w' user modes.  'a' needs to
        be set via /away.  The remaining modes are hardcoded.

        For channels, users can set or query 'b' and 'I', set 'o' and
        'Z, toggle 'i', and query 'O'.  The remaining modes are
        hardcoded and ignored.  Some of these may require chanop or
        admin privileges.

        User mode 'e' can be changed to configure the emoji conversion.

        Modes 'b', 'i', 'I' and 'o' only work for rooms admins or
        higher.  Regardin 'b', just setting the mode doesn't do much but
        when someone is kicked and their nick matches the ban, they also
        get banned from all rooms.  Once that happens, the ban is
        forgotten on our side.

        Wildcards are not accepted.

        All channels have the 'n' and 't' modes because that's just our
        reality.

        Parsing is handled via parse_mode().

        :param args: <nick/channel> [<+-><modes>[+-<modes>]* [<params>]]*
        """
        if not args:
            self.error(IRC.ERR_NEEDMOREPARAMS)
        args = args.split(' ', 1)
        if args[0].startswith('#'):
            self.lock['channels'].acquire()
            if args[0] not in self.channels:
                self.error(IRC.ERR_USERNOTINCHANNEL)
                self.lock['channels'].release()
                return
            if len(args) > 1 and args[1]:
                parser_result = parse_mode(args[1])
                if not parser_result:
                    self.error(IRC.ERR_UNKNOWNMODE)
                    self.lock['channels'].release()
                    return
                else:
                    modes, unknown = parser_result
                for mode in modes:
                    if mode.mode == 'b':
                        if not mode.argument:
                            for banned in self.channels[args[0]]['banlist']:
                                self.reply(
                                    msg='{code} {user} {banned}',
                                    code=IRC.RPL_BANLIST,
                                    banned=banned,
                                )
                            self.reply(
                                msg='{code} {user} :End of ban list.',
                                code=IRC.RPL_ENDOFBANLIST,
                            )
                        else:
                            # FIXME: Implement changing banlist
                            pass
                    elif mode.mode == 'i':
                        # FIXME: Implement locking
                        # This needs to happen at the end, though, to
                        # rspect the invite list
                        pass
                    elif mode.mode == 'I':
                        if not mode.argument:
                            if self.channels[args[0]]['allowed'] \
                                not in ('all', 'none'):
                                self.reply(
                                    msg='{code} {user} {allowed}',
                                    code=IRC.RPL_INVITELIST,
                                    allowed=self.channels[args[0]]['allowed'],
                                )
                            self.reply(
                                msg='{code} {user} :End of invite list.',
                                code=IRC.RPL_ENDOFINVITELIST,
                            )
                        else:
                            # FIXME: Implement changing allow list
                            pass
                    elif mode.mode == 'o':
                        # FIXME: Implement changing chanop
                        pass
                    elif mode.mode == 'O':
                        # Implement creator queries
                        pass
                    elif mode.mode == 'Z':
                        if self.user not in self.channels[args[0]]['ops']:
                            pass
                        if mode.argument == 'clear':
                            # FIXME: Implement clear
                            pass
                        elif mode.argument == 'destroy':
                            # FIXME: Implement destroy
                            pass
                if unknown:
                    self.error(
                        IRC.ERR_UNKNOWNMODE,
                        'Unknown mode flags: {}'.format(
                            ''.join(sorted(unknown)),
                        ),
                    )
            else:
                mode_string = '+{}nt'.format(
                    'i' \
                        if self.channels[args[0]]['allowed'] != 'all' \
                        else '',
                )
                self.reply(
                    msg='{code} {user} {channel} {modes}',
                    code=IRC.RPL_CHANNELMODEIS,
                    channel=args[0],
                    modes=mode_string,
                )
            self.lock['channels'].release()
        elif args[0] == self.user:
            if len(args) > 1 and args[1]:
                parser_result = parse_mode(args[1], user=True)
                if not parser_result:
                    self.error(
                        IRC.ERR_UMODEUNKNOWNFLAG,
                        'Modes not understood.',
                    )
                    return
                else:
                    modes, unknown = parser_result
                if unknown:
                    # Report unknown modes
                    pass
                if not modes:
                    # Failed to parse modes
                    pass
                positive = set()
                negative = set()
                for mode in modes:
                    if mode.mode == 'e':
                        self.modes.e = mode.state
                    elif mode.mode == 'i':
                        self.modes.i = mode.state
                    elif mode.mode == 's':
                        self.modes.s = mode.state
                    elif mode.mode == 'w':
                        self.modes.w = mode.state
                    else:
                        continue
                    if mode.state:
                        positive.add(mode.mode)
                        if mode.mode in negative:
                            negative.remove(mode.mode)
                    else:
                        negative.add(mode.mode)
                        if mode.mode in positive:
                            positive.remove(mode.mode)
                mode_string = '{}{}{}{}'.format(
                    '+' if positive else '',
                    ''.join(sorted(positive)),
                    '-' if negative else '',
                    ''.join(sorted(negative)),
                )
                if unknown:
                    self.error(
                        IRC.ERR_UMODEUNKNOWNFLAG,
                        'Unknown mode flags: {}'.format(
                            ''.join(sorted(unknown)),
                        ),
                    )
                if mode_string:
                    self.do_mode(
                        self.user,
                        self.user,
                        mode_string,
                    )
            else:
                modes = list()
                if self.modes.a:
                    modes.append('a')
                if self.modes.e:
                    modes.append('e')
                if self.modes.i:
                    modes.append('i')
                if self.modes.o:
                    modes.append('o')
                if self.modes.O:
                    modes.append('O')
                if self.modes.r:
                    modes.append('r')
                if self.modes.s:
                    modes.append('s')
                if self.modes.w:
                    modes.append('w')
                modes = '+{}'.format(''.join(modes)) \
                    if modes \
                    else ''
                self.reply(
                    msg='{code} {user} {modes}',
                    code=IRC.RPL_UMODEIS,
                    modes=modes,
                )
        else:
            self.error(IRC.ERR_USERSDONTMATCH)

    def handle_service(self, args):
        """Handle SERVICE commands.

        Services don't make any sense in our context.  Parse the message
        but refuse to cooperate.

        :param args: <nick> <reserved> <dist> <type> <reserved> <info>
        """
        if not args or args.count(' ') < 5:
            self.error(IRC.ERR_NEEDMOREPARAMS)
            return
        self.error(IRC.ERR_UNKNOWNCOMMAND)

    # FIXME: Leave all channels once done debugging
    def handle_quit(self, args):
        """Handle QUIT commands.

        The client is quitting.  Part all the active channels,
        unregister our selector and close the socket, unless they've
        already done that.

        Except that we don't do part the channels yet.  It's too early
        in the development and it could hinder testing with unnecessary
        spam.
        """
        self.do_error('Buh-bye!')
        self.terminate()

    def handle_squit(self, args):
        """Handle SQUIT commands.

        Used to close server-to-server links.  Makes zero sense in our
        context.  Parse the message but return a privilege error.  No
        one is an operator here.

        :param args: <server> <comment>
        """
        if not args or not args.count(' '):
            self.error(IRC.ERR_NEEDMOREPARAMS)
            return
        self.error(IRC.ERR_NOPRIVILEGES)

    def handle_join(self, args):
        """Handle JOIN commands.

        Attempts to join the specified channels.  Potential keys are of
        no use to us so they're ignored.  JOIN must be confirmed upon
        success.  The client should also receive the channel topic and
        the list of users, including self.  Additionaly, we also send
        all visible backlog messages as the server user.

        Joining "0" is an equivalent of parting all the currently joined
        channels.

        Joining with no arguments is non-standard but common.  We accept
        it and do nothing.

        :param args: [0|<channel>[,<channel>]* [<key>[,<key>]*]]
        """
        if not args:
            return
        if args == '0':
            self.lock['channels'].acquire()
            for chan in self.channels:
                if self.post(
                        'rooms/{}'.format(chan[1:]),
                        data={
                            'action': 'leave'
                        },
                    ):
                    del self.channels[chan]
                    self.do_part(self.user, chan)
                else:
                    self.error(
                        IRC.ERR_UNAVAILRESOURCE,
                        response=('Cannot leave {channel}, '
                        'resource is temporarily unavailable.'),
                        channel=chan,
                    )
            self.lock['channels'].release()
            return
        for chan in args.split(' ')[0].split(','):
            res = self.get('rooms/{}'.format(chan[1:]))
            if not res:
                self.error(
                    IRC.ERR_UNAVAILRESOURCE,
                    response=('Cannot join {channel}, '
                    'resource is temporarily unavailable.'),
                    channel=chan,
                )
                continue
            if res.code != requests.codes.ok:
                continue
            settings = self.get(
                'rooms/{}'.format(chan[1:]),
                params={
                    'query': 'settings',
            })
            if settings:
                color = settings.data['color']
            else:
                color = config['color']
            ops = set()
            halfops = set()
            voices = set()
            for usr in res.data['users']:
                if usr['name'] not in cache.ids and usr['id']:
                    cache.ids[usr['name']] = usr['id']
                if usr['name'] not in cache.links and usr['link']:
                    cache.links[usr['name']] = usr['link']
                prefix = get_prefix(usr['admin'])
                if prefix == '@':
                    ops.add(usr['name'])
                elif prefix == '%':
                    halfops.add(usr['name'])
                elif prefix == '+':
                    voices.add(usr['name'])
            self.do_join(self.user, chan)
            topic = '{}{}'.format(
                res.data['name'],
                ' | {}'.format(res.data['topic']) if res.data['topic'] else '',
            )
            self.reply(
                msg='{code} {user} {channel} :{topic}',
                code=IRC.RPL_TOPIC,
                channel=chan,
                topic=topic,
            )
            self.reply(
                msg='{code} {user} = {channel} :{names}',
                code=IRC.RPL_NAMEREPLY,
                channel=chan,
                names=[
                    '{}{}'.format(
                        get_prefix(x['admin']),
                        x['name'],
                    ) for x in res.data['users']
                ],
            )
            self.reply(
                msg='{code} {user} {channel} :End of NAMES list.',
                code=IRC.RPL_ENDOFNAMES,
                channel=chan,
            )
            self.do_mode(config['server'], chan, '+Ont {}'.format(
                res.data['creator'],
            ))
            if res.data['allowed'] != 'all':
                if res.data['allowed'] == 'none':
                    self.do_mode(config['server'], chan, '+i')
                else:
                    self.do_mode(config['server'], chan, '+iI {}'.format(
                            res.data['allowed'],
                        )
                    )
            messages = res.data['messages']
            messages.reverse()
            for msg in messages:
                self.do_privmsg(
                    config['server'],
                    chan,
                    '@{} {}: {}'.format(
                        msg['time'] or '??:??:??',
                        ircencode(msg['from']) or ':',
                        emoji(msg['message']),
                    ) \
                        if not msg['to'] else \
                        '@{} {} → {}: {}'.format(
                            msg['time'] or '??:??:??',
                            ircencode(msg['from']) or ':',
                            ircencode(msg['to']),
                            emoji(msg['message']),
                        ),
                )
            self.lock['channels'].acquire()
            self.channels[chan] = {
                'activity': int(time.time()),
                'color': color,
                'topic': topic,
                'creator': res.data['creator'],
                'ops': ops,
                'halfops': halfops,
                'voices': voices,
                'allowed': res.data['allowed'],
                'banlist': set(),
            }
            if messages:
                self.channels[chan]['seen'] = messages[-1]
            self.lock['channels'].release()

    def handle_part(self, args):
        """Handle PART commands.

        Leaves the selected list of channels.  We do not have to check
        their presence as the backend doesn't care, although we could
        potentially cut a few nonsensical requests.

        We don't have anywhere to send the parting message so we simply
        ignore it.

        :param args: <channel>[,<channel>]*[ :<message>]
        """
        if not args:
            self.error(IRC.ERR_NEEDMOREPARAMS)
            return
        for chan in args.split(' ')[0].split(','):
            self.lock['channels'].acquire()
            if self.post(
                    'rooms/{}'.format(chan[1:]),
                    data={
                        'action': 'leave'
                    },
                ):
                del self.channels[chan]
                self.do_part(self.user, chan)
            else:
                self.error(
                    IRC.ERR_UNAVAILRESOURCE,
                    response=('Cannot leave {channel}, '
                    'resource is temporarily unavailable.'),
                    channel=chan,
                )
            self.lock['channels'].release()

    def handle_topic(self, args):
        """Handle TOPIC commands.

        Queries or sets the channel topic.  Setting the channel topic
        requires the channel operator status in all cases.

        Due to the current backend limitations, we can only query topics
        for channels the user has joined.

        :param args: <channel> [:<topic>]
        """
        if not args:
            self.error(IRC.ERR_NEEDMOREPARAMS)
            return
        if args.find(' ') != -1:
            chan, topic = args.split(' ', 1)
        else:
            chan = args
            topic = None
        self.lock['channels'].acquire()
        if chan not in self.channels:
            self.error(IRC.ERR_NOTONCHANNEL)
        else:
            if topic:
                topic = topic[1:]
                if self.user not in self.channels[chan]['ops']:
                    self.error(IRC.ERR_CHANOPRIVSNEEDED)
                else:
                    self.post(
                        'rooms/{}'.format(chan[1:]),
                        data={
                            'action': 'post',
                            'to': 0,
                            'message': '[Z]{}'.format(ircdecode(topic)),
                            'color': self.channels[chan]['color'],
                        },
                    )
            else:
                self.reply(
                    msg='{code} {user} {channel} :{topic}',
                    code=IRC.RPL_TOPIC,
                    channel=chan,
                    topic=self.channels[args]['topic'],
                )
        self.lock['channels'].release()

    # FIXME: Update the ops if it's our room
    def handle_names(self, args):
        """Handle NAMES commands.

        The command provides yet another method to list users on the
        given channel or channels.

        Channels are optional.  With no arguments lists all the channels
        and their occupants.  Bad channel names are okay.

        Target doesn't make sense here, so we ignore it.

        :param args: [<channel>[,<channel>]* [<target>]]
        """
        rooms = self.rooms()
        if not rooms:
            self.error(
                IRC.ERR_UNAVAILRESOURCE,
                response=('Cannot fetch names, '
                'resource temporarily unavailable.'),
            )
            return
        only = set()
        if args:
            only.update(x[1:] for x in args.split(','))
        for room in rooms:
            if not room['id']:
                continue
            if only and room['id'] not in only:
                continue
            self.reply(
                msg='{code} {user} = #{channel} :{names}',
                code=IRC.RPL_NAMEREPLY,
                channel=room['id'],
                # FIXME: Send the right prefix
                names=[
                    '{}{}'.format(
                        get_prefix(x['admin']),
                        x['name'],
                    ) for x in room['users']
                ],
            )
            self.reply(
                msg='{code} {user} #{channel} :End of NAMES list.',
                code=IRC.RPL_ENDOFNAMES,
                channel=room['id'],
            )

    def handle_list(self, args):
        """Handle LIST commands.

        Queries existing channels.  Lists all channels without
        arguments, or limits the output to channels that match one of
        the provided channel names.  These may contain the '*' wildcard,
        which we translate to '.*' in a regex.

        The optional target argument is ignored.

        The output includes the number of users in that channel, as well
        as its actual name.

        Rooms the user cannot join are not listed as the have no known
        ID.

        :param args: [<channel>[,<channel>]* [<target>]]
        """
        only = set()
        if args:
            for pattern in args.split(' ')[0].split(','):
                pattern = re.escape(pattern)
                pattern = pattern.replace('\\#', '#')
                pattern = pattern.replace('\\?', '.')
                pattern = pattern.replace('\\*', '.*')
                only.add(re.compile('^{}$'.format(pattern)))
        rooms = self.rooms()
        if not rooms:
            self.error(
                IRC.ERR_UNAVAILRESOURCE,
                response=('Cannot process rooms, '
                'resource is temporarily unavailable.'),
            )
            return
        for room in rooms:
            show = True
            if only:
                show = False
                for pattern in only:
                    if pattern.match(room['id']):
                        show = True
                        break
            if room['id'] is None:
                show = False
            if not show:
                continue
            self.reply(
                msg='{code} {user} #{channel} {number} :{topic}',
                code=IRC.RPL_LIST,
                channel=room['id'],
                number=len(room['users']),
                topic='{}{}'.format(
                    room['name'],
                    ' | {}'.format(room['topic']) if room['topic'] else '',
                ),
            )
        self.reply(
            msg='{code} {user} :End of LIST',
            code=IRC.RPL_LISTEND,
        )

    def handle_invite(self, args):
        """Handle INVITE commands.

        Invite other users to channels, notifying them.  There is no
        comparable backend feature, perhaps besides sending Alík mail to
        the user.  We just return an error like with PRIVMSG.

        :param args: <nickname> <channel>
        """
        if not args:
            self.error(IRC.ERR_NEEDMOREPARAMS)
            return
        if args.find(' ') == -1:
            self.error(IRC.ERR_NEEDMOREPARAMS)
            return
        self.error(IRC.ERR_NOSUCHNICK)

    # FIXME: Implement this
    def handle_kick(self, args):
        self.error(IRC.ERR_UNKNOWNCOMMAND)

    def handle_privmsg(self, args):
        """Handle PRIVMSG commands.

        The standard IRC messaging method, sends commands to channels or
        users.  Due to Alík's limitations, private messages cannot work
        outside of channels.  Messaging users is therefore disabled.

        Sending messages to channels one hasn't joined is similarly
        impossible and returns an error.

        ACTION messages are converted to "*<text>*" and sent as regular
        messages instead.  SOH is stripped.

        :param args: <msgtarget> <text>
        """
        if not args:
            self.error(IRC.ERR_NEEDMOREPARAMS)
            return
        chan, msg = args.split(' ', 1)
        if not chan.startswith('#'):
            self.error(IRC.ERR_NOSUCHNICK)
            return
        if len(msg) < 2:
            self.error(IRC.ERR_NOTEXTTOSEND)
            return
        self.lock['channels'].acquire()
        if chan not in self.channels:
            self.error(IRC.ERR_CANNOTSENDTOCHAN)
            return
        msg = msg[1:]
        recipient = 0
        if msg.startswith('\x01') and msg.endswith('\x01'):
            msg = msg[1:-1]
            if msg.startswith('ACTION '):
                msg = '*{}*'.format(msg[7:])
            else:
                self.do_privmsg(
                    config['server'],
                    chan,
                    '{}, ctcp: Unsupported extended data'.format(
                        self.user,
                    ),
                )
                self.lock['channels'].release()
                return
        elif msg.startswith('@'):
            msg = msg[1:].lstrip()
            pos = msg.find(' ')
            if pos == -1:
                self.do_privmsg(
                    config['server'],
                    chan,
                    '{}, whisper usage: @ <target> <message>'.format(
                        self.user,
                    ),
                )
                self.lock['channels'].release()
                return
            else:
                recipient = ircdecode(msg[0:pos])
                msg = msg[pos + 1:]
                if recipient not in cache.ids:
                    self.do_privmsg(
                        config['server'],
                        chan,
                        '{}, whisper error: Recipient "{}" not found'.format(
                            self.user,
                            ircencode(recipient),
                        ),
                    )
                    self.lock['channels'].release()
                    return
                recipient = cache.ids[recipient]
        msg = msg.strip()
        if self.post(
                'rooms/{}'.format(chan[1:]),
                data={
                    'action': 'post',
                    'to': recipient,
                    'message': ircdecode(msg),
                    'color': self.channels[chan]['color'],
                },
            ):
            self.channels[chan]['activity'] = int(time.time())
        else:
            self.error(
                IRC.ERR_UNAVAILRESOURCE,
                response=('Message ({channel}, {message}) not sent; '
                'resource temporarily unavailable.'),
                channel=chan,
                message=msg,
            )
        self.lock['channels'].release()

    # FIXME: Don't send messages; use this for reporting misdemeanor
    # Blocked by Malicek
    def handle_notice(self, args):
        """Handle NOTICE commands.

        This just calls handle_privmsg() as they're virtually
        identical in our context.

        :param args: <msgtarget> <text>
        """
        self.handle_privmsg(args)

    def handle_motd(self, args):
        """Handle MOTD commands.

        Reload server MOTD and echo it.  This could provide some welcome
        message as well as a bssic manual.  The MOTD file path should be
        specified in the config.

        Arguments ignored.

        :param args: [<target>]
        """
        try:
            with open(config['motd']) as motd_fh:
                self.reply(
                    msg='{code} {user} :- {server} message of the day',
                    code=IRC.RPL_MOTDSTART,
                )
                for motd in motd_fh.readlines():
                    self.reply(
                        msg='{code} {user} :- {message}',
                        code=IRC.RPL_MOTD,
                        message=motd,
                    )
                self.reply(
                    msg='{code} {user} :End of MOTD.',
                    code=IRC.RPL_ENDOFMOTD,
                )
        except FileNotFoundError:
            self.error(IRC.ERR_NOMOTD)
            logger.error('motd file %s not found.', config['motd'])
        except PermissionError:
            self.error(IRC.ERR_NOMOTD)
            logger.error('cannot open motd file %s.', config['motd'])
        except Exception:
            self.error(IRC.ERR_NOMOTD)
            logger.exception('unhandled exception')

    # FIXME: Implement this
    # Potentially list all other mlck clients, or just LUSER_ME
    def handle_lusers(self, args):
        """Handle LUSERS commands.

        The command queries the local IRC users.

        :param args: [<mask> [<target>]]
        """
        self.error(IRC.ERR_UNKNOWNCOMMAND)

    def handle_version(self, args):
        """Handle VERSION commands.

        Queries the server version.  To make this somewhat more useful,
        we also query Malíček for its version and add it to the comment.

        Arguments are ignored.

        :param args: [<target>]
        """
        malicek = self.get('malicek')
        if malicek and malicek.data:
            malicek = '{}-{}'.format(
                malicek.data['app'],
                malicek.data['version'],
            )
        else:
            malicek = 'Malíček version unavailable'
        self.reply(
            msg=('{code} {user} mlck-{version}.{debug} {server} '
                ':mlck-{version}, {malicek}'),
            code=IRC.RPL_VERSION,
            version=VERSION,
            debug=config['loglevel'].lower(),
            malicek=malicek,
        )

    # FIXME: Implement this
    # Maybe, if we decide to publish these things; also see LUSERS
    def handle_stats(self, args):
        """Handle STATS commands.

        Provides server statistics for the query, or all queries if
        unspecified.  Target is ignored.

        Queries can combine multiple letters and the meaning is:

            * l - server connections
            * m - usage count for commands
            * o - number of configured ops
            * u - uptime

        :param args: [<query> [<target>]]
        """
        self.error(IRC.ERR_UNKNOWNCOMMAND)

    def handle_links(self, args):
        """Handle LINKS commands.

        Shows links to other servers.  We don't have any, ever.

        :param args: [[<target>] <mask>]
        """
        if not args:
            mask = '*'
        else:
            pos = args.find(' ')
            if pos != -1:
                mask = args[pos + 1:]
            else:
                mask = args
        self.reply(
            msg='{code} {user} {mask} :End of LINKS',
            code=IRC.RPL_ENDOFLINKS,
            mask=mask,
        )

    def handle_time(self, args):
        """Handle TIME commands.

        Just return the timestamp of the local server time.  No
        formatting is specified, so we'll just use the same as in
        uptime.  Target is ignored.

        :param args: [<target>]
        """
        self.reply(
            msg='{code} {user} :{time}',
            code=IRC.RPL_TIME,
            time=time.strftime('%a %b %d %Y at %H:%M:%S', time.localtime()),
        )

    def handle_connect(self, args):
        """Handle CONNECT commands.

        Connect the server to another server.  Server operator only, so
        unsupported in our case.

        :param args: <remote> <port> [<target>]
        """
        if not args:
            self.error(IRC.ERR_NEEDMOREPARAMS)
            return
        if args.find(' ') == -1:
            self.error(IRC.ERR_NEEDMOREPARAMS)
            return
        self.error(IRC.ERR_NOPRIVILEGES)

    # FIXME: Implement this
    def handle_trace(self, args):
        self.error(IRC.ERR_UNKNOWNCOMMAND)

    def handle_admin(self, args):
        """Handle ADMIN commands.

        Provides information about the server admin, who it is and their
        contact info.  Sourced from our configuration.

        :param args: none
        """
        self.reply(
            msg='{code} {user} :{server} administration',
            code=IRC.RPL_ADMINME,
        )
        self.reply(
            msg='{code} {user} :{message}',
            code=IRC.RPL_ADMINLOC1,
            message=config['location'],
        )
        self.reply(
            msg='{code} {user} :{message}',
            code=IRC.RPL_ADMINLOC2,
            message=config['admin'],
        )
        self.reply(
            msg='{code} {user} :{message}',
            code=IRC.RPL_ADMINEMAIL,
            message=config['email'],
        )

    def handle_info(self, args):
        """Handle INFO commands.

        Provide some basic information about our server.  The target
        argument is ignored.

        We provide our name, version, Malicek endpoint, Malicek version,
        our uptime, our admin, our loglevel, our default color, refresh
        and timeout values, encoding, and, buffer size and the user.

        :param args: [<target>]
        """
        malicek = self.get('malicek')
        if malicek and malicek.data:
            malicek = malicek.data['version']
        else:
            malicek = 'unknown'
        self.reply(
            msg='{code} {user} :This is {server}',
            code=IRC.RPL_INFO,
        )
        self.reply(
            msg='{code} {user} :{message}',
            code=IRC.RPL_INFO,
            message='mlck, version {}.{}'.format(
                VERSION,
                config['loglevel'].lower(),
            ),
        )
        self.reply(
            msg='{code} {user} :{message}',
            code=IRC.RPL_INFO,
            message='Malíček, version {}'.format(malicek),
        )
        self.reply(
            msg='{code} {user} :{message}',
            code=IRC.RPL_INFO,
            message='Connected to {}/'.format(config['endpoint']),
        )
        self.reply(
            msg='{code} {user} :{message}',
            code=IRC.RPL_INFO,
            message='Started on {}'.format(STARTED),
        )
        self.reply(
            msg='{code} {user} :{message}',
            code=IRC.RPL_INFO,
            message='Run by {} <{}>'.format(config['admin'], config['email']),
        )
        self.reply(
            msg='{code} {user} :{message}',
            code=IRC.RPL_INFO,
            message='Location: {}'.format(config['location']),
        )
        self.reply(
            msg='{code} {user} :{message}',
            code=IRC.RPL_INFO,
            message='Encoding: {}'.format(config['encoding']),
        )
        self.reply(
            msg='{code} {user} :{message}',
            code=IRC.RPL_INFO,
            message='Buffer size: {} bytes'.format(config['buffer']),
        )
        self.reply(
            msg='{code} {user} :{message}',
            code=IRC.RPL_INFO,
            message='Fallback chat color: {}'.format(config['color']),
        )
        self.reply(
            msg='{code} {user} :{message}',
            code=IRC.RPL_INFO,
            message='Refresh interval: {}s'.format(config['refresh']),
        )
        self.reply(
            msg='{code} {user} :{message}',
            code=IRC.RPL_INFO,
            message='Request timeout: {}s'.format(config['timeout']),
        )
        self.reply(
            msg='{code} {user} :End of INFO list.',
            code=IRC.RPL_ENDOFINFO,
        )

    def handle_servlist(self, args):
        """Handle SERVLIST commands.

        Lists services connected to the server.  We don't have any.

        :param args: [<mask> [<type>]]
        """
        if args:
            if args.find(' ') != -1:
                mask, servtype = args.split(' ', 1)
                servtype = servtype or 'any'
            else:
                mask = args
                servtype = 'any'
        else:
            mask = '*'
            servtype = 'all'
        self.reply(
            msg='{code} {user} {mask] {servtype} :End of service listing',
            code=IRC.RPL_SERVLISTEND,
            mask=mask,
            servtype=servtype,
        )

    def handle_squery(self, args):
        """Handle SQUERY commands.

        Messaging services.  We don't have any services.  Parse the
        input but say there is no such service, whatever it is.  It's
        true.

        :param args: <servicename> <text>
        """
        if not args:
            self.error(IRC.ERR_NEEDMOREPARAMS)
            return
        if args.find(' ') == -1:
            self.error(IRC.ERR_NEEDMOREPARAMS)
            return
        self.error(IRC.ERR_NOSUCHSERVICE)

    def handle_who(self, args):
        """Handle WHO commands.

        RFC 2812 is rather vague and unclear on this topic, however, WHO
        commands require one or two arguments, the first of which is a
        mask, the other is optionally the letter 'o'.

        The mask can be both a user mask or a channel mask and may
        include wildcards.

        We support this, except that if the mask begins with a '#',
        channel is assumed, user otherwise.  To search for channels
        beginning with a wildcard, '#*foo' must be requested.

        If 'o' is requested, the list returns only operators and above.

        For channels, we query every matching channel.  For user queries
        we aggregate the results and return them for channel '*',
        omitting operator flags.  This seems to be the common practice.

        The "real name" part is replaced by user details for
        convenience.

        User mask doesn't match against our virtual hostnames or
        servers, only IDs, nicknames and details.

        Channel queries omit channels the user cannot join as they have
        no visible room IDs.

        :param args: [<mask> [o]]
        """
        args = args or ''
        rooms = self.rooms()
        if not rooms:
            self.error(
                IRC.ERR_UNAVAILRESOURCE,
                response=('Cannot list users, ',
                'resource is temporarily unavailable.'),
            )
            return
        args = args.split(' ')
        if args[0] in ('', '0'):
            args[0] = '*'
        mask = re.escape(args[0])
        mask = mask.replace('\\#', '#')
        mask = mask.replace('\\?', '.')
        mask = mask.replace('\\*', '.*')
        mask = re.compile('^{}$'.format(mask))
        if args[0].startswith('#'):
            for room in rooms:
                if not room['id'] or not mask.match('#{}'.format(room['id'])):
                    continue
                for nick in room['users']:
                    if len(args) > 1 and args[1] == 'o' and not nick['admin']:
                        continue
                    self.reply(
                        msg=('{code} {user} #{channel} {link} alik.cz '
                        '{server} {names} H{prefix} :0 {description}'),
                        code=IRC.RPL_WHOREPLY,
                        channel=room['id'],
                        link=nick['link'],
                        names=(nick['name'],),
                        prefix=get_prefix(nick['admin']),
                        description='{}, {}, https://alik.cz/u/{}'.format(
                            nick['age'] or '?',
                            nick['sex'],
                            nick['link'],
                        ),
                    )
        else:
            nicks = dict()
            for room in rooms:
                for nick in room['users']:
                    if len(args) > 1 and args[1] == 'o' and not nick['admin']:
                        continue
                    nicks[nick['name']] = {
                        'link': nick['link'],
                        'age': nick['age'],
                        'sex': nick['sex'],
                    }
            for nick, data in sorted(nicks.items()):
                if not mask.match(nick) and \
                    not mask.match(data['link']) and \
                    not mask.match(data['sex']):
                    continue
                self.reply(
                    msg=('{code} {user} * {link} alik.cz '
                    '{server} {names} H :0 {description}'),
                    code=IRC.RPL_WHOREPLY,
                    link=data['link'],
                    names=(nick,),
                    description='{}, {}, https://alik.cz/u/{}'.format(
                        data['age'] or '?',
                        data['sex'],
                        data['link'],
                    ),
                )
        self.reply(
            msg='{code} {user} {query} :End of /WHO list.',
            code=IRC.RPL_ENDOFWHO,
            query=args[0],
        )

    # FIXME: Implement this
    # This should query the profile page info, so blocked by Malicek
    def handle_whois(self, args):
        """Handle WHOIS commands.

        Not implemented.

        :param args: [target] <mask>[,<mask>]*
        """
        if not args:
            self.error(IRC.ERR_NEEDMOREPARAMS)
            return
        self.error(IRC.ERR_UNKNOWNCOMMAND)

    # FIXME: Implement this
    def handle_whowas(self, args):
    # This should query the profile page info, so blocked by Malicek
    # Virtually identical to WHOIS but uses different resposse codes
        """Handle WHOWAS commands.

        Not implemented.

        :param args: <nick>[,<nick>]* [<count> [<target>]]
        """
        if not args:
            self.error(IRC.ERR_NEEDMOREPARAMS)
            return
        self.error(IRC.ERR_UNKNOWNCOMMAND)

    def handle_kill(self, args):
        """Handle KILL commands.

        Used to disconnect other users on nickname or other conflicts.
        Not applicable.  Requires a server operator status.  Just parse
        and refuse.

        :param args: <nickname> <comment>
        """
        if not args:
            self.error(IRC.ERR_NEEDMOREPARAMS)
            return
        if args.find(' ') == -1:
            self.error(IRC.ERR_NEEDMOREPARAMS)
            return
        irc.error(IRC.ERR_NOPRIVILEGES)

    def handle_ping(self, args):
        """Handle PING commands.

        Respond with an appropriate PONG.  Since we are a virtual
        server, we speak for all of them.  In the two argument form, the
        first argument is the origin, such as the user name or another
        server, and the other argument is the target.

        In a single argument form, only the target is given.

        :param args: <server> [<server>]
        """
        if not args:
            self.error(IRC.ERR_NEEDMOREPARAMS)
            return
        self.reply(
            msg='PONG {server}',
        )

    def handle_pong(self, args):
        """Handle PONG commands.

        Responses to PINGs.  We don't send any PINGs but it's okay if
        the client somehow sends these.  Just drop them.

        :param args: <server> [<server>]
        """
        if not args:
            self.error(IRC.ERR_NEEDMOREPARAMS)
            return

    def handle_error(self, args):
        """Handle ERROR commands.

        The ERROR command is only supposed to be sent by servers, not
        clients.  There are no defined numerics for responses to these.
        We could send ERR_UNKNOWNCOMMAND, but since we're handling it,
        we'll just ignore it.

        :param args: <message>
        """
        if not args:
            self.error(IRC.ERR_NEEDMOREPARAMS)
            return

    def handle_away(self, args):
        """Handle AWAY commands.

        Sets or resets the user's away status.  We ignore the contents
        of the message currently but at least set the mode.  This has no
        impact on anything at the moment.

        :param args: [:<message>]
        """
        if args:
            self.modes.a = True
            self.reply(
                msg='{code} {user} :Please, come back...',
                code=IRC.RPL_NOWAWAY,
            )
        else:
            self.modes.a = False
            self.reply(
                msg='{code} {user} :Yay, welcome back!',
                code=IRC.RPL_UNAWAY,
            )

    def handle_rehash(self, args):
        """Handle REHASH commands.

        Reloads the server configuration.  In general.

        This is an operator thing.  Refuse.

        :param args: none
        """
        self.error(IRC.ERR_NOPRIVILEGES)

    def handle_die(self, args):
        """Handle DIE commands.

        Shuts down the server.  In general.

        This is an operator thing.  Refuse.

        :param args: none
        """
        self.error(IRC.ERR_NOPRIVILEGES)

    def handle_restart(self, args):
        """Handle RESTART commands.

        Restarts the server.  In general.

        This is an operator thing.  Refuse.

        :param args: none
        """
        self.error(IRC.ERR_NOPRIVILEGES)

    def handle_summon(self, args):
        """Handle SUMMON commands.

        Summons users on the server host or elsewhere to IRC,
        potentially even a specific channel.  In theory.

        This does not make any sense here.  Parse but refuse.
        :param args: <user> [<target> [<channel>]]
        """
        if not args:
            self.error(IRC.ERR_NORECIPIENT)
            return
        self.error(IRC.ERR_SUMMONDISABLED)

    def handle_users(self, args):
        """Handle USERS commands.

        Lists the users on the server system.  In theory.

        We are not going to provide this information.

        :param args: [<target>]
        """
        self.error(IRC.ERR_USERSDISABLED)

    def handle_wallops(self, args):
        """Handle WALLOPS commands.

        Sends messages to everyone with +w.  There is no one else but
        us.  Our default is +w but can be toggled.  This whole thing is
        fairly pointless but if the user wishes, we'll send them the
        message back.

        :param args: <message>
        """
        if not args:
            self.error(IRC.ERR_NEEDMOREPARAMS)
            return
        if self.modes.w:
            self.send(
                msg=':{user} WALLOPS :{message}',
                message=args[1:],
            )

    def handle_userhost(self, args):
        """Handle USERHOST commands.

        Takes a list of nicks and returns their user information.  In
        our case we'll return the user link name + @alik.cz, if we know
        it.  Nicks not found, not present or unknown, are simply
        ignored.

        :param args: <nick> [<nick> [...]]
        """
        if not args:
            self.error(IRC.ERR_NEEDMOREPARAMS)
            return
        self.rooms()
        for nick in args.split(' '):
            if nick not in cache.links:
                continue
            self.reply(
                msg='{code} {user} :{names} = - {link}@alik.cz',
                code=IRC.RPL_USERHOST,
                names=(nick,),
                link=cache.links[nick],
            )

    def handle_ison(self, args):
        """Handle ISON commands.

        Accepts a list of nicks and response with a list of nicks that
        match the query and are also online.

        :param args: <nick> [<nick> [...]]
        """
        if not args:
            self.error(IRC.ERR_NEEDMOREPARAMS)
            return
        nicks = set(map(ircdecode, args.split(' ')))
        online = set()
        rooms = self.rooms()
        if rooms:
            for room in rooms:
                for usr in room['users']:
                    if ircencode(usr['name']) in nicks:
                        online.add(ircencode(usr['name']))
        self.reply(
            msg='{code} {user} :{names}',
            code=IRC.RPL_ISON,
            names=online,
        )

def ircencode(identifier):
    """Encode nicks for IRC.

    Sanitize nicks that are not generally acceptable or break the IRC
    protocol.  Namely this replaces spaces with non-breakable spaces.

    :param nick: The original identifier
    :returns: Encoded identifier
    """
    return identifier.replace('\x20', '\xa0') \
        if identifier is not None \
        else None

def ircdecode(identifier):
    """Decode IRC nicks into real nicks again.

    Modify sanitized nicks into their original forms.

    :param nick: The encoded identifier
    :returns: Decoded identifier
    """
    return identifier.replace('\xa0', '\x20') \
        if identifier is not None \
        else None

def emoji(msg, use_emoji=True):
    """Replace Alík's emoticons with corresponding emojis.

    These are not exact match but are close enough.  If use_emoji is
    false, we don't actually return emojis but rather some text forms.
    This can be controlled via the 'e' user mode.

    :param msg: The original message
    :param use_emoji: Convert to emoji if True
    :returns: Message with emoji substitutions
    """
    if not use_emoji:
        return msg
    msg = msg.replace('[:-)]', '🙂')
    msg = msg.replace('[:->]', '😀')
    msg = msg.replace('[:-D]', '😆')
    msg = msg.replace('[;-D]', '🤣')
    msg = msg.replace('[;-)]', '😉')
    msg = msg.replace('[:-|]', '😐')
    msg = msg.replace('[:-P]', '😛')
    msg = msg.replace('[8-D]', '😂')
    msg = msg.replace('[8-o]', '😲')
    msg = msg.replace('[:-o]', '😮')
    msg = msg.replace('[:-(]', '😟')
    msg = msg.replace('[:-E]', '😡')
    msg = msg.replace('[;-(]', '😢')
    msg = msg.replace('[:-c]', '😩')
    msg = msg.replace('[:-Q]', '🤮')
    msg = msg.replace('[8-P]', '😜')
    msg = msg.replace('[:-3]', '😊')
    msg = msg.replace('[:-$]', '🤢')
    msg = msg.replace('[O:-)]', '😇')
    msg = msg.replace('[]:-)]', '😈')
    msg = msg.replace('[Z]', '⚡')
    msg = msg.replace('[?]', '❓')
    msg = msg.replace('[!]', '❗')
    msg = msg.replace('[R^]', '👍')
    msg = msg.replace('[Rv]', '👎')
    msg = msg.replace('[O=]', '💡')
    msg = msg.replace('[@)->-]', '🌹')
    msg = msg.replace('[@=]', '💣')
    msg = msg.replace('[8=]', '💀')
    msg = msg.replace('[$>]', '💗')
    msg = msg.replace('B-)', '😎')
    msg = msg.replace(':-[)', '😷')
    msg = msg.replace('8-[)', '😷')
    msg = msg.replace('(8:O)', '🦛')
    msg = msg.replace('(8:o)', '🦛')
    msg = msg.replace('(8:°)', '🦛')
    msg = msg.replace('(ÍÍÍ)', '🍰')
    msg = msg.replace('(prali)', '🦕')
    return msg

def get_prefix(admin_list):
    """Returns an IRC prefix for the given admin list

    Examines the provided admin_list, as returned by Malíček, and
    returns a corresponding IRC prefix.

    Returns an empty string if the list is empty.

    :param admin_list: List of admin modes
    """
    if 'rooms' in admin_list \
        or 'master' in admin_list \
        or 'guru' in admin_list:
        return '@'
    if 'chat' in admin_list:
        return '+'
    return ''

def parse_mode(mode_string, user=False):
    """Mode string parser.

    This is a helper function which accepts MODE strings.

    The original RFC specification is way too flexible and makes it
    virtually impossible to distinguish unsigned queries from arguments,
    so we only follow the 'Modern IRC' document here.  All modes must
    have signs, arguments don't.

    Arguments are taken as they are.  Modes may combines signs within
    their blocks, e.g. '+ab-c' is valid.

    We then process modes from left to right and pair them with
    arguments from left to right, if they take any.  Arguments may
    appear anywhere: after a cluster of modes or after each individual
    mode.

    For 'set or query' modes ('b', 'i') we ignore the sign if there are
    no arguments.  In either case it's considered a query.

    Modes can be specified multiple times, for instance to assign
    multiple operators.

    Examples:

        +b foo
        Bans foo
        +bi foo
        Bans foo and sets the channel as invite only
        +b-i foo
        Bans foo and unlocks the channel
        -i+b foo
        Same as above
        +b foo -i
        Same as above
        +biI foo boys
        Bans foo, sets the channel as invite only, boys allowed
        +b foo +i +I boys
        Same as above
        +i +Ib boys foo
        Same as above
        +iboI foo bar girls
        Also sets bar as the chanop
        +oo-o foo bar baz
        Make foo and bar operators, deop baz

    The return value is tuple of two lists:

        A list of simple mode objects in order they were specified.
        Each has three properties: 'state', indicating True for '+' or
        False for '-', mode, a single character indicating the mode
        itself, and argument, a string representing the mode argument,
        or None if no arguments are present.

        And a list of single characters representing unknown modes.

    The 'user' argument controls whether we're processing user or
    channel modes, which may affect how arguments are processed.

    :param mode_string: The MODE string after user or channel
    :param user: True if handling user modes, False by default
    :returns: A tuple of lists, or None if parsing failed
    """
    items = mode_string.split(' ')
    modes = list()
    arguments = list()
    applied = list()
    unknown = list()
    for item in items:
        if item[:1] in ('+', '-'):
            for mode in item:
                if mode in ('+', '-'):
                    sign = mode
                    continue
                modes.append('{}{}'.format(sign, mode))
        else:
            arguments.append(item)
    if not modes:
        return None
    for mode in modes:
        state = True \
            if mode.startswith('+') \
            else False
        mode = mode[1:]
        if user:
            if mode in ('e', 'i', 's', 'w'):
                applied.append(types.SimpleNamespace(
                    state=state,
                    mode=mode,
                    argument=None,
                ))
            elif mode in ('a', 'o', 'O', 'r'):
                continue
            else:
                unknown.append(mode)
        else:
            if mode in ('b', 'I'):
                # Query or set
                applied.append(types.SimpleNamespace(
                    state=state,
                    mode=mode,
                    argument=arguments.pop(0) \
                        if arguments \
                        else None,
                ))
            elif mode in ('o', 'Z'):
                # Requires arguments
                if not arguments:
                    return None
                applied.append(types.SimpleNamespace(
                    state=state,
                    mode=mode,
                    argument=arguments.pop(0),
                ))
            elif mode in ('i', 'O'):
                # Toggle or query  only
                applied.append(types.SimpleNamespace(
                    state=state,
                    mode=mode,
                    argument=None,
                ))
            elif mode in ('h', 'n', 't', 'v'):
                # Recognized but ignored
                continue
            else:
                unknown.append(mode)
    if arguments:
        return None
    return applied, unknown

def accept(sock):
    """Accept new connections.

    Accepts all incoming connections, configures them as non-blocking
    and registers them with our master selector.

    Creates a buffer and a Client instance for each connection.

    :param sock: Listening socket with the pending connection request
    """
    conn, addr = sock.accept()
    logger.info(addr)
    conn.setblocking(False)
    sel.register(
        conn,
        (selectors.EVENT_READ),
        data=types.SimpleNamespace(buf=b'', client=Client(conn, addr[0]))
    )

def handle(key, mask):
    """Handle read evenets from the selector.

    Reads all pending data into a buffer, splitting it by CRLF to
    generate IRC messages.  These are later passed to Client.handle().

    Closes sockets and unregisters them from the selector when
    appropriate.

    :param key: The selector key object
    :param mask: The selector event mask
    """
    sock = key.fileobj
    data = key.data
    if mask & selectors.EVENT_READ:
        try:
            recv = sock.recv(config['buffer'])
        # We've seen ConnectionResetError and TimeoutError
        except socket.error:
            try:
                sel.unregister(sock)
                sock.close()
            except Exception:
                pass
            return
        else:
            if recv:
                data.buf += recv
            else:
                try:
                    sel.unregister(sock)
                    sock.close()
                except Exception:
                    pass
                return
    while data.buf:
        pos = data.buf.find(b'\r\n')
        if pos != -1:
            cmd = data.buf[0:pos]
            data.buf = data.buf[pos + 2:]
            data.client.handle(cmd)
            continue
        buflength = len(data.buf)
        if buflength > 512:
            logger.warning(
                '%s, client message too long: %d',
                sock.getpeername()[0],
                buflength,
            )
        if buflength >= config['buffer'] and buflength > 512:
            logger.error(
                '%s, protocol mismatch, terminating',
                sock.getpeername()[0],
            )
            data.client.do_error('Uh, this seems really wrong.  Buh-bye!')
            sel.unregister(sock)
            sock.close()
        break

def load_config(configFile):
    """Load mlck configuration file.

    Attempts to read the supplied config file and populates the global
    config, conf.

    :param configFile: Path to the configuration file
    """
    global config
    defaults = {
        'server': 'mlck',
        'admin': 'Unidentified administrator',
        'email': 'admin@example.com',
        'location': 'The Internet',
        'motd': 'mlck.motd',
        'loglevel': 'INFO',
        'listen': '127.0.0.1',
        'port': 6667,
        'buffer': 1024,
        'encoding': 'utf-8',
        'endpoint': 'http://localhost:3000',
        'refresh': 5,
        'timeout': 2.5,
        'color': '#000000',
    }
    cnf = configparser.ConfigParser()
    if not os.path.isfile(configFile):
        logger.critical('configuration file "%s" not found', configFile)
        sys.exit(1)
    try:
        cnf.read(configFile)
    except Exception:
        logger.critical('failed to parse the configuration file')
        sys.exit(1)
    if not cnf.has_section('mlck'):
        logger.critical('configuration is missing the mlck section')
        sys.exit(1)
    try:
        # Strings
        for opt in ('server', 'loglevel', 'listen', 'encoding', \
            'endpoint', 'color', 'admin', 'email', 'location', 'motd'):
            config[opt] = cnf.get('mlck', opt, fallback=defaults[opt])
        # Ints
        for opt in ('port', 'buffer'):
            config[opt] = cnf.getint('mlck', opt, fallback=defaults[opt])
        # Floats
        for opt in ('refresh', 'timeout'):
            config[opt] = cnf.getfloat('mlck', opt, fallback=defaults[opt])
    except ValueError:
        logger.critical('failed parsing the configuration file')
        sys.exit(1)
    loglevel = getattr(logging, config['loglevel'].upper())
    if not isinstance(loglevel, int):
        logger.critical('invalid log level configured')
        sys.exit(1)
    logger.setLevel(loglevel)
    if config['endpoint'].endswith('/'):
        config['endpoint'] = config['endpoint'][:-1]

def main():
    """The main body of the application.

    Sets everything up and begins listening for connections.

    Relies on selectors for input.
    """
    defaultconfig = os.path.join(os.sep, 'etc', 'mlck.conf')
    ap = argparse.ArgumentParser()
    ap.add_argument(
        '-c', '--config',
        dest='config',
        default=defaultconfig,
        help='mlck configuration file, defaults to {}'.format(defaultconfig),
    )
    ap.add_argument(
        '-q', '--quiet',
        dest='quiet',
        action='store_true',
        default=False,
        help='disable all output, regardless of loglevel configuration',
    )
    args = ap.parse_args()
    load_config(args.config)
    if args.quiet:
        logging.disable(logging.CRITICAL)
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock6 = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock6.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind((config['listen'], config['port']))
    sock.listen()
    sock.setblocking(False)
    sel.register(sock, selectors.EVENT_READ, data=None)
    logger.info('%s, %s starting...', config['server'], AGENT)
    logger.info('listening for connections on %s, port %s',
        config['listen'], config['port'])
    while True:
        events = sel.select(timeout=None)
        for k, m in events:
            if k.data is None:
                accept(k.fileobj)
            else:
                handle(k, m)

if __name__ == '__main__':
    main()
