import io
import threading
from collections import namedtuple
from itertools import chain
from queue import Queue
from typing import Optional

import moderngl
import numpy as np
from PIL import Image
from shapely.geometry import CAP_STYLE, JOIN_STYLE, Polygon
from shapely.ops import unary_union

from c3nav.mapdata.render.engines.base import FillAttribs, StrokeAttribs
from c3nav.mapdata.render.engines.base3d import Base3DEngine
from c3nav.mapdata.render.geometry import HybridGeometry
from c3nav.mapdata.utils.mesh import triangulate_polygon


class RenderContext(namedtuple('RenderContext', ('width', 'height', 'ctx', 'prog', 'fbo'))):
    """
    A OpenGL Render Context with program and framebuffer. Can only be used by thread that created it.
    """
    __slots__ = ()

    @classmethod
    def create(cls, width, height):
        ctx = moderngl.create_standalone_context()

        color_rbo = ctx.renderbuffer((width, height), samples=ctx.max_samples)
        fbo = ctx.framebuffer([color_rbo])
        fbo.use()

        prog = ctx.program(
            vertex_shader='''
                #version 330
                in vec3 in_vert;
                in vec4 in_color;
                out vec4 v_color;

                uniform mat4 mvp;

                void main() {
                    gl_Position = mvp * vec4(in_vert, 1.0);
                    v_color = in_color;
                }
            ''',
            fragment_shader='''
                #version 330
                in vec4 v_color;
                out vec4 f_color;
                void main() {
                    f_color = v_color;
                }
            '''
        )

        ctx.enable(moderngl.BLEND)

        return cls(width, height, ctx, prog, fbo)


class RenderTask:
    """
    Async Render Task
    """
    __slots__ = ('width', 'height', 'mvp', 'background_rgb', 'vertices', 'event', 'result')

    def __init__(self, width, height, mvp, background_rgb, vertices):
        self.width = width
        self.height = height
        self.mvp = mvp
        self.background_rgb = background_rgb
        self.vertices = vertices

        self.event = threading.Event()
        self.result = None

    def get_result(self) -> bytes:
        """
        Wait the task to complete and return the result.
        """
        self.event.wait()
        return self.result

    def set_result(self, result: bytes):
        """
        Set the task result and mark it as completed.
        """
        self.result = result
        self.event.set()


class OpenGLWorker(threading.Thread):
    """
    OpenGL Worker Thread
    This is needed to reuse OpenGL resources, because they have to be always accessed from the same thread.
    """
    def __init__(self):
        threading.Thread.__init__(self, daemon=True)
        self._queue = Queue()
        self.ctx = None

    def _get_ctx(self, width, height):
        ctx = self.ctx
        if ctx is None or ctx.width != width or ctx.height != height:
            ctx = RenderContext.create(width, height)
            self.ctx = ctx
        return ctx

    def run(self):
        while True:
            task = self._queue.get()

            ctx = self._get_ctx(task.width, task.height)
            ctx.ctx.clear(*task.background_rgb)

            ctx.prog['mvp'].value = task.mvp

            if task.vertices:
                vbo = ctx.ctx.buffer(task.vertices)
                # noinspection PyTypeChecker
                vao = ctx.ctx.simple_vertex_array(ctx.prog, vbo, 'in_vert', 'in_color')
                vao.render()

            color_rbo2 = ctx.ctx.renderbuffer((task.width, task.height))
            fbo2 = ctx.ctx.framebuffer([color_rbo2])
            ctx.ctx.copy_framebuffer(fbo2, ctx.fbo)

            img = Image.frombytes('RGB', (task.width, task.height), fbo2.read(components=3))

            f = io.BytesIO()
            img.save(f, 'PNG')
            f.seek(0)
            task.set_result(f.read())

    def render(self, width: int, height: int, mvp: tuple, background_rgb: tuple, vertices: bytes):
        """
        Render image and return it as PNG bytes
        """
        task = RenderTask(width, height, mvp, background_rgb, vertices)
        self._queue.put(task, timeout=3)
        return task.get_result()


class OpenGLEngine(Base3DEngine):
    filetype = 'png'

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        scale_x = self.scale / self.width * 2
        scale_y = self.scale / self.height * 2
        scale_z = 0.05

        self.gl_scale = (scale_x, -scale_y, scale_z)
        self.gl_offset = (-self.minx * scale_x - 1, self.maxy * scale_y - 1, -self.base_z * scale_z)
        self.gl_mvp = (
            scale_x, 0, 0, 0,
            0, -scale_y, 0, 0,
            0, 0, scale_z, 0,
            -self.minx * scale_x - 1, self.maxy * scale_y - 1, -self.base_z * scale_z, 1,
        )

        self.vertices = []

    def _add_geometry(self, geometry, fill: Optional[FillAttribs], stroke: Optional[StrokeAttribs], **kwargs):
        if fill is not None:
            self.vertices.append(self._place_geometry(geometry, self.color_to_rgb(fill.color), offset=False))

        if stroke is not None:
            width = max(stroke.width, (stroke.min_px or 0) / self.scale) / 2

            # if width would be <1px, emulate it through opacity on a 1px width
            one_pixel = 1 / self.scale / 2
            if width < one_pixel:
                alpha = width/one_pixel
                width = one_pixel
            else:
                alpha = 1

            self.vertices.append(self._create_border(geometry, width, self.color_to_rgb(stroke.color, alpha=alpha)))

    def _create_border(self, geometry: HybridGeometry, width, append=None):
        altitude = (np.vstack(chain(*(mesh.tolist() for mesh in geometry.faces)))[:, :, 2].max()+1)/1000
        geometry = self.buffered_bbox.intersection(geometry.geom)

        lines = tuple(chain(*(
            ((geom.exterior, *geom.interiors) if isinstance(geom, Polygon) else (geom,))
            for geom in getattr(geometry, 'geoms', (geometry,))
        )))

        if not lines:
            return np.empty((0, 3, 3+len(append)))

        lines = unary_union(lines).buffer(width, cap_style=CAP_STYLE.flat, join_style=JOIN_STYLE.mitre)

        vertices, faces = triangulate_polygon(lines)
        triangles = np.dstack((vertices[faces], np.full((faces.size, 1), fill_value=altitude).reshape((-1, 3, 1))))

        return self._append_to_vertices(triangles.astype(np.float32), append)

    worker = OpenGLWorker()

    def render(self, filename=None) -> bytes:
        return self.worker.render(self.width, self.height, self.gl_mvp, self.background_rgb,
                                  np.vstack(self.vertices).astype(np.float32).tobytes() if self.vertices else b'')


OpenGLEngine.worker.start()
