package com.srbenoit.math.grapher;

import java.awt.Color;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;

/**
 * A container for a generated image of a graph.
 */
public class GraphImage {

    /** the width of the graph image */
    private final int width;

    /** the height of the graph image */
    private final int height;

    /** the offscreen image to which to render */
    private final transient BufferedImage image;

    /** the functions to be graphed */
    private transient Graphable[] toGraph;

    /**
     * Create a <code>GraphImage</code> to render a function's graph.
     *
     * @param  theWidth   the width of the image
     * @param  theHeight  the height of the image
     */
    public GraphImage(final int theWidth, final int theHeight) {

        super();

        this.width = theWidth;
        this.height = theHeight;
        this.image = new BufferedImage(theWidth, theHeight, BufferedImage.TYPE_INT_RGB);
    }

    /**
     * Gets the image in which the graph will be rendered.
     *
     * @return  the image
     */
    public BufferedImage getImage() {

        return this.image;
    }

    /**
     * Draws the graph, including axes, grid lines, etc.
     *
     * @param  graphables  the <code>Graphable</code> objects to draw
     */
    public void graph(final Graphable... graphables) {

        double[] domain;
        double[] range;
        Graphable fxn;
        Graphics2D g2d;

        this.toGraph = graphables.clone();

        g2d = (Graphics2D) this.image.getGraphics();
        g2d.setColor(Color.WHITE);
        g2d.fillRect(0, 0, this.width, this.height);

        // Compute the overall domain and range
        domain = new double[] { Double.MAX_VALUE, -Double.MAX_VALUE };
        range = new double[] { Double.MAX_VALUE, -Double.MAX_VALUE };

        for (int inx = 0; inx < this.toGraph.length; inx++) {
            fxn = this.toGraph[inx];

            if (fxn.defaultDomain()[0] < domain[0]) {
                domain[0] = fxn.defaultDomain()[0];
            }

            if (fxn.defaultDomain()[1] > domain[1]) {
                domain[1] = fxn.defaultDomain()[1];
            }

            if (fxn.defaultRange()[0] < range[0]) {
                range[0] = fxn.defaultRange()[0];
            }

            if (fxn.defaultRange()[1] > range[1]) {
                range[1] = fxn.defaultRange()[1];
            }
        }

        drawAxesAndGrid(g2d, domain, range);
        drawFunctions(g2d, domain, range);
    }

    /**
     * Draws a set of vertical and horizontal axes and grid lines.
     *
     * @param  grx     the <code>Graphics</code> to which to draw
     * @param  domain  the lower and upper limit for the domain (x axis)
     * @param  range   the lower and upper limit for the range (y axis)
     */
    private void drawAxesAndGrid(final Graphics2D grx, final double[] domain,
        final double[] range) {

        double[] xGrid;
        double[] yGrid;
        double current;
        double delta;
        double pct;
        int pixel;
        String lbl;
        Font origFont;
        Font font;
        FontMetrics metrics;
        int strWidth;
        int xLabelY;
        int yLabelX;
        boolean yLabelRight = false;
        boolean xLabelUp = false;
        Color gridColor;

        gridColor = new Color(220, 220, 255);
        xGrid = new double[2];
        yGrid = new double[2];

        // Build the font for axis labels and get its metrics
        font = new Font("Dialog", Font.PLAIN, 11);
        origFont = grx.getFont();
        grx.setFont(font);
        metrics = grx.getFontMetrics();
        strWidth = metrics.stringWidth("0.000001");

        // Compute grid sizes in the x and y directions
        computeGrid(domain, xGrid);
        computeGrid(range, yGrid);

        // Draw the vertical grid lines and the Y axis, and as we go, figure
        // out the X position for our labels of the Y axis
        yLabelX = -1;
        delta = domain[1] - domain[0];
        current = xGrid[0];

        while (current < domain[1]) {
            pct = (current - domain[0]) / delta;
            pixel = (int) ((this.width * pct) + 0.5);

            if (Math.abs(current / xGrid[1]) < 0.00001) {

                if ((pixel + 2) < (this.width - strWidth)) {
                    yLabelX = pixel;
                } else {
                    yLabelX = pixel;
                    yLabelRight = true;
                }

                grx.setColor(Color.BLACK);
            } else {
                grx.setColor(gridColor);
            }

            grx.drawLine(pixel, 0, pixel, this.height);
            current += xGrid[1];
        }

        if (yLabelX == -1) {

            if (domain[1] > 0) {
                yLabelX = 2;
            } else {
                yLabelX = this.width - 2;
                yLabelRight = false;
            }
        }

        // Draw the horizontal grid lines and the X axis, and as we go, figure
        // out the Y position for our labels of the X axis
        xLabelY = -1;
        delta = range[1] - range[0];
        current = yGrid[0];

        while (current < range[1]) {
            pct = (current - range[0]) / delta;
            pixel = this.height - (int) ((this.height * pct) + 0.5);

            if (Math.abs(current / yGrid[1]) < ((yGrid[1] - yGrid[0]) * 0.00001)) {

                if ((pixel + 2) < (this.width - strWidth)) {
                    xLabelY = pixel;
                } else {
                    xLabelY = pixel;
                    xLabelUp = true;
                }

                grx.setColor(Color.BLACK);
            } else {
                grx.setColor(gridColor);
            }

            grx.drawLine(0, pixel, this.width, pixel);
            current += yGrid[1];
        }

        if (xLabelY == -1) {

            if (domain[1] > 0) {
                xLabelY = 2;
            } else {
                xLabelY = this.height - 2;
                xLabelUp = true;
            }
        }

        grx.setColor(Color.BLACK);

        // Draw the labels for the X axis
        delta = domain[1] - domain[0];
        current = xGrid[0];

        if (Math.abs(current) < (xGrid[1] / 1000)) {
            current = 0;
        }

        while (current < domain[1]) {
            pct = (current - domain[0]) / delta;
            pixel = (int) ((this.width * pct) + 0.5);

            if (pixel != yLabelX) {
                lbl = Float.toString((float) current);

                strWidth = metrics.stringWidth(lbl);

                if (xLabelUp) {
                    grx.drawString(lbl, pixel - (strWidth / 2), xLabelY - 1);
                } else {
                    grx.drawString(lbl, pixel - (strWidth / 2), xLabelY + metrics.getAscent() + 1);
                }
            }

            current += xGrid[1];
        }

        // Draw the labels for the Y axis
        delta = range[1] - range[0];
        current = yGrid[0];
        current = xGrid[0];

        if (Math.abs(current) < (yGrid[1] / 1000)) {
            current = 0;
        }

        while (current < range[1]) {
            pct = (current - range[0]) / delta;
            pixel = this.height - (int) ((this.height * pct) + 0.5);

            lbl = Float.toString((float) current);

            strWidth = metrics.stringWidth(lbl);

            if (yLabelRight) {
                grx.drawString(lbl, yLabelX - strWidth - 4, pixel + metrics.getAscent() + 1);
            } else {
                grx.drawString(lbl, yLabelX + 4, pixel + metrics.getAscent() + 1);
            }

            current += yGrid[1];
        }

        grx.setFont(origFont);
    }

    /**
     * Computes the settings for a grid.
     *
     * @param  limits  the lower and upper limits of the graph along the axis of interest (may be
     *                 adjusted by this method)
     * @param  grid    an array of 2 doubles which will contain the coordinate of the first grid
     *                 line (in [0]) and the step size (in [1]) of the grid
     */
    private void computeGrid(final double[] limits, final double[] grid) {

        double extents;
        double exp;
        double scale;
        double scaledExtents;
        double scaledStep;
        double scaledLower;
        int sign;

        // See what extent of the real line the limits encompass
        extents = limits[1] - limits[0];

        // Get the order of magnitude of the extent, then compute 10 raised to
        // that power. For example, if the extent is 3.5, the scale will be
        // 1. Dividing the extents by the scale will produce a scaled extents
        // value between 1 and 10.

        exp = Math.log10(extents);

        if (exp >= 0) {
            scale = Math.pow(10, (int) exp);
        } else {
            scale = Math.pow(10, (int) (exp - 1));
        }

        scaledExtents = extents / scale;
        scaledLower = limits[0] / scale;

        // System.out.println("Extents = " + (float)extents + ", scale = "
        // + scale + ", Scaled extents = " + (float)scaledExtents
        // + ", scaled lower = " + scaledLower);

        // TODO: Refine the choices below based on number of pixels

        sign = (scaledLower >= 0) ? 1 : 0;

        if (scaledExtents >= 6) {
            scaledStep = 1.0;
            grid[0] = (int) (scaledLower + (sign * 0.99));
        } else if (scaledExtents >= 3) {
            scaledStep = 0.5;
            grid[0] = (int) ((scaledLower + (sign * 0.499)) * 2);
        } else if (scaledExtents >= 2) {
            scaledStep = 0.25;
            grid[0] = (int) ((scaledLower + (sign * 0.249)) * 4);
        } else if (scaledExtents >= 1.4) {
            scaledStep = 0.2;
            grid[0] = (int) ((scaledLower + (sign * 0.199)) * 5);
        } else {
            scaledStep = 0.1;
            grid[0] = (int) ((scaledLower + (sign * 0.099)) * 10);
        }

        grid[1] = scaledStep * scale;

        if (Math.abs(grid[0] - limits[0]) < (grid[1] / 10)) {
            limits[0] -= grid[1] / 10;
        }
    }

    /**
     * Draws a set of vertical and horizontal axes and grid lines.
     *
     * @param  grx     the <code>Graphics</code> to which to draw
     * @param  domain  the lower and upper limit for the domain (x axis)
     * @param  range   the lower and upper limit for the range (y axis)
     */
    private void drawFunctions(final Graphics2D grx, final double[] domain, final double[] range) {

        double[] pos;
        Graphable fxn;
        double value;
        double prior;
        double frac;
        int yPrior;
        int yCurrent;
        int col;

        pos = new double[] { domain[0] };

        for (int inx = 0; inx < this.toGraph.length; inx++) {
            fxn = this.toGraph[inx];
            col = 255 * inx / this.toGraph.length;

            prior = fxn.valueAt(pos);
            yPrior = (int) ((prior - range[0]) / (range[1] - range[0]) * this.height);

            grx.setColor(new Color(col, 0, 255 - col)); // NOPMD SRB

            for (int x = 1; x < this.width; x++) {
                frac = (double) x / this.width;
                pos[0] = domain[0] + (frac * (domain[1] - domain[0]));
                value = fxn.valueAt(pos);

                if (!Double.isNaN(value)) {
                    yCurrent = (int) ((value - range[0]) / (range[1] - range[0]) * this.height);

                    if (!Double.isNaN(prior)) {
                        grx.drawLine(x - 1, this.height - yPrior, x, this.height - yCurrent);
                    }

                    yPrior = yCurrent;
                    prior = value;
                }
            }
        }
    }
}
