/*
 * Copyright (c) 2009-2016 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.material;

import com.jme3.asset.AssetManager;
import com.jme3.light.LightList;
import com.jme3.math.Matrix4f;
import com.jme3.renderer.RenderManager;
import com.jme3.scene.Geometry;
import com.jme3.scene.shape.Box;
import com.jme3.shader.Shader;
import com.jme3.shader.Uniform;
import com.jme3.shader.VarType;
import java.util.Arrays;
import java.util.HashSet;
import org.junit.Assert;
import org.junit.Test;

import static com.jme3.scene.MPOTestUtils.*;
import com.jme3.scene.Node;
import com.jme3.shader.DefineList;
import com.jme3.system.NullRenderer;
import com.jme3.system.TestUtil;
import com.jme3.texture.Image.Format;
import com.jme3.texture.Texture;
import com.jme3.texture.Texture2D;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import org.junit.Before;

/**
 * Validates how {@link MatParamOverride MPOs} work on the material level.
 *
 * @author Kirill Vainer
 */
public class MaterialMatParamOverrideTest {

    private static final HashSet<String> IGNORED_UNIFORMS = new HashSet<String>(
            Arrays.asList(new String[]{"m_ParallaxHeight", "m_Shininess", "m_BackfaceShadows"}));

    @Test
    public void testBoolMpoOnly() {
        material("Common/MatDefs/Light/Lighting.j3md");
        inputMpo(mpoBool("UseMaterialColors", true));
        outDefines(def("MATERIAL_COLORS", VarType.Boolean, true));
        outUniforms(uniform("UseMaterialColors", VarType.Boolean, true));
    }

    @Test
    public void testBoolMpOnly() {
        material("Common/MatDefs/Light/Lighting.j3md");
        inputMp(mpoBool("UseMaterialColors", true));
        outDefines(def("MATERIAL_COLORS", VarType.Boolean, true));
        outUniforms(uniform("UseMaterialColors", VarType.Boolean, true));
    }

    @Test
    public void testBoolOverrideFalse() {
        material("Common/MatDefs/Light/Lighting.j3md");
        inputMp(mpoBool("UseMaterialColors", true));
        inputMpo(mpoBool("UseMaterialColors", false));
        outDefines();
        outUniforms(uniform("UseMaterialColors", VarType.Boolean, false));
    }

    @Test
    public void testBoolOverrideTrue() {
        material("Common/MatDefs/Light/Lighting.j3md");
        inputMp(mpoBool("UseMaterialColors", false));
        inputMpo(mpoBool("UseMaterialColors", true));
        outDefines(def("MATERIAL_COLORS", VarType.Boolean, true));
        outUniforms(uniform("UseMaterialColors", VarType.Boolean, true));
    }

    @Test
    public void testFloatMpoOnly() {
        material("Common/MatDefs/Light/Lighting.j3md");
        inputMpo(mpoFloat("AlphaDiscardThreshold", 3.12f));
        outDefines(def("DISCARD_ALPHA", VarType.Float, 3.12f));
        outUniforms(uniform("AlphaDiscardThreshold", VarType.Float, 3.12f));
    }

    @Test
    public void testFloatMpOnly() {
        material("Common/MatDefs/Light/Lighting.j3md");
        inputMp(mpoFloat("AlphaDiscardThreshold", 3.12f));
        outDefines(def("DISCARD_ALPHA", VarType.Float, 3.12f));
        outUniforms(uniform("AlphaDiscardThreshold", VarType.Float, 3.12f));
    }

    @Test
    public void testFloatOverride() {
        material("Common/MatDefs/Light/Lighting.j3md");
        inputMp(mpoFloat("AlphaDiscardThreshold", 3.12f));
        inputMpo(mpoFloat("AlphaDiscardThreshold", 2.79f));
        outDefines(def("DISCARD_ALPHA", VarType.Float, 2.79f));
        outUniforms(uniform("AlphaDiscardThreshold", VarType.Float, 2.79f));
    }

    @Test
    public void testForcedOverride() {
        material("Common/MatDefs/Light/Lighting.j3md");
        inputMp(mpoFloat("AlphaDiscardThreshold", 3.12f));
        inputMpo(mpoFloat("AlphaDiscardThreshold", 2.79f));
        inputFpo(mpoFloat("AlphaDiscardThreshold", 1.23f));
        outDefines(def("DISCARD_ALPHA", VarType.Float, 1.23f));
        outUniforms(uniform("AlphaDiscardThreshold", VarType.Float, 1.23f));

        reset();
        root.clearMatParamOverrides();
        root.updateGeometricState();
        outDefines(def("DISCARD_ALPHA", VarType.Float, 2.79f));
        outUniforms(uniform("AlphaDiscardThreshold", VarType.Float, 2.79f));
    }

    @Test
    public void testChildOverridesParent() {
        material("Common/MatDefs/Light/Lighting.j3md");

        inputParentMpo(mpoFloat("AlphaDiscardThreshold", 3.12f));
        inputMpo(mpoFloat("AlphaDiscardThreshold", 2.79f));

        outUniforms(uniform("AlphaDiscardThreshold", VarType.Float, 2.79f));
        outDefines(def("DISCARD_ALPHA", VarType.Float, 2.79f));
    }

    @Test
    public void testMpoDisable() {
        material("Common/MatDefs/Light/Lighting.j3md");
        inputMp(mpoFloat("AlphaDiscardThreshold", 3.12f));

        MatParamOverride override = mpoFloat("AlphaDiscardThreshold", 2.79f);
        inputMpo(override);
        outDefines(def("DISCARD_ALPHA", VarType.Float, 2.79f));
        outUniforms(uniform("AlphaDiscardThreshold", VarType.Float, 2.79f));

        reset();
        override.setEnabled(false);
        outDefines(def("DISCARD_ALPHA", VarType.Float, 3.12f));
        outUniforms(uniform("AlphaDiscardThreshold", VarType.Float, 3.12f));

        reset();
        override.setEnabled(true);
        outDefines(def("DISCARD_ALPHA", VarType.Float, 2.79f));
        outUniforms(uniform("AlphaDiscardThreshold", VarType.Float, 2.79f));
    }

    @Test
    public void testIntMpoOnly() {
        material("Common/MatDefs/Light/Lighting.j3md");
        inputMpo(mpoInt("NumberOfBones", 1234));
        outDefines(def("NUM_BONES", VarType.Int, 1234));
        outUniforms(uniform("NumberOfBones", VarType.Int, 1234));
    }

    @Test
    public void testIntMpOnly() {
        material("Common/MatDefs/Light/Lighting.j3md");
        inputMp(mpoInt("NumberOfBones", 1234));
        outDefines(def("NUM_BONES", VarType.Int, 1234));
        outUniforms(uniform("NumberOfBones", VarType.Int, 1234));
    }

    @Test
    public void testIntOverride() {
        material("Common/MatDefs/Light/Lighting.j3md");
        inputMp(mpoInt("NumberOfBones", 1234));
        inputMpo(mpoInt("NumberOfBones", 4321));
        outDefines(def("NUM_BONES", VarType.Int, 4321));
        outUniforms(uniform("NumberOfBones", VarType.Int, 4321));
    }

    @Test
    public void testMatrixArray() {
        Matrix4f[] matrices = new Matrix4f[]{
            new Matrix4f()
        };

        material("Common/MatDefs/Light/Lighting.j3md");
        inputMpo(mpoMatrix4Array("BoneMatrices", matrices));
        outDefines();
        outUniforms(uniform("BoneMatrices", VarType.Matrix4Array, matrices));
    }

    @Test
    public void testNonExistentParameter() {
        material("Common/MatDefs/Light/Lighting.j3md");
        inputMpo(mpoInt("NonExistent", 4321));
        outDefines();
        outUniforms();
    }

    @Test
    public void testWrongType() {
        material("Common/MatDefs/Light/Lighting.j3md");
        inputMpo(mpoInt("UseMaterialColors", 4321));
        outDefines();
        outUniforms();
    }

    @Test
    public void testParamOnly() {
        material("Common/MatDefs/Light/Lighting.j3md");
        inputMpo(mpoFloat("ShadowMapSize", 3.12f));
        outDefines();
        outUniforms(uniform("ShadowMapSize", VarType.Float, 3.12f));
    }

    @Test
    public void testRemove() {
        material("Common/MatDefs/Light/Lighting.j3md");

        reset();
        inputMp(mpoInt("NumberOfBones", 1234));
        outDefines(def("NUM_BONES", VarType.Int, 1234));
        outUniforms(uniform("NumberOfBones", VarType.Int, 1234));

        reset();
        inputMpo(mpoInt("NumberOfBones", 4321));
        outDefines(def("NUM_BONES", VarType.Int, 4321));
        outUniforms(uniform("NumberOfBones", VarType.Int, 4321));

        reset();
        geometry.clearMatParamOverrides();
        root.updateGeometricState();
        outDefines(def("NUM_BONES", VarType.Int, 1234));
        outUniforms(uniform("NumberOfBones", VarType.Int, 1234));

        reset();
        geometry.getMaterial().clearParam("NumberOfBones");
        outDefines();
        outUniforms();

        reset();
        inputMpo(mpoInt("NumberOfBones", 4321));
        outDefines(def("NUM_BONES", VarType.Int, 4321));
        outUniforms(uniform("NumberOfBones", VarType.Int, 4321));

        reset();
        inputMp(mpoInt("NumberOfBones", 1234));
        outDefines(def("NUM_BONES", VarType.Int, 4321));
        outUniforms(uniform("NumberOfBones", VarType.Int, 4321));
    }

    public void testRemoveOverride() {
        material("Common/MatDefs/Light/Lighting.j3md");

        reset();
        inputMp(mpoInt("NumberOfBones", 1234));
        outDefines(def("NUM_BONES", VarType.Int, 1234));
        outUniforms(uniform("NumberOfBones", VarType.Int, 1234));

        reset();
        inputMpo(mpoInt("NumberOfBones", 4321));
        outDefines(def("NUM_BONES", VarType.Int, 4321));
        outUniforms(uniform("NumberOfBones", VarType.Int, 4321));

        reset();
        geometry.clearMatParamOverrides();
        outDefines(def("NUM_BONES", VarType.Int, 1234));
        outUniforms(uniform("NumberOfBones", VarType.Int, 1234));
    }

    @Test
    public void testRemoveMpoOnly() {
        material("Common/MatDefs/Light/Lighting.j3md");

        reset();
        inputMpo(mpoInt("NumberOfBones", 4321));
        outDefines(def("NUM_BONES", VarType.Int, 4321));
        outUniforms(uniform("NumberOfBones", VarType.Int, 4321));

        reset();
        geometry.clearMatParamOverrides();
        root.updateGeometricState();
        outDefines();
        outUniforms();
    }

    @Test
    public void testTextureMpoOnly() {
        material("Common/MatDefs/Light/Lighting.j3md");
        Texture2D tex = new Texture2D(128, 128, Format.RGBA8);

        inputMpo(mpoTexture2D("DiffuseMap", tex));
        outDefines(def("DIFFUSEMAP", VarType.Texture2D, tex));
        outUniforms(uniform("DiffuseMap", VarType.Int, 0));
        outTextures(tex);
    }

    @Test
    public void testTextureOverride() {
        material("Common/MatDefs/Light/Lighting.j3md");
        Texture2D tex1 = new Texture2D(128, 128, Format.RGBA8);
        Texture2D tex2 = new Texture2D(128, 128, Format.RGBA8);

        inputMp(mpoTexture2D("DiffuseMap", tex1));
        inputMpo(mpoTexture2D("DiffuseMap", tex2));

        outDefines(def("DIFFUSEMAP", VarType.Texture2D, tex2));
        outUniforms(uniform("DiffuseMap", VarType.Int, 0));
        outTextures(tex2);
    }

    @Test
    public void testRemoveTexture() {
        material("Common/MatDefs/Light/Lighting.j3md");
        Texture2D tex = new Texture2D(128, 128, Format.RGBA8);

        reset();
        inputMpo(mpoTexture2D("DiffuseMap", tex));
        outDefines(def("DIFFUSEMAP", VarType.Texture2D, tex));
        outUniforms(uniform("DiffuseMap", VarType.Int, 0));
        outTextures(tex);

        reset();
        geometry.clearMatParamOverrides();
        root.updateGeometricState();
        outDefines();
        outUniforms();
        outTextures();
    }

    @Test
    public void testRemoveTextureOverride() {
        material("Common/MatDefs/Light/Lighting.j3md");
        Texture2D tex1 = new Texture2D(128, 128, Format.RGBA8);
        Texture2D tex2 = new Texture2D(128, 128, Format.RGBA8);

        reset();
        inputMp(mpoTexture2D("DiffuseMap", tex1));
        outDefines(def("DIFFUSEMAP", VarType.Texture2D, tex1));
        outUniforms(uniform("DiffuseMap", VarType.Int, 0));
        outTextures(tex1);

        reset();
        inputMpo(mpoTexture2D("DiffuseMap", tex2));
        outDefines(def("DIFFUSEMAP", VarType.Texture2D, tex2));
        outUniforms(uniform("DiffuseMap", VarType.Int, 0));
        outTextures(tex2);

        reset();
        geometry.clearMatParamOverrides();
        root.updateGeometricState();
        outDefines(def("DIFFUSEMAP", VarType.Texture2D, tex1));
        outUniforms(uniform("DiffuseMap", VarType.Int, 0));
        outTextures(tex1);
    }

    private static final class Define {

        public String name;
        public VarType type;
        public Object value;

        @Override
        public int hashCode() {
            int hash = 3;
            hash = 89 * hash + this.name.hashCode();
            hash = 89 * hash + this.type.hashCode();
            hash = 89 * hash + this.value.hashCode();
            return hash;
        }

        @Override
        public boolean equals(Object obj) {
            final Define other = (Define) obj;
            return this.name.equals(other.name) && this.type.equals(other.type) && this.value.equals(other.value);
        }
    }

    private final Geometry geometry = new Geometry("Geometry", new Box(1, 1, 1));
    private final Node root = new Node("Root Node");
    private final LightList lightList = new LightList(geometry);

    @Before
    public void setUp() {
        root.attachChild(geometry);
    }

    private final NullRenderer renderer = new NullRenderer() {
        @Override
        public void setShader(Shader shader) {
            MaterialMatParamOverrideTest.this.usedShader = shader;
            evaluated = true;
        }

        @Override
        public void setTexture(int unit, Texture texture) {
            MaterialMatParamOverrideTest.this.usedTextures[unit] = texture;
        }
    };
    private final RenderManager renderManager = new RenderManager(renderer);

    private boolean evaluated = false;
    private Shader usedShader = null;
    private final Texture[] usedTextures = new Texture[32];

    private void inputMp(MatParam... params) {
        if (evaluated) {
            throw new IllegalStateException();
        }
        Material mat = geometry.getMaterial();
        for (MatParam param : params) {
            mat.setParam(param.getName(), param.getVarType(), param.getValue());
        }
    }

    private void inputMpo(MatParamOverride... overrides) {
        if (evaluated) {
            throw new IllegalStateException();
        }
        for (MatParamOverride override : overrides) {
            geometry.addMatParamOverride(override);
        }
        root.updateGeometricState();
    }

    private void inputParentMpo(MatParamOverride... overrides) {
        if (evaluated) {
            throw new IllegalStateException();
        }
        for (MatParamOverride override : overrides) {
            root.addMatParamOverride(override);
        }
        root.updateGeometricState();
    }

    private void inputFpo(MatParamOverride... overrides) {
        if (evaluated) {
            throw new IllegalStateException();
        }
        for (MatParamOverride override : overrides) {
            renderManager.addForcedMatParam(override);
        }
    }

    private void reset() {
        evaluated = false;
        usedShader = null;
        Arrays.fill(usedTextures, null);
        for (MatParamOverride override : new ArrayList<>(renderManager.getForcedMatParams())) {
            renderManager.removeForcedMatParam(override);
        }
    }

    private Define def(String name, VarType type, Object value) {
        Define d = new Define();
        d.name = name;
        d.type = type;
        d.value = value;
        return d;
    }

    private Uniform uniform(String name, VarType type, Object value) {
        Uniform u = new Uniform();
        u.setName("m_" + name);
        u.setValue(type, value);
        return u;
    }

    private void material(String path) {
        AssetManager assetManager = TestUtil.createAssetManager();
        geometry.setMaterial(new Material(assetManager, path));
    }

    private void evaluateTechniqueDef() {
        Assert.assertFalse(evaluated);
        Material mat = geometry.getMaterial();
        mat.render(geometry, lightList, renderManager);
        Assert.assertTrue(evaluated);
    }

    private void outTextures(Texture... textures) {
        for (int i = 0; i < usedTextures.length; i++) {
            if (i < textures.length) {
                Assert.assertSame(textures[i], usedTextures[i]);
            } else {
                Assert.assertNull(usedTextures[i]);
            }
        }
    }

    private void outDefines(Define... expectedDefinesArray) {
        Map<String, Define> nameToDefineMap = new HashMap<String, Define>();
        for (Define define : expectedDefinesArray) {
            nameToDefineMap.put(define.name, define);
        }

        if (!evaluated) {
            evaluateTechniqueDef();
        }

        Material mat = geometry.getMaterial();
        Technique tech = mat.getActiveTechnique();
        TechniqueDef def = tech.getDef();
        DefineList actualDefines = tech.getDynamicDefines();

        String[] defineNames = def.getDefineNames();
        VarType[] defineTypes = def.getDefineTypes();

        Assert.assertEquals(defineNames.length, defineTypes.length);

        for (int index = 0; index < defineNames.length; index++) {
            String name = defineNames[index];
            VarType type = defineTypes[index];
            Define expectedDefine = nameToDefineMap.remove(name);
            Object expectedValue = null;

            if (expectedDefine != null) {
                Assert.assertEquals(expectedDefine.type, type);
                expectedValue = expectedDefine.value;
            }

            switch (type) {
                case Boolean:
                    if (expectedValue != null) {
                        Assert.assertEquals((boolean) (Boolean) expectedValue, actualDefines.getBoolean(index));
                    } else {
                        Assert.assertEquals(false, actualDefines.getBoolean(index));
                    }
                    break;
                case Int:
                    if (expectedValue != null) {
                        Assert.assertEquals((int) (Integer) expectedValue, actualDefines.getInt(index));
                    } else {
                        Assert.assertEquals(0, actualDefines.getInt(index));
                    }
                    break;
                case Float:
                    if (expectedValue != null) {
                        Assert.assertEquals((float) (Float) expectedValue, actualDefines.getFloat(index), 0f);
                    } else {
                        Assert.assertEquals(0f, actualDefines.getFloat(index), 0f);
                    }
                    break;
                default:
                    if (expectedValue != null) {
                        Assert.assertEquals(1, actualDefines.getInt(index));
                    } else {
                        Assert.assertEquals(0, actualDefines.getInt(index));
                    }
                    break;
            }
        }

        Assert.assertTrue(nameToDefineMap.isEmpty());
    }

    private void outUniforms(Uniform... uniforms) {
        if (!evaluated) {
            evaluateTechniqueDef();
        }

        HashSet<Uniform> actualUniforms = new HashSet<>();

        for (Uniform uniform : usedShader.getUniformMap().values()) {
            if (uniform.getName().startsWith("m_")
                    && !IGNORED_UNIFORMS.contains(uniform.getName())) {
                actualUniforms.add(uniform);
            }
        }

        HashSet<Uniform> expectedUniforms = new HashSet<>(Arrays.asList(uniforms));

        if (!expectedUniforms.equals(actualUniforms)) {
            Assert.fail("Uniform lists must match: " + expectedUniforms + " != " + actualUniforms);
        }
    }
}
