/*******************************************************************************
 * Copyright 2011 See AUTHORS file.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 ******************************************************************************/

package com.badlogic.gdx.backends.lwjgl;

import com.badlogic.gdx.Application;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Graphics;
import com.badlogic.gdx.graphics.Cursor.SystemCursor;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.GL30;
import com.badlogic.gdx.graphics.Pixmap;
import com.badlogic.gdx.graphics.Pixmap.Format;
import com.badlogic.gdx.graphics.glutils.GLVersion;
import com.badlogic.gdx.utils.Array;
import com.badlogic.gdx.utils.GdxRuntimeException;
import com.badlogic.gdx.utils.SharedLibraryLoader;
import org.lwjgl.LWJGLException;
import org.lwjgl.input.Mouse;
import org.lwjgl.opengl.ContextAttribs;
import org.lwjgl.opengl.Display;
import org.lwjgl.opengl.GL11;
import org.lwjgl.opengl.PixelFormat;

import java.awt.*;
import java.nio.ByteBuffer;

/** An implementation of the {@link Graphics} interface based on Lwjgl.
 * @author mzechner */
public class LwjglGraphics implements Graphics {

    /** The suppored OpenGL extensions */
    static Array<String> extensions;
    static GLVersion glVersion;

    GL20 gl20;
    GL30 gl30;
    long frameId = -1;
    float deltaTime = 0;
    long frameStart = 0;
    int frames = 0;
    int fps;
    long lastTime = System.nanoTime();
    Canvas canvas;
    boolean vsync = false;
    boolean resize = false;
    LwjglApplicationConfiguration config;
    BufferFormat bufferFormat = new BufferFormat(8, 8, 8, 8, 16, 8, 0, false);
    volatile boolean isContinuous = true;
    volatile boolean requestRendering = false;
    boolean softwareMode;
    boolean usingGL30;

    // deltaTime kalman filter related variables
    private float kalmanEstimate = 1.0f/60.0f;
    private float kalmanErrorCovariance = 1.0f;
    private final float kalmanErrorRate = 0.2f; // 0.2: empirical value
    private final float kalmanUpdateThreshold = 0.1f;
    private final float getMagnitudeDifferenceEpsilon = 0.00001f;

    LwjglGraphics (LwjglApplicationConfiguration config) {
        this.config = config;
    }

    LwjglGraphics (Canvas canvas) {
        this.config = new LwjglApplicationConfiguration();
        config.width = canvas.getWidth();
        config.height = canvas.getHeight();
        this.canvas = canvas;
    }

    LwjglGraphics (Canvas canvas, LwjglApplicationConfiguration config) {
        this.config = config;
        this.canvas = canvas;
    }


    public int getHeight () {
        if (canvas != null)
            return Math.max(1, canvas.getHeight());
        else
            return (int)(Display.getHeight() * Display.getPixelScaleFactor());
    }

    public int getWidth () {
        if (canvas != null)
            return Math.max(1, canvas.getWidth());
        else
            return (int)(Display.getWidth() * Display.getPixelScaleFactor());
    }

    @Override
    public int getBackBufferWidth () {
        return getWidth();
    }

    @Override
    public int getBackBufferHeight () {
        return getHeight();
    }


    public long getFrameId () {
        return frameId;
    }

    public float getDeltaTime () {
        return kalmanEstimate;
    }

    private void resetDeltaSmoothingHistory() {
        kalmanEstimate = 1.0f/60.0f;
        kalmanErrorCovariance = 1.0f;
    }

    // only for a > 0 && b > 0
    private float getMagnitudeDifference(float a, float b) {
        if (a < getMagnitudeDifferenceEpsilon || b < getMagnitudeDifferenceEpsilon) {
            return (a + b) / getMagnitudeDifferenceEpsilon;
        }

        if (a > b) {
            return a / b;
        }
        else {
            return b / a;
        }
    }

    private void updateKalmanRenderDelta() {
        // The problem with this kalman filter is that it assumes most simplistic situation:
        //   1. the actual delta (measured delta - noise) is constant (that is, not constantly increasing or something)
        //   2. everything is linear
        // We may need to implement Extended Kalman Filter but what is Jacobian, I suck at maths.
        //
        // Instead, this implementation will reset itself when difference in magnitude between
        // old and new is greater than set value.
        //
        // It's not perfect but it works, much better than averaging.

        if (getMagnitudeDifference(deltaTime, kalmanEstimate) >= 2.0) {
            resetDeltaSmoothingHistory();
        }

        // measurement value
        float observation = deltaTime;

        if (observation <= kalmanUpdateThreshold) {
            // time update
            float priorEstimate = kalmanEstimate;
            float priorError = kalmanErrorCovariance;

            // measurement update
            float gain = priorError / (priorError + kalmanErrorRate);
            float newEstimate = priorEstimate + gain * (observation - priorEstimate);
            float newError = (1.0f - gain) * priorError;

            kalmanEstimate = newEstimate;
            kalmanErrorCovariance = newError;
        }
    }

    public float getDeltaTime () {
        return deltaTime;
    }

    public GraphicsType getType () {
        return GraphicsType.LWJGL;
    }

    public GLVersion getGLVersion () {
        return glVersion;
    }

    public boolean isGL20Available () {
        return gl20 != null;
    }

    public GL20 getGL20 () {
        return gl20;
    }

    @Override
    public void setGL20 (GL20 gl20) {
        this.gl20 = gl20;
        if (gl30 == null) {
            Gdx.gl = gl20;
            Gdx.gl20 = gl20;
        }
    }

    @Override
    public boolean isGL30Available () {
        return gl30 != null;
    }

    @Override
    public GL30 getGL30 () {
        return gl30;
    }

    @Override
    public void setGL30 (GL30 gl30) {
        this.gl30 = gl30;
        if (gl30 != null) {
            this.gl20 = gl30;

            Gdx.gl = gl20;
            Gdx.gl20 = gl20;
            Gdx.gl30 = gl30;
        }
    }

    public int getFramesPerSecond () {
        return fps;
    }

    void updateTime () {
        long time = System.nanoTime();
        deltaTime = (time - lastTime) / 1000000000.0f;
        lastTime = time;

        if (time - frameStart >= 1000000000) {
            fps = frames;
            frames = 0;
            frameStart = time;
        }
        frames++;

        updateKalmanRenderDelta();
    }

    void setupDisplay () throws LWJGLException {
        if (config.useHDPI) {
            System.setProperty("org.lwjgl.opengl.Display.enableHighDPI", "true");
        }

        if (canvas != null) {
            Display.setParent(canvas);
        } else {
            boolean displayCreated = false;

            if(!config.fullscreen) {
                displayCreated = setWindowedMode(config.width, config.height);
            } else {
                DisplayMode bestMode = null;
                for(DisplayMode mode: getDisplayModes()) {
                    if(mode.width == config.width && mode.height == config.height) {
                        if(bestMode == null || bestMode.refreshRate < this.getDisplayMode().refreshRate) {
                            bestMode = mode;
                        }
                    }
                }
                if(bestMode == null) {
                    bestMode = this.getDisplayMode();
                }
                displayCreated = setFullscreenMode(bestMode);
            }
            if (!displayCreated) {
                if (config.setDisplayModeCallback != null) {
                    config = config.setDisplayModeCallback.onFailure(config);
                    if (config != null) {
                        displayCreated = setWindowedMode(config.width, config.height);
                    }
                }
                if (!displayCreated) {
                    throw new GdxRuntimeException("Couldn't set display mode " + config.width + "x" + config.height + ", fullscreen: "
                            + config.fullscreen);
                }
            }
            if (config.iconPaths.size > 0) {
                ByteBuffer[] icons = new ByteBuffer[config.iconPaths.size];
                for (int i = 0, n = config.iconPaths.size; i < n; i++) {
                    Pixmap pixmap = new Pixmap(Gdx.files.getFileHandle(config.iconPaths.get(i), config.iconFileTypes.get(i)));
                    if (pixmap.getFormat() != Format.RGBA8888) {
                        Pixmap rgba = new Pixmap(pixmap.getWidth(), pixmap.getHeight(), Format.RGBA8888);
                        rgba.drawPixmap(pixmap, 0, 0);
                        pixmap.dispose();
                        pixmap = rgba;
                    }
                    icons[i] = ByteBuffer.allocateDirect(pixmap.getPixels().limit());
                    icons[i].put(pixmap.getPixels()).flip();
                    pixmap.dispose();
                }
                Display.setIcon(icons);
            }
        }
        Display.setTitle(config.title);
        Display.setResizable(config.resizable);
        Display.setInitialBackground(config.initialBackgroundColor.r, config.initialBackgroundColor.g,
                config.initialBackgroundColor.b);

        Display.setLocation(config.x, config.y);
        createDisplayPixelFormat(config.useGL30, config.gles30ContextMajorVersion, config.gles30ContextMinorVersion);
        initiateGL();
    }

    /**
     * Only needed when setupDisplay() is not called.
     */
    void initiateGL() {
        extractVersion();
        extractExtensions();
        initiateGLInstances();
    }

    private static void extractVersion () {
        String versionString = org.lwjgl.opengl.GL11.glGetString(GL11.GL_VERSION);
        String vendorString = org.lwjgl.opengl.GL11.glGetString(GL11.GL_VENDOR);
        String rendererString = org.lwjgl.opengl.GL11.glGetString(GL11.GL_RENDERER);
        glVersion = new GLVersion(Application.ApplicationType.Desktop, versionString, vendorString, rendererString);
    }

    private static void extractExtensions () {
        extensions = new Array<String>();
        if (glVersion.isVersionEqualToOrHigher(3, 2)) {
            int numExtensions = GL11.glGetInteger(GL30.GL_NUM_EXTENSIONS);
            for (int i = 0; i < numExtensions; ++i)
                extensions.add(org.lwjgl.opengl.GL30.glGetStringi(GL20.GL_EXTENSIONS, i));
        } else {
            extensions.addAll(org.lwjgl.opengl.GL11.glGetString(GL20.GL_EXTENSIONS).split(" "));
        }
    }

    /** @return whether the supported OpenGL (not ES) version is compatible with OpenGL ES 3.x. */
    private static boolean fullCompatibleWithGLES3 () {
        // OpenGL ES 3.0 is compatible with OpenGL 4.3 core, see http://en.wikipedia.org/wiki/OpenGL_ES#OpenGL_ES_3.0
        return glVersion.isVersionEqualToOrHigher(4, 3);
    }

    /** @return whether the supported OpenGL (not ES) version is compatible with OpenGL ES 2.x. */
    private static boolean fullCompatibleWithGLES2 () {
        // OpenGL ES 2.0 is compatible with OpenGL 4.1 core
        // see https://www.opengl.org/registry/specs/ARB/ES2_compatibility.txt
        return glVersion.isVersionEqualToOrHigher(4, 1) || extensions.contains("GL_ARB_ES2_compatibility", false);
    }

    private static boolean supportsFBO () {
        // FBO is in core since OpenGL 3.0, see https://www.opengl.org/wiki/Framebuffer_Object
        return glVersion.isVersionEqualToOrHigher(3, 0) || extensions.contains("GL_EXT_framebuffer_object", false)
                || extensions.contains("GL_ARB_framebuffer_object", false);
    }

    private void createDisplayPixelFormat (boolean useGL30, int gles30ContextMajor, int gles30ContextMinor) {
        try {
            if (useGL30) {
                ContextAttribs context = new ContextAttribs(gles30ContextMajor, gles30ContextMinor).withForwardCompatible(false)
                                                                                                   .withProfileCore(true);
                try {
                    Display.create(new PixelFormat(config.r + config.g + config.b, config.a, config.depth, config.stencil,
                            config.samples), context);
                } catch (Exception e) {
                    System.out.println("LwjglGraphics: OpenGL " + gles30ContextMajor + "." + gles30ContextMinor
                            + "+ core profile (GLES 3.0) not supported.");
                    createDisplayPixelFormat(false, gles30ContextMajor, gles30ContextMinor);
                    return;
                }
                System.out.println("LwjglGraphics: created OpenGL " + gles30ContextMajor + "." + gles30ContextMinor
                        + "+ core profile (GLES 3.0) context. This is experimental!");
                usingGL30 = true;
            } else {
                Display
                        .create(new PixelFormat(config.r + config.g + config.b, config.a, config.depth, config.stencil, config.samples));
                usingGL30 = false;
            }
            bufferFormat = new BufferFormat(config.r, config.g, config.b, config.a, config.depth, config.stencil, config.samples,
                    false);
        } catch (Exception ex) {
            Display.destroy();
            try {
                Thread.sleep(200);
            } catch (InterruptedException ignored) {
            }
            try {
                Display.create(new PixelFormat(0, 16, 8));
                if (getDisplayMode().bitsPerPixel == 16) {
                    bufferFormat = new BufferFormat(5, 6, 5, 0, 16, 8, 0, false);
                }
                if (getDisplayMode().bitsPerPixel == 24) {
                    bufferFormat = new BufferFormat(8, 8, 8, 0, 16, 8, 0, false);
                }
                if (getDisplayMode().bitsPerPixel == 32) {
                    bufferFormat = new BufferFormat(8, 8, 8, 8, 16, 8, 0, false);
                }
            } catch (Exception ex2) {
                Display.destroy();
                try {
                    Thread.sleep(200);
                } catch (InterruptedException ignored) {
                }
                try {
                    Display.create(new PixelFormat());
                } catch (Exception ex3) {
                    if (!softwareMode && config.allowSoftwareMode) {
                        softwareMode = true;
                        System.setProperty("org.lwjgl.opengl.Display.allowSoftwareOpenGL", "true");
                        createDisplayPixelFormat(useGL30, gles30ContextMajor, gles30ContextMinor);
                        return;
                    }
                    throw new GdxRuntimeException("OpenGL is not supported by the video driver.", ex3);
                }
                if (getDisplayMode().bitsPerPixel == 16) {
                    bufferFormat = new BufferFormat(5, 6, 5, 0, 8, 0, 0, false);
                }
                if (getDisplayMode().bitsPerPixel == 24) {
                    bufferFormat = new BufferFormat(8, 8, 8, 0, 8, 0, 0, false);
                }
                if (getDisplayMode().bitsPerPixel == 32) {
                    bufferFormat = new BufferFormat(8, 8, 8, 8, 8, 0, 0, false);
                }
            }
        }
    }

    public void initiateGLInstances () {
        if (usingGL30) {
            gl30 = new LwjglGL30();
            gl20 = gl30;
        } else {
            gl20 = new LwjglGL20();
        }

        if (!glVersion.isVersionEqualToOrHigher(2, 0))
            throw new GdxRuntimeException("OpenGL 2.0 or higher with the FBO extension is required. OpenGL version: "
                    + GL11.glGetString(GL11.GL_VERSION) + "\n" + glVersion.getDebugVersionString());

        if (!supportsFBO()) {
            throw new GdxRuntimeException("OpenGL 2.0 or higher with the FBO extension is required. OpenGL version: "
                    + GL11.glGetString(GL11.GL_VERSION) + ", FBO extension: false\n" + glVersion.getDebugVersionString());
        }

        Gdx.gl = gl20;
        Gdx.gl20 = gl20;
        Gdx.gl30 = gl30;
    }

    @Override
    public float getPpiX () {
        return Toolkit.getDefaultToolkit().getScreenResolution();
    }

    @Override
    public float getPpiY () {
        return Toolkit.getDefaultToolkit().getScreenResolution();
    }

    @Override
    public float getPpcX () {
        return (Toolkit.getDefaultToolkit().getScreenResolution() / 2.54f);
    }

    @Override
    public float getPpcY () {
        return (Toolkit.getDefaultToolkit().getScreenResolution() / 2.54f);
    }

    @Override
    public float getDensity () {
        if (config.overrideDensity != -1) return config.overrideDensity / 160f;
        return (Toolkit.getDefaultToolkit().getScreenResolution() / 160f);
    }

    @Override
    public boolean supportsDisplayModeChange () {
        return true;
    }

    @Override
    public Monitor getPrimaryMonitor () {
        return new LwjglMonitor(0, 0, "Primary Monitor");
    }

    @Override
    public Monitor getMonitor () {
        return getPrimaryMonitor();
    }

    @Override
    public Monitor[] getMonitors () {
        return new Monitor[] { getPrimaryMonitor() };
    }

    @Override
    public DisplayMode[] getDisplayModes (Monitor monitor) {
        return getDisplayModes();
    }

    @Override
    public DisplayMode getDisplayMode (Monitor monitor) {
        return getDisplayMode();
    }

    @Override
    public boolean setFullscreenMode (DisplayMode displayMode) {
        org.lwjgl.opengl.DisplayMode mode = ((LwjglDisplayMode)displayMode).mode;
        try {
            if (!mode.isFullscreenCapable()) {
                Display.setDisplayMode(mode);
            } else {
                Display.setDisplayModeAndFullscreen(mode);
            }
            float scaleFactor = Display.getPixelScaleFactor();
            config.width = (int)(mode.getWidth() * scaleFactor);
            config.height = (int)(mode.getHeight() * scaleFactor);
            if (Gdx.gl != null) Gdx.gl.glViewport(0, 0, config.width, config.height);
            resize = true;
            return true;
        } catch (LWJGLException e) {
            return false;
        }
    }

    /** Kindly stolen from http://lwjgl.org/wiki/index.php?title=LWJGL_Basics_5_(Fullscreen), not perfect but will do. */
    @Override
    public boolean setWindowedMode (int width, int height) {
        if (getWidth() == width && getHeight() == height && !Display.isFullscreen()) {
            return true;
        }

        try {
            org.lwjgl.opengl.DisplayMode targetDisplayMode = null;
            boolean fullscreen = false;

            if (fullscreen) {
                org.lwjgl.opengl.DisplayMode[] modes = Display.getAvailableDisplayModes();
                int freq = 0;

                for (int i = 0; i < modes.length; i++) {
                    org.lwjgl.opengl.DisplayMode current = modes[i];

                    if ((current.getWidth() == width) && (current.getHeight() == height)) {
                        if ((targetDisplayMode == null) || (current.getFrequency() >= freq)) {
                            if ((targetDisplayMode == null) || (current.getBitsPerPixel() > targetDisplayMode.getBitsPerPixel())) {
                                targetDisplayMode = current;
                                freq = targetDisplayMode.getFrequency();
                            }
                        }

                        // if we've found a match for bpp and frequence against the
                        // original display mode then it's probably best to go for this one
                        // since it's most likely compatible with the monitor
                        if ((current.getBitsPerPixel() == Display.getDesktopDisplayMode().getBitsPerPixel())
                                && (current.getFrequency() == Display.getDesktopDisplayMode().getFrequency())) {
                            targetDisplayMode = current;
                            break;
                        }
                    }
                }
            } else {
                targetDisplayMode = new org.lwjgl.opengl.DisplayMode(width, height);
            }

            if (targetDisplayMode == null) {
                return false;
            }

            boolean resizable = !fullscreen && config.resizable;

            Display.setDisplayMode(targetDisplayMode);
            Display.setFullscreen(fullscreen);
            // Workaround for bug in LWJGL whereby resizable state is lost on DisplayMode change
            if (resizable == Display.isResizable()) {
                Display.setResizable(!resizable);
            }
            Display.setResizable(resizable);

            float scaleFactor = Display.getPixelScaleFactor();
            config.width = (int)(targetDisplayMode.getWidth() * scaleFactor);
            config.height = (int)(targetDisplayMode.getHeight() * scaleFactor);
            if (Gdx.gl != null) Gdx.gl.glViewport(0, 0, config.width, config.height);
            resize = true;
            return true;
        } catch (LWJGLException e) {
            return false;
        }
    }

    @Override
    public DisplayMode[] getDisplayModes () {
        try {
            org.lwjgl.opengl.DisplayMode[] availableDisplayModes = Display.getAvailableDisplayModes();
            DisplayMode[] modes = new DisplayMode[availableDisplayModes.length];

            int idx = 0;
            for (org.lwjgl.opengl.DisplayMode mode : availableDisplayModes) {
                if (mode.isFullscreenCapable()) {
                    modes[idx++] = new LwjglDisplayMode(mode.getWidth(), mode.getHeight(), mode.getFrequency(),
                            mode.getBitsPerPixel(), mode);
                }
            }

            return modes;
        } catch (LWJGLException e) {
            throw new GdxRuntimeException("Couldn't fetch available display modes", e);
        }
    }

    @Override
    public DisplayMode getDisplayMode () {
        org.lwjgl.opengl.DisplayMode mode = Display.getDesktopDisplayMode();
        return new LwjglDisplayMode(mode.getWidth(), mode.getHeight(), mode.getFrequency(), mode.getBitsPerPixel(), mode);
    }

    @Override
    public void setTitle (String title) {
        Display.setTitle(title);
    }

    /**
     * Display must be reconfigured via {@link #setWindowedMode(int, int)} for the changes to take
     * effect.
     */
    @Override
    public void setUndecorated (boolean undecorated) {
        System.setProperty("org.lwjgl.opengl.Window.undecorated", undecorated ? "true" : "false");
    }

    /**
     * Display must be reconfigured via {@link #setWindowedMode(int, int)} for the changes to take
     * effect.
     */
    @Override
    public void setResizable (boolean resizable) {
        this.config.resizable = resizable;
        Display.setResizable(resizable);
    }

    @Override
    public BufferFormat getBufferFormat () {
        return bufferFormat;
    }

    @Override
    public void setVSync (boolean vsync) {
        this.vsync = vsync;
        Display.setVSyncEnabled(vsync);
    }

    @Override
    public boolean supportsExtension (String extension) {
        return extensions.contains(extension, false);
    }

    @Override
    public void setContinuousRendering (boolean isContinuous) {
        this.isContinuous = isContinuous;
    }

    @Override
    public boolean isContinuousRendering () {
        return isContinuous;
    }

    @Override
    public void requestRendering () {
        synchronized (this) {
            requestRendering = true;
        }
    }

    public boolean shouldRender () {
        synchronized (this) {
            boolean rq = requestRendering;
            requestRendering = false;
            return rq || isContinuous || Display.isDirty();
        }
    }

    @Override
    public boolean isFullscreen () {
        return Display.isFullscreen();
    }

    public boolean isSoftwareMode () {
        return softwareMode;
    }

    /** A callback used by LwjglApplication when trying to create the display */
    public interface SetDisplayModeCallback {
        /** If the display creation fails, this method will be called. Suggested usage is to modify the passed configuration to use a
         * common width and height, and set fullscreen to false.
         * @return the configuration to be used for a second attempt at creating a display. A null value results in NOT attempting
         *         to create the display a second time */
        public LwjglApplicationConfiguration onFailure (LwjglApplicationConfiguration initialConfig);
    }

    @Override
    public com.badlogic.gdx.graphics.Cursor newCursor (Pixmap pixmap, int xHotspot, int yHotspot) {
        return new LwjglCursor(pixmap, xHotspot, yHotspot);
    }

    @Override
    public void setCursor (com.badlogic.gdx.graphics.Cursor cursor) {
        if (canvas != null && SharedLibraryLoader.isMac) {
            return;
        }
        try {
            Mouse.setNativeCursor(((LwjglCursor)cursor).lwjglCursor);
        } catch (LWJGLException e) {
            throw new GdxRuntimeException("Could not set cursor image.", e);
        }
    }

    @Override
    public void setSystemCursor (SystemCursor systemCursor) {
        if (canvas != null && SharedLibraryLoader.isMac) {
            return;
        }
        try {
            Mouse.setNativeCursor(null);
        } catch (LWJGLException e) {
            throw new GdxRuntimeException("Couldn't set system cursor");
        }
    }

    private class LwjglDisplayMode extends DisplayMode {
        org.lwjgl.opengl.DisplayMode mode;

        public LwjglDisplayMode (int width, int height, int refreshRate, int bitsPerPixel, org.lwjgl.opengl.DisplayMode mode) {
            super(width, height, refreshRate, bitsPerPixel);
            this.mode = mode;
        }
    }

    private class LwjglMonitor extends Monitor {
        protected LwjglMonitor (int virtualX, int virtualY, String name) {
            super(virtualX, virtualY, name);
        }
    }
}
