/*
 * Copyright (c) 2009-2022 jMonkeyEngine
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * * Redistributions of source code must retain the above copyright
 *   notice, this list of conditions and the following disclaimer.
 *
 * * Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
 *   may be used to endorse or promote products derived from this software
 *   without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
 * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
 * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */
package com.jme3.post;

import com.jme3.asset.AssetManager;
import com.jme3.export.*;
import com.jme3.material.Material;
import com.jme3.profile.*;
import com.jme3.renderer.*;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.texture.FrameBuffer;
import com.jme3.texture.Image.Format;
import com.jme3.texture.Texture;
import com.jme3.texture.Texture2D;
import com.jme3.texture.FrameBuffer.FrameBufferTarget;
import com.jme3.ui.Picture;
import com.jme3.util.SafeArrayList;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;

/**
 * A FilterPostProcessor is a processor that can apply several {@link Filter}s to a rendered scene<br>
 * It manages a list of filters that will be applied in the order in which they've been added to the list
 * @author Rémy Bouquet aka Nehon
 */
public class FilterPostProcessor implements SceneProcessor, Savable {

    public static final String FPP = FilterPostProcessor.class.getSimpleName();
    private RenderManager renderManager;
    private Renderer renderer;
    private ViewPort viewPort;
    private FrameBuffer renderFrameBufferMS;
    private int numSamples = 1;
    private FrameBuffer renderFrameBuffer;
    private Texture2D filterTexture;
    private Texture2D depthTexture;
    private SafeArrayList<Filter> filters = new SafeArrayList<>(Filter.class);
    private AssetManager assetManager;
    private Picture fsQuad;
    private boolean computeDepth = false;
    private FrameBuffer outputBuffer;
    private int width;
    private int height;
    private float bottom;
    private float left;
    private float right;
    private float top;
    private int originalWidth;
    private int originalHeight;
    private int lastFilterIndex = -1;
    private boolean cameraInit = false;
    private boolean multiView = false;
    private AppProfiler prof;

    private Format fbFormat = Format.RGB111110F;
    private Format depthFormat = Format.Depth;

    /**
     * Create a FilterProcessor
     * @param assetManager the assetManager
     */
    public FilterPostProcessor(AssetManager assetManager) {
        this.assetManager = assetManager;
    }

    /**
     * Don't use this constructor, use {@link #FilterPostProcessor(AssetManager assetManager)}<br>
     * This constructor is used for serialization only
     */
    protected FilterPostProcessor() {
    }

    /**
     * Adds a filter to the filters list<br>
     * @param filter the filter to add
     */
    public void addFilter(Filter filter) {
        if (filter == null) {
            throw new IllegalArgumentException("Filter cannot be null.");
        }
        filters.add(filter);

        if (isInitialized()) {
            initFilter(filter, viewPort);
        }

        setFilterState(filter, filter.isEnabled());

    }

    /**
     * removes this filters from the filters list
     *
     * @param filter the Filter to remove (not null)
     */
    public void removeFilter(Filter filter) {
        if (filter == null) {
            throw new IllegalArgumentException("Filter cannot be null.");
        }
        filters.remove(filter);
        filter.cleanup(renderer);
        updateLastFilterIndex();
    }

    public Iterator<Filter> getFilterIterator() {
        return filters.iterator();
    }

    @Override
    public void initialize(RenderManager rm, ViewPort vp) {
        renderManager = rm;
        renderer = rm.getRenderer();
        viewPort = vp;
        fsQuad = new Picture("filter full screen quad");
        fsQuad.setWidth(1);
        fsQuad.setHeight(1);

        if (!renderer.getCaps().contains(Caps.PackedFloatTexture)) {
            if(renderer.getCaps().contains(Caps.FloatColorBufferRGB)){
                fbFormat = Format.RGB16F;
            } else if(renderer.getCaps().contains(Caps.FloatColorBufferRGBA)){
                fbFormat = Format.RGBA16F;
            } else {
                fbFormat = Format.RGB8;
            }
        }

        Camera cam = vp.getCamera();

        //save view port dimensions
        left = cam.getViewPortLeft();
        right = cam.getViewPortRight();
        top = cam.getViewPortTop();
        bottom = cam.getViewPortBottom();
        originalWidth = cam.getWidth();
        originalHeight = cam.getHeight();
        //first call to reshape
        reshape(vp, cam.getWidth(), cam.getHeight());
    }

    public Format getDefaultPassTextureFormat() {
        return fbFormat;
    }

    /**
     * init the given filter
     * @param filter
     * @param vp
     */
    private void initFilter(Filter filter, ViewPort vp) {
        filter.setProcessor(this);
        if (filter.isRequiresDepthTexture()) {
            if (!computeDepth && renderFrameBuffer != null) {
                depthTexture = new Texture2D(width, height, depthFormat);
                renderFrameBuffer.setDepthTarget(FrameBufferTarget.newTarget(depthTexture));
            }
            computeDepth = true;
            filter.init(assetManager, renderManager, vp, width, height);
            filter.setDepthTexture(depthTexture);
        } else {
            filter.init(assetManager, renderManager, vp, width, height);
        }
    }

    /**
     * renders a filter on a fullscreen quad
     * @param r
     * @param buff
     * @param mat
     */
    private void renderProcessing(Renderer r, FrameBuffer buff, Material mat) {
        if (buff == outputBuffer) {
            viewPort.getCamera().resize(originalWidth, originalHeight, false);
            viewPort.getCamera().setViewPort(left, right, bottom, top);
            // update is redundant because resize and setViewPort will both
            // run the appropriate (and same) onXXXChange methods.
            // Also, update() updates some things that don't need to be updated.
            //viewPort.getCamera().update();
            renderManager.setCamera( viewPort.getCamera(), false);
            if (mat.getAdditionalRenderState().isDepthWrite()) {
                mat.getAdditionalRenderState().setDepthTest(false);
                mat.getAdditionalRenderState().setDepthWrite(false);
            }
        } else {
            viewPort.getCamera().resize(buff.getWidth(), buff.getHeight(), false);
            viewPort.getCamera().setViewPort(0, 1, 0, 1);
            // update is redundant because resize and setViewPort will both
            // run the appropriate (and same) onXXXChange methods.
            // Also, update() updates some things that don't need to be updated.
            //viewPort.getCamera().update();
            renderManager.setCamera( viewPort.getCamera(), false);
            mat.getAdditionalRenderState().setDepthTest(true);
            mat.getAdditionalRenderState().setDepthWrite(true);
        }


        fsQuad.setMaterial(mat);
        fsQuad.updateGeometricState();

        r.setFrameBuffer(buff);
        r.clearBuffers(true, true, true);
        renderManager.renderGeometry(fsQuad);
    }

    @Override
    public boolean isInitialized() {
        return viewPort != null;
    }

    @Override
    public void postQueue(RenderQueue rq) {
        for (Filter filter : filters.getArray()) {
            if (filter.isEnabled()) {
                if (prof != null) prof.spStep(SpStep.ProcPostQueue, FPP, filter.getName());
                filter.postQueue(rq);
            }
        }
    }

    /**
     * iterate through the filter list and renders filters
     * @param r
     * @param sceneFb
     */
    private void renderFilterChain(Renderer r, FrameBuffer sceneFb) {
        Texture2D tex = filterTexture;
        FrameBuffer buff = sceneFb;
        boolean msDepth = depthTexture != null && depthTexture.getImage().getMultiSamples() > 1;
        for (int i = 0; i < filters.size(); i++) {
            Filter filter = filters.get(i);
            if (prof != null) prof.spStep(SpStep.ProcPostFrame, FPP, filter.getName());
            if (filter.isEnabled()) {
                if (filter.getPostRenderPasses() != null) {
                    for (Iterator<Filter.Pass> it1 = filter.getPostRenderPasses().iterator(); it1.hasNext();) {
                        Filter.Pass pass = it1.next();
                        if (prof != null) prof.spStep(SpStep.ProcPostFrame, FPP, filter.getName(), pass.toString());
                        pass.beforeRender();
                        if (pass.requiresSceneAsTexture()) {
                            pass.getPassMaterial().setTexture("Texture", tex);
                            if (tex.getImage().getMultiSamples() > 1) {
                                pass.getPassMaterial().setInt("NumSamples", tex.getImage().getMultiSamples());
                            } else {
                                pass.getPassMaterial().clearParam("NumSamples");

                            }
                        }
                        if (pass.requiresDepthAsTexture()) {
                            pass.getPassMaterial().setTexture("DepthTexture", depthTexture);
                            if (msDepth) {
                                pass.getPassMaterial().setInt("NumSamplesDepth", depthTexture.getImage().getMultiSamples());
                            } else {
                                pass.getPassMaterial().clearParam("NumSamplesDepth");
                            }
                        }
                        renderProcessing(r, pass.getRenderFrameBuffer(), pass.getPassMaterial());
                    }
                }
                if (prof != null) prof.spStep(SpStep.ProcPostFrame, FPP, filter.getName(), "postFrame");
                filter.postFrame(renderManager, viewPort, buff, sceneFb);

                Material mat = filter.getMaterial();
                if (msDepth && filter.isRequiresDepthTexture()) {
                    mat.setInt("NumSamplesDepth", depthTexture.getImage().getMultiSamples());
                }

                if (filter.isRequiresSceneTexture()) {
                    mat.setTexture("Texture", tex);
                    if (tex.getImage().getMultiSamples() > 1) {
                        mat.setInt("NumSamples", tex.getImage().getMultiSamples());
                    } else {
                        mat.clearParam("NumSamples");
                    }
                }

                boolean wantsBilinear = filter.isRequiresBilinear();
                if (wantsBilinear) {
                    tex.setMagFilter(Texture.MagFilter.Bilinear);
                    tex.setMinFilter(Texture.MinFilter.BilinearNoMipMaps);
                }

                buff = outputBuffer;
                if (i != lastFilterIndex) {
                    buff = filter.getRenderFrameBuffer();
                    tex = filter.getRenderedTexture();

                }
                if (prof != null) prof.spStep(SpStep.ProcPostFrame, FPP, filter.getName(), "render");
                renderProcessing(r, buff, mat);
                if (prof != null) prof.spStep(SpStep.ProcPostFrame, FPP, filter.getName(), "postFilter");
                filter.postFilter(r, buff);

                if (wantsBilinear) {
                    tex.setMagFilter(Texture.MagFilter.Nearest);
                    tex.setMinFilter(Texture.MinFilter.NearestNoMipMaps);
                }
            }
        }
    }

    @Override
    public void postFrame(FrameBuffer out) {

        FrameBuffer sceneBuffer = renderFrameBuffer;
        if (renderFrameBufferMS != null && !renderer.getCaps().contains(Caps.OpenGL32)) {
            renderer.copyFrameBuffer(renderFrameBufferMS, renderFrameBuffer, true, true);
        } else if (renderFrameBufferMS != null) {
            sceneBuffer = renderFrameBufferMS;
        }
        renderFilterChain(renderer, sceneBuffer);
        renderer.setFrameBuffer(outputBuffer);

        //viewport can be null if no filters are enabled
        if (viewPort != null) {
            renderManager.setCamera(viewPort.getCamera(), false);
        }
    }

    @Override
    public void preFrame(float tpf) {
        if (filters.isEmpty() || lastFilterIndex == -1) {
            //If the camera is initialized and there are no filter to render, the camera viewport is restored as it was
            if (cameraInit) {
                viewPort.getCamera().resize(originalWidth, originalHeight, true);
                viewPort.getCamera().setViewPort(left, right, bottom, top);
                viewPort.setOutputFrameBuffer(outputBuffer);
                cameraInit = false;
            }

        } else {
           setupViewPortFrameBuffer();
           //if we are in a multiview situation we need to resize the camera
           //to the viewport size so that the back buffer is rendered correctly
           if (multiView) {
                viewPort.getCamera().resize(width, height, false);
                viewPort.getCamera().setViewPort(0, 1, 0, 1);
                viewPort.getCamera().update();
                renderManager.setCamera(viewPort.getCamera(), false);
           }
        }

        for (Filter filter : filters.getArray()) {
            if (filter.isEnabled()) {
                if (prof != null) prof.spStep(SpStep.ProcPreFrame, FPP, filter.getName());
                filter.preFrame(tpf);
            }
        }

    }

    /**
     * sets the filter to enabled or disabled
     *
     * @param filter the Filter to modify (not null)
     * @param enabled true to enable, false to disable
     */
    protected void setFilterState(Filter filter, boolean enabled) {
        if (filters.contains(filter)) {
            filter.enabled = enabled;
            updateLastFilterIndex();
        }
    }

    /**
     * compute the index of the last filter to render
     */
    private void updateLastFilterIndex() {
        lastFilterIndex = -1;
        for (int i = filters.size() - 1; i >= 0 && lastFilterIndex == -1; i--) {
            if (filters.get(i).isEnabled()) {
                lastFilterIndex = i;
                // The FPP is initialized, but the viewport framebuffer is the
                // original out framebuffer, so we must recover from a situation
                // where no filter was enabled. So we set the correct framebuffer
                // on the viewport.
                if(isInitialized() && viewPort.getOutputFrameBuffer()==outputBuffer){
                    setupViewPortFrameBuffer();
                }
                return;
            }
        }
        if (isInitialized() && lastFilterIndex == -1) {
            //There is no enabled filter, we restore the original framebuffer
            //to the viewport to bypass the fpp.
            viewPort.setOutputFrameBuffer(outputBuffer);
        }
    }

    @Override
    public void cleanup() {
        if (viewPort != null) {
            //reset the viewport camera viewport to its initial value
            viewPort.getCamera().resize(originalWidth, originalHeight, true);
            viewPort.getCamera().setViewPort(left, right, bottom, top);
            viewPort.setOutputFrameBuffer(outputBuffer);
            viewPort = null;

            if(renderFrameBuffer != null){
                renderFrameBuffer.dispose();
            }
            if(depthTexture!=null){
               depthTexture.getImage().dispose();
            }
            filterTexture.getImage().dispose();
            if(renderFrameBufferMS != null){
               renderFrameBufferMS.dispose();
            }
            for (Filter filter : filters.getArray()) {
                filter.cleanup(renderer);
            }
        }

    }

    @Override
    public void setProfiler(AppProfiler profiler) {
        this.prof = profiler;
    }

    @Override
    public void reshape(ViewPort vp, int w, int h) {
        Camera cam = vp.getCamera();
        //this has no effect at first init but is useful when resizing the canvas with multi views
        cam.setViewPort(left, right, bottom, top);
        //resizing the camera to fit the new viewport and saving original dimensions
        cam.resize(w, h, true);
        left = cam.getViewPortLeft();
        right = cam.getViewPortRight();
        top = cam.getViewPortTop();
        bottom = cam.getViewPortBottom();
        originalWidth = w;
        originalHeight = h;

        //computing real dimension of the viewport and resizing the camera
        width = (int) (w * (Math.abs(right - left)));
        height = (int) (h * (Math.abs(bottom - top)));
        width = Math.max(1, width);
        height = Math.max(1, height);

        //Testing original versus actual viewport dimension.
        //If they are different we are in a multiview situation and
        //camera must be handled differently
        if(originalWidth!=width || originalHeight!=height){
            multiView = true;
        }

        cameraInit = true;
        computeDepth = false;

        if (renderFrameBuffer == null && renderFrameBufferMS == null) {
            outputBuffer = viewPort.getOutputFrameBuffer();
        }

        Collection<Caps> caps = renderer.getCaps();

        //antialiasing on filters only supported in opengl 3 due to depth read problem
        if (numSamples > 1 && caps.contains(Caps.FrameBufferMultisample)) {
            renderFrameBufferMS = new FrameBuffer(width, height, numSamples);
            if (caps.contains(Caps.OpenGL32)) {
                Texture2D msColor = new Texture2D(width, height, numSamples, fbFormat);
                Texture2D msDepth = new Texture2D(width, height, numSamples, depthFormat);
                renderFrameBufferMS.setDepthTarget(FrameBufferTarget.newTarget(msDepth));
                renderFrameBufferMS.addColorTarget(FrameBufferTarget.newTarget(msColor));
                filterTexture = msColor;
                depthTexture = msDepth;
            } else {
                renderFrameBufferMS.setDepthTarget(FrameBufferTarget.newTarget(depthFormat));
                renderFrameBufferMS.addColorTarget(FrameBufferTarget.newTarget(fbFormat));
            }
        }

        if (numSamples <= 1 || !caps.contains(Caps.OpenGL32)) {
            renderFrameBuffer = new FrameBuffer(width, height, 1);
            renderFrameBuffer.setDepthTarget(FrameBufferTarget.newTarget(depthFormat));
            filterTexture = new Texture2D(width, height, fbFormat);
            renderFrameBuffer.addColorTarget(FrameBufferTarget.newTarget(filterTexture));
        }

        for (Filter filter : filters.getArray()) {
            initFilter(filter, vp);
        }
        setupViewPortFrameBuffer();
    }

    /**
     * return the number of samples for antialiasing
     * @return numSamples
     */
    public int getNumSamples() {
        return numSamples;
    }

    /**
     *
     * Removes all the filters from this processor
     */
    public void removeAllFilters() {
        filters.clear();
        updateLastFilterIndex();
    }

    /**
     * Sets the number of samples for antialiasing
     * @param numSamples the number of Samples
     */
    public void setNumSamples(int numSamples) {
        if (numSamples <= 0) {
            throw new IllegalArgumentException("numSamples must be > 0");
        }

        this.numSamples = numSamples;
    }

    /**
     * Sets the asset manager for this processor
     *
     * @param assetManager to load assets
     */
    public void setAssetManager(AssetManager assetManager) {
        this.assetManager = assetManager;
    }

    /**
     * Sets the format to be used for the internal frame buffer's color buffer
     *
     * @param fbFormat the format
     */
    public void setFrameBufferFormat(Format fbFormat) {
        this.fbFormat = fbFormat;
    }

    /**
     * Sets the format to be used for the internal frame buffer's depth buffer
     *
     * @param depthFormat the format
     */
    public void setFrameBufferDepthFormat(Format depthFormat) {
        this.depthFormat = depthFormat;
    }

    /**
     * Returns the depth format currently used for the internal frame buffer's depth buffer
     * 
     * @return the depth format
     */
    public Format getFrameBufferDepthFormat() {
        return depthFormat;
    }

    @Override
    @SuppressWarnings("unchecked")
    public void write(JmeExporter ex) throws IOException {
        OutputCapsule oc = ex.getCapsule(this);
        oc.write(numSamples, "numSamples", 0);
        oc.writeSavableArrayList(new ArrayList(filters), "filters", null);
    }

    @Override
    @SuppressWarnings("unchecked")
    public void read(JmeImporter im) throws IOException {
        InputCapsule ic = im.getCapsule(this);
        numSamples = ic.readInt("numSamples", 0);
        filters = new SafeArrayList<>(Filter.class, ic.readSavableArrayList("filters", null));
        for (Filter filter : filters.getArray()) {
            filter.setProcessor(this);
            setFilterState(filter, filter.isEnabled());
        }
        assetManager = im.getAssetManager();
    }

    /**
     * For internal use only<br>
     * returns the depth texture of the scene
     * @return the depth texture
     */
    public Texture2D getDepthTexture() {
        return depthTexture;
    }

    /**
     * For internal use only<br>
     * returns the rendered texture of the scene
     * @return the filter texture
     */
    public Texture2D getFilterTexture() {
        return filterTexture;
    }

    /**
     * returns the first filter in the list assignable from the given type
     *
     * @param <T> the filter type
     * @param filterType the filter type
     * @return a filter assignable from the given type
     */
    @SuppressWarnings("unchecked")
    public <T extends Filter> T getFilter(Class<T> filterType) {
        for (Filter c : filters.getArray()) {
            if (filterType.isAssignableFrom(c.getClass())) {
                return (T) c;
            }
        }
        return null;
    }

    /**
     * returns an unmodifiable version of the filter list.
     * @return the filters list
     */
    public List<Filter> getFilterList(){
        return Collections.unmodifiableList(filters);
    }

    private void setupViewPortFrameBuffer() {
        if (renderFrameBufferMS != null) {
            viewPort.setOutputFrameBuffer(renderFrameBufferMS);
        } else {
            viewPort.setOutputFrameBuffer(renderFrameBuffer);
        }
    }
    }
