#-*- coding:utf-8 -*-

#  Copyright © 2009-2015  B. Clausius <barcc@gmx.de>
#
#  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 3 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, see <http://www.gnu.org/licenses/>.


import os
from collections import namedtuple
import math
from time import sleep
from contextlib import contextmanager

#XXX: Workaround for lp:941826, "dlopen(libGL.so) resolves to mesa rather than nvidia"
#     Was: "PyQt cannot compile shaders with Ubuntu's Nvidia drivers"
#     https://bugs.launchpad.net/ubuntu/+source/python-qt4/+bug/941826
import ctypes.util
ctypes.CDLL(ctypes.util.find_library('GL'), ctypes.RTLD_GLOBAL)

from PyQt5.QtCore import QElapsedTimer, QMutex, Qt, QThread, QTimer, QWaitCondition
from PyQt5.QtCore import pyqtSignal as Signal
from PyQt5.QtGui import (QColor, QCursor, QImage, QPixmap, QTransform, QSurfaceFormat,
                        QOpenGLTexture, QOpenGLFramebufferObject)
from PyQt5.QtWidgets import QOpenGLWidget

from .debug import (debug, DEBUG, DEBUG_MSG, DEBUG_DRAW, DEBUG_MOUSEPOS, DEBUG_FPS, DEBUG_VFPS, 
                    DEBUG_PUREPYTHON, DEBUG_LIVESHADER, DEBUG_LOGGL)

if DEBUG_LOGGL:
    try:
        import OpenGL
        OpenGL.FULL_LOGGING = True
        import OpenGL.GL
    except ImportError:
        pass

from . import config
from .theme import Theme
from .settings import settings
from .model import Model


class ShaderInterface:
    shader_prefix = b'''
#version 120
#line 0
'''
    
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.shadername = None
        
    def gl_init_shaders(self):
        self.gl_load_shader()
        if DEBUG_DRAW:
            debug('Creating "hud" shaders:')
            vertex_source = self._read_shader('hud.vert')
            fragment_source = self._read_shader('pick.frag')
            self.glarea.gl_create_hud_program(vertex_source, fragment_source)
        debug('Creating "pick" shaders:')
        vertex_source = self._read_shader('pick.vert')
        fragment_source = self._read_shader('pick.frag')
        self.glarea.gl_create_pick_program(vertex_source, fragment_source)
        
    @staticmethod
    def _read_shader(filename):
        if DEBUG_MSG or DEBUG_LIVESHADER:
            print('Loading shader:', filename)
        filename = os.path.join(config.SHADER_DIR, filename)
        with open(filename, 'rb') as sfile:
            source = sfile.read()
        return ShaderInterface.shader_prefix + source
        
    def gl_set_shader(self):
        if self.shadername is None:
            return
        vertex_source = self._read_shader(self.shadername + '.vert')
        fragment_source = self._read_shader(self.shadername + '.frag')
        self.glarea.gl_create_render_program(vertex_source, fragment_source)
        
    def gl_load_shader(self):
        self.shadername = settings.draw.shader_nick
        self.gl_set_shader()
        
        
class AnimationInterface:
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        
        self.stop_requested = False
        self.animation_active = False
        
        self.timer_animate = QTimer()
        self.timer_animate.timeout.connect(self._on_animate)
        
    def animate_rotation(self, move_data, blocks, stop_after):
        self.stop_requested = stop_after
        angle = 360. / self.model.symmetries[move_data.axis]
        axisx, axisy, axisz = self.model.axes[move_data.axis]
        if move_data.dir:
            self.gldraw.set_animation_start(blocks, angle, -axisx, -axisy, -axisz)
        else:
            self.gldraw.set_animation_start(blocks, angle, axisx, axisy, axisz)
        self.animation_active = True
        self.timer_animate.start(0 if DEBUG_VFPS else 20)
        self.update_selection()
            
    def animation_end(self):
        self.animation_active = False
        self.timer_animate.stop()
        
    def _on_animate(self):
        increment = self.speed * 1e-02 * 20
        increment = min(increment, 45)
        if self.gldraw.set_animation_next(increment):
            self.update()
            return
            
        self.animation_ending.emit(self.stop_requested)
        self.update()
        self.update_selection()
        self.stop_requested = False
        
    def animate_abort(self, update=True):
        if update:
            self.animation_ending.emit(self.stop_requested)
            self.update()
            self.update_selection()
        else:
            self.animation_end()
        self.stop_requested = False
        
        
class CubeArea (QOpenGLWidget, ShaderInterface, AnimationInterface):
    animation_ending = Signal(bool)
    request_rotation = Signal((int, int, bool))
    request_swap_blocks = Signal((int, int, int, bool))
    request_rotate_block = Signal((int, bool))
    drop_color = Signal((int, str, str))
    drop_file = Signal((int, str, str))
    debug_info = Signal(str, object)
    
    def __init__(self, opts, model, **kwargs):
        super().__init__(**kwargs)
        
        self.texture = None
        
        self.theme = Theme()
        self.mirror_distance = None
        self.model = model
        
        self.last_mouse_x = -1
        self.last_mouse_y = -1
        self.button_down_background = False
        self.mouse_xy = -1, -1
        self.pickdata = None
        self.editing_model = False
        settings.keystore.changed.connect(self.on_settings_changed, Qt.QueuedConnection)
        
        if DEBUG_FPS:
            self.monotonic_time = QElapsedTimer()
            self.monotonic_time.start()
            self.render_count = 0
            self.fps = 0.
            
        self.speed = settings.draw.speed
        
        # Initialize the render engine
        self.gldraw, self.glarea = self.import_gl_engine()
        self.glarea.init_module()
        self.gldraw.init_module()
        self.rotation_x, self.rotation_y = self.glarea.set_rotation_xy(*self.model.default_rotation)
        self.glarea.set_antialiasing(self.format().samples() > 1)
        self.init_theme()
        
        self.setAcceptDrops(True)
        self.setFocusPolicy(Qt.StrongFocus)
        self.setMinimumSize(300, 300)
        
        self.cursors = None
        self.load_cursors()
        self.update_selection_pending = False
        self.timer_update_selection = QTimer(self)
        self.timer_update_selection.setSingleShot(True)
        self.timer_update_selection.timeout.connect(self.on_idle_update_selection)
        self.set_cursor()
        self.setMouseTracking(True)
        
    @classmethod
    def set_default_surface_format(cls):
        glformat = QSurfaceFormat()
        if settings.draw.samples > 0:
            glformat.setSamples(2**settings.draw.samples)
        if DEBUG_VFPS:
            glformat.setSwapInterval(0)
        cls.default_format = QSurfaceFormat.defaultFormat()
        QSurfaceFormat.setDefaultFormat(glformat)
        
    def import_gl_engine(self):
        if not DEBUG_PUREPYTHON:
            from . import _gldraw as gldraw
            from . import _glarea as glarea
        else:
            try:
                import OpenGL.GL
            except ImportError as e:
                print('The pure Python mode needs PyOpenGL (for Python 3):', e)
                raise SystemExit(1)
            from . import gldraw
            from . import glarea
        return gldraw, glarea
        
    @contextmanager
    def lock_glcontext(self):
        self.makeCurrent()
        yield
        self.doneCurrent()
        
    def stop(self):
        with self.lock_glcontext():
            self.glarea.gl_exit()
            self.gl_delete_textures()
        # this line prevents a segmentation fault with PySide if game->quit selected
        self.setMouseTracking(False)
        settings.keystore.changed.disconnect(self.on_settings_changed)
        self.timer_update_selection.timeout.disconnect(self.on_idle_update_selection)
        self.timer_animate.timeout.disconnect(self._on_animate)
        
    def load_cursors(self):
        cursors = []
        # Load 3 cursors from file (n - ne)
        for i, (x, y) in enumerate([(8, 0), (15, 0), (15, 0)]):
            filename = os.path.join(config.UI_DIR, 'cursors', 'mouse_{}.png'.format(i))
            image = QImage(filename)
            cursors.append((image, x, y))
        # 1 cursor (nnw)
        image, x, y = cursors[1]
        cursors.insert(0, (image.mirrored(True, False), 15-x, y))
        # 12 cursors (ene - nw)
        transform = QTransform()
        transform.rotate(90)
        for i in range(4, 16):
            image, x, y = cursors[-4]
            cursors.append((image.transformed(transform), 15-y, x))
        cursors.append(cursors[0])
        self.cursors = [QCursor(QPixmap.fromImage(image), x, y) for image, x, y in cursors[1:]]
        # cursor for center faces
        filename = os.path.join(config.UI_DIR, 'cursors', 'mouse_ccw.png')
        cursor = QCursor(QPixmap(filename), 7, 7)
        self.cursors.append(cursor)
        # background cursor
        cursor = QCursor()
        cursor.setShape(Qt.CrossCursor)
        self.cursors.append(cursor)
        
    def init_theme(self):
        rgba = QColor()
        for facekey, unused_facename in Model.cache_index['facenames']:
            rgba.setNamedColor(settings.theme.faces[facekey].color)
            self.theme.faces[facekey].color = rgba.red(), rgba.green(), rgba.blue()
            self.theme.faces[facekey].imagemode = settings.theme.faces[facekey].mode
        self.set_background_color(settings.theme.bgcolor)
        
    def load_face_texture(self, facekey):
        filename = settings.theme.faces[facekey].image
        if not self.theme.load_face_image(facekey, filename):
            del settings.theme.faces[facekey].image
        
    def gl_set_texture_atlas(self):
        width, height = 0, 0
        texrects = []
        faces = [self.theme.faces[fk] for fk, fn in Model.cache_index['facenames']]
        for fd in faces:
            x = width
            w = fd.image.width()
            h = fd.image.height()
            width += w
            height = max(h, height)
            texrects.append((x, w, h))
        w = h = 64
        while w < width:
            w *= 2
        while h < height:
            h *= 2
        width, height = w, h
        
        if self.texture is not None:
            self.texture.destroy()
        self.texture = QOpenGLTexture(QOpenGLTexture.Target2D)
        self.texture.setFormat(QOpenGLTexture.RGBA32F)
        self.texture.setSize(width, height)
        self.texture.setMinMagFilters(QOpenGLTexture.Linear, QOpenGLTexture.Linear)
        self.texture.allocateStorage()
        self.texture.bind()
        for (x, w, h), fd in zip(texrects, faces):
            fd.texrect = (x+.5)/width, .5/height, (x+w-.5)/width, (h-.5)/height
            data = fd.image.bits()
            data.setsize(fd.image.byteCount())
            data = bytes(data)
            self.gldraw.gl_set_atlas_texture(x, w, h, data)
            
    def gl_delete_textures(self):
        if self.texture is not None:
            self.texture.release()
            self.texture.destroy()
        for fd in self.theme.faces.values():
            fd.image = None
        
    def set_glmodel_full(self, model=None, rotations=None):
        if model is not None:
            self.model = model
            self.rotation_x, self.rotation_y = self.glarea.set_rotation_xy(*self.model.default_rotation)
        #TODO: The bounding_sphere_radius is optimised for the far clipping plane,
        #      for the near clipping plane the radius without the mirror_distance
        #      would be sufficient.
        s = max(self.model.sizes) if self.model.sizes else 1
        if settings.draw.mirror_faces:
            self.mirror_distance = settings.draw.mirror_distance * s
            sm = s + self.mirror_distance
        else:
            self.mirror_distance = None
            sm = s
        bounding_sphere_radius = math.sqrt(2*s*s + sm*sm)
        self.glarea.set_frustum(bounding_sphere_radius, settings.draw.zoom)
        
        self.set_glmodel_selection_mode(settings.draw.selection)
        if rotations is not None:
            self.set_transformations(rotations)
        self.update_selection()
        self.update()
        
    def set_glmodel_selection_mode(self, selection_mode):
        glmodeldata, self.glpickdata = self.model.gl_vertex_data(selection_mode, self.theme, self.mirror_distance)
        with self.lock_glcontext():
            self.gldraw.gl_set_data(*glmodeldata)
        
    def set_transformations(self, rotations):
        indices = self.model.rotations_symbolic_to_index(rotations)
        self.gldraw.set_transformations(indices)
        
    def gl_create_pickbuffer(self, width, height):
        self.pickbuffer = QOpenGLFramebufferObject(width, height)
        self.pickbuffer.setAttachment(QOpenGLFramebufferObject.Depth)
        
    def initializeGL(self):
        if DEBUG_MSG:
            glformat = self.format()
            glrformat = QSurfaceFormat.defaultFormat() # requested format
            gldformat = self.default_format
            def printglattr(name, *attrs):
                print('  {}: '.format(name), end='')
                def get_value(glformat, attr):
                    if isinstance(attr, str):
                        return getattr(glformat, attr)()
                    else:
                        return attr(glformat)
                values = [get_value(glformat, a) for a in attrs]
                rvalues = [get_value(glrformat, a) for a in attrs]
                dvalues = [get_value(gldformat, a) for a in attrs]
                print(*values, end='')
                if values != rvalues:
                    print(' (', end='')
                    print(*rvalues, end=')')
                if rvalues != dvalues:
                    print(' [', end='')
                    print(*dvalues, end=']')
                print()
            print('Surface format (requested (), default []):')
            printglattr('alpha', 'alphaBufferSize')
            printglattr('rgb', 'redBufferSize', 'greenBufferSize', 'blueBufferSize')
            printglattr('depth', 'depthBufferSize')
            printglattr('options', lambda glformat: str(int(glformat.options())))
            printglattr('profile', 'profile')
            printglattr('renderableType', 'renderableType')
            printglattr('samples', 'samples')
            printglattr('stencil', 'stencilBufferSize')
            printglattr('stereo', 'stereo')
            printglattr('swapBehavior', 'swapBehavior')
            printglattr('swapInterval', 'swapInterval')
            printglattr('version', lambda glformat: '{}.{}'.format(*glformat.version()))
        self.gl_init_shaders()
        for facekey, unused_facename in Model.cache_index['facenames']:
            self.load_face_texture(facekey)
        self.gl_set_texture_atlas()
        self.glarea.gl_init()
        self.gl_create_pickbuffer(self.width(), self.height())
        
    def paintGL(self):
        self.texture.bind()
        self.glarea.gl_render()
        if DEBUG:
            if DEBUG_DRAW:
                if self.pickdata is not None and self.pickdata.angle is not None:
                    self.glarea.gl_render_select_debug()
            if DEBUG_FPS:
                self.render_count += 1
                if self.monotonic_time.elapsed() > 1000:
                    elapsed = self.monotonic_time.restart()
                    self.fps = 1000. / elapsed * self.render_count
                    self.render_count = 0
                    self.debug_info.emit('fps', self.fps)
        
    def resizeGL(self, width, height):
        self.glarea.gl_resize(width, height)
        self.gl_create_pickbuffer(width, height)
        
    MODIFIER_MASK = int(Qt.ShiftModifier | Qt.ControlModifier)
    def keyPressEvent(self, event):
        modifiers = int(event.modifiers()) & self.MODIFIER_MASK
        if modifiers:
            return QOpenGLWidget.keyPressEvent(self, event)
        elif event.key() == Qt.Key_Right:
            self.rotation_x += 2
        elif event.key() == Qt.Key_Left:
            self.rotation_x -= 2
        elif event.key() == Qt.Key_Up:
            self.rotation_y -= 2
        elif event.key() == Qt.Key_Down:
            self.rotation_y += 2
        else:
            return QOpenGLWidget.keyPressEvent(self, event)
            
        self.rotation_x, self.rotation_y = self.glarea.set_rotation_xy(self.rotation_x, self.rotation_y)
        self.update()
        self.update_selection()
        
    PickData = namedtuple('PickData', 'maxis mslice mdir blockpos symbol angle')
    def pick_polygons(self, x, y):
        height = self.height()
        with self.lock_glcontext():
            self.pickbuffer.bind()
            index = self.glarea.gl_pick_polygons(x, height-y)
            self.pickbuffer.release()
        if not index:
            self.pickdata = None
            return
        maxis, mslice, mdir, blockpos, symbol, arrowdir = self.glpickdata[index]
        
        if arrowdir is None:
            angle = None
        else:
            angle = self.glarea.get_cursor_angle(x, height-y, arrowdir)
        self.pickdata = self.PickData(maxis, mslice, mdir, blockpos, symbol, angle)
        if DEBUG_DRAW:
            info = {'face': self.model.faces.index(symbol)}
            self.debug_info.emit('pick', info)
        
    def update_selection(self):
        if self.animation_active:
            if self.pickdata is not None:
                self.pickdata = None
                self.set_cursor()
            return
        if self.update_selection_pending:
            return
        self.update_selection_pending = True
        self.timer_update_selection.start()
        
    def on_idle_update_selection(self):
        if self.animation_active:
            if self.pickdata is not None:
                self.pickdata = None
                self.set_cursor()
        else:
            self.pick_polygons(*self.mouse_xy)
            self.set_cursor()
            if DEBUG_DRAW:
                self.update()
        self.update_selection_pending = False
        
    def mouseMoveEvent(self, event):
        self.mouse_xy = event.x(), event.y()
        if DEBUG_MOUSEPOS:
            print(self.mouse_xy)
        
        if not self.button_down_background:
            self.update_selection()
            return
            
        # perform rotation
        offset_x = event.x() - self.last_mouse_x
        offset_y = event.y() - self.last_mouse_y
        self.rotation_x, self.rotation_y = self.glarea.set_rotation_xy(
                                                        self.rotation_x + offset_x,
                                                        self.rotation_y + offset_y)
        self.rotation_x -= offset_x
        self.rotation_y -= offset_y
        self.update()
        
    def mousePressEvent(self, event):
        if self.pickdata is not None:
            if self.animation_active:
                return
            # make a move
            if self.editing_model:
                if event.modifiers() & Qt.ControlModifier:
                    if event.button() == Qt.LeftButton:
                        self.request_rotate_block.emit(self.pickdata.blockpos, False)
                    elif event.button() == Qt.RightButton:
                        self.request_rotate_block.emit(self.pickdata.blockpos, True)
                else:
                    if event.button() == Qt.LeftButton:
                        self.request_swap_blocks.emit(self.pickdata.blockpos,
                                    self.pickdata.maxis, self.pickdata.mslice, self.pickdata.mdir)
            else:
                mslice = -1 if event.modifiers() & Qt.ControlModifier else self.pickdata.mslice
                if event.button() == Qt.LeftButton:
                    self.request_rotation.emit(self.pickdata.maxis, mslice, self.pickdata.mdir)
                elif event.button() == Qt.RightButton and settings.draw.selection_nick == 'simple':
                    self.request_rotation.emit(self.pickdata.maxis, mslice, not self.pickdata.mdir)
        elif event.button() == Qt.LeftButton:
            # start rotation
            self.button_down_background = True
            self.last_mouse_x = event.x()
            self.last_mouse_y = event.y()
        self.update()
        
    def mouseReleaseEvent(self, event):
        if event.button() != Qt.LeftButton:
            return
            
        if self.button_down_background:
            # end rotation
            self.rotation_x += event.x() - self.last_mouse_x
            self.rotation_y += event.y() - self.last_mouse_y
            self.button_down_background = False
            self.update_selection()
            
    def wheelEvent(self, event):
        zoom = settings.draw.zoom * math.pow(1.1, -event.angleDelta().y() / 120)
        zoom_min, zoom_max = settings.draw.zoom_range
        if zoom < zoom_min:
            zoom = zoom_min
        if zoom > zoom_max:
            zoom = zoom_max
        settings.draw.zoom = zoom
            
    def dragEnterEvent(self, event):
        mime_data = event.mimeData()
        debug('drag enter:', mime_data.formats())
        if mime_data.hasColor():
            event.acceptProposedAction()
        elif mime_data.hasUrls() and mime_data.urls()[0].isLocalFile():
            event.acceptProposedAction()
            
    def dropEvent(self, event):
        # when a drag is in progress the pickdata is not updated, so do it now
        self.pick_polygons(event.pos().x(), event.pos().y())
        
        mime_data = event.mimeData()
        if mime_data.hasColor():
            color = mime_data.colorData()
            if self.pickdata is not None:
                self.drop_color.emit(self.pickdata.blockpos, self.pickdata.symbol, color.name())
            else:
                self.drop_color.emit(-1, '', color.name())
        elif mime_data.hasUrls():
            if self.pickdata is None:
                debug('Background image is not supported.')
                return
            uris = mime_data.urls()
            for uri in uris:
                if not uri.isLocalFile():
                    debug('filename "%s" not found or not a local file.' % uri.toString())
                    continue
                filename = uri.toLocalFile()
                if not filename or not os.path.exists(filename):
                    debug('filename "%s" not found.' % filename)
                    continue
                self.drop_file.emit(self.pickdata.blockpos, self.pickdata.symbol, filename)
                break  # ignore other uris
        debug('Skip mime type:', ' '.join(mime_data.formats()))
        
    def set_cursor(self):
        if self.pickdata is None or self.button_down_background:
            index = -1
        elif self.pickdata.angle is None:
            index = -2
        else:
            index = int((self.pickdata.angle+180) / 22.5 + 0.5) % 16
        self.setCursor(self.cursors[index])
        
    def set_std_cursor(self):
        #FIXME: then there still is the wrong cursor
        QTimer.singleShot(0, self.unsetCursor)
        
    def set_editing_mode(self, enable):
        self.editing_model = enable
        self.set_glmodel_selection_mode(0 if enable else settings.draw.selection)
        self.update_selection()
        
    def set_background_color(self, color):
        rgba = QColor()
        rgba.setNamedColor(color)
        self.glarea.set_background_color(rgba.redF(), rgba.greenF(), rgba.blueF())
        
    def reset_rotation(self):
        '''Reset cube rotation'''
        self.rotation_x, self.rotation_y = self.glarea.set_rotation_xy(*self.model.default_rotation)
        self.update()
        
    def reload_shader(self):
        with self.lock_glcontext():
            self.gl_set_shader()
        self.update()
        
    def on_settings_changed(self, key):
        if key == 'draw.speed':
            self.speed = settings.draw.speed
        elif key == 'draw.shader':
            with self.lock_glcontext():
                self.gl_load_shader()
        elif key == 'draw.samples':
            if self.format().samples():
                samples = 2**settings.draw.samples
                if samples == 1:
                    self.glarea.set_antialiasing(False)
                elif samples == self.format().samples():
                    self.glarea.set_antialiasing(True)
        elif key == 'draw.selection':
            self.set_glmodel_selection_mode(settings.draw.selection)
            self.update_selection()
        elif key in ['draw.mirror_faces', 'draw.mirror_distance']:
            self.set_glmodel_full()
        elif key.startswith('theme.faces.'):
            facekey, faceattr = key.split('.')[2:]
            if faceattr == 'color':
                color = QColor()
                color.setNamedColor(settings.theme.faces[facekey].color)
                self.theme.faces[facekey].color = (color.red(), color.green(), color.blue())
                self.set_glmodel_selection_mode(settings.draw.selection)
            elif faceattr == 'image':
                self.load_face_texture(facekey)
                with self.lock_glcontext():
                    self.gl_set_texture_atlas()
                self.set_glmodel_selection_mode(settings.draw.selection)
            elif faceattr == 'mode':
                self.theme.faces[facekey].imagemode = settings.theme.faces[facekey].mode
                self.set_glmodel_selection_mode(settings.draw.selection)
        elif key == 'theme.bgcolor':
            self.set_background_color(settings.theme.bgcolor)
        elif key == 'draw.zoom':
            self.glarea.set_frustum(0, settings.draw.zoom)
        else:
            debug('Unknown settings key changed:', key)
        self.update()
        
        

