""" LibCIA.IRC.Bots

A small library for managing multiple IRC bots on multiple IRC networks.
The code in this module runs in a separate daemon, so other CIA components
can be restarted without effecting the bots.

"""
#
# CIA open source notification system
# Copyright (C) 2003-2007 Micah Dowty <micah@navi.cx>
#
#  This program is free software; you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation; either version 2 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program; if not, write to the Free Software
#  Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
#

import random
import queue

from twisted.protocols import basic
from twisted.words.protocols import irc
from twisted.internet import protocol, reactor, defer
from twisted.python import log, util

import time
from cia.LibCIA.IRC import Network


class CommandHandler(basic.LineOnlyReceiver):
    """The Protocol subclass that takes messages from the RPC server.

       We use a line-based protocol and not something like NetString
       even though the latter would handle things like newlines in strings,
       so that the communication stays properly human-readable
       and we don't depend on twisted too much.

       Anyway, we'd better not be receiving newlines inside our input
       in the first place, since we're utterly unprepared to handle it.

       Our protocol consists of the following messages:

       --- messages sent to us, without replies ---
       ADD target
       DEL target
       MSG target message
       START_SYNC
       SYNC target
       END_SYNC

       --- requests sent to us that we reply to ---
       <-- STATUS
       --> BEGIN_STATUS
       {--> STATUS target usercount@botnick
       --> END_STATUS

       <-- REPORT target
       --> REPORT target usercount@botnick

       <-- TOTALS
       --> TOTALS * dictstr

       <-- MSGLOG
       --> BEGIN_MSGLOG
       --> MSGLOG time nick@server command arguments
       --> END_MSGLOG

       Where target is of the form network[:port]/channel
       ([] denote optional, channel includes all # characters).

       The idea of the *SYNC construct is to allow a restarted rpc server
       to synchronize its active requests: Everything between START_SYNC
       and END_SYNC is ADD'ed if we don't have it already, and everything
       not mentioned by the time we get END_SYNC is DEL'd
    """

    def __init__(self, botNet):
        self.botNet = botNet
        self.handlers = {
            "ADD": self.handle_add,
            "DEL": self.handle_del,
            "MSG": self.handle_msg,
            "START_SYNC": self.handle_start_sync,
            "SYNC"      : self.handle_sync,
            "END_SYNC"  : self.handle_end_sync,
            "STATUS" : self.handle_status,
            "REPORT" : self.handle_report,
            "TOTALS" : self.handle_totals,
            "MSGLOG" : self.handle_msglog,
        }
        self.sync_id = -1

    def Send(self, message):
        print("Sending %s" % message)
        self.sendLine(message.encode())

    def lineReceived(self, line):
        """Parses a line and calls the appropriate handler."""
        line = line.decode('utf-8')
        log.msg("Received command: " + line)
        parts = line.split(None, 2)
        command = parts[0]
        log.msg("Command Protocol: %s(%s)" % (command, parts[1:]))
        handler = self.handlers[command]
        handler(*parts[1:])

    def handle_add(self, target):
        (network, channel) = self.parse_target(target)
        if self.botNet.findRequest(network, channel):
            #log.msg("Asked to create %s twice" % target)
            return
        Request(self.botNet, network, channel)

    def handle_del(self, target):
        (network, channel) = self.parse_target(target)
        request = self.botNet.findRequest(network, channel)
        if not request:
            log.msg("Del on nonexistant %s" % target)
            return
        request.cancel()

    def handle_msg(self, target, message):
        (network, channel) = self.parse_target(target)
        request = self.botNet.findRequest(network, channel)
        if not request:
            log.msg("Msg on nonexistant %s" % target)
            return
        request.msg(message)

    def handle_start_sync(self):
        self.sync_id = self.botNet.start_sync()

    def handle_sync(self, target):
        (network, channel) = self.parse_target(target)
        if self.sync_id == self.botNet.sync_id:
            request = self.botNet.findRequest(network, channel)
            if request:
                request.have_sync = True
            else:
                Request(self.botNet, network, channel)

    def handle_end_sync(self):
        self.botNet.end_sync(self.sync_id)

    def handle_status(self):
        self.Send("BEGIN_STATUS")
        for request in self.botNet.requests:
            target = str(request.network) + '/' + request.channel
            if request.bots:
                botNick = request.bots[0].nickname
            else:
                botNick = "---"
            numClients = request.getUserCount() or 0
            self.Send("STATUS %s %d@%s" % (target, numClients, botNick))
        self.Send("END_STATUS")

    def handle_report(self, target):
        (network, channel) = self.parse_target(target)
        request = self.botNet.findRequest(network, channel)
        if request:
            if request.bots:
                botNick = request.bots[0].nickname
            else:
                botNick = "---"
            numClients = request.getUserCount() or 0
        else:
            botNick = '???'
            numClients = -1

        self.Send("REPORT %s %d@%s" % (target, numClients, botNick))

    def handle_totals(self):
        self.Send("TOTALS * " + str(self.botNet.getTotals()))

    def handle_msglog(self):
        self.Send("BEGIN_MSGLOG")
        for logRecord in self.botNet.unknownMessageLog.buffer:
            self.Send("MSGLOG %s %s@%s %s %s" % logRecord)
        self.Send("END_MSGLOG")

    def parse_target(self, target):
        """Parses a name[:port]/target spec.

           returns a tuple (network, target) where network is a Network object
           and target is the target as a string.
        """
        network, channel = target.split("/", 1)
        hostport = network.split(":", 1)
        if len(hostport) > 1:
            network = hostport[0]
            port = hostport[1]
        else:
            port = None
        net_obj = Network.find(network, port)
        return (net_obj, channel)

class CommandHandlerFactory(protocol.Factory):
    def __init__(self, botNet):
        self.botNet = botNet

    def buildProtocol(self, addr):
        return CommandHandler(self.botNet)

class Request:
    """The Request object specifies a network, optionally a channel, and
       a number of bots that need to inhabit that network/channel.

       When a request is created, it registers itself with the BotNetwork,
       which then tries its hardest to keep the request satisfied. The
       bots satisfying this request are available in its 'bots' member.
       """
    def __init__(self, botNet, network, channel=None, numBots=1):
        self.bots = []
        self._active = True
        self.have_sync = True

        self.botNet = botNet
        self.network = network
        self.channel = channel
        self.numBots = numBots

        log.msg("New %r" % self)
        botNet.addRequest(self)

    def __repr__(self):
        if self.numBots == 1:
            botInfo = "1 bot"
        else:
            botInfo = "%d bots" % self.numBots
        if self.channel:
            chanInfo = " in %s" % self.channel
        else:
            chanInfo = ''
        return "<Request for %s%s on %s>" % (botInfo, chanInfo, self.network)

    def active(self):
        """Return True if this request is still active, similar to DelayedCall's interface"""
        return self._active

    def cancel(self):
        """Indicate that this request is no longer needed. It is removed from the bot network."""
        self.botNet.removeRequest(self)
        self._active = False
        log.msg("Cancelled %r" % self)

    def findBots(self):
        """Find bots that match this request, storing them in self.bots"""
        # Look for our network and channel in the map from networks to bot lists
        matches = []
        if self.network in self.botNet.networks:
            # Look for our channel in the map from channel names to bot lists
            for bot in self.botNet.networks[self.network]:
                if (not self.channel) or (self.channel in bot.channels):
                    matches.append(bot)

        # Ignore any bots we don't need
        self.bots = matches[:self.numBots]

    def isFulfilled(self):
        return len(self.bots) == self.numBots

    def getUserCount(self):
        """Return the number of users this request services directly.
           This only works with requests for a channel, as we can't really
           tell for channel requests. The returned number is the number of
           users in the channel minus the number of bots we're using.
           If the number of users can't be determined, this returns None.
           """
        if self.channel and self.bots:
            nicks = self.bots[0].channels[self.channel].nicks
            if nicks:
                return len(nicks) - len(self.bots)

    # XXX - was: msg(self. target, line), supported nick targets
    def msg(self, line):
        """Use an arbitrary bot in this request to send a message to a
           supplied target (channel or nickname). Currently this always
           uses the first bot if possible and ignores the request if no
           bots are available. This may be a good place to implement
           smarter queueing and load balancing later. Currently bots
           implement their own anti-flood queues.
           """
        if self.bots:
            self.bots[0].queueMessage(self.channel, line)


class NickAllocator:
    """A nick allocator is responsible for determining what constitutes a valid nick,
       and generating a list of valid nicks. This is an abstract base class.
       """
    username = 'CIA'
    realname = 'CIA Bot (http://cia.stacksmash.net/)'

    def isValid(self, nick):
        """Returns True if the given nickname would be a valid output for our generator"""
        return False

    def generate(self):
        """Generate a sequence of valid nicks, starting with the most preferable.
           This must be an infinite (or nearly infinite) generator.
           """
        pass


class SequentialNickAllocator(NickAllocator):
    """Generate sequentially numbered nicks starting with a given prefix"""
    def __init__(self, prefix):
        self.prefix = prefix

    def isValid(self, nick):
        if not nick.startswith(self.prefix):
            return False
        numericPart = nick[len(self.prefix):]
        try:
            int(numericPart)
            return True
        except ValueError:
            return False

    def generate(self):

        i = 1
        while True:
            yield self.prefix + str(i)
            i += 1


class RandomAcronymNickAllocator(NickAllocator):
    """A nick allocator that generates random acronyms of a given length.
       No, it doesn't know what they mean yet :)
       """
    def __init__(self, length=3, alphabet="ABCDEFGHIJKLMNOPQRSTUVWXYZ"):
        self.length = length
        self.alphabet = alphabet

    def isValid(self, nick):
        if len(nick) != self.length:
            return False
        for letter in nick:
            if letter not in self.alphabet:
                return False
        return True

    def generate(self):
        while True:
            yield "".join([random.choice(self.alphabet) for i in range(self.length)])



class MessageLog:
    """A log that stores a fixed number of messages"""
    numMessages = 20

    def __init__(self):
        self.buffer = []

    def log(self, message):
        self.buffer = self.buffer[-self.numMessages:] + [message]


class BotNetwork:
    """A collection of IRC bots that work to satisfy a collection of Request objects.
       Users should interact with the BotNetwork via Request instances.
       """

    # As we don't want to flood the upstream router with SYN packets,
    # we only create a BotFactory every globalConnectInterval seconds
    globalConnectInterval = 0.1

    # In addition to checking bot status immediately after changes that*******
    # are likely to be important, we check bot status periodically, every
    # botCheckInterval seconds.
    botCheckInterval = 60

    # Bots are given this many seconds after being marked inactive before they're
    # disconnected. This way if a request is deleted then immediately replaced
    # with another that has similar requirements, we don't end up replacing
    # a bot we just deleted. I'm sure it has other good qualities too.
    botInactivityTimeout = 60 * 5

    # Maximum acceptable lag, in seconds. After this much the bot is disconnected
    # This should be a fairly large number, since the bots may experience a minute
    # or more of lag routinely when trying to join channels on connection.
    maximumLag = 60 * 3

    botCheckTimer = None

    def __init__(self, nickAllocator):
        self.nickAllocator = nickAllocator
        self.requests = []
        self.sync_id = 0
        self.unknownMessageLog = MessageLog()
        self.connectCheckTimer = None
        self.connectCheckQueue = queue.Queue()

        # A map from Network to list of Bots
        self.networks = {}

        # A list of all networks for which bots are being created currently.
        # Maps from BaseNetwork instance to a DelayedCall signalling a timeout.
        self.newBotNetworks = {}

        # Lists all bots we're thinking about deleting due to inactivity.
        # Maps from Bot instance to a DelayedCall.
        self.inactiveBots = {}

        self.wantBotCheck = True
        self.fastBotCheckTimer = None

        # Start the bot checking cycle
        self.checkBots()

    def findBot(self, host, nickname, port=None):
        """Find the bot currently on the given network with the given nick.
           This is mostly for use with the debug console. Note that for
           convenience, the network is specified as a host and port here.
           A BaseNetwork instance will be created.
           """
        network = Network.find(host, port)
        try:
            bots = self.networks[network]
        except KeyError:
            return None
        for bot in bots:
            if bot.nickname == nickname:
                return bot

    def findRequest(self, network, channel):
        """Find a request matching the given network, and channel"""
        for req in self.requests:
            if req.network == network and req.channel == channel:
                return req

    def addRequest(self, request):
        """Add a request to be serviced by this bot network. This should
           only be called by the Request class, as it automatically registers
           itself with the botNet it was constructed for.
           """
        self.requests.append(request)
        self.checkBots()

    def removeRequest(self, request):
        """Indicates that a request is no longer needed"""
        self.requests.remove(request)
        self.checkBots()

    def start_sync(self):
        """Starts a sync process, marking all requests as "not synced".

           Returns a "sync ID", a value you should check against our
           sync_id field whenever doing sync related things. This guards
           against two control sources syncing at the same time and
           the first end_sync killing stuff that wasn't confirmed since
           the second start_sync.
           (later wins, to prevent an unfinished sync from stalling all
            later syncs)
        """
        self.sync_id += 1
        for request in self.requests:
            request.have_sync = False
        return self.sync_id

    def end_sync(self, sync_id):
        if self.sync_id != sync_id:
            log.msg("Stale sync (%d)" % sync_id)
            return

        self.sync_id += 1
        stale_requests = []
        for request in self.requests:
            if not request.have_sync:
                stale_requests.append(request)
        for request in stale_requests:
            self.requests.remove(request)

        self.checkBots()

    def checkBots(self):
        """Schedules a bot check. If bots have not been checked within the
           last second, they are checked now, otherwise a new check is scheduled.
           """
        #log.msg("in checkBots")
        self.wantBotCheck = True
        if not self.fastBotCheckTimer:
            self._checkBots()

    def _checkBots(self):
        """Calls doCheckBots if a bot check has been requested.
           This ensures doCheckBots is called not too often, it eats CPU
           """
        #log.msg("in _checkBots")
        self.fastBotCheckTimer = None
        if not self.wantBotCheck:
            return
        self.wantBotCheck = False
        self.doCheckBots()
        self.fastBotCheckTimer = reactor.callLater(10, self._checkBots)

    def doCheckBots(self):
        """Search for unfulfilled requests, trying to satisfy them, then search
           for unused bots and channels, deleting them or scheduling them for deletion.
           """
        log.msg("Checking bots...")
        # Scan through all requests, trying to satisfy those that aren't.
        # Make note of which bots and which channels are actually being used.
        # activeBots is a map from Bot instance to a map of channels that are being used.
        usedBots = {}
        for request in self.requests:
            request.findBots()

            if not request.isFulfilled():
                # This request isn't fulfilled, try to change that
                self.tryToFulfill(request)

            for reqBot in request.bots:
                # Make note of the bots and channels this request needs,
                # and if the bot is already marked as inactive, cancel that.
                usedBots.setdefault(reqBot, util.InsensitiveDict())[request.channel] = True

                # Make sure that any referenced bots are no longer marked inactive
                if reqBot in self.inactiveBots:
                    timer = self.inactiveBots[reqBot]
                    if timer.active():
                        timer.cancel()
                    del self.inactiveBots[reqBot]

        # Now look for unused bots and/or channels
        for network in self.networks.values():
            for bot in network:
                if bot in usedBots:
                    usedChannels = usedBots[bot]

                    # We need this bot.. but are all the channels necessary?
                    for channel in bot.channels.keys():
                        if channel not in usedChannels:
                            bot.part(channel)

                    # Since we need this bot, make sure it's still responsive. If its lag
                    # is too high, force it to give up. IF we have to disconnect the bot,
                    # give up this checkBots() and start over when botDisconnected() calls
                    # us again.
                    if bot.getLag() > self.maximumLag:
                        bot.quit()
                        self.botDisconnected(bot)
                        return

                else:
                    # We don't need this bot. Tell it to part all of its channels,
                    # and if it isn't already, schedule it for deletion.
                    for channel in bot.channels.keys():
                        bot.part(channel)
                    if bot not in self.inactiveBots:
                        self.inactiveBots[bot] = reactor.callLater(
                            self.botInactivityTimeout, self.botInactivityCallback, bot)

        # Set up the next round of bot checking
        if self.botCheckTimer and self.botCheckTimer.active():
            self.botCheckTimer.cancel()
        self.botCheckTimer = reactor.callLater(self.botCheckInterval, self.checkBots)

    def tryToFulfill(self, request):
        """Given an unfulfilled request, try to take actions to fulfill it"""
        # How many more bots do we need?
        neededBots = request.numBots - len(request.bots)

        # Do we have any existing bots that can join a channel to fulfill the request?
        if request.channel and request.network in self.networks:
            for bot in self.networks[request.network]:
                if bot not in request.bots:
                    # If the bot's already trying to connect to our channel,
                    # decrease the needed bots count so we don't end up asking
                    # all our bots to join this channel before the first one succeeds
                    if request.channel in bot.requestedChannels:
                        neededBots -= 1
                    elif not bot.isFull():
                        bot.join(request.channel)
                        neededBots -= 1
                    if neededBots <= 0:
                        return

        # Nope... how about asking more bots to join the request's network?
        if not request.network in self.newBotNetworks:
            self.scheduleBot(request.network)

    def scheduleBot(self, network):
        """Schedule a new bot for a given network"""
        log.msg("Scheduling a new IRC bot for %s" % network)
        self.connectCheckQueue.put_nowait(network)
        self.newBotNetworks[network] = False
        if not self.connectCheckTimer:
            self.checkConnectQueue()

    def checkConnectQueue(self):
        log.msg("in checkConnectQueue")
        try:
            network = self.connectCheckQueue.get_nowait()
            self.createBot(network)
            self.connectCheckTimer = reactor.callLater(self.globalConnectInterval, self.checkConnectQueue)
        except queue.Empty:
            self.connectCheckTimer = None

    def createBot(self, network):
        """Create a new bot for the given network, retrying if necessary"""
        # We're not already trying to connect a bot, or our previous attempt failed.
        # Start trying to connect a bot, and set a timeout.
        log.msg("Creating a new IRC bot for %s" % network)
        BotFactory(self, network)
        # Slightly randomize reconnect time, so we don't have big reconnect blasts every BaseNetwork.newBotTimeout seconds
        self.newBotNetworks[network] = reactor.callLater(random.uniform(0.8, 1.2) * network.newBotTimeout, self.newBotTimedOut, network)

    def newBotTimedOut(self, network):
        """We just timed out waiting for a new bot connection. Try again."""
        log.msg("Timed out waiting for an IRC bot to connect to %s" % network)
        del self.newBotNetworks[network]
        # Don't immediately assume that we need to try again, but give us a chance to check
        self.checkBots()

    def botInactivityCallback(self, bot):
        """Bots that have been unused for a while eventually end up here, and are disconnected"""
        log.msg("Disconnecting inactive bot %r" % bot)
        bot.quit("Disconnecting due to inactivity")

    def botConnected(self, bot):
        """Called by a bot when it has been successfully connected."""
        self.networks.setdefault(bot.network, []).append(bot)

        try:
            timer = self.newBotNetworks[bot.network]
            if timer:
                if timer.active():
                    timer.cancel()
            del self.newBotNetworks[bot.network]
        except KeyError:
            # Hmm, we weren't waiting for this bot to connect?
            # Oh well, this bot will be garbage collected soon if that's really the case.
            pass
        self.checkBots()

    def botDisconnected(self, bot):
        """Called by a bot when it has been disconnected for any reason. Since
           this might have been a disconnection we didn't intend, do another bot
           check right away.
           """
        try:
            self.networks[bot.network].remove(bot)
            if not self.networks[bot.network]:
                del self.networks[bot.network]
        except:
            # The bot might have not been in our network list in the first
            # place, if it got disconnected before becoming fully connected
            # or if its disconnection gets detected in multiple ways (socket
            # closed, ping timeout, etc)
            pass

        self.inactiveBots.pop(bot, None)

        # The maximum lag autodisconnect code in checkBots relies on this being
        # called whether or not we actually removed the bot- the above 'pass'
        # can not be safely replaced with 'return'.
        self.checkBots()

    def botJoined(self, bot, channel):
        self.checkBots()

    def botLeft(self, bot, channel):
        self.checkBots()

    def botKicked(self, bot, channel, kicker, message):
        # FIXME: remove the ruleset
        pass

    def getTotals(self):
        """Return a dictionary of impressive-looking totals related to the bots"""
        totals = dict(
            networks = 0,
            bots = 0,
            channels = 0,
            users = 0,
            requests = 0,
            unfulfilled = 0,
            )

        for network in self.networks.keys():
            totals['networks'] += 1
            for bot in self.networks[network]:
                totals['bots'] += 1
                totals['channels'] += len(bot.channels)

        for request in self.requests:
            totals['requests'] += 1
            totals['users'] += request.getUserCount() or 0
            if not request.isFulfilled():
                totals['unfulfilled'] += 1

        return totals

    def disconnectAll(self, reason):
        """ Disconnect all bots on all networks (e.g, core shutdown) """
        # This seems like a nasty hack.. - Justasic
        botstogo = []
        for n in self.networks.values():
            # First, iterate the networks and their bots
            # Then add them to a list
            for bot in n:
                botstogo.append(bot)

        # Then start disconnecting the bots globally.
        for bot in botstogo:
            self.botDisconnected(bot)
            bot.quit(reason)

    def shutdown(self, reason):
        """ Disconnect all bots with a reason and exit the process cleanly """
        self.disconnectAll(reason)
        reactor.stop()


class ChannelInfo:
    """A container for information about an IRC channel. Currently
       this just holds things we will be told without asking, like the
       channel's occupants and its topic.
       """
    def __init__(self, name):
        self.name = name
        self.nicks = []
        self.topic = None

        # This list collects names mentioned in an RPL_NAMREPLY.
        # Upon receiving an RPL_ENDOFNAMES this is transferred to
        # 'nicks' and replaced with a new empty list.
        self.nickCollector = []


class MessageQueue:
    """A single message FIFO with a fixed maximum size. If messages
       are dropped, this emits a warning at the proper time.

       Normally, this acts just like a bare Queue object, except that
       it never blocks. Once it fills up, each new message increments
       a 'dropped' counter.

       Logically, the dropped messages will always be at the back of
       the queue. After the queue empties, a new message is added
       indicating how many lines were dropped. Normal queueing resumes
       after this.
       """
    def __init__(self, maxSize):
        self._fifo = queue.Queue(maxSize)
        self._numDropped = 0
        self._next = None

    def put(self, message):
        """Push a new message onto the Queue. Never blocks, but it
           may drop messages if the queue is full. The message
           should never be None.
           """
        if self._numDropped:
            self._numDropped += 1
        else:
            try:
                self._fifo.put(message, False)
            except queue.Full:
                self._numDropped = 1

    def get(self):
        """Return the next message, discarding it from the queue."""
        try:
            return self._fifo.get(False)
        except queue.Empty:
            if self._numDropped:
                n = self._numDropped
                self._numDropped = 0
                return "(%d lines omitted)" % n


class FairQueue:
    """A FairQueue tracks pending messages to any number of targets,
       each of which own their own independent queue. This prevents
       floods to one target from affecting timely delivery to other
       targets. All queues are checked in round-robin order.
       """
    def __init__(self, queueSize):
        self._queueSize = queueSize
        self._targetDict = {}
        self._pendingQueue = queue.Queue()
        self._next = None

    def put(self, target, message):
        """Queue up a new message to the supplied target"""
        if target not in self._targetDict:
            queue = MessageQueue(self._queueSize)
            self._targetDict[target] = queue
            self._pendingQueue.put(target, False)
        else:
            queue = self._targetDict[target]
        queue.put(message)

    def peek(self):
        """Return the next message to be processed, without discarding
           it. A subsequent call to peek() will return the same message.
           Returns a (target, message) tuple if a message is available,
           or None if all queues are empty.
           """
        if self._next is None:
            while 1:
                # Get the next queue target in round-robin order
                try:
                    target = self._pendingQueue.get(False)
                except queue.Empty:
                    break

                localqueue = self._targetDict[target]
                message = localqueue.get()

                if message:
                    # We got a message. Reschedule this queue for later.
                    self._next = (target, message)
                    self._pendingQueue.put(target, False)
                    break

                else:
                    # This queue is empty. Discard it.
                    del self._targetDict[target]

        return self._next

    def flush(self):
        """Discard the last message returned by peek()"""
        self._next = None


class Bot(irc.IRCClient):
    """An IRC bot connected to one network any any number of channels,
       sending messages on behalf of the BotController.

       The Bot class is responsible for keeping track of the timers and
       limits associated with joining channels, but it doesn't map itself
       onto Requests, nor does it manage bot connection and disconnection.
       """
    # Timeout, in seconds, for joining channels
    joinTimeout = 60

    # Timeout from TCP connection until we give up trying to get a nick
    # Useful to weed out "zombie" connections
    signonTimeout = 5*60

    # Important timestamps
    lastPingTimestamp = None
    lastPongTimestamp = None
    signonTimestamp = None
    lastPingTransmitTimestamp = None

    # Byte counters. We maintain a perpetually incrementing byte counter,
    # tracking the amount of data sent over the life of the connection.
    # This byte counter is sent with PING, and we record the value returned
    # with the last PONG. Taking (txByteCount - txConfirmedBytes) gives us
    # the number of bytes transmitted but not confirmed: the maximum amount
    # of data the server may be buffering on our behalf. This buffer
    # level must be controlled, to avoid being kicked for flooding.
    txByteCount = 0
    txConfirmedBytes = 0

    # Maximum number of lines to queue per target (channel/user)
    maxQueueSize = 20

    # Unhandled commands to ignore, rather than log
    ignoredCommands = [
        "ERR_NOCHANMODES",     # Freenode spamming us to register
        506,                   # PLD spamming us to register
        333,                   # Freenode sends these with channel registration info
        ]

    def __init__(self):
        self.emptyChannels()
        self._messageQueue = FairQueue(self.maxQueueSize)
        self.pendingWhoisTests = {}
        self.connectTimestamp = None
        self.pingTimer = None
        # Just close the TCP connection,
        # because at this point we don't trust the server to properly respond.
        self.signonTimer = reactor.callLater(self.signonTimeout,
          self.loseConnection)

    def loseConnection(self):
        self.transport.loseConnection()

    def emptyChannels(self):
        """Called when we know we're not in any channels and we shouldn't
           be trying to join any yet.
           """
        # Map from channel name to ChannelInfo instance. This only holds
        # channels we're actually in, not those we've been asked to join
        # but aren't in yet.
        self.channels = util.InsensitiveDict()

        # clear stale timers
        if hasattr(self, 'requestedChannels'):
            for timer in self.requestedChannels.values():
                if timer.active():
                    timer.cancel()

        # A map from channel name to a DelayedCall instance representing its timeout
        self.requestedChannels = util.InsensitiveDict()

    def __repr__(self):
        return "<Bot %r on network %s>" % (self.nickname, self.network)

    def isFull(self):
        return len(self.channels) + len(self.requestedChannels) >= self.network.maxChannels

    def connectionMade(self):
        """Called by IRCClient when we have a socket connection to the server."""
        self.emptyChannels()
        self.connectTimestamp = time.time()
        self.network = self.factory.network
        self.botNet = self.factory.botNet
        self.password = self.network.password

        # Start picking an initial nickname. This is really only expected to work
        # on servers where this is the only CIA bot. If this one is in use, we get
        # an ERR_NICKNAMEINUSE which we handle by picking a temporary nick we can
        # use to search for a better one.
        self.nickname = self.findNickQuickly()
        self.nicknames = [self.nickname]
        irc.IRCClient.connectionMade(self)

    def irc_ERR_NICKNAMEINUSE(self, prefix, params):
        """An alternate nickname-in-use error handler that generates a random
           temporary nick. This will let us at least connect to the server and
           issue WHOIS queries to efficiently find a better nick.

           As soon as we get the nickChanged back from this operation, we will
           realize this nick doesn't match those allowed by nickAllocator and
           start looking for a better one.
           """
        tempNick = "CIAtmp%03d" % random.randint(0, 999)
        self.nicknames.append(tempNick)
        self.setNick(tempNick)


    def irc_ERR_UNAVAILRESOURCE(self, prefix, params):
        """On Freenode this error happens when we cant find a proper nickname
           Retry with a temporary nick from irc_ERR_NICKNAMEINUSE."""
        if isinstance(self.network, Network.Freenode):
            self.irc_ERR_NICKNAMEINUSE(prefix, params)


    def sendLine(self, line):
        # Override sendLine() to update txByteCount.
        # Note that the text of 'line' doesn't count a CRLF,
        # but we should include that in our buffer estimates.
        self.txByteCount += len(line) + 2
        irc.IRCClient.sendLine(self, line)

    def nickChanged(self, newname):
        irc.IRCClient.nickChanged(self, newname)
        if self.botNet.nickAllocator.isValid(newname):
            # The nick was valid. If we aren't completely connected yet, fix that
            if self.signonTimestamp is None:
                self.finishConnection()
        else:
            # We got a bad nick, try to find a better one. If it doesn't
            # work within 1 minute, self-destruct.
            log.msg("%r starting nick negotiation" % self)
            reactor.callLater(60, self.enforceNickDeadline)
            self.findNick().addCallback(self.foundBetterNick)

    def enforceNickDeadline(self):
        """If this bot doesn't have a valid nick yet, kill it."""
        if not self.botNet.nickAllocator.isValid(self.nickname):
            self.sendLine("QUIT")

    def register(self, nickname, hostname='foo', servername='bar'):
        """Twisted's default register() is silly in that it doesn't let us
           specify a new username. We want all the usernames to be the
           same, so filters can be written and things are just in general
           better when bots are renaming themselves dynamically.
           """
        if self.password is not None:
            self.sendLine("PASS %s" % self.password)
        self.setNick(nickname)
        self.sendLine("USER %s %s %s :%s" % (self.botNet.nickAllocator.username,
                                             hostname,
                                             servername,
                                             self.botNet.nickAllocator.realname))

    def foundBetterNick(self, nick):
        log.msg("%r found a better nick, renaming to %r" % (self, nick))
        self.nicknames.append(nick)
        self.setNick(nick)

    def findNickQuickly(self):
        """This is used to get an initial nick during registration, before
           we're allowed to make WHOIS queries. It only checks whether a nick
           is already in use by one of our bots. If we happened to grab a nick
           that's already in use, the server will rename us and our nickChanged()
           handler will try to find a better nick.
           """
        for nick in self.botNet.nickAllocator.generate():
            if not nick in self.botNet.networks.get(self.network, []):
                # XXX: Hotfix to fix some freenode faggot who registered CIA-1, this really should
                # be part if that Network.Freenode class so we can ban more than one nickname.
                if nick == "CIA-1" and isinstance(self.network, Network.Freenode):
                    log.msg("Bot server tried to rename to %s on Freenode." % nick)
                    continue
                else:
                    return nick

    def findNick(self):
        """Find a new unused nickname for this bot. As this requires
           testing whether each desired nickname is in use, it returns a Deferred.
           """
        result = defer.Deferred()
        self._findNick(True, None, self.botNet.nickAllocator.generate(), result)
        return result

    def _findNick(self, isUsed, testedNick, generator, result):
        """Callback implementing the guts of findNick.
           On the first call, isUsed is True and testedNick is None.
           We grab the next nick from the provided generator and
           start testing it, providing this function as the deferred's
           callback. If the nick isn't used, we send the nick to
           our result callback. Otherwise, the cycle continues.
           """
        if isUsed:
            nextNick = next(generator)
            self.isNickUsed(nextNick).addCallback(self._findNick, nextNick, generator, result)
        else:
            result.callback(testedNick)

    def isNickUsed(self, nick):
        """Determine if the given nick is in use, using WHOIS.
           Returns a Deferred that eventually resolves to a boolean.

           If the server doesn't respond to the WHOIS, we assume the nick
           isn't in use. This way if we're on a server that somehow has a broken
           WHOIS, we end up with an ugly nick rather than sitting in an infinite loop.
           """
        result = defer.Deferred()

        """Because some jackass on freenode registered CIA-1
           it casues the bot core to freak out when services
           renames to Guest????? nicks. This is a bug in the CIA
           core and should be fixed later.
           """
        if isinstance(self.network, Network.Freenode) and nick == "CIA-1":
            log.msg("Bot server tried to rename to %s on Freenode." % nick)
            result.callback(True)
            return result

        # First check whether any of our own bots are using this nick
        for bot in self.botNet.networks.get(self.network, []):
            if nick == bot.nickname:
                result.callback(True)
                return result

        # It's not that easy- try a WHOIS query
        if nick in self.pendingWhoisTests:
            # We already have a request on the wire, tack another deferred onto it
            self.pendingWhoisTests[nick].append(result)

        else:
            # Send a new request
            self.pendingWhoisTests[nick] = [result]
            self.sendLine("WHOIS %s" % nick)

        return result

    def irc_RPL_WHOISUSER(self, prefix, params):
        """Reply to the WHOIS command we use to evaluate if a nick is used or not.
           This one would indicate that the nick is indeed used.
           """
        nick = params[1]
        if nick in self.pendingWhoisTests:
            for result in self.pendingWhoisTests[nick]:
                result.callback(True)
            del self.pendingWhoisTests[nick]

    def irc_ERR_NOSUCHNICK(self, prefix, params):
        """Reply to the WHOIS command we use to evaluate if a nick is used or not.
           This indicates that the nick is available.
           """
        nick = params[1]
        if nick in self.pendingWhoisTests:
            for result in self.pendingWhoisTests[nick]:
                result.callback(False)
            del self.pendingWhoisTests[nick]

    # These are several other WHOIS replies that we want to ignore
    def irc_RPL_WHOISSERVER(self, prefix, params):
        pass
    def irc_RPL_WHOISIDLE(self, prefix, params):
        pass
    def irc_RPL_WHOISCHANNELS(self, prefix, params):
        pass
    def irc_RPL_ENDOFWHOIS(self, prefix, params):
        pass

    def signedOn(self):
        """IRCClient is notifying us that we've finished connecting to
           the IRC server and can finally start joining channels.
           """
        self.emptyChannels()

        # Check our initial nick, finish our connection if it was good
        self.nickChanged(self.nickname)

    def finishConnection(self):
        log.msg("%r connected" % self)

        # XXX - Bleh.
        # Abominations in python. We're hotfixing, baby!
        # Freenode has channel forwards. We don't like forwards. This disables them.
        if isinstance(self.network, Network.Freenode):
            self.sendLine("MODE %s +Q" % self.nickname)

        self.botNet.botConnected(self)
        self.signonTimer.cancel()
        self.signonTimestamp = time.time()

        # Start the cycle of pinging the server to ensure our connection
        # is still up and measure lag. IRC servers seem to often fail in
        # ways that leave clients' sockets connected but ignore all data
        # from them, and this lets us measure lag for free.
        self._lagPingLoop()

    def sendServerPing(self):
        """Send a ping stamped with the current time and byte count"""
        self.lastPingTransmitTimestamp = time.time()
        self.sendLine("PING %s-%f" % (self.txByteCount, self.lastPingTransmitTimestamp))

    def _lagPingLoop(self):
        self.pingTimer = reactor.callLater(self.network.pingInterval, self._lagPingLoop)
        self.sendServerPing()

    def irc_PONG(self, prefix, params):
        """Handle the responses to pings sent with sendServerPing. This compares
           the timestamp in the pong (from when the ping was sent) and the current
           time, storing the lag and the current time.
           """
        try:
            # Most IRC servers send back a server name as params[0] then the
            # ping argument as params[1].. but some (broken?) ones send back
            # only a single argument, with the ping parameter.
            if len(params) >= 2:
                pingParam = params[1]
            else:
                pingParam = params[0]

            byteCount, timestamp = pingParam.split("-")
            self.txConfirmedBytes = int(byteCount)
            self.lastPingTimestamp = float(timestamp)

        except (ValueError, IndexError):
            # This must be some broken IRC server that's not preserving our ping timestamp.
            # The best we can do is assume this is the pong for the most recent ping we sent.
            log.msg("%r received bad PONG reply: %r" % (self, params))
            self.lastPingTimestamp = self.lastPingTransmitTimestamp
            self.txConfirmedBytes = self.txByteCount

        now = time.time()
        self.lastPongTimestamp = now
        self._pollMessageQueue(now)

    def connectionLost(self, reason):
        self.emptyChannels()
        if not self.pingTimer is None:
            self.pingTimer.cancel()
        self.pingTimer = None
        log.msg("%r disconnected" % self)
        self.botNet.botDisconnected(self)
        irc.IRCClient.connectionLost(self, "disconnected")

    def getLag(self):
        """Calculate a single figure for the lag between us and the server.
           If pings have been coming back on time this is just the raw lag,
           but if our latest ping has been particularly late, it's the average
           of the latest successful ping's lag and the amount of time we've been
           waiting for this late ping.
           """
        if self.lastPongTimestamp is None:
            # We've never received a successful pong
            if self.signonTimestamp is None:
                # Hmm, we've also never signed on. Nothing more we can do
                return None
            else:
                # We've signed on, but never received a good pong. Let's pretend
                # the time since the last pong is just the time since signon- if
                # we really have that funky of a connection this will at least eventually
                # let us detect that.
                timeSincePong = time.time() - self.signonTimestamp
            lag = None
        else:
            timeSincePong = time.time() - self.lastPongTimestamp
            if self.lastPingTimestamp is None:
                lag = None
            else:
                lag = self.lastPongTimestamp - self.lastPingTimestamp

        if timeSincePong < self.network.pingInterval * 2:
            # We're doing fine, report the raw lag
            return lag
        else:
            # Yikes, it's been a while since we've had a good pong.
            # Weigh that in to the returned lag figure as described above.
            return ((lag or 0) + (timeSincePong - self.network.pingInterval)) / 2

    def join(self, channel):
        """Called by the bot's owner to request that a channel be joined.
           If this channel isn't already on our requests list, we send a join
           command and set up a timeout.
           """
        if channel not in self.requestedChannels and channel not in self.channels:
            self.requestedChannels[channel] = reactor.callLater(self.joinTimeout, self.joinTimedOut, channel)
            irc.IRCClient.join(self, channel)

    def cancelRequest(self, channel):
        """Cancels a request to join the given channel if we have one"""
        if channel in self.requestedChannels:
            timer = self.requestedChannels[channel]
            if timer.active():
                timer.cancel()
            del self.requestedChannels[channel]

    def joinTimedOut(self, channel):
        """Our join() timed out, remove the channel from our request list"""
        self.cancelRequest(channel)

    def part(self, channel):
        """Called to request that a bot leave the given channel.
           This removes the channel from our requests list if necessary
           before sending the part command.
           """
        self.cancelRequest(channel)
        irc.IRCClient.part(self, channel)

    def joined(self, channel):
        """A channel has successfully been joined"""
        log.msg("%r joined %r" % (self, channel))
        self.cancelRequest(channel)
        self.channels[channel] = ChannelInfo(channel)
        self.factory.botNet.botJoined(self, channel)

    def left(self, channel):
        """Called when part() is successful and we've left a channel.
           Implicitly also called when we're kicked via kickedFrom().
           """
        log.msg("%r left %r" % (self, channel))
        del self.channels[channel]
        self.factory.botNet.botLeft(self, channel)

    def kickedFrom(self, channel, kicker, message):
        log.msg("%r was kicked from %r by %r: %r" % (self, channel, kicker, message))
        self.left(channel)
        self.factory.botNet.botKicked(self, channel, kicker, message)

    def ctcpUnknownQuery(self, user, channel, tag, data):
        """Ignore unknown queries, so if someone sends a CTCP BAGEL to
           the channel CIA doesn't respond needlessly.
           """
        pass

    def irc_unknown(self, prefix, command, params):
        """Log unknown commands, making debugging easier. This also lets
           us see responses in the log for commands sent via debug_tool.
           """
        if command in self.ignoredCommands:
            return

        log.msg("%r received unknown IRC command %s: %r" % (self, command, params))

        self.factory.botNet.unknownMessageLog.log((
            time.time(),
            self.nickname,
            str(self.network),
            command,
            repr(params)[1:-1],
            ))

    def topicUpdated(self, user, channel, newTopic):
        self.channels[channel].topic = newTopic

    def irc_JOIN(self, prefix, params):
        """This is a modified implementation that checks the nick against
           both our current nickname and our previous one. This hopefully
           avoids a race condition when we're joining a channel and changing
           our nick at nearly the same time.
           """
        nick = prefix.split('!')[0]
        channel = params[-1]
        if nick in self.nicknames:
            self.joined(channel)
        else:
            self.userJoined(nick, channel)

    def irc_NICK(self, prefix, params):
        """This is a modified implementation that checks the nick against
           both our current nickname and our previous one. This ensures
           that we get confirmation for our own nick changes.
           """
        nick = prefix.split('!')[0]
        if nick in self.nicknames:
            self.nickChanged(params[0])
        else:
            self.userRenamed(nick, params[0])

    def irc_RPL_NAMREPLY(self, prefix, params):
        """Collect usernames from this channel. Several of these
           messages may be sent to cover the channel's full nicklist.
           An RPL_ENDOFNAMES signals the end of the list.
           """
        # We just separate these into individual nicks and stuff them in
        # the nickCollector, transferred to 'nicks' when we get the RPL_ENDOFNAMES.
        channel = self.channels[params[2]]
        for name in params[3].split():
            # Remove operator and voice prefixes
            if name[0] in '@+':
                name = name[1:]
            channel.nickCollector.append(name)

    def irc_RPL_ENDOFNAMES(self, prefix, params):
        """This is sent after zero or more RPL_NAMREPLY commands to
           terminate the list of users in a channel.
           """
        channel = self.channels[params[1]]
        channel.nicks = channel.nickCollector
        channel.nickCollector = []

    def userJoined(self, user, channel):
        """Update the channel's nick list when we see someone join"""
        self.channels[channel].nicks.append(user)

    def irc_QUIT(self, prefix, params):
        # Another user quit, remove them from any of our channel lists
        nick = prefix.split('!')[0]
        for channel in self.channels.values():
            try:
                channel.nicks.remove(nick)
            except ValueError:
                pass

    def userLeft(self, user, channel):
        """Update the channel's nick list when a user voluntarily leaves"""
        self.channels[channel].nicks.remove(user)

    def userKicked(self, user, channel, kicker, message):
        """Update the channel's nick list when a user is kicked"""
        self.channels[channel].nicks.remove(user)

    def userRenamed(self, oldname, newname):
        # Blah, this doesn't give us a channel name. Search for this user
        # in each of our channels, renaming them.
        for channel in self.channels.values():
            try:
                channel.nicks.remove(oldname)
                channel.nicks.append(newname)
            except ValueError:
                pass

    def queueMessage(self, target, text):
        """A msg() workalike which queues messages and provides flood protection.
           Text sent with queueMessage() isn't guaranteed to ever be sent to the
           server.
           """
        self._messageQueue.put(target, text)
        self._pollMessageQueue(time.time())

    def _pollMessageQueue(self, now):
        """Check whether it's safe to send any queued messages"""
        while self._messageQueue.peek():
            target, text = self._messageQueue.peek()

            # Length estimate: Includes the "PRIVMSG %s :%s" boilerplate, plus the CRLF
            length = len(text) + len(target) + 12

            # Make a worst-case prediction of the server's buffer fill level after we
            # would have sent this string.
            predictedFill = self.txByteCount - self.txConfirmedBytes

            # If we're up to half our buffer fill and we haven't sent a ping
            # recently, send another in an attempt to lower the predicted
            # fill estimate.
            if predictedFill > self.network.bufferSize / 2 and self.lastPingTransmitTimestamp + 5 < now:
                self.sendServerPing()

            # If we'd go above the hard limit, we can't send the message.
            if predictedFill > self.network.bufferSize:
                break

            self.msg(target, text)
            self._messageQueue.flush()

    # For some reason, Twisted doesn't have this even though the documentation
    # says it does. We'll add it back anyway.
    def me(self, channel, message):
        self.say(channel, "\001ACTION %s\001" % message)

    def action(self, user, channel, message):
        """Just for fun"""
        text = message.lower().strip()
        me = self.nickname.lower()
        them = user.split('!')[0]

        if text == 'hugs %s' % me:
            self.me(channel, 'hugs %s' % them)

        elif text == 'kicks %s' % me:
            self.say(channel, 'ow')

        elif text == 'kills %s' % me:
            self.me(channel, 'dies')

        elif text == 'punches %s' % me:
            self.me(channel, 'fights %s' % them)

        elif text == 'eats %s' % me:
            self.me(channel, 'tastes crunchy')

        elif text == "rubs %s's tummy" % me:
            self.say(channel, "*purr*")


class BotFactory(protocol.ClientFactory):
    """Twisted ClientFactory for creating Bot instances"""
    protocol = Bot

    def __init__(self, botNet, network):
        self.botNet = botNet
        self.network = network

        host, port = network.getNextServer()
        log.msg("Using server %s:%s for %r" % (host, port, network))
        reactor.connectTCP(host, port, self)

    def clientConnectionLost(self, connector, reason):
        log.msg("IRC Connection to %r lost: %r" % (self.network, reason))

    def clientConnectionFailed(self, connector, reason):
        log.msg("IRC Connection to %r failed: %r" % (self.network, reason))

### The End ###
