/**
 * ThisTime 2.0 (2008-03-30)
 * Copyright 2007 Zach Scrivena
 * zachscrivena@gmail.com
 * http://thistime.sourceforge.net/
 *
 * Simple clock and timer program.
 *
 * TERMS AND CONDITIONS:
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package thistime;

import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Image;
import java.awt.Rectangle;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.MouseWheelEvent;
import java.awt.image.BufferedImage;
import javax.imageio.ImageIO;
import javax.swing.JComponent;


/**
 * Timer display.
 */
class TimerDisplay
        extends JComponent
{
    /** unscaled images for digits, colon, and negative-sign (first 10 elements are digits 0-9) */
    private static final BufferedImage[] UNSCALED_IMAGES = new BufferedImage[12];

    /** array index of the colon image in UNSCALED_IMAGES[] */
    private static final int COLON_INDEX = 10;

    /** array index of the negative-sign image in UNSCALED_IMAGES[] */
    private static final int NEGATIVE_SIGN_INDEX = 11;

    /** width of an unscaled digit image */
    private static int UNSCALED_DIGIT_WIDTH;

    /** height of an unscaled digit image */
    private static int UNSCALED_DIGIT_HEIGHT;

    /** width of the unscaled colon image */
    private static int UNSCALED_COLON_WIDTH;

    /** scaling ratio for a small digit (should be less than 1) */
    private static final double SMALL_DIGIT_RATIO = 0.6;

    /** horizontal and vertical padding between digits */
    private static final int PADDING = 20;

    /** "set" mode box color */
    private static final Color SET_MODE_BOX_COLOR = new Color(255, 0, 0);

    /** scaled images for digits, colon, and negative-sign (first 10 elements are digits 0-9) */
    private final Image[] scaledImages = new Image[12];

    /** scaled images for small digits */
    private final Image[] smallScaledImages = new Image[10];

    /** hash code for the unscaled total width and height corresponding to scaledImages[] and smallScaledImages[] */
    private int unscaledSizeHashCode = -1;

    /** hash code for the scaled total width and height corresponding to scaledImages[] and smallScaledImages[] */
    private int scaledSizeHashCode = -1;

    /** is the timer display in "set" mode? */
    private volatile boolean setMode;

    /** "normalized" timer value to be displayed */
    private final TimerValue value;


    /**
    * Static initialization block for loading image resources.
    * The following assumptions are made:
    * 1. All digit images and the negative sign image have the same dimensions.
    * 2. Digits and the colon images have the same height.
    */
    static
    {
        try
        {
            for (int i = 0; i <= 9; i++)
            {
                UNSCALED_IMAGES[i] = ImageIO.read(TimerDisplay.class.getResource("/thistime/resources/digit" + i + ".png"));
            }

            UNSCALED_IMAGES[COLON_INDEX] = ImageIO.read(TimerDisplay.class.getResource("/thistime/resources/colon.png"));
            UNSCALED_IMAGES[NEGATIVE_SIGN_INDEX] = ImageIO.read(TimerDisplay.class.getResource("/thistime/resources/neg.png"));

            UNSCALED_DIGIT_WIDTH = UNSCALED_IMAGES[0].getWidth();
            UNSCALED_DIGIT_HEIGHT = UNSCALED_IMAGES[0].getHeight();
            UNSCALED_COLON_WIDTH = UNSCALED_IMAGES[COLON_INDEX].getWidth();
        }
        catch (Exception e)
        {
            throw new RuntimeException(e.getMessage(), e.getCause());
        }
    }


    /**
    * Constructor.
    *
    * @param value
    *      "normalized" timer value to be displayed
    */
    TimerDisplay(
            final TimerValue value)
    {
        this.value = value;

        /* add mouse listener for "set" mode */
        this.addMouseListener(new MouseAdapter()
        {
            @Override
            public void mouseClicked(
                    MouseEvent e)
            {
                if (TimerDisplay.this.setMode)
                {
                    synchronized (TimerDisplay.this.value)
                    {
                        doAction(Action.MOUSE_CLICKED, e);
                    }
                }
            }
        });

        /* add mouse wheel listener for "set" mode */
        this.addMouseWheelListener(new MouseAdapter()
        {
            @Override
            public void mouseWheelMoved(
                    MouseWheelEvent e)
            {
                if (TimerDisplay.this.setMode)
                {
                    synchronized (TimerDisplay.this.value)
                    {
                        doAction(Action.MOUSE_WHEEL_MOVED, e);
                    }
                }
            }
        });
    }


    /**
    * Set the "set" mode of this timer display.
    *
    * @param setMode
    *      true if timer display is to be in "set" mode; false otherwise
    */
    void setSetMode(
            final boolean setMode)
    {
        this.setMode = setMode;
    }


    /**
    * Paint the timer display. This method is invoked by Swing to draw components.
    *
    * @param g
    *      the Graphics context in which to paint
    */
    @Override
    public void paint(
            Graphics g)
    {
        synchronized (value)
        {
            doAction(Action.PAINT, g);
        }
    }


    /**
    * Perform the specified action on the display.
    * Assumes that this.value has been externally synchronized.
    * This method must run on the EDT.
    *
    * @param action
    *      action to be performed
    * @param arg
    *      argument value for the action
    */
    private void doAction(
            final Action action,
            final Object arg)
    {
        /************************************************
        * (1) PARSE AGRUMENTS FOR THE SPECIFIED ACTION *
        ************************************************/

        Graphics g = null;
        int mouseX = 0;
        int mouseY = 0;
        int mouseScroll = 0;

        switch (action)
        {
            case PAINT:
                g = (Graphics) arg;
                break;

            case MOUSE_CLICKED:
                final MouseEvent me = (MouseEvent) arg;
                mouseX = me.getX();
                mouseY = me.getY();
                break;

            case MOUSE_WHEEL_MOVED:
                final MouseWheelEvent mwe = (MouseWheelEvent) arg;
                mouseX = mwe.getX();
                mouseY = mwe.getY();
                mouseScroll = (mwe.getWheelRotation() < 0) ? 1 : -1;
                break;
        }

        /******************************************************
        * (2) COMPUTE DIMENSIONS OF TIMER DISPLAY COMPONENTS *
        ******************************************************/

        /* length of the hour-value */
        final int hourValueLength = String.valueOf(value.h).length();

        /* width and height of the canvas */
        final Rectangle bounds = getBounds();
        final int canvasWidth = (int) bounds.getWidth();
        final int canvasHeight = (int) bounds.getHeight();

        /* total width and height of timer display, in unscaled units */
        final int unscaledTotalWidth = UNSCALED_COLON_WIDTH +
                (int) ((SMALL_DIGIT_RATIO * 2 + hourValueLength + 2 + (value.negative ? 1 : 0)) * UNSCALED_DIGIT_WIDTH) +
                (6 + hourValueLength + (value.negative ? 1 : 0)) * PADDING;

        final int unscaledTotalHeight = UNSCALED_DIGIT_HEIGHT + 2 * PADDING;

        /* compute scaled widths and heights */
        final Dimension scaledDimension = GraphicsManipulator.scaleToFitKeepRatio(
                unscaledTotalWidth, unscaledTotalHeight,
                canvasWidth, canvasHeight);

        final int scaledTotalWidth = (int) scaledDimension.getWidth();
        final int scaledTotalHeight = (int) scaledDimension.getHeight();

        final int digitWidth = scaledTotalWidth * UNSCALED_DIGIT_WIDTH / unscaledTotalWidth;
        final int digitHeight = scaledTotalHeight * UNSCALED_DIGIT_HEIGHT / unscaledTotalHeight;
        final int colonWidth = scaledTotalWidth * UNSCALED_COLON_WIDTH / unscaledTotalWidth;
        final int smallDigitWidth = (int) (SMALL_DIGIT_RATIO * digitWidth);
        final int smallDigitHeight = (int) (SMALL_DIGIT_RATIO * digitHeight);
        final int padding = scaledTotalWidth * PADDING / unscaledTotalWidth;

        /* initial horizontal and vertical offsets */
        int offsetX = padding + (canvasWidth - scaledTotalWidth) / 2;
        int offsetY = padding + (canvasHeight - scaledTotalHeight) / 2;

        /***************************************************
        * (3) CHECK IF CACHED SCALED IMAGES CAN BE REUSED *
        ***************************************************/

        /* compute new hash codes */
        final int newUnscaledSizeHashCode = unscaledTotalWidth * unscaledTotalHeight;
        final int newScaledSizeHashCode = scaledTotalWidth * scaledTotalHeight;

        if ((newUnscaledSizeHashCode != unscaledSizeHashCode) ||
                (newScaledSizeHashCode != scaledSizeHashCode))
        {
            /* proceed to generate new scaled images */
            for (int i = 0; i <= 9; i++)
            {
                scaledImages[i] = ((digitWidth == 0) || (digitHeight == 0)) ?
                    UNSCALED_IMAGES[i] :
                    UNSCALED_IMAGES[i].getScaledInstance(digitWidth, digitHeight, Image.SCALE_SMOOTH);

                smallScaledImages[i] = ((smallDigitWidth == 0) || (smallDigitHeight == 0)) ?
                    UNSCALED_IMAGES[i] :
                    UNSCALED_IMAGES[i].getScaledInstance(smallDigitWidth, smallDigitHeight, Image.SCALE_SMOOTH);
            }

            scaledImages[COLON_INDEX] = ((colonWidth == 0) || (digitHeight == 0)) ?
                UNSCALED_IMAGES[COLON_INDEX] :
                UNSCALED_IMAGES[COLON_INDEX].getScaledInstance(colonWidth, digitHeight, Image.SCALE_SMOOTH);

            scaledImages[NEGATIVE_SIGN_INDEX] = ((digitWidth == 0) || (digitHeight == 0)) ?
                UNSCALED_IMAGES[NEGATIVE_SIGN_INDEX] :
                UNSCALED_IMAGES[NEGATIVE_SIGN_INDEX].getScaledInstance(digitWidth, digitHeight, Image.SCALE_SMOOTH);

            /* update hash codes */
            unscaledSizeHashCode = newUnscaledSizeHashCode;
            scaledSizeHashCode = newScaledSizeHashCode;
        }

        /***********************
         * (4) DISPLAY "HOURS" *
         ***********************/

        if (setMode)
        {
            /* size of the "set" mode box around all the hour digits and negative-sign, if any */
            final int boxWidth = (digitWidth + padding) * (hourValueLength + (value.negative ? 1 : 0)) - padding;
            final int boxHeight = digitHeight;

            switch (action)
            {
                case PAINT:

                    /* set graphic color for rectangle */
                    g.setColor(SET_MODE_BOX_COLOR);

                    /* draw the set box */
                    g.drawRect(
                            offsetX,
                            offsetY,
                            boxWidth,
                            boxHeight);

                    /* draw dotted line through the middle of the box */
                    for (int i = 2; i < boxWidth; i += 5)
                    {
                        g.drawLine(
                                offsetX + i - 2,
                                offsetY + boxHeight / 2,
                                offsetX + i,
                                offsetY + boxHeight / 2);
                    }

                    break;

                case MOUSE_CLICKED:
                case MOUSE_WHEEL_MOVED:

                    /* check if mouse event occured inside the "set" mode box */
                    if ((mouseX >= offsetX) &&
                            (mouseX <= (offsetX + boxWidth)) &&
                            (mouseY >= offsetY) &&
                            (mouseY <= (offsetY + boxHeight)))
                    {
                        boolean increment;

                        if (action == Action.MOUSE_CLICKED)
                        {
                            increment = (mouseY <= (offsetY + boxHeight / 2));
                        }
                        else
                        {
                            increment = (mouseScroll > 0);
                        }

                        if (increment)
                        {
                            /* increment hour-value */
                            if (value.negative)
                            {
                                if (value.h == 0)
                                {
                                    value.negative = false;
                                }
                                else
                                {
                                    value.h--;
                                }
                            }
                            else
                            {
                                value.h++;
                            }
                        }
                        else
                        {
                            /* decrement hour-value */
                            if (value.negative)
                            {
                                value.h++;
                            }
                            else
                            {
                                if (value.h == 0)
                                {
                                    if ((value.m == 0) && (value.s == 0))
                                    {
                                        value.h = 1;
                                    }

                                    value.negative = true;
                                }
                                else
                                {
                                    value.h--;
                                }
                            }
                        }

                        if ((value.h == 0) &&
                                (value.m == 0) &&
                                (value.s == 0))
                        {
                            value.negative = false;
                        }

                        repaint();
                        return;
                    }

                    break;
            }
        }

        /* draw the negative sign, if necessary */
        if (value.negative)
        {
            if (action == Action.PAINT)
            {
                g.drawImage(scaledImages[TimerDisplay.NEGATIVE_SIGN_INDEX], offsetX, offsetY, null);
            }

            offsetX += (digitWidth + padding);
        }

        /* draw the digits in "hours", starting with the most significant digit */
        long hourValueLeft = value.h;

        for (int i = (hourValueLength - 1); i >= 0; i--)
        {
            final int divisor = (int) Math.pow(10, i);
            final int digit = (int) (hourValueLeft / divisor);
            hourValueLeft -= (digit * divisor);

            if (action == Action.PAINT)
            {
                g.drawImage(scaledImages[digit], offsetX, offsetY, null);
            }

            offsetX += (digitWidth + padding);
        }

        /* draw the colon */
        if (action == Action.PAINT)
        {
            g.drawImage(scaledImages[COLON_INDEX], offsetX, offsetY, null);
        }

        offsetX += (colonWidth + padding);

        /*************************
         * (5) DISPLAY "MINUTES" *
         *************************/

        /* draw the digits in "minutes", starting with the most significant digit */
        int minuteValueLeft = value.m;

        for (int i = 1; i >= 0; i--)
        {
            final int divisor = (int) Math.pow(10, i);
            final int digit = minuteValueLeft / divisor;
            minuteValueLeft -= (digit * divisor);

            if (action == Action.PAINT)
            {
                g.drawImage(scaledImages[digit], offsetX, offsetY, null);
            }

            if (setMode)
            {
                switch (action)
                {
                    case PAINT:

                        /* draw the "set" mode box */
                        g.drawRect(
                                offsetX,
                                offsetY,
                                digitWidth,
                                digitHeight);

                        /* draw dotted line through the middle of the box */
                        for (int j = 2; j < digitWidth; j += 5)
                        {
                            g.drawLine(
                                    offsetX + j - 2,
                                    offsetY + digitHeight / 2,
                                    offsetX + j,
                                    offsetY + digitHeight / 2);
                        }

                        break;

                    case MOUSE_CLICKED:
                    case MOUSE_WHEEL_MOVED:

                        /* check if mouse event occured inside the "set" mode box */
                        if ((mouseX >= offsetX) &&
                                (mouseX <= (offsetX + digitWidth)) &&
                                (mouseY >= offsetY) &&
                                (mouseY <= (offsetY + digitHeight)))
                        {
                            boolean increment;

                            if (action == Action.MOUSE_CLICKED)
                            {
                                increment = (mouseY <= (offsetY + digitHeight / 2));
                            }
                            else
                            {
                                increment = (mouseScroll > 0);
                            }

                            if (increment)
                            {
                                /* increment minute-value */
                                if ((value.m + divisor) <= 59)
                                {
                                    value.m += divisor;
                                }
                            }
                            else
                            {
                                /* decrement minute-value */
                                if ((value.m - divisor) >= 0)
                                {
                                    value.m -= divisor;
                                }
                            }

                            if ((value.h == 0) &&
                                    (value.m == 0) &&
                                    (value.s == 0))
                            {
                                value.negative = false;
                            }

                            repaint();
                            return;
                        }

                        break;
                }
            }

            offsetX += (digitWidth + padding);
        }

        /*************************
         * (6) DISPLAY "SECONDS" *
         *************************/

        /* draw the digits in "seconds", starting with the most significant digit */
        int secondValueLeft = value.s;

        for (int i = 1; i >= 0; i--)
        {
            final int divisor = (int) Math.pow(10, i);
            final int digit = secondValueLeft / divisor;
            secondValueLeft -= (digit * divisor);

            if (action == Action.PAINT)
            {
                g.drawImage(smallScaledImages[digit], offsetX, offsetY + digitHeight - smallDigitHeight, null);
            }

            if (setMode)
            {
                switch (action)
                {
                    case PAINT:

                        /* draw the "set" mode box */
                        g.drawRect(
                                offsetX,
                                offsetY + digitHeight - smallDigitHeight,
                                smallDigitWidth,
                                smallDigitHeight);

                        /* draw dotted line through the middle of the box */
                        for (int j = 2; j < smallDigitWidth; j += 5)
                        {
                            g.drawLine(
                                    offsetX + j - 2,
                                    offsetY + digitHeight - smallDigitHeight / 2,
                                    offsetX + j,
                                    offsetY + digitHeight - smallDigitHeight / 2);
                        }

                        break;

                    case MOUSE_CLICKED:
                    case MOUSE_WHEEL_MOVED:

                        /* check if mouse event occured inside the "set" mode box */
                        if ((mouseX >= offsetX) &&
                                (mouseX <= (offsetX + smallDigitWidth)) &&
                                (mouseY >= (offsetY + digitHeight - smallDigitHeight)) &&
                                (mouseY <= (offsetY + digitHeight)))

                        {
                            boolean increment;

                            if (action == Action.MOUSE_CLICKED)
                            {
                                increment = (mouseY <= (offsetY + digitHeight - smallDigitHeight / 2));
                            }
                            else
                            {
                                increment = (mouseScroll > 0);
                            }

                            if (increment)
                            {
                                /* increment second-value */
                                if ((value.s + divisor) <= 59)
                                {
                                    value.s += divisor;
                                }
                            }
                            else
                            {
                                /* decrement second-value */
                                if ((value.s - divisor) >= 0)
                                {
                                    value.s -= divisor;
                                }
                            }

                            if ((value.h == 0) &&
                                    (value.m == 0) &&
                                    (value.s == 0))
                            {
                                value.negative = false;
                            }

                            repaint();
                            return;
                        }

                        break;
                }
            }

            offsetX += (smallDigitWidth + padding);
        }
    }

    /*****************
    * INNER CLASSES *
    *****************/

    /**
    * Types of action for the doAction() method.
    */
    private static enum Action
    {
        PAINT,
        MOUSE_CLICKED,
        MOUSE_WHEEL_MOVED
    };
}