#!/usr/bin/env python3

import collections
import datetime
import functools
import math
import os
import os.path
import re
import signal
import sys

from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *

import bottisota.constants
import bottisota.gui.sprites

BOT_NAMES = ("Blue", "Red")
BOT_COLORS = (Qt.blue, Qt.red)
GHOST_COLORS = (QColor(0, 0, 255, 128), QColor(255, 0, 0, 128))

_ANIMATION_TICK_MS = 20
_WAYPOINTS = (bottisota.constants.TICK_MS - 60) // _ANIMATION_TICK_MS

class ZoomableGraphicsView(QGraphicsView):

    ZOOM_FACTOR = 1.1

    def __init__(self, *args):
        QGraphicsView.__init__(self, *args)
        self.setDragMode(QGraphicsView.RubberBandDrag)
        self.__zoomLevel = 0

    def wheelEvent(self, event):
        if not event.modifiers() & Qt.ControlModifier:
            QGraphicsView.wheelEvent(self, event)
        else:
            self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)

            if event.angleDelta().y() > 8 and self.__zoomLevel < 10:
                self.__zoomLevel += 1
                self.scale(ZoomableGraphicsView.ZOOM_FACTOR,
                           ZoomableGraphicsView.ZOOM_FACTOR)
                event.accept()
            elif event.angleDelta().y() < -8 and self.__zoomLevel > -10:
                self.__zoomLevel -= 1
                self.scale(1 / ZoomableGraphicsView.ZOOM_FACTOR,
                           1 / ZoomableGraphicsView.ZOOM_FACTOR)
                event.accept()

    def fitSceneInView(self):
        if self.scene() is not None:
            sceneRect = self.scene().sceneRect()
            sceneRectMargins = QMarginsF(15, 15, 15, 15)
            self.fitInView(sceneRect.marginsAdded(sceneRectMargins), Qt.KeepAspectRatio)

    def resizeEvent(self, event):
        QGraphicsView.resizeEvent(self, event)
        if self.__zoomLevel == 0:
            self.fitSceneInView()

class GraphicsBotItem(QGraphicsPolygonItem):

    def __init__(self, color, parent=None):
        polygon = QPolygonF([QPointF(-6.6, -5), QPointF(-6.6, 5), QPointF(-6.6 + 20, 0)])
        QGraphicsPolygonItem.__init__(self, polygon, parent)

        self.__healthLine = QGraphicsLineItem(-6.6, -10, -6.6 + 20, -10, self)
        healthLinePen = QPen(Qt.green)
        healthLinePen.setWidth(2)
        healthLinePen.setCapStyle(Qt.RoundCap)
        self.__healthLine.setPen(healthLinePen)

        self.__waypoints = collections.deque()
        self.__beam = None

        self.__death_explosion_count = 0

        self.setBrush(color)

    def setHealth(self, health):
        if health == bottisota.constants.BOT_HEALTH_MIN:
            self.__healthLine.hide()
            self.setBrush(QBrush(Qt.NoBrush))
            if self.__death_explosion_count < 3:
                self.scene().addItem(GraphicsExplosionItem(self.pos()))
                self.__death_explosion_count += 1
            return

        health_line_length = max(0, round(20 / bottisota.constants.BOT_HEALTH_MAX * health))

        self.__healthLine.setLine(-6.6, -10, -6.6 + health_line_length, -10)

    def setTargetPos(self, x, y):
        dx = (x - self.x()) / _WAYPOINTS
        dy = (y - self.y()) / _WAYPOINTS

        xs = [self.x() + i * dx for i in range(_WAYPOINTS)]
        xs.append(x)

        ys = [self.y() + i * dy for i in range(_WAYPOINTS)]
        ys.append(y)

        for x, y in zip(xs, ys):
            self.__waypoints.append((x, y))

    def advance(self, phase):
        if phase == 0:
            return

        if self.__waypoints:
            x, y = self.__waypoints.popleft()
            self.setPos(x, y)

    def setScanBeam(self, direction, resolution):
        if self.__beam is None:
            self.__beam = QGraphicsPathItem(self)
            self.__beam.setBrush(QColor(255, 255, 0, 32))

        path = QPainterPath()
        path.lineTo(-math.cos(math.radians(90 - resolution)) * bottisota.SCN_DISTANCE,
                    +math.sin(math.radians(90 - resolution)) * bottisota.SCN_DISTANCE)
        path.quadTo(0, bottisota.SCN_DISTANCE,
                    +math.cos(math.radians(90 - resolution)) * bottisota.SCN_DISTANCE,
                    +math.sin(math.radians(90 - resolution)) * bottisota.SCN_DISTANCE)
        path.closeSubpath()

        self.__beam.setPath(path)
        self.__beam.setRotation(direction - self.rotation() - 90)

        if resolution == 0:
            self.__beam.hide()
        else:
            self.__beam.show()

class GraphicsGhostItem(QGraphicsPolygonItem):

    def __init__(self, color, parent=None):
        polygon = QPolygonF([QPointF(-6.6, -5), QPointF(-6.6, 5), QPointF(13.4, 0)])
        QGraphicsPolygonItem.__init__(self, polygon, parent)

        self.setBrush(color)

class GraphicsExplosionItem(QGraphicsItem):

    def __init__(self, pos, parent=None):
        QGraphicsItem.__init__(self, parent)
        self.setPos(pos)

        sprite_name = "explosion_128_128_8_5"

        (self.__sprite_width,
         self.__sprite_height,
         self.__x_frames,
         self.__y_frames) = [int(v) for v in sprite_name.split("_")[1:]]
        self.__x = -self.__sprite_width // 2
        self.__y = -self.__sprite_height // 2

        self.__pixmap = QPixmap(":sprites/%s" % sprite_name)

        self.__frame = 0

        self.__boundingRect = QRectF(self.__x, self.__y,
                                     self.__sprite_width, self.__sprite_height)

    def advance(self, phase):
        if phase == 0:
            return

        self.__frame += 1
        self.update(self.__boundingRect)

        if self.__frame >= 40:
            self.hide()
            self.scene().removeItem(self)

    def boundingRect(self):
        return self.__boundingRect

    def paint(self, painter, option, widget):
        painter.drawPixmap(self.__x, self.__y, self.__pixmap,
                           self.__frame // self.__x_frames * self.__sprite_width,
                           self.__frame // self.__y_frames * self.__sprite_height,
                           self.__sprite_width, self.__sprite_height)

class GraphicsMissileItem(QGraphicsPolygonItem):

    def __init__(self, parent=None):
        polygon = QPolygonF([QPointF(-5, -2), QPointF(-5, 2), QPointF(10, 0)])
        QGraphicsPolygonItem.__init__(self, polygon, parent)

        self.__waypoints = collections.deque()
        self.__is_exploding = False

        self.setBrush(Qt.black)

    def setTargetPos(self, x, y):
        dx = (x - self.x()) / _WAYPOINTS
        dy = (y - self.y()) / _WAYPOINTS

        xs = [self.x() + i * dx for i in range(_WAYPOINTS)]
        xs.append(x)

        ys = [self.y() + i * dy for i in range(_WAYPOINTS)]
        ys.append(y)

        for waypoint in zip(xs, ys):
            self.__waypoints.append(waypoint)

    def explode(self):
        self.__is_exploding = True

    def advance(self, phase):
        if phase == 0:
            return

        if self.__waypoints:
            x, y = self.__waypoints.popleft()
            self.setPos(x, y)
        elif self.__is_exploding:
            self.__is_exploding = False
            self.hide()
            self.scene().addItem(GraphicsExplosionItem(self.pos()))
            self.scene().removeItem(self)

class ArenaWidget(QWidget):

    def __init__(self, timer, *args):
        QWidget.__init__(self, *args)

        layout = QVBoxLayout(self)

        self.__arenaGraphicsScene = QGraphicsScene(0, 0, bottisota.ARENA_WIDTH, bottisota.ARENA_HEIGHT, self)
        self.__arenaRectItem = self.__arenaGraphicsScene.addRect(self.__arenaGraphicsScene.sceneRect(), QPen(Qt.NoPen), QBrush(Qt.gray))
        self.__arenaGraphicsView = ZoomableGraphicsView(self)
        self.__arenaGraphicsView.setScene(self.__arenaGraphicsScene)
        self.__arenaGraphicsView.setRenderHints(QPainter.Antialiasing)
        self.__arenaTextItem = self.__arenaGraphicsScene.addText("")
        self.showText("")

        timer.timeout.connect(self.__arenaGraphicsScene.advance)

        layout.addWidget(self.__arenaGraphicsView)

    def addGhost(self, x, y, direction, color):
        bot = GraphicsGhostItem(color)
        bot.setParentItem(self.__arenaRectItem)
        bot.setPos(x, y)
        bot.setRotation(direction)

        return bot

    def addBot(self, x, y, direction, color):
        bot = GraphicsBotItem(color)
        bot.setParentItem(self.__arenaRectItem)
        bot.setPos(x, y)
        bot.setRotation(direction)

        return bot

    def addMissile(self, x, y, direction):
        missile = GraphicsMissileItem()
        missile.setParentItem(self.__arenaRectItem)
        missile.setPos(x, y)
        missile.setRotation(direction)

        return missile

    def removeItem(self, entity):
        self.__arenaGraphicsScene.removeItem(entity)

    def showText(self, text):
        self.__arenaTextItem.setPlainText(text)
        x = (self.__arenaGraphicsScene.width() - self.__arenaTextItem.boundingRect().width()) / 2
        y = (self.__arenaGraphicsScene.height() - self.__arenaTextItem.boundingRect().height()) / 2
        self.__arenaTextItem.setPos(x, y)

class LogWidget(QWidget):

    def __init__(self, *args):
        QWidget.__init__(self, *args)

        self.__logTabWidget = QTabWidget()
        self.__tabs = {}
        self.__logdir = None
        self.__logfiles = {}
        layout = QVBoxLayout(self)

        layout.addWidget(self.__logTabWidget)

    def addLine(self, log_source, line):
        try:
            i = self.__tabs[log_source]
        except KeyError:
            logTextEdit = QTextEdit()
            logTextEdit.setReadOnly(True)
            logTextEdit.setFontFamily("monospace")
            self.__logTabWidget.addTab(logTextEdit, log_source)
            i = self.__logTabWidget.count() - 1
            self.__tabs[log_source] = i

        self.__logTabWidget.widget(i).insertPlainText(line)

        try:
            logfile = self.__logfiles[log_source]
        except KeyError:
            logfile = open(os.path.join(self.__logdir, log_source), "w")
            self.__logfiles[log_source] = logfile

        logfile.write(line)
        logfile.flush()

    def reset(self, logdir):
        self.__logdir = logdir

        while self.__logfiles:
            _, logfile = self.__logfiles.popitem()
            logfile.close()

        while self.__tabs:
            _, i = self.__tabs.popitem()
            self.__logTabWidget.widget(i).clear()

        self.__logTabWidget.clear()

class BeginDialog(QDialog):

    def __init__(self, *args):
        QDialog.__init__(self, *args)

        self.setModal(True)

        self.setWindowTitle("Begin battle")

        layout = QVBoxLayout(self)

        formLayout = QFormLayout()

        bot_programs = [f for f in os.listdir(os.path.dirname(sys.argv[0])) if f.startswith("bottisota-bot-ai-")]

        self.__aiProgramComboBoxes = {}

        for bot_name in BOT_NAMES:
            comboBox = QComboBox(self)

            for bot_program in bot_programs:
                comboBox.addItem(bot_program)

            formLayout.addRow("%s bot AI program" % bot_name, comboBox)

            self.__aiProgramComboBoxes[bot_name] = comboBox

        layout.addLayout(formLayout)

        buttonRowLayout = QHBoxLayout()
        buttonRowLayout.addStretch()

        cancelButton = QPushButton("&Cancel")
        cancelButton.clicked.connect(self.reject)

        okButton = QPushButton("&Ok")
        okButton.setEnabled(len(bot_programs) > 0)
        okButton.clicked.connect(self.accept)

        buttonRowLayout.addWidget(cancelButton)
        buttonRowLayout.addWidget(okButton)

        layout.addLayout(buttonRowLayout)

    def getAIProgram(self, bot_name):
        return self.__aiProgramComboBoxes[bot_name].currentText()

class MainWindow(QMainWindow):

    def __init__(self):
        QMainWindow.__init__(self)

        self.__serverProcess = None

        self.__botProcesses = []

        self.setWindowTitle("Bottisota")

        self.__timer = QTimer(self)
        self.__timer.setTimerType(Qt.PreciseTimer)

        self.__arenaWidget = ArenaWidget(self.__timer, self)
        self.__arenaWidget.setEnabled(False)

        self.__logWidget = LogWidget(self)

        self.setCentralWidget(self.__arenaWidget)

        self.__logDockWidget = QDockWidget(self)
        self.__logDockWidget.setWidget(self.__logWidget)
        self.__logDockWidget.setWindowTitle("Logs")
        self.__logDockWidget.setVisible(False)
        self.addDockWidget(Qt.BottomDockWidgetArea, self.__logDockWidget)

        self.__beginBattleAction = QAction(self)
        self.__beginBattleAction.setText("&Begin...")
        self.__beginBattleAction.setShortcut(QKeySequence("F2"))
        self.__beginBattleAction.triggered.connect(self.__beginBattle)

        self.__pauseBattleAction = QAction(self)
        self.__pauseBattleAction.setText("&Pause")
        self.__pauseBattleAction.setShortcut(QKeySequence("F3"))
        self.__pauseBattleAction.setEnabled(False)
        self.__pauseBattleAction.triggered.connect(self.__pauseBattle)

        self.__continueBattleAction = QAction(self)
        self.__continueBattleAction.setText("&Continue")
        self.__continueBattleAction.setShortcut(QKeySequence("F4"))
        self.__continueBattleAction.setEnabled(False)
        self.__continueBattleAction.triggered.connect(self.__continueBattle)

        self.__endBattleAction = QAction(self)
        self.__endBattleAction.setText("&End")
        self.__endBattleAction.setShortcut(QKeySequence("F5"))
        self.__endBattleAction.setEnabled(False)
        self.__endBattleAction.triggered.connect(self.__endBattle)

        self.__quitAction = QAction(self)
        self.__quitAction.setText("&Quit")
        self.__quitAction.setShortcut(QKeySequence("Ctrl+Q"))
        self.__quitAction.triggered.connect(self.close)

        self.__toggleGhostsAction = QAction(self)
        self.__toggleGhostsAction.setCheckable(True)
        self.__toggleGhostsAction.setChecked(True)
        self.__toggleGhostsAction.setText("&Ghosts")
        self.__toggleGhostsAction.toggled.connect(self.__toggleGhosts)

        menuBar = QMenuBar()

        fileMenu = menuBar.addMenu("&File")
        fileMenu.addAction(self.__quitAction)

        viewMenu = menuBar.addMenu("&View")
        viewMenu.addAction(self.__logDockWidget.toggleViewAction())
        viewMenu.addAction(self.__toggleGhostsAction)

        battleMenu = menuBar.addMenu("&Battle")
        battleMenu.addAction(self.__beginBattleAction)
        battleMenu.addAction(self.__pauseBattleAction)
        battleMenu.addAction(self.__continueBattleAction)
        battleMenu.addAction(self.__endBattleAction)

        self.setMenuBar(menuBar)

        self.__bots = {}
        self.__ghosts = {}
        self.__missiles = {}

    def __toggleGhosts(self, visible):
        for ghost in self.__ghosts.values():
            ghost.setVisible(visible)

    def __beginBattle(self):
        while self.__bots:
            botid, bot = self.__bots.popitem()
            self.__arenaWidget.removeItem(bot)

        while self.__missiles:
            missileid, missile = self.__missiles.popitem()
            self.__arenaWidget.removeItem(missile)

        while self.__ghosts:
            ghostid, ghost = self.__ghosts.popitem()
            self.__arenaWidget.removeItem(ghost)

        timestamp = datetime.datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
        battle_dir = os.path.join(os.path.expanduser("~/.local/share/bottisota/battles"), timestamp)
        try:
            os.makedirs(battle_dir)
        except FileExistsError:
            QMessageBox.critical(self, "Failed to begin a battle", "<p>Strange! Tried to create a battle data directory, but it already exists:</p><p><pre>%s</pre></p>" % battle_dir, QMessageBox.Ok)
            return

        self.__logWidget.reset(battle_dir)

        self.__serverProcess = QProcess(self)
        self.__serverProcess.started.connect(self.__battleBegun)
        self.__serverProcess.finished.connect(self.__battleEnded)
        self.__serverProcess.readyReadStandardOutput.connect(self.__readServerOut)
        self.__serverProcess.readyReadStandardError.connect(self.__readServerErr)

        self.__serverProcess.start(os.path.join(os.path.dirname(sys.argv[0]), "bottisota-server"), QIODevice.ReadOnly)

    def __pauseBattle(self):
        os.kill(self.__serverProcess.processId(), signal.SIGSTOP)
        self.__continueBattleAction.setEnabled(True)
        self.__pauseBattleAction.setEnabled(False)
        self.__endBattleAction.setEnabled(False)
        self.__arenaWidget.showText("PAUSED")

    def __continueBattle(self):
        os.kill(self.__serverProcess.processId(), signal.SIGCONT)
        self.__continueBattleAction.setEnabled(False)
        self.__pauseBattleAction.setEnabled(True)
        self.__endBattleAction.setEnabled(True)
        self.__arenaWidget.showText("")

    def __endBattle(self):
        self.__serverProcess.terminate()

    def __battleBegun(self):
        beginDialog = BeginDialog(self)
        if not beginDialog.exec():
            self.__serverProcess.terminate()
            return

        for botid, bot_name in enumerate(BOT_NAMES, 1):
            botProcess = QProcess(self)
            botProcess.setProgram(os.path.join(os.path.dirname(sys.argv[0]), beginDialog.getAIProgram(bot_name)))
            botProcess.readyReadStandardOutput.connect(functools.partial(self.__readBotOut, botid))
            botProcess.readyReadStandardError.connect(functools.partial(self.__readBotErr, botid))
            self.__botProcesses.append(botProcess)

        self.__botProcesses[0].start()

        self.__beginBattleAction.setEnabled(False)
        self.__endBattleAction.setEnabled(True)
        self.__pauseBattleAction.setEnabled(True)
        self.__continueBattleAction.setEnabled(False)
        self.__arenaWidget.setEnabled(True)

    def __battleEnded(self):
        for botProcess in self.__botProcesses:
            botProcess.terminate()
        self.__botProcesses.clear()
        self.__beginBattleAction.setEnabled(True)
        self.__endBattleAction.setEnabled(False)
        self.__pauseBattleAction.setEnabled(False)
        self.__continueBattleAction.setEnabled(False)
        self.__arenaWidget.setEnabled(False)

    def __readServerErr(self):
        try:
            text = self.__serverProcess.readAllStandardError().data().decode('utf-8')
            self.__logWidget.addLine("Server.err", text)
        except:
            self.close()
            raise

    def __readServerOut(self):
        try:
            while self.__serverProcess.canReadLine():
                line = self.__serverProcess.readLine().data().decode("utf-8")
                self.__logWidget.addLine("Server.out", line)

                log_entry = line.strip()

                match = re.match(r'^Bot (\d+): x (\d+), y (\d+), health (\d+), direction (\d+), speed (\d+), scan (\d+)±(\d+)$', log_entry)
                if match:
                    botid, x, y, health, direction, speed, scan_direction, scan_resolution = [int(v) for v in match.groups()]
                    bot_name = BOT_NAMES[botid - 1]
                    color = BOT_COLORS[botid - 1]
                    if not self.__bots:
                        self.__botProcesses[botid].start()
                    try:
                        bot = self.__bots[botid]
                    except KeyError:
                        bot = self.__bots[botid] = self.__arenaWidget.addBot(x, y, direction, color)
                    else:
                        bot.setTargetPos(x, y)
                        bot.setRotation(direction)
                        bot.setScanBeam(scan_direction, scan_resolution)
                        bot.setHealth(health)
                    continue

                match = re.match(r'^Missile (\d+): x (\d+), y (\d+), health (\d+), direction (\d+), speed (\d+)$', log_entry)
                if match:
                    missileid, x, y, health, direction, speed = [int(v) for v in match.groups()]
                    try:
                        missile = self.__missiles[missileid]
                    except KeyError:
                        missile = self.__missiles[missileid] = self.__arenaWidget.addMissile(x, y, direction)
                    else:
                        missile.setTargetPos(x, y)
                        missile.setRotation(direction)
                        if health == 0:
                            missile.explode()
                    continue

                if re.match(r'^begin$', log_entry):
                    self.__timer.start(_ANIMATION_TICK_MS)
                    continue

                if re.match(r'^end$', log_entry):
                    self.__timer.stop()
                    continue
        except:
            self.close()
            raise

    def __readBotErr(self, botid):
        try:
            botProcess = self.__botProcesses[botid - 1]
            text = botProcess.readAllStandardError().data().decode("utf-8")
            self.__logWidget.addLine("%s.err" % BOT_NAMES[botid - 1], text)
        except:
            self.close()
            raise

    def __readBotOut(self, botid):
        try:
            botProcess = self.__botProcesses[botid - 1]

            while botProcess.canReadLine():
                line = botProcess.readLine().data().decode("utf-8")
                self.__logWidget.addLine("%s.out" % BOT_NAMES[botid - 1], line)
                log_entry = line.strip()

                match = re.match(r'^I guess bot (\d+) is at (\d+), (\d+), heading to (\d+) with speed (\d+)$', log_entry)
                if match:
                    enemy_botid, enemy_x, enemy_y, enemy_direction, enemy_speed = [int(v) for v in match.groups()]
                    color = GHOST_COLORS[enemy_botid - 1]
                    try:
                        enemy_ghost = self.__ghosts[enemy_botid]
                    except KeyError:
                        enemy_ghost = self.__ghosts[enemy_botid] = self.__arenaWidget.addGhost(enemy_x, enemy_y, enemy_direction, color)
                        enemy_ghost.setVisible(self.__toggleGhostsAction.isChecked())
                        enemy_ghost.setPen(QPen(Qt.NoPen))
                    else:
                        enemy_ghost.setPos(enemy_x, enemy_y)
                        enemy_ghost.setRotation(enemy_direction)
                    continue

                match = re.match(r'^I guess I am at (\d+), (\d+), heading to (\d+)$', log_entry)
                if match:
                    x, y, heading = [int(v) for v in match.groups()]
                    color = GHOST_COLORS[botid - 1]
                    try:
                        ghost = self.__ghosts[botid - 1]
                    except KeyError:
                        ghost = self.__ghosts[botid - 1] = self.__arenaWidget.addGhost(x, y, heading, color)
                        ghost.setVisible(self.__toggleGhostsAction.isChecked())
                        ghost.setPen(QPen(Qt.NoPen))
                    else:
                        ghost.setPos(x, y)
                        ghost.setRotation(heading)
                    continue

        except:
            self.close()
            raise

    def closeEvent(self, event):
        if self.__serverProcess is not None:
            self.__serverProcess.close()

def main():
    app = QApplication(sys.argv)

    win = MainWindow()
    win.show()

    return app.exec_()

if __name__ == "__main__":
    sys.exit(main())
