package com.brackeen.javagamebook.graphics3D.texture;

import java.lang.ref.SoftReference;
import java.util.List;
import com.brackeen.javagamebook.math3D.*;

/**
    Klasa ShadedSurface jest zawczasu cieniowaną teksturą Texture, która
    jest następnie mapowana na wielokąt.
*/
public final class ShadedSurface extends Texture {

    public static final int SURFACE_BORDER_SIZE = 1;

    public static final int SHADE_RES_BITS = 4;
    public static final int SHADE_RES = 1 << SHADE_RES_BITS;
    public static final int SHADE_RES_MASK = SHADE_RES - 1;
    public static final int SHADE_RES_SQ = SHADE_RES*SHADE_RES;
    public static final int SHADE_RES_SQ_BITS = SHADE_RES_BITS*2;

    private short[] buffer;
    private SoftReference bufferReference;
    private boolean dirty;
    private ShadedTexture sourceTexture;
    private Rectangle3D sourceTextureBounds;
    private Rectangle3D surfaceBounds;
    private byte[] shadeMap;
    private int shadeMapWidth;
    private int shadeMapHeight;

    // dla przyrostowego wyliczania wartości cieniowania
    private int shadeValue;
    private int shadeValueInc;

    /**
        Tworzy powierzchnię ShadedSurface o określonej szerokości
        i wysokości.
    */
    public ShadedSurface(int width, int height) {
        this(null, width, height);
    }


    /**
        Tworzy powierzchnię ShadedSurface z podanym buforem,
        i o określonej szerokości i wysokości.
    */
    public ShadedSurface(short[] buffer, int width, int height) {
        super(width, height);
        this.buffer = buffer;
        bufferReference = new SoftReference(buffer);
        sourceTextureBounds = new Rectangle3D();
        dirty = true;
    }

    /**
        Tworzy powierzchnię ShadedSurface dla danego wielokąta. 
        Mapa cieniowana tworzona jest dla określonej listy punktowych
        źródeł światła o ustalonej intensywności.
    */
    public static void createShadedSurface(
        TexturedPolygon3D poly, ShadedTexture texture,
        List lights, float ambientLightIntensity)
    {
        // create the texture bounds
        Vector3D origin = poly.getVertex(0);
        Vector3D dv = new Vector3D(poly.getVertex(1));
        dv.subtract(origin);
        Vector3D du = new Vector3D();
        du.setToCrossProduct(poly.getNormal(), dv);
        Rectangle3D bounds = new Rectangle3D(origin, du, dv,
            texture.getWidth(), texture.getHeight());

        createShadedSurface(poly, texture, bounds,
            lights, ambientLightIntensity);
    }

    /**
        Tworzy powierzchnię ShadedSurface dla danego wielokąta. 
        Mapa cieniowana tworzona jest dla określonej listy punktowych
        źródeł światła o ustalonej intensywności.
    */
    public static void createShadedSurface(
        TexturedPolygon3D poly, ShadedTexture texture,
        Rectangle3D textureBounds,
        List lights, float ambientLightIntensity)
    {

        // tworzy granice powierzchni
        poly.setTexture(texture, textureBounds);
        Rectangle3D surfaceBounds = poly.calcBoundingRectangle();

        // dodaje granicom surfaceBounds dodatkową zewnętrzną granicę,
        // by skorygować drobne błędy powstałe w trakcie mapowania tekstury
        Vector3D du = new Vector3D(surfaceBounds.getDirectionU());
        Vector3D dv = new Vector3D(surfaceBounds.getDirectionV());
        du.multiply(SURFACE_BORDER_SIZE);
        dv.multiply(SURFACE_BORDER_SIZE);
        surfaceBounds.getOrigin().subtract(du);
        surfaceBounds.getOrigin().subtract(dv);
        int width = (int)Math.ceil(surfaceBounds.getWidth() +
            SURFACE_BORDER_SIZE*2);
        int height = (int)Math.ceil(surfaceBounds.getHeight() +
            SURFACE_BORDER_SIZE*2);
        surfaceBounds.setWidth(width);
        surfaceBounds.setHeight(height);

        // tworzy teksturę cieniowanej powierzchni
        ShadedSurface surface = new ShadedSurface(width, height);
        surface.setTexture(texture, textureBounds);
        surface.setSurfaceBounds(surfaceBounds);

        // tworzy mapę cieniowania dla powierzchni
        surface.buildShadeMap(lights, ambientLightIntensity);

        // definiuje powierzchnię wielokąta
        poly.setTexture(surface, surfaceBounds);
    }

    /**
        Pobiera 16-bitowy kolor piksela w lokacji (x,y) na mapie
        bitowej. Przyjmuje się, że wartości x i y znajdują się
        w obrębie granic przestrzeni; w przeciwnym razie pojawi się
        wyjątek ArrayIndexOutOfBoundsException.
    */
    public short getColor(int x, int y) {
        //try {
            return buffer[x + y * width];
        //}
        //catch (ArrayIndexOutOfBoundsException ex) {
        //    return -2048;
        //}

    }

    /**
        Pobiera 16-bitowy kolor piksela w lokacji (x,y) na mapie
        bitowej. Sprawdzamy, czy wartości x i y są w obrębie
        granic powierzchni, jeśli nie, zwracany jest piksel
        krawędzi tekstury.
    */
    public short getColorChecked(int x, int y) {
        if (x < 0) {
            x = 0;
        }
        else if (x >= width) {
            x = width-1;
        }
        if (y < 0) {
            y = 0;
        }
        else if (y >= height) {
            y = height-1;
        }
        return getColor(x,y);
    }


    /**
        Zaznacza, czy ta powierzchnia jest „brudna” (dirty). Może być  
        konieczne oczyszczanie takich powierzchni poza naszym mechanizmem.
    */
    public void setDirty(boolean dirty) {
        this.dirty = dirty;
    }


    /**
        Sprawdza, czy powierzchnia jest „brudna” (dirty). Może być  
        konieczne oczyszczanie takich powierzchni poza naszym mechanizmem.
    */
    public boolean isDirty() {
        return dirty;
    }

    /**
        Tworzy nową powierzchnię i miękkie odwołanie SoftReference do niej.
    */
    protected void newSurface(int width, int height) {
        buffer = new short[width*height];
        bufferReference = new SoftReference(buffer);
    }

    /**
        Oczyszcza tę powierzchnię pozwalając mechanizmowi usuwania 
        śmieci usunąć ją z pamięci, jeśli trzeba.
    */
    public void clearSurface() {
        buffer = null;
    }

    /**
        Sprawdza, czy powierzchnia została oczyszczona.
    */
    public boolean isCleared() {
        return (buffer == null);
    }

    /**
        Jeśli wcześniej zbudowany bufor został oczyszczony, ale
        został jeszcze usunięty z pamięci przez mechanizm oczyszczania 
        pamięci, to metoda ta spróbuje go odzyskać. Jeśli się to uda
        metoda zwraca wartość true.
    */
    public boolean retrieveSurface() {
        if (buffer == null) {
            buffer = (short[])bufferReference.get();
        }
        return !(buffer == null);
    }

    /**
        Sets the source texture for this ShadedSurface.
    */
    public void setTexture(ShadedTexture texture) {
        this.sourceTexture = texture;
        sourceTextureBounds.setWidth(texture.getWidth());
        sourceTextureBounds.setHeight(texture.getHeight());
    }

    /**
        Ustawia źródłową teksturę i jej granice dla tej
        powierzchni ShadedSurface.
    */
    public void setTexture(ShadedTexture texture,
        Rectangle3D bounds)
    {
        setTexture(texture);
        sourceTextureBounds.setTo(bounds);
    }


    /**
        Ustawia granice powierzchni ShadedSurface.
    */
    public void setSurfaceBounds(Rectangle3D surfaceBounds) {
        this.surfaceBounds = surfaceBounds;
    }


    /**
        Pobiera granice dla tej powierzchni ShadedSurface.
    */
    public Rectangle3D getSurfaceBounds() {
        return surfaceBounds;
    }

    /**
        Buduje powierzchnię. Najpierw metoda ta przywołuje
        retrieveSurface(), by sprawdzić, czy powierzchnia powinna
        być przebudowywana. Jeśli nie, powierzchnia jest budowana 
        poprzez ułożenie tekstury i nałożenie już gotowej mapy cieniowania.
    */
    public void buildSurface() {

        if (retrieveSurface()) {
            return;
        }

        int width = (int)surfaceBounds.getWidth();
        int height = (int)surfaceBounds.getHeight();

        // utwórz nową powierzchnię (bufor)
        newSurface(width, height);

        // buduje powierzchnię
        // zakładając, że granice powierzchni i granice tekstury są
        // zorientowane tak samo (choć mogą się zaczynać w różnych punktach)
        Vector3D origin = sourceTextureBounds.getOrigin();
        Vector3D directionU = sourceTextureBounds.getDirectionU();
        Vector3D directionV = sourceTextureBounds.getDirectionV();

        Vector3D d = new Vector3D(surfaceBounds.getOrigin());
        d.subtract(origin);
        int startU = (int)((d.getDotProduct(directionU) -
            SURFACE_BORDER_SIZE));
        int startV = (int)((d.getDotProduct(directionV) -
            SURFACE_BORDER_SIZE));
        int offset = 0;
        int shadeMapOffsetU = SHADE_RES - SURFACE_BORDER_SIZE -
            startU;
        int shadeMapOffsetV = SHADE_RES - SURFACE_BORDER_SIZE -
            startV;

        for (int v=startV; v<startV + height; v++) {
            sourceTexture.setCurrRow(v);
            int u = startU;
            int amount = SURFACE_BORDER_SIZE;
            while (u < startU + width) {
                getInterpolatedShade(u + shadeMapOffsetU,
                    v + shadeMapOffsetV);

                // rysuj, dopóki nie zajdzie konieczność ponownego wyliczenia
                // interpolowanego cienia. (każdego piksela SHADE_RES)
                int endU = Math.min(startU + width, u + amount);
                while (u < endU) {
                    buffer[offset++] =
                        sourceTexture.getColorCurrRow(u,
                             shadeValue >> SHADE_RES_SQ_BITS);
                    shadeValue+=shadeValueInc;
                    u++;
                }
                amount = SHADE_RES;
            }
        } 

        // jeśli granice powierzchni nie są zorientowane według granic
        // tekstury, użyj tego (wolniejszego) kodu.
        /*Vector3D origin = sourceTextureBounds.getOrigin();
        Vector3D directionU = sourceTextureBounds.getDirectionU();
        Vector3D directionV = sourceTextureBounds.getDirectionV();

        Vector3D d = new Vector3D(surfaceBounds.getOrigin());
        d.subtract(origin);
        int initTextureU = (int)(SCALE *
            (d.getDotProduct(directionU) - SURFACE_BORDER_SIZE));
        int initTextureV = (int)(SCALE *
            (d.getDotProduct(directionV) - SURFACE_BORDER_SIZE));
        int textureDu1 = (int)(SCALE * directionU.getDotProduct(
            surfaceBounds.getDirectionV()));
        int textureDv1 = (int)(SCALE * directionV.getDotProduct(
            surfaceBounds.getDirectionV()));
        int textureDu2 = (int)(SCALE * directionU.getDotProduct(
            surfaceBounds.getDirectionU()));
        int textureDv2 = (int)(SCALE * directionV.getDotProduct(
            surfaceBounds.getDirectionU()));

        int shadeMapOffset = SHADE_RES - SURFACE_BORDER_SIZE;

        for (int v=0; v<height; v++) {
            int textureU = initTextureU;
            int textureV = initTextureV;

            for (int u=0; u<width; u++) {
                if (((u + shadeMapOffset) & SHADE_RES_MASK) == 0) {
                    getInterpolatedShade(u + shadeMapOffset,
                        v + shadeMapOffset);
                }
                buffer[offset++] = sourceTexture.getColor(
                        textureU >> SCALE_BITS,
                        textureV >> SCALE_BITS,
                        shadeValue >> SHADE_RES_SQ_BITS);
                    textureU+=textureDu2;
                    textureV+=textureDv2;
                    shadeValue+=shadeValueInc;

            }
            initTextureU+=textureDu1;
            initTextureV+=textureDv1;
        }*/
    }

    /**
        Pobiera cieniowanie (z mapy cieniowania) dla wybranej 
        lokacji (u,v). Bity wartości u i v powinny zostać przesunięte
        w lewo SHADE_RES_BITS, a dodatkowe bity są wykorzystywane
        do wyliczania interpolowanych wartości. Przykład interpolacji:
        lokacja w połowie drogi między wartościami 1 i 3 cieniowania
        będzie miała wartość 2.
    */
    public int getInterpolatedShade(int u, int v) {

        int fracU = u & SHADE_RES_MASK;
        int fracV = v & SHADE_RES_MASK;

        int offset = (u >> SHADE_RES_BITS) +
            ((v >> SHADE_RES_BITS) * shadeMapWidth);

        int shade00 = (SHADE_RES-fracV) * shadeMap[offset];
        int shade01 = fracV * shadeMap[offset + shadeMapWidth];
        int shade10 = (SHADE_RES-fracV) * shadeMap[offset + 1];
        int shade11 = fracV * shadeMap[offset + shadeMapWidth + 1];

        shadeValue = SHADE_RES_SQ/2 +
            (SHADE_RES-fracU) * shade00 +
            (SHADE_RES-fracU) * shade01 +
            fracU * shade10 +
            fracU * shade11;

        // wartość zwiększana wraz ze zwiększaniem u
        shadeValueInc = -shade00 - shade01 + shade10 + shade11;

        return shadeValue >> SHADE_RES_SQ_BITS;
    }



    /**
        Pobiera cieniowanie (ze zbudowanej mapy cieniowania) dla 
        podanej lokacji (u,v).
    */
    public int getShade(int u, int v) {
        return shadeMap[u + v * shadeMapWidth];
    }

    /**
        Tworzy mapę cieniowania dla tej powierzchni, uwzględniając
        listę punktowych źródeł światła oraz intensywność światła otoczenia.
    */
    public void buildShadeMap(List pointLights,
        float ambientLightIntensity)
    {

        Vector3D surfaceNormal = surfaceBounds.getNormal();

        int polyWidth = (int)surfaceBounds.getWidth() -
            SURFACE_BORDER_SIZE*2;
        int polyHeight = (int)surfaceBounds.getHeight() -
            SURFACE_BORDER_SIZE*2;
        // zakładamy, że SURFACE_BORDER_SIZE <= SHADE_RES
        shadeMapWidth = polyWidth / SHADE_RES + 4;
        shadeMapHeight = polyHeight / SHADE_RES + 4;
        shadeMap = new byte[shadeMapWidth * shadeMapHeight];

        // wylicz początek układu współrzędnych dla mapy cieniowania
        Vector3D origin = new Vector3D(surfaceBounds.getOrigin());
        Vector3D du = new Vector3D(surfaceBounds.getDirectionU());
        Vector3D dv = new Vector3D(surfaceBounds.getDirectionV());
        du.multiply(SHADE_RES - SURFACE_BORDER_SIZE);
        dv.multiply(SHADE_RES - SURFACE_BORDER_SIZE);
        origin.subtract(du);
        origin.subtract(dv);

        // wylicz cieniowanie dla każdego przykładowego punktu.
        Vector3D point = new Vector3D();
        du.setTo(surfaceBounds.getDirectionU());
        dv.setTo(surfaceBounds.getDirectionV());
        du.multiply(SHADE_RES);
        dv.multiply(SHADE_RES);
        for (int v=0; v<shadeMapHeight; v++) {
            point.setTo(origin);
            for (int u=0; u<shadeMapWidth; u++) {
                shadeMap[u + v * shadeMapWidth] =
                    calcShade(surfaceNormal, point,
                    pointLights, ambientLightIntensity);
                point.add(du);
            }
            origin.add(dv);
        }
    }


    /**
        Ustala cieniowanie wybranego punktu na powierzchni wielokąta.
        Wylicza oświetlenie według wzoru Lamberta dla każdego punktu
        na płaszczyźnie. Każde punktowe źródło światła ma określoną
        intensywność i odległość całkowitego wygaszenia, niemniej nie są
        tutaj wyliczane cienie lub odbicia innych wielokątów. Zwraca wartość 
        z przedziału od 0 do ShadedTexture.MAX_LEVEL.
    */
    protected byte calcShade(Vector3D normal, Vector3D point,
        List pointLights, float ambientLightIntensity)
    {
        float intensity = 0;
        Vector3D directionToLight = new Vector3D();

        for (int i=0; i<pointLights.size(); i++) {
            PointLight3D light = (PointLight3D)pointLights.get(i);
            directionToLight.setTo(light);
            directionToLight.subtract(point);

            float distance = directionToLight.length();
            directionToLight.normalize();
            float lightIntensity = light.getIntensity(distance)
                * directionToLight.getDotProduct(normal);
            lightIntensity = Math.min(lightIntensity, 1);
            lightIntensity = Math.max(lightIntensity, 0);
            intensity += lightIntensity;
        }

        intensity = Math.min(intensity, 1);
        intensity = Math.max(intensity, 0);

        intensity+=ambientLightIntensity;

        intensity = Math.min(intensity, 1);
        intensity = Math.max(intensity, 0);
        int level = Math.round(intensity*ShadedTexture.MAX_LEVEL);
        return (byte)level;
    }
}