/*
 * Copyright 2016 Tomi Virtanen
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.tltv.gantt.client;

import static org.tltv.gantt.client.shared.GanttUtil.getBoundingClientRectLeft;
import static org.tltv.gantt.client.shared.GanttUtil.getBoundingClientRectRight;
import static org.tltv.gantt.client.shared.GanttUtil.getBoundingClientRectWidth;

import java.util.Date;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;

import org.tltv.gantt.client.shared.GanttUtil;
import org.tltv.gantt.client.shared.Resolution;

import com.google.gwt.core.client.GWT;
import com.google.gwt.dom.client.DivElement;
import com.google.gwt.dom.client.Element;
import com.google.gwt.dom.client.Node;
import com.google.gwt.dom.client.NodeList;
import com.google.gwt.dom.client.Style.Display;
import com.google.gwt.dom.client.Style.Position;
import com.google.gwt.dom.client.Style.Unit;
import com.google.gwt.dom.client.StyleElement;
import com.google.gwt.dom.client.StyleInjector;
import com.google.gwt.i18n.shared.DateTimeFormat;
import com.google.gwt.user.client.DOM;
import com.google.gwt.user.client.Timer;
import com.google.gwt.user.client.ui.AbstractNativeScrollbar;
import com.google.gwt.user.client.ui.Widget;

/**
 * GWT widget to build a scalable timeline that supports more than one
 * resolutions ({@link Resolution}). When timeline element doesn't overflow
 * horizontally in it's parent element, it scales the content width up to fit in
 * the space available.
 * <p>
 * When this component scales up, all widths are calculated as percentages.
 * Pixel widths are used otherwise. Some browsers may not support percentages
 * accurately enough, and for those it's best to call
 * {@link #setAlwaysCalculatePixelWidths(boolean)} with 'true' to disable
 * percentage values.
 * <p>
 * There's always a minimum width calculated and updated to the timeline
 * element. Percentage values set some limitation for the component's width.
 * Wider the component (&gt; 4000px), bigger the chance to get year, month and
 * date blocks not being vertically in-line with each others.
 * <p>
 * Supports setting a scroll left position.
 * <p>
 * After construction, attach the component to it's parent and call update
 * method with a required parameters and the timeline is ready. After that, all
 * widths are calculated and all other API methods available can be used safely.
 *
 * @author Tltv
 *
 */
public class TimelineWidget extends Widget {

    public static final String STYLE_TIMELINE = "timeline";
    public static final String STYLE_ROW = "row";
    public static final String STYLE_COL = "col";
    public static final String STYLE_MONTH = "month";
    public static final String STYLE_YEAR = "year";
    public static final String STYLE_DAY = "day";
    public static final String STYLE_WEEK = "w";
    public static final String STYLE_RESOLUTION = "resolution";
    public static final String STYLE_WEEK_FIRST = "week-f";
    public static final String STYLE_WEEK_LAST = "week-l";
    public static final String STYLE_WEEK_MIDDLE = "week-m";
    public static final String STYLE_EVEN = "even";
    public static final String STYLE_WEEKEND = "weekend";
    public static final String STYLE_SPACER = "spacer";
    public static final String STYLE_FIRST = "f-col";
    public static final String STYLE_CENTER = "c-col";
    public static final String STYLE_LAST = "l-col";
    public static final String STYLE_MEASURE = "measure";
    public static final String STYLE_NOW = "now";

    public static final String DAY_CHECK_FORMAT = "yyyyMMdd";
    public static final String HOUR_CHECK_FORMAT = "yyyyMMddHH";

    public static final int DAYS_IN_WEEK = 7;
    public static final int HOURS_IN_DAY = 24;
    public static final long DAY_INTERVAL = 24 * 60 * 60 * 1000;
    public static final long HOUR_INTERVAL = 60 * 60 * 1000;
    public int resolutionWeekDayblockWidth = 4;

    private boolean ie;

    private boolean forceUpdateFlag;

    private LocaleDataProvider localeDataProvider;
    private DateTimeFormat yearDateTimeFormat;
    private DateTimeFormat monthDateTimeFormat;
    private DateTimeFormat weekDateTimeFormat;
    private DateTimeFormat dayDateTimeFormat;
    private DateTimeFormat hour12DateTimeFormat;
    private DateTimeFormat hour24DateTimeFormat;
    private DateTimeFormat customHourDateTimeFormat;

    private String locale;
    private Resolution resolution;
    /** Zoned start date. */
    private long startDate;
    /** Zoned end date. */
    private long endDate;
    /*
     * Normal start and end dates without daylight saving time adjustments.
     */
    private long normalStartDate;
    private long normalEndDate;
    private int firstDayOfWeek;
    private int lastDayOfWeek;
    private int firstDayOfRange;
    private int firstHourOfRange;
    private String[] monthNames;
    private String[] weekdayNames;
    private String currentDate = "";
    private String currentHour = "";
    private long timestamp;
    private long browserTimestamp;
    boolean showCurrentTime;

    /*
     * number of blocks in resolution range. Days for Day/Week resolution, Hours
     * for hour resolution..
     */
    private int blocksInRange = 0;
    /*
     * number of elements in resolution range. Same as blocksInRange for
     * Day/Hour resolution. blocksInRange / 7 for Week resolution.
     */
    private int resolutionBlockCount = 0;
    private int firstResBlockCount;
    private int lastResBlockCount;
    private boolean firstDay;
    private boolean timelineOverflowingHorizontally;
    private boolean noticeVerticalScrollbarWidth;
    private boolean monthRowVisible;
    private boolean yearRowVisible;
    private String monthFormat;
    private String yearFormat;
    private String weekFormat;
    private String dayFormat;
    private String hourFormat;

    /*
     * resolutionDiv contains the resolution specific elements that represents a
     * timeline's sub-parts like hour, day or week.
     */
    private DivElement resolutionDiv;
    private DivElement resSpacerDiv;
    private Set<DivElement> spacerBlocks = new HashSet<DivElement>();

    private BlockRowData yearRowData = new BlockRowData();
    private BlockRowData monthRowData = new BlockRowData();
    // days/daysLength are needed only with resolutions smaller than Day.
    private BlockRowData dayRowData = new BlockRowData();

    /*
     * Currently active widths. Updated each time when timeline column widths
     * are updated.
     */
    private double dayWidthPercentage;
    private double dayOrHourWidthPx;
    private double resBlockMinWidthPx;
    private double resBlockWidthPx;
    private double resBlockWidthPercentage;

    private int minResolutionWidth = -1;
    private int minWidth = -1;
    private boolean calcPixels = false;
    private double positionLeft;

    private Timer lazyResolutionPaint = new Timer() {

        @Override
        public void run() {
            fillVisibleTimeline();
        }

    };

    private StyleElement styleElement;
    private StyleElement styleElementForLeft;

    private boolean firstWeekBlockHidden;

    enum Weekday {
        First,
        Between,
        Last
    }

    /**
     * Constructs the widget. Call
     * {@link #update(Resolution, long, long, int, int, LocaleDataProvider)}
     * after the component is attached to some parent widget.
     */
    public TimelineWidget() {
        setElement(DivElement.as(DOM.createDiv()));
        setStyleName(STYLE_TIMELINE);
    }

    @Override
    protected void onUnload() {
        super.onDetach();

        if (styleElement != null) {
            styleElement.removeFromParent();
        }
        if (styleElementForLeft != null) {
            styleElementForLeft.removeFromParent();
        }
    }

    /**
     * <p>
     * Updates the content of this widget. Builds the time-line and calculates
     * width and heights for the content (calls in the end
     * {@link #updateWidths()}). This should be called explicitly. Otherwise the
     * widget will be empty.
     * <p>
     * Date values should always follow specification in {@link Date#getTime()}.
     * Start and end date is always required.
     *
     * @param resolution
     *            Resolution enum (not null)
     * @param startDate
     *            Time-line's start date in milliseconds. (not null)
     * @param endDate
     *            Time-line's end date in milliseconds. (not null)
     * @param firstDayOfRange
     *            First day of the whole range. Allowed values are 1-7. 1 is
     *            Sunday. Required with {@link Resolution#Week}.
     * @param firstHourOfRange
     *            First hour of the range. Allowed values are 0-23. Required
     *            with {@link Resolution#Hour}.
     * @param localeDataProvider
     *            Data provider for locale specific data. month names, first day
     *            of week etc.
     *
     */
    public void update(Resolution resolution, long startDate, long endDate, int firstDayOfRange, int firstHourOfRange,
            LocaleDataProvider localeDataProvider) {
        if (localeDataProvider == null) {
            GWT.log(getClass().getSimpleName() + " requires LocaleDataProvider. Can't complete update(...) operation.");
            return;
        }
        if (isChanged(resolution, startDate, endDate, localeDataProvider.getFirstDayOfWeek(), firstDayOfRange,
                firstHourOfRange, localeDataProvider.getLocale())) {
            clear();
            GWT.log(getClass().getSimpleName() + " content cleared.");
        } else {
            return;
        }

        GWT.log(getClass().getSimpleName() + " Updating content.");

        injectStyle();
        injectLeftStyle();

        if (styleElementForLeft != null) {
            StyleInjector.setContents(styleElementForLeft, "." + STYLE_COL + " { position: relative; left: 0px; }");
        }

        this.localeDataProvider = localeDataProvider;
        locale = localeDataProvider.getLocale();
        this.resolution = resolution;
        this.startDate = startDate;
        this.endDate = endDate;
        normalStartDate = toNormalDate(startDate);
        normalEndDate = toNormalDate(endDate);
        // Required with Resolution.Week.
        firstDayOfWeek = localeDataProvider.getFirstDayOfWeek();
        lastDayOfWeek = (firstDayOfWeek == 1) ? 7 : Math.max((firstDayOfWeek - 1) % 8, 1);
        this.firstDayOfRange = firstDayOfRange;
        this.firstHourOfRange = firstHourOfRange;
        monthNames = localeDataProvider.getMonthNames();
        weekdayNames = localeDataProvider.getWeekdayNames();
        resolutionDiv = DivElement.as(DOM.createDiv());
        resolutionDiv.setClassName(STYLE_ROW + " " + STYLE_RESOLUTION);

        if (minResolutionWidth < 0) {
            minResolutionWidth = calculateResolutionMinWidth();
        }

        if (resolution == Resolution.Day || resolution == Resolution.Week) {
            prepareTimelineForDayResolution(startDate, endDate);
        } else if (resolution == Resolution.Hour) {
            prepareTimelineForHourResolution(startDate, endDate);
        } else {
            GWT.log(getClass().getSimpleName() + " resolution " + (resolution != null ? resolution.name() : "null")
                    + " is not supported");
            return;
        }

        if (isYearRowVisible()) {
            appendTimelineBlocks(yearRowData, STYLE_YEAR);
        }
        if (isMonthRowVisible()) {
            appendTimelineBlocks(monthRowData, STYLE_MONTH);
        }
        if (isDayRowVisible()) {
            appendTimelineBlocks(dayRowData, STYLE_DAY);
        }
        getElement().appendChild(resolutionDiv);

        GWT.log(getClass().getSimpleName() + " Constructed content.");

        updateWidths();

        GWT.log(getClass().getSimpleName() + " is updated for resolution " + resolution.name() + ".");
    }

    /**
     * Injects custom stylesheet just for this widget. It helps to update styles
     * for a big group of elements in the DOM, like resolution blocks.
     * <p>
     * Warning, this feature is not working with Internet Explorer reliably
     * enough. Read more at {@link StyleInjector#injectStylesheetAtEnd(String)}.
     * This method has no effect when {@link #ie} is set to true.
     */
    private void injectStyle() {
        if (ie || styleElement != null) {
            return;
        }

        styleElement = StyleInjector.injectStylesheetAtEnd(
                "." + STYLE_FIRST + " { } ." + STYLE_CENTER + " { } ." + STYLE_LAST + " { } ." + STYLE_COL + " { } ");

        StyleInjector.flush();
    }

    private void injectLeftStyle() {
        if (ie || styleElementForLeft != null) {
            return;
        }

        styleElementForLeft = StyleInjector.injectStylesheetAtEnd("." + STYLE_COL + " { } ");

        StyleInjector.flush();
    }

    /**
     * Set minimum width (pixels) of this widget's root DIV element. Default is
     * -1. Notice that
     * {@link #update(Resolution, long, long, int, int, LocaleDataProvider)}
     * will calculate min-width and call this internally.
     *
     * @param minWidth
     *            Minimum width in pixels.
     */
    public void setMinWidth(int minWidth) {
        this.minWidth = minWidth;
        getElement().getStyle().setProperty("minWidth", this.minWidth + "px");
        getResolutionDiv().getStyle().setProperty("minWidth", this.minWidth + "px");
    }

    /**
     * Return minimum width (pixels) of this widget's root DIV element. Returns
     * -1 if not set.
     *
     * @return min-width
     */
    public int getMinWidth() {
        return minWidth;
    }

    /**
     * Calculate matching left offset in percentage for a date (
     * {@link Date#getTime()}).
     *
     * @param date
     *            Target date in milliseconds.
     * @param contentWidth
     *            Width of the content that the given 'date' is relative to.
     * @return Left offset in percentage.
     */
    public double getLeftPositionPercentageForDate(long date, double contentWidth) {
        double timelineLeft = getLeftPositionForDate(date);
        double relativeLeft = convertRelativeLeftPosition(timelineLeft, contentWidth);

        double width = getResolutionWidth();
        return (100.0 / width) * relativeLeft;
    }

    /**
     * Calculate CSS value for 'left' property matching left offset in
     * percentage for a date ( {@link Date#getTime()}).
     * <p>
     * May return '2.123456%' or 'calc(2.123456%)' if IE;
     *
     * @param date
     *            Target date in milliseconds.
     * @param contentWidth
     *            Width of the content that the given 'date' is relative to.
     * @return Left offset as a String value.
     */
    public String getLeftPositionPercentageStringForDate(long date, double contentWidth) {
        double timelineLeft = getLeftPositionForDate(date);
        double relativeLeft = convertRelativeLeftPosition(timelineLeft, contentWidth);

        double width = getResolutionWidth();
        String calc = createCalcCssValue(width, relativeLeft);

        if (calc != null) {
            return calc;
        }
        return (100.0 / width) * relativeLeft + "" + Unit.PCT.getType();
    }

    public String getLeftPositionPercentageStringForDate(long date, double rangeWidth, long rangeStartDate,
            long rangeEndDate) {
        double rangeLeft = getLeftPositionForDate(date, rangeWidth, rangeStartDate, rangeEndDate);
        double width = rangeWidth;
        String calc = createCalcCssValue(width, rangeLeft);

        if (calc != null) {
            return calc;
        }
        return (100.0 / width) * rangeLeft + "" + Unit.PCT.getType();
    }

    /**
     * Calculate CSS value for 'width' property matching date interval inside
     * the time-line. Returns percentage value. Interval is in milliseconds.
     * <p>
     * May return '2.123456%' or 'calc(2.123456%)' if IE;
     *
     * @param interval
     *            Date interval in milliseconds.
     * @return
     */
    public String getWidthPercentageStringForDateInterval(long interval) {
        double range = endDate - startDate;
        return getWidthPercentageStringForDateInterval(interval, range);
    }

    /** @see #getWidthPercentageStringForDateInterval(long) */
    public String getWidthPercentageStringForDateInterval(long interval, double range) {
        String calc = createCalcCssValue(range, interval);
        if (calc != null) {
            return calc;
        }
        return (100.0 / range) * interval + "" + Unit.PCT.getType();
    }

    /**
     * Calculate matching left offset in pixels for a date (
     * {@link Date#getTime()}).
     *
     * @param date
     *            Target date in milliseconds.
     * @return Left offset in pixels.
     */
    public double getLeftPositionForDate(long date) {
        return getLeftPositionForDate(date, getResolutionWidth(), startDate, endDate);
    }

    public double getLeftPositionForDate(long date, double rangeWidth, long rangeStartDate, long rangeEndDate) {
        double width = rangeWidth;
        double range = rangeEndDate - rangeStartDate;
        if (range <= 0) {
            return 0;
        }
        double p = width / range;
        double offset = date - rangeStartDate;
        double left = p * offset;
        return left;
    }

    /**
     * Calculate matching date ({@link Date#getTime()}) for the target left
     * pixel offset.
     *
     * @param left
     *            Left offset in pixels.
     * @return Date in a milliseconds.
     */
    public long getDateForLeftPosition(double left) {
        return getDateForLeftPosition(left, resolution == Resolution.Hour);
    }

    public long getDateForLeftPosition(double left, boolean noticeDST) {
        double width = getResolutionWidth();
        if (width <= 0) {
            return 0;
        }
        double range = normalEndDate - normalStartDate;
        if (noticeDST) {
            range = adjustDateRangeByDST(range);
        }
        double p = range / width;
        double offset = p * left;
        long date = startDate + (long) offset;

        GWT.log("Zoned: " + getLocaleDataProvider().formatDate(new Date(date), "dd. HH:mm") + "  DST: "
                + getLocaleDataProvider().getDaylightAdjustment(new Date(date)) / 60000);
        return date;
    }

    /**
     * Convert left position for other relative target width.
     *
     * @param left
     * @param contentWidthToConvertFor
     * @return
     */
    public double convertRelativeLeftPosition(double left, double contentWidthToConvertFor) {
        double width = getResolutionWidth();
        if (width <= 0 || contentWidthToConvertFor <= 0) {
            return 0;
        }

        double relativePosition = (1.0 / contentWidthToConvertFor) * left;
        double timelineLeft = relativePosition * width;
        return timelineLeft;
    }

    /**
     * Set horizontal scroll position for the time-line.
     *
     * @param left
     *            Scroll position in pixels.
     */
    public void setScrollLeft(double left) {
        if (positionLeft == left) {
            return;
        }
        positionLeft = left;
        getElement().getStyle().setLeft(-left, Unit.PX);
        lazyResolutionPaint.schedule(20);
    }

    /**
     * Re-calculates required widths for this widget.
     * <p>
     * Re-creates and fills the visible part of the resolution element.
     */
    public void updateWidths() {
        if (resolutionDiv == null) {
            GWT.log(getClass().getSimpleName() + " is not ready for updateWidths() call. Call update(...) instead.");
            return;
        }

        GWT.log(getClass().getSimpleName() + " Started updating widths.");

        // start by clearing old content in resolution element
        resolutionDiv.removeAllChildren();

        setMinWidth(blocksInRange * minResolutionWidth);

        // update horizontal overflow state here, after min-width is updated.
        updateTimelineOverflowingHorizontally();

        createTimelineElementsOnVisibleArea();
        // fill timeline
        fillVisibleTimeline();

        // remove spacer block if it exist
        removeResolutionSpacerBlock();

        // calculate new block width for day-resolution.
        // Year and month blocks are vertically in-line with days.
        dayWidthPercentage = 100.0 / blocksInRange;
        dayOrHourWidthPx = calculateDayOrHourResolutionBlockWidthPx(blocksInRange);

        // calculate block width for currently selected resolution
        // (day,week,...)
        // resolution div's content may not be vertically in-line with
        // year/month blocks. This is the case for example with Week resolution.
        resBlockMinWidthPx = minResolutionWidth;
        resBlockWidthPx = calculateActualResolutionBlockWidthPx(dayOrHourWidthPx);
        resBlockWidthPercentage = 100.0 / resolutionBlockCount;
        String pct = createCalcCssValue(resolutionBlockCount);
        if (resolution == Resolution.Week) {
            resBlockMinWidthPx = DAYS_IN_WEEK * minResolutionWidth;
            resBlockWidthPercentage = dayWidthPercentage * DAYS_IN_WEEK;
            pct = createCalcCssValue(blocksInRange, DAYS_IN_WEEK);

        }

        // update resolution block widths
        updateResolutionBlockWidths(pct);

        if (isYearRowVisible()) {
            // update year block widths
            updateBlockWidths(yearRowData);
        }

        if (isMonthRowVisible()) {
            // update month block widths
            updateBlockWidths(monthRowData);
        }

        if (isDayRowVisible()) {
            updateBlockWidths(dayRowData);
        }

        if (isAlwaysCalculatePixelWidths()) {
            updateSpacerBlocks(dayOrHourWidthPx);
        }

        GWT.log(getClass().getSimpleName() + " Widths are updated.");
    }

    /*
     * Calculates either day or hour resolution block width depending on the
     * current resolution.
     */
    private double calculateDayOrHourResolutionBlockWidthPx(int blockCount) {
        double dayOrHourWidthPx = Math.round(resolutionDiv.getClientWidth() / blockCount);
        while ((resolutionDiv.getClientWidth() % (blockCount * dayOrHourWidthPx)) >= blockCount) {
            dayOrHourWidthPx++;
        }

        return dayOrHourWidthPx;
    }

    /*
     * Calculates the actual width of one resolution block element. For example:
     * week resolution will return 7 * dayOrHourBlockWidthPx.
     */
    private double calculateActualResolutionBlockWidthPx(double dayOrHourBlockWidthPx) {
        if (resolution == Resolution.Week) {
            return DAYS_IN_WEEK * dayOrHourBlockWidthPx;
        }
        return dayOrHourBlockWidthPx;
    }

    /**
     * Returns true if the timeline is overflowing the parent's width. This
     * works only when this widget is attached to some parent.
     *
     * @return True when timeline width is more than the parent's width (@see
     *         {@link Element#getClientWidth()}).
     */
    public boolean isTimelineOverflowingHorizontally() {
        return timelineOverflowingHorizontally;
    }

    /**
     * Updates horizontal overflow state and returns true if the timeline is
     * overflowing the parent's width. This works only when this widget is
     * attached to some parent.
     *
     * @return True when timeline width is more than the parent's width (@see
     *         {@link Element#getClientWidth()}).
     */
    public boolean checkTimelineOverflowingHorizontally() {
        updateTimelineOverflowingHorizontally();
        return isTimelineOverflowingHorizontally();
    }

    /**
     * Return true if timeline should notice vertical scrollbar width in it's
     * calculations.
     *
     * @return
     */
    public boolean isNoticeVerticalScrollbarWidth() {
        return noticeVerticalScrollbarWidth;
    }

    public void setNoticeVerticalScrollbarWidth(boolean noticeVerticalScrollbarWidth) {
        this.noticeVerticalScrollbarWidth = noticeVerticalScrollbarWidth;
        if (noticeVerticalScrollbarWidth) {
            getElement().getStyle().setMarginRight(AbstractNativeScrollbar.getNativeScrollbarWidth(), Unit.PX);
        } else {
            getElement().getStyle().clearMarginRight();
        }
    }

    public void setBrowserInfo(boolean ie, int majorVersion) {
        this.ie = ie;
    }

    /**
     * Tells this Widget to calculate widths by itself. Percentage widths are
     * not used. Some browsers may not handle sub-pixel calculating accurately
     * enough. Setting this to true works as a fallback mode for those browsers.
     * <p>
     * Default value is false.
     *
     * @param calcPx
     */
    public void setAlwaysCalculatePixelWidths(boolean calcPx) {
        calcPixels = calcPx;
    }

    /**
     * Returns true if Widget is set to calculate widths by itself. Default is
     * false.
     *
     * @return
     */
    public boolean isAlwaysCalculatePixelWidths() {
        return calcPixels;
    }

    /**
     * Get actual width of the timeline.
     *
     * @return
     */
    public double getResolutionWidth() {
        if (!isTimelineOverflowingHorizontally()) {
            return calculateTimelineWidth();
        }

        double width = getResolutionDivWidth();
        if (isAlwaysCalculatePixelWidths() && containsResBlockSpacer()) {
            width = width - getElementWidth(resSpacerDiv);
        }
        return width;
    }

    /**
     * Calculate the exact width of the timeline. Excludes any spacers in the
     * end.
     *
     * @return
     */
    public double calculateTimelineWidth() {
        Element last = getLastResolutionElement();
        if (last == null) {
            return 0.0;
        }
        double r = getBoundingClientRectRight(last);
        double l = getBoundingClientRectLeft(getFirstResolutionElement());
        double timelineRealWidth = r - l;
        return timelineRealWidth;
    }

    /*
     * Get width of the resolution div element.
     */
    private double getResolutionDivWidth() {
        if (!isTimelineOverflowingHorizontally()) {
            return getElementWidth(resolutionDiv);
        }

        return blocksInRange * minResolutionWidth;
    }

    private double getElementWidth(Element element) {
        return GanttUtil.getBoundingClientRectWidth(element);
    }

    public boolean isDayRowVisible() {
        return resolution == Resolution.Hour;
    }

    public boolean isMonthRowVisible() {
        return monthRowVisible;
    }

    public boolean isYearRowVisible() {
        return yearRowVisible;
    }

    public void setMonthRowVisible(boolean monthRowVisible) {
        this.monthRowVisible = monthRowVisible;
    }

    public void setYearRowVisible(boolean yearRowVisible) {
        this.yearRowVisible = yearRowVisible;
    }

    public String getMonthFormat() {
        return monthFormat;
    }

    public void setMonthFormat(String monthFormat) {
        this.monthFormat = monthFormat;
    }

    public String getYearFormat() {
        return yearFormat;
    }

    public void setYearFormat(String yearFormat) {
        this.yearFormat = yearFormat;
    }

    public void setWeekFormat(String weekFormat) {
        this.weekFormat = weekFormat;
    }

    public void setDayFormat(String dayFormat) {
        this.dayFormat = dayFormat;
    }

    public void setHourFormat(String hourFormat) {
        if (hourFormat == null || !hourFormat.equals(this.hourFormat)) {
            customHourDateTimeFormat = null;
        }
        this.hourFormat = hourFormat;
    }

    public String getHourFormat() {
        return hourFormat;
    }

    /**
     * Sets force update flag up. Next
     * {@link #update(Resolution, long, long, int, int, LocaleDataProvider)}
     * call knows then to update everything.
     */
    public void setForceUpdate() {
        forceUpdateFlag = true;
    }

    public DateTimeFormat getYearDateTimeFormat() {
        if (yearDateTimeFormat == null) {
            yearDateTimeFormat = DateTimeFormat.getFormat("yyyy");
        }
        return yearDateTimeFormat;
    }

    public DateTimeFormat getMonthDateTimeFormat() {
        if (monthDateTimeFormat == null) {
            monthDateTimeFormat = DateTimeFormat.getFormat("M");
        }
        return monthDateTimeFormat;
    }

    public DateTimeFormat getWeekDateTimeFormat() {
        if (weekDateTimeFormat == null) {
            weekDateTimeFormat = DateTimeFormat.getFormat("d");
        }
        return weekDateTimeFormat;
    }

    public DateTimeFormat getDayDateTimeFormat() {
        if (dayDateTimeFormat == null) {
            dayDateTimeFormat = DateTimeFormat.getFormat("d");
        }
        return dayDateTimeFormat;
    }

    public DateTimeFormat getHour12DateTimeFormat() {
        if (hour12DateTimeFormat == null) {
            hour12DateTimeFormat = DateTimeFormat.getFormat("h");
        }
        return hour12DateTimeFormat;
    }

    public DateTimeFormat getHour24DateTimeFormat() {
        if (hour24DateTimeFormat == null) {
            hour24DateTimeFormat = DateTimeFormat.getFormat("HH");
        }
        return hour24DateTimeFormat;
    }

    public DateTimeFormat getCustomHourDateTimeFormat() {
        if (customHourDateTimeFormat == null) {
            customHourDateTimeFormat = DateTimeFormat.getFormat(hourFormat);
        }
        return customHourDateTimeFormat;
    }

    /**
     * Returns a width of the first resolution block.
     *
     * @return
     */
    public double getFirstResolutionElementWidth() {
        if (isFirstResBlockShort()) {
            if (isTimelineOverflowingHorizontally()) {
                return firstResBlockCount * minResolutionWidth;
            } else {
                return getBoundingClientRectWidth(getFirstResolutionElement());
            }
        } else {
            if (isTimelineOverflowingHorizontally()) {
                return resBlockMinWidthPx;
            } else {
                return getBoundingClientRectWidth(getFirstResolutionElement());
            }
        }
    }

    /**
     * Returns the amount of visible blocks in the timeline for the active
     * resolution. Day blocks for Day/Week, hour blocks for Hour resolution.
     *
     * @return
     */
    public int getVisibleResolutionBlockCount() {
        return resolutionBlockCount;
    }

    private double adjustDateRangeByDST(double range) {
        /*
         * Notice extra block(s) or missing block(s) in range when start time is
         * in DST and end time is not, or vice versa.
         */
        long dstStart = getLocaleDataProvider().getDaylightAdjustment(new Date(startDate));
        long dstEnd = getLocaleDataProvider().getDaylightAdjustment(new Date(endDate));
        if (dstStart > dstEnd) {
            range -= Math.abs(dstStart - dstEnd);
        } else if (dstEnd > dstStart) {
            range += Math.abs(dstEnd - dstStart);
        }
        return range;
    }

    private void fillVisibleTimeline() {
        if (isTimelineOverflowingHorizontally()) {
            showResolutionBlocksOnView();
        } else {
            showAllResolutionBlocks();
        }
    }

    private Element getLastResolutionElement() {
        DivElement div = getResolutionDiv();
        if (div == null) {
            return null;
        }
        NodeList<Node> nodeList = div.getChildNodes();
        if (nodeList == null) {
            return null;
        }
        int blockCount = nodeList.getLength();
        if (blockCount < 1) {
            return null;
        }
        if (containsResBlockSpacer()) {
            int index = blockCount - 2;
            if (blockCount > 1 && index >= 0) {
                return Element.as(getResolutionDiv().getChild(index));
            }
            return null;
        }
        return Element.as(getResolutionDiv().getLastChild());
    }

    private Element getFirstResolutionElement() {
        if (getResolutionDiv().hasChildNodes()) {
            return getResolutionDiv().getFirstChildElement();
        }
        return null;
    }

    private void appendTimelineBlocks(BlockRowData rowData, String style) {
        for (Entry<String, Element> entry : rowData.getBlockEntries()) {
            getElement().appendChild(entry.getValue());
        }
        if (isAlwaysCalculatePixelWidths()) {
            getElement().appendChild(createSpacerBlock(style));
        }
    }

    /**
     * Update horizontal overflow state.
     */
    private void updateTimelineOverflowingHorizontally() {
        timelineOverflowingHorizontally = (getElementWidth(resolutionDiv) > getElementWidth(
                getElement().getParentElement()));
    }

    private DivElement createSpacerBlock(String className) {
        DivElement block = DivElement.as(DOM.createDiv());
        block.setClassName(STYLE_ROW + " " + STYLE_YEAR);
        block.addClassName(STYLE_SPACER);
        block.setInnerText(" ");
        block.getStyle().setDisplay(Display.NONE); // not visible by default
        spacerBlocks.add(block);
        return block;
    }

    private void updateSpacerBlocks(double dayWidthPx) {
        double spaceLeft = getResolutionDivWidth() - (blocksInRange * dayWidthPx);
        if (spaceLeft > 0) {
            for (DivElement e : spacerBlocks) {
                e.getStyle().clearDisplay();
                e.getStyle().setWidth(spaceLeft, Unit.PX);
            }

            resSpacerDiv = createResolutionBlock();
            resSpacerDiv.addClassName(STYLE_SPACER);
            resSpacerDiv.getStyle().setWidth(spaceLeft, Unit.PX);
            resSpacerDiv.setInnerText(" ");
            resolutionDiv.appendChild(resSpacerDiv);
        } else {
            hideSpacerBlocks();
        }
    }

    private void hideSpacerBlocks() {
        for (DivElement e : spacerBlocks) {
            e.getStyle().setDisplay(Display.NONE);
        }
    }

    private void updateBlockWidths(BlockRowData rowData) {
        for (Entry<String, Element> entry : rowData.getBlockEntries()) {
            setWidth(entry.getValue(), rowData.getBlockLength(entry.getKey()));
        }
    }

    private boolean isFirstResBlockShort() {
        return firstResBlockCount > 0 && ((resolution == Resolution.Week && firstResBlockCount < DAYS_IN_WEEK));
    }

    private boolean isLastResBlockShort() {
        return lastResBlockCount > 0 && ((resolution == Resolution.Week && lastResBlockCount < DAYS_IN_WEEK));
    }

    private void updateResolutionBlockWidths(String pct) {

        if (styleElement == null) {
            if (!isTimelineOverflowingHorizontally()) {
                resolutionDiv.getStyle().setProperty("display", "flex");
            } else {
                resolutionDiv.getStyle().clearProperty("display");
            }
            boolean firstResBlockIsShort = isFirstResBlockShort();
            boolean lastResBlockIsShort = isLastResBlockShort();
            // styleElement is not set, set width for each block explicitly.
            int count = resolutionDiv.getChildCount();
            if (containsResBlockSpacer()) {
                count--;
            }
            int lastIndex = count - 1;
            int i;
            Element resBlock;
            for (i = 0; i < count; i++) {
                resBlock = Element.as(resolutionDiv.getChild(i));

                // first and last week blocks may be thinner than other
                // resolution blocks.
                if (firstResBlockIsShort && i == 0) {
                    setWidth(resBlock, firstResBlockCount);
                } else if (lastResBlockIsShort && i == lastIndex) {
                    setWidth(resBlock, lastResBlockCount);
                } else {
                    setWidth(resBlockWidthPx, pct, resBlock);
                }
            }

        } else {
            // set widths by updating injected styles in one place. Faster than
            // setting widths explicitly for each element.
            String center = getWidthStyleValue(pct);
            String first = center;
            String last = center;
            if (isFirstResBlockShort()) {
                first = getWidth(firstResBlockCount);
            }
            if (isLastResBlockShort()) {
                last = getWidth(lastResBlockCount);
            }
            StyleInjector.setContents(styleElement, "." + STYLE_CENTER + " { width: " + center + "; } ." + STYLE_FIRST
                    + " { width: " + first + "; } ." + STYLE_LAST + " { width: " + last + "; } ");
        }
    }

    private void removeResolutionSpacerBlock() {
        if (containsResBlockSpacer()) {
            resSpacerDiv.removeFromParent();
        }
    }

    private boolean containsResBlockSpacer() {
        return resSpacerDiv != null && resSpacerDiv.hasParentElement()
                && resSpacerDiv.getParentElement().equals(resolutionDiv);
    }

    private void prepareTimelineForHourResolution(long startDate, long endDate) {
        firstDay = true;
        prepareTimelineForHourResolution(HOUR_INTERVAL, startDate, endDate, new ResolutionBlockRegisterer() {

            int hourCounter = firstHourOfRange;

            @Override
            public void registerResolutionBlock(int index, Date date, String currentYear, boolean lastTimelineBlock) {

                registerHourResolutionBlock();
                hourCounter = Math.max((hourCounter + 1) % 25, 1);
            }
        });
    }

    private void prepareTimelineForDayResolution(long startDate, long endDate) {
        prepareTimelineForResolution(DAY_INTERVAL, startDate, endDate, new ResolutionBlockRegisterer() {

            int dayCounter = firstDayOfRange;
            Weekday weekday;
            boolean firstWeek = true;

            @Override
            public void registerResolutionBlock(int index, Date date, String currentYear, boolean lastTimelineBlock) {

                weekday = getWeekday(dayCounter);

                if (resolution == Resolution.Week) {
                    registerWeekResolutionBlock(index, weekday, lastTimelineBlock, firstWeek);
                    if (firstWeek && (weekday == Weekday.Last || lastTimelineBlock)) {
                        firstWeek = false;
                    }
                } else {
                    registerDayResolutionBlock();
                }

                dayCounter = Math.max((dayCounter + 1) % 8, 1);
            }
        });
    }

    private void fillTimelineForResolution(final long startDate, long endDate, final int left) {
        if (resolution == Resolution.Day || resolution == Resolution.Week) {
            fillTimelineForDayResolution(startDate, endDate, left);
        } else if (resolution == Resolution.Hour) {
            fillTimelineForHourResolution(startDate, endDate, left);
        } else {
            GWT.log(getClass().getSimpleName() + " resolution " + (resolution != null ? resolution.name() : "null")
                    + " is not supported");
            return;
        }

        GWT.log(getClass().getSimpleName() + " Filled new data and styles to visible timeline elements");
    }

    private void fillTimelineForHourResolution(final long startDate, long endDate, final int left) {
        firstDay = true;
        fillTimelineForHourResolution(HOUR_INTERVAL, startDate, endDate, new ResolutionBlockFiller() {

            int hourCounter = getFirstHourOfVisibleRange(startDate);
            boolean even = isEven(startDate);

            @Override
            public void fillResolutionBlock(int index, Date date, String currentYear, boolean lastTimelineBlock) {
                int childCount = getResolutionDiv().getChildCount();
                if (isValidChildIndex(index, childCount)) {
                    DivElement resBlock = DivElement.as(Element.as(getResolutionDiv().getChild(index)));
                    fillHourResolutionBlock(resBlock, date, index, hourCounter, lastTimelineBlock, left, even);
                    hourCounter = (hourCounter + 1) % 24;
                    even = !even;
                } else {
                    logIndexOutOfBounds("hour", index, childCount);
                    return;
                }
            }

            private boolean isEven(long startDate) {
                long normalDate = toNormalDate(startDate);
                if (normalStartDate < normalDate) {
                    int hours = (int) ((normalDate - normalStartDate) / HOUR_INTERVAL);
                    return (hours % 2) == 1;
                }
                return false;
            }

            private int getFirstHourOfVisibleRange(long startDate) {
                long normalDate = toNormalDate(startDate);
                if (normalStartDate < normalDate) {
                    int hours = (int) ((normalDate - normalStartDate) / HOUR_INTERVAL);
                    return ((firstHourOfRange + hours) % 24);
                }
                return firstHourOfRange;
            }
        });
    }

    private void fillTimelineForDayResolution(final long startDate, long endDate, final int left) {
        fillTimelineForResolution(DAY_INTERVAL, startDate, endDate, new ResolutionBlockFiller() {

            int dayCounter = getFirstDayOfVisibleRange(startDate);
            boolean even = isEven(startDate, firstDayOfRange);
            boolean firstWeek = true;
            int currentMarkedWeek = -1;
            int weekIndex = 0;
            Weekday weekday;

            @Override
            public void fillResolutionBlock(int index, Date date, String currentYear, boolean lastTimelineBlock) {

                try {
                    weekday = getWeekday(dayCounter);

                    if (resolution == Resolution.Week) {
                        fillWeekBlock(left, index, date, lastTimelineBlock);
                    } else {
                        fillDayBlock(left, index, date);
                    }

                } finally {
                    dayCounter = Math.max((dayCounter + 1) % 8, 1);
                }
            }

            private void fillDayBlock(final int left, int index, Date date) {
                int childCount = getResolutionDiv().getChildCount();
                if (isValidChildIndex(index, childCount)) {
                    DivElement resBlock = DivElement.as(Element.as(getResolutionDiv().getChild(index)));
                    fillDayResolutionBlock(resBlock, date, index, isWeekEnd(dayCounter), left);
                } else {
                    logIndexOutOfBounds("day", index, childCount);
                    return;
                }
            }

            private void fillWeekBlock(final int left, int index, Date date, boolean lastTimelineBlock) {
                DivElement resBlock = null;
                if (index > 0 && weekday == Weekday.First) {
                    weekIndex++;
                    firstWeek = false;
                    even = !even;
                }
                boolean fillWeekBlock = index == 0 || weekday == Weekday.First;
                int childCount = getResolutionDiv().getChildCount();
                if (isValidChildIndex(weekIndex, childCount)) {
                    resBlock = DivElement.as(Element.as(getResolutionDiv().getChild(weekIndex)));
                } else {
                    logIndexOutOfBounds("week", weekIndex, childCount);
                    return;
                }

                if (showCurrentTime && getLocaleDataProvider().formatDate(date, DAY_CHECK_FORMAT).equals(currentDate)) {
                    resBlock.addClassName(STYLE_NOW);
                    currentMarkedWeek = weekIndex;
                } else if (currentMarkedWeek != weekIndex) {
                    resBlock.removeClassName(STYLE_NOW);
                }

                fillWeekResolutionBlock(resBlock, fillWeekBlock, date, weekIndex, weekday, firstWeek,
                        lastTimelineBlock, left, even);
            }

            private int calcDaysLeftInFirstWeek(int startDay) {
                int daysLeftInWeek = 0;
                if (startDay != firstDayOfWeek) {
                    for (int i = startDay;; i++) {
                        daysLeftInWeek++;
                        if (Math.max(i % 8, 1) == lastDayOfWeek) {
                            break;
                        }
                    }
                }
                return daysLeftInWeek;
            }

            private boolean isEven(long startDate, int startDay) {
                long visibleRangeNormalStartDate = toNormalDate(startDate);
                if (normalStartDate < visibleRangeNormalStartDate) {
                    int daysHidden = (int) ((visibleRangeNormalStartDate - normalStartDate) / DAY_INTERVAL);
                    GWT.log("Days hidden: " + daysHidden);
                    GWT.log("firstWeekBlockHidden = " + firstWeekBlockHidden);
                    if (daysHidden == 0) {
                        return false;
                    }
                    int daysLeftInFirstWeek = calcDaysLeftInFirstWeek(startDay);
                    if (daysHidden > daysLeftInFirstWeek) {
                        daysHidden -= daysLeftInFirstWeek;
                    }
                    int weeks = daysHidden / DAYS_IN_WEEK;
                    boolean even = (weeks % 2) == 1;
                    return (firstWeekBlockHidden) ? !even : even;
                }
                return false;
            }

            private int getFirstDayOfVisibleRange(long startDate) {
                long visibleRangeNormalStartDate = toNormalDate(startDate);
                if (normalStartDate < visibleRangeNormalStartDate) {
                    int days = (int) ((visibleRangeNormalStartDate - normalStartDate) / DAY_INTERVAL);
                    return ((firstDayOfRange - 1 + days) % 7) + 1;
                }
                return firstDayOfRange;
            }

        });
    }

    private void logIndexOutOfBounds(String indexName, int index, int childCount) {
        GWT.log(indexName + " index " + index + " out of bounds with childCount " + childCount
                + ". Can't fill content.");
    }

    private void prepareTimelineForHourResolution(long interval, long startDate, long endDate,
            ResolutionBlockRegisterer resBlockRegisterer) {
        blocksInRange = 0;
        resolutionBlockCount = 0;
        firstResBlockCount = 0;
        lastResBlockCount = 0;
        String currentYear = null;
        String currentMonth = null;
        String currentDay = null;
        long pos = startDate;
        final long end = endDate;
        int index = 0;
        boolean lastTimelineBlock = false;
        Date date;

        while (pos <= end) {
            date = new Date(pos);
            Date nextHour = new Date(pos + interval);
            lastTimelineBlock = nextHour.getTime() > end;

            resBlockRegisterer.registerResolutionBlock(index, date, currentYear, lastTimelineBlock);

            if (isYearRowVisible()) {
                currentYear = addYearBlock(currentYear, date);
            }
            if (isMonthRowVisible()) {
                currentMonth = addMonthBlock(currentMonth, date);
            }
            if (isDayRowVisible()) {
                currentDay = addDayBlock(currentDay, date);
            }
            pos = nextHour.getTime();
            index++;
        }
    }

    private void prepareTimelineForResolution(long interval, long startDate, long endDate,
            ResolutionBlockRegisterer resBlockRegisterer) {
        blocksInRange = 0;
        resolutionBlockCount = 0;
        firstResBlockCount = 0;
        lastResBlockCount = 0;
        String currentYear = null;
        String currentMonth = null;
        String currentDay = null;
        long pos = adjustToMiddleOfDay(startDate);
        final long end = endDate;
        int index = 0;
        boolean lastTimelineBlock = false;
        Date date;
        boolean isDST = false;
        boolean isPreviousDst = getLocaleDataProvider().getTimeZone().isDaylightTime(new Date(startDate));

        while (!lastTimelineBlock) {
            long dstAdjusted = getDSTAdjustedDate(isPreviousDst, pos);
            date = new Date(dstAdjusted);
            pos = dstAdjusted;
            isDST = getLocaleDataProvider().getTimeZone().isDaylightTime(date);
            lastTimelineBlock = (getDSTAdjustedDate(isDST, date.getTime() + interval)) > end;

            resBlockRegisterer.registerResolutionBlock(index, date, currentYear, lastTimelineBlock);

            if (isYearRowVisible()) {
                currentYear = addYearBlock(currentYear, date);
            }
            if (isMonthRowVisible()) {
                currentMonth = addMonthBlock(currentMonth, date);
            }
            if (isDayRowVisible()) {
                currentDay = addDayBlock(currentDay, date);
            }
            isPreviousDst = isDST;
            pos += interval;
            index++;
        }
    }

    private void fillTimelineForResolution(long interval, long startDate, long endDate,
            ResolutionBlockFiller resBlockFiller) {
        String currentYear = null;
        long pos = startDate;
        pos = adjustToMiddleOfDay(pos);
        final long end = endDate;
        int index = 0;
        boolean lastTimelineBlock = false;
        Date date;
        boolean isDST = false;
        boolean previousIsDST = getLocaleDataProvider().getTimeZone().isDaylightTime(new Date(startDate));

        while (!lastTimelineBlock) {
            long dstAdjusted = getDSTAdjustedDate(previousIsDST, pos);
            date = new Date(dstAdjusted);
            pos = dstAdjusted;
            isDST = getLocaleDataProvider().getTimeZone().isDaylightTime(date);
            lastTimelineBlock = (getDSTAdjustedDate(isDST, date.getTime() + interval)) > end;

            resBlockFiller.fillResolutionBlock(index, date, currentYear, lastTimelineBlock);

            previousIsDST = isDST;
            pos += interval;
            index++;
        }
    }

    private void fillTimelineForHourResolution(long interval, long startDate, long endDate,
            ResolutionBlockFiller resBlockFiller) {
        String currentYear = null;
        long pos = startDate;
        final long end = endDate;
        int index = 0;
        boolean lastTimelineBlock = false;
        Date date;

        while (pos <= end) {
            date = new Date(pos);
            Date nextHour = new Date(pos + interval);
            lastTimelineBlock = nextHour.getTime() > end;

            resBlockFiller.fillResolutionBlock(index, date, currentYear, lastTimelineBlock);

            pos = nextHour.getTime();
            index++;
        }
    }

    private long adjustToMiddleOfDay(long zonedDate) {
        DateTimeFormat hourFormat = DateTimeFormat.getFormat("HH");
        String hourStr = hourFormat.format(new Date(zonedDate), getLocaleDataProvider().getTimeZone());
        int h = Integer.parseInt(hourStr);
        int addHours = 12 - h;
        return zonedDate + (addHours * HOUR_INTERVAL);
    }

    private long getDSTAdjustedDate(boolean previousIsDST, long zonedDate) {
        // adjusts previously without dst adjusted date by dst
        // ((date + interval) - dst )
        // Note! intervals that are less or equal to dst are not supported
        // currently.
        long dstAdjustment = getLocaleDataProvider().getDaylightAdjustment(new Date(zonedDate));
        boolean isDST = dstAdjustment > 0;
        if (previousIsDST && !isDST) {
            // previously added interval is shorter than the real interval.
            // with 24h interval and 1h dst: real interval is 25h.
            return zonedDate + dstAdjustment;
        } else if (!previousIsDST && isDST) {
            // previously added interval is longer than the real interval.
            // with 24h interval and 1h dst: real interval is 23h.
            return zonedDate - dstAdjustment;
        }
        return zonedDate;
    }

    private interface ResolutionBlockRegisterer {
        void registerResolutionBlock(int index, Date date, String currentYear, boolean lastTimelineBlock);
    }

    private interface ResolutionBlockFiller {
        void fillResolutionBlock(int index, Date date, String currentYear, boolean lastTimelineBlock);
    }

    public LocaleDataProvider getLocaleDataProvider() {
        return localeDataProvider;
    }

    private Weekday getWeekday(int dayCounter) {
        if (dayCounter == firstDayOfWeek) {
            return Weekday.First;
        }
        if (dayCounter == lastDayOfWeek) {
            return Weekday.Last;
        }
        return Weekday.Between;
    }

    private boolean isWeekEnd(int dayCounter) {
        return dayCounter == 1 || dayCounter == 7;
    }

    private String key(String prefix, BlockRowData rowData) {
        return prefix + "_" + (rowData.size());

    }

    private String newKey(String prefix, BlockRowData rowData) {
        return prefix + "_" + (rowData.size() + 1);
    }

    private String addBlock(String current, String target, Date date, BlockRowData rowData, Operation operation) {
        String key;
        if (!target.equals(current)) {
            current = target;
            key = newKey("" + current, rowData);
            operation.run(target, key, date);
        } else {
            key = key("" + current, rowData);
            rowData.setBlockLength(key, rowData.getBlockLength(key) + 1);
        }
        return current;
    }

    private interface Operation {
        void run(String target, String value, Date date);
    }

    private String addDayBlock(String currentDay, Date date) {
        String day = getDay(date);

        return addBlock(currentDay, day, date, dayRowData, new Operation() {

            @Override
            public void run(String day, String key, Date date) {
                addDayBlock(key, formatDayCaption(day, date));
            }
        });
    }

    private String addMonthBlock(String currentMonth, Date date) {
        final int month = getMonth(date);

        return addBlock(currentMonth, String.valueOf(month), date, monthRowData, new Operation() {

            @Override
            public void run(String target, String key, Date date) {
                addMonthBlock(key, formatMonthCaption(month, date));
            }
        });
    }

    private String addYearBlock(String currentYear, Date date) {
        String year = getYear(date);

        return addBlock(currentYear, year, date, yearRowData, new Operation() {

            @Override
            public void run(String year, String key, Date date) {
                addYearBlock(key, formatYearCaption(year, date));
            }
        });
    }

    private void addMonthBlock(String key, String text) {
        DivElement monthBlock = createTimelineBlock(key, text, STYLE_MONTH, monthRowData);
    }

    private void addYearBlock(String key, String text) {
        createTimelineBlock(key, text, STYLE_YEAR, yearRowData);
    }

    private void addDayBlock(String key, String text) {
        DivElement dayBlock = createTimelineBlock(key, text, STYLE_DAY, dayRowData);
    }

    private DivElement createTimelineBlock(String key, String text, String styleSuffix, BlockRowData rowData) {
        DivElement div = DivElement.as(DOM.createDiv());
        div.setClassName(STYLE_ROW + " " + styleSuffix);
        div.setInnerText(text);
        rowData.setBlockLength(key, 1);
        rowData.setBlock(key, div);
        return div;
    }

    private String formatDayCaption(String day, Date date) {
        if (dayFormat == null || dayFormat.isEmpty()) {
            return day;
        }
        return getLocaleDataProvider().formatDate(date, dayFormat);
    }

    private String formatYearCaption(String year, Date date) {
        if (yearFormat == null || yearFormat.isEmpty()) {
            return year;
        }
        return getLocaleDataProvider().formatDate(date, yearFormat);
    }

    private String formatWeekCaption(Date date) {
        if (weekFormat == null || weekFormat.isEmpty()) {
            return "" + getWeekNumber(date, getLocaleDataProvider().getTimeZoneOffset(date),
                    getLocaleDataProvider().getFirstDayOfWeek());
        }
        return getLocaleDataProvider().formatDate(date, weekFormat);
    }

    private String formatMonthCaption(int month, Date date) {
        if (monthFormat == null || monthFormat.isEmpty()) {
            return monthNames[month];
        }
        return getLocaleDataProvider().formatDate(date, monthFormat);
    }

    private String formatHourCaption(Date date) {
        if (hourFormat == null || hourFormat.isEmpty()) {
            if (getLocaleDataProvider().isTwelveHourClock()) {
                return getLocaleDataProvider().formatDate(date, getHour12DateTimeFormat());
            }
            return getLocaleDataProvider().formatDate(date, getHour24DateTimeFormat());
        }
        return getLocaleDataProvider().formatDate(date, getCustomHourDateTimeFormat());
    }

    /** Clears Daylight saving time adjustment from the given time. */
    private long toNormalDate(long zonedDate) {
        return zonedDate - getLocaleDataProvider().getDaylightAdjustment(new Date(zonedDate));
    }

    private String getDay(Date date) {
        // by adjusting the date to the middle of the day before formatting is a
        // workaround to avoid DST issues with DateTimeFormatter.
        Date adjusted = new Date(adjustToMiddleOfDay(date.getTime()));
        return getLocaleDataProvider().formatDate(adjusted, getDayDateTimeFormat());
    }

    private String getYear(Date date) {
        return getLocaleDataProvider().formatDate(date, getYearDateTimeFormat());
    }

    private int getMonth(Date date) {
        String m = getLocaleDataProvider().formatDate(date, getMonthDateTimeFormat());
        return Integer.parseInt(m) - 1;
    }

    String createCalcCssValue(int resolutionBlockCount) {
        return createCalcCssValue(resolutionBlockCount, null);
    }

    String createCalcCssValue(int resolutionBlockCount, Integer multiplier) {
        if (ie) {
            // IEs up to 11 don't support more than two-decimal precision.
            // That's why we use calc(100% / x) or calc(123.12345%) css value to
            // workaround this limitation.
            if (multiplier != null) {
                double percents = 100.0 / resolutionBlockCount * multiplier.intValue();
                return "calc(" + percents + "%)";
            }
            return "calc(100% / " + resolutionBlockCount + ")";
        }
        return null;
    }

    private String createCalcCssValue(double v, double multiplier) {
        if (ie) {
            // see comments in createCalcCssValue(int, Integer)
            double percents = 100.0 / v * multiplier;
            return "calc(" + percents + "%)";
        }
        return null;
    }

    /**
     * If unit is '%' , returns a 'calc(xx.xx%)' for IE, or just a 'xx.xx%' for
     * other browsers.
     *
     * @param value
     *            Number
     * @param unit
     *            unit
     * @return Combined number value + unit string that can be passed for
     *         example to a element's css width/height.
     */
    public String toCssCalcOrNumberString(double value, String unit) {
        if (ie) {
            return "calc(" + value + unit + ")";
        }
        return value + unit;
    }

    private void setWidth(Element element, int multiplier) {
        if (isTimelineOverflowingHorizontally()) {
            element.getStyle().setWidth(multiplier * minResolutionWidth, Unit.PX);
        } else {
            if (isAlwaysCalculatePixelWidths()) {
                element.getStyle().setWidth(multiplier * dayOrHourWidthPx, Unit.PX);
            } else {
                setCssPercentageWidth(element, blocksInRange, dayWidthPercentage, multiplier);
            }
        }
    }

    private String getWidth(int multiplier) {
        if (isTimelineOverflowingHorizontally()) {
            return (multiplier * minResolutionWidth) + Unit.PX.getType();
        } else {
            if (isAlwaysCalculatePixelWidths()) {
                return multiplier * dayOrHourWidthPx + Unit.PX.getType();
            } else {
                return getCssPercentageWidth(blocksInRange, dayWidthPercentage, multiplier);
            }
        }
    }

    private void setWidth(double resBlockWidthPx, String pct, Element element) {
        if (isTimelineOverflowingHorizontally()) {
            element.getStyle().setWidth(resBlockMinWidthPx, Unit.PX);
        } else {
            if (isAlwaysCalculatePixelWidths()) {
                element.getStyle().setWidth(resBlockWidthPx, Unit.PX);
            } else {
                if (ie) {
                    element.getStyle().setProperty("flex", "1");
                }
                setCssPercentageWidth(element, resBlockWidthPercentage, pct);
            }
        }
    }

    private String getWidthStyleValue(String pct) {
        if (isTimelineOverflowingHorizontally()) {
            return resBlockMinWidthPx + Unit.PX.getType();
        } else {
            if (isAlwaysCalculatePixelWidths()) {
                return resBlockWidthPx + Unit.PX.getType();
            } else {
                return getCssPercentageWidth(resBlockWidthPercentage, pct);
            }
        }
    }

    private void setCssPercentageWidth(Element element, int daysInRange, double width, int position) {
        String pct = createCalcCssValue(daysInRange, position);
        setCssPercentageWidth(element, position * width, pct);
    }

    private String getCssPercentageWidth(int daysInRange, double width, int position) {
        String pct = createCalcCssValue(daysInRange, position);
        return getCssPercentageWidth(position * width, pct);
    }

    private void setCssPercentageWidth(Element element, double nValue, String pct) {

        if (pct != null) {
            element.getStyle().setProperty("width", pct);
        } else {
            element.getStyle().setWidth(nValue, Unit.PCT);
        }
    }

    private String getCssPercentageWidth(double nValue, String pct) {
        if (pct != null) {
            return pct;
        } else {
            return nValue + Unit.PCT.getType();
        }
    }

    private void registerDayResolutionBlock() {
        blocksInRange++;
        resolutionBlockCount++;
    }

    private void fillDayResolutionBlock(DivElement resBlock, Date date, int index, boolean weekend, int left) {
        resBlock.setInnerText(getLocaleDataProvider().formatDate(date, getDayDateTimeFormat()));

        if (showCurrentTime && getLocaleDataProvider().formatDate(date, DAY_CHECK_FORMAT).equals(currentDate)) {
            resBlock.addClassName(STYLE_NOW);
        } else {
            resBlock.removeClassName(STYLE_NOW);
        }
        if (weekend) {
            resBlock.addClassName(STYLE_WEEKEND);
        } else {
            resBlock.removeClassName(STYLE_WEEKEND);
        }

        if (styleElementForLeft == null && isTimelineOverflowingHorizontally()) {
            resBlock.getStyle().setPosition(Position.RELATIVE);
            resBlock.getStyle().setLeft(left, Unit.PX);
        }
    }

    private void registerWeekResolutionBlock(int index, Weekday weekDay, boolean lastBlock, boolean firstWeek) {
        if (index == 0 || weekDay == Weekday.First) {
            resolutionBlockCount++;
        }

        if (firstWeek && (weekDay == Weekday.Last || lastBlock)) {
            firstResBlockCount = index + 1;
        } else if (lastBlock) {
            lastResBlockCount = (index + 1 - firstResBlockCount) % 7;
        }

        blocksInRange++;
    }

    private void fillWeekResolutionBlock(DivElement resBlock, boolean fillWeekBlock, Date date, int index,
            Weekday weekDay, boolean firstWeek,
            boolean lastBlock, int left, boolean even) {
        if (fillWeekBlock) {
            resBlock.setInnerText(formatWeekCaption(date));

            if (even) {
                resBlock.addClassName(STYLE_EVEN);
            } else {
                resBlock.removeClassName(STYLE_EVEN);
            }

            if (styleElementForLeft == null && isTimelineOverflowingHorizontally()) {
                resBlock.getStyle().setPosition(Position.RELATIVE);
                resBlock.getStyle().setLeft(left, Unit.PX);
            }

            resBlock.removeClassName(STYLE_FIRST);
            resBlock.removeClassName(STYLE_LAST);
        }

        if (firstWeek && (weekDay == Weekday.Last || lastBlock)) {
            Element firstEl = resolutionDiv.getFirstChildElement();
            if (!firstEl.hasClassName(STYLE_FIRST)) {
                firstEl.addClassName(STYLE_FIRST);
            }
        } else if (lastBlock) {
            Element lastEl = Element.as(resolutionDiv.getLastChild());
            if (!lastEl.hasClassName(STYLE_LAST)) {
                lastEl.addClassName(STYLE_LAST);
            }
        }
    }

    private void registerHourResolutionBlock() {
        blocksInRange++;
        resolutionBlockCount++;
    }

    private void fillHourResolutionBlock(DivElement resBlock, Date date, int index, int hourCounter, boolean lastBlock,
            int left, boolean even) {
        resBlock.setInnerText(formatHourCaption(date));

        if (showCurrentTime
                && getLocaleDataProvider().formatDate(date, HOUR_CHECK_FORMAT).equals(currentDate + currentHour)) {
            resBlock.addClassName(STYLE_NOW);
        } else {
            resBlock.removeClassName(STYLE_NOW);
        }
        if (even) {
            resBlock.addClassName(STYLE_EVEN);
        } else {
            resBlock.removeClassName(STYLE_EVEN);

        }

        if (firstDay && (hourCounter == 24 || lastBlock)) {
            firstDay = false;
            firstResBlockCount = index + 1;
        } else if (lastBlock) {
            lastResBlockCount = (index + 1 - firstResBlockCount) % 24;
        }

        if (styleElementForLeft == null && isTimelineOverflowingHorizontally()) {
            resBlock.getStyle().setPosition(Position.RELATIVE);
            resBlock.getStyle().setLeft(left, Unit.PX);
        }
    }

    private DivElement createResolutionBlock() {
        DivElement resBlock = DivElement.as(DOM.createDiv());
        resBlock.setClassName("col");
        return resBlock;
    }

    private DivElement createHourResolutionBlock() {
        DivElement resBlock = createResolutionBlock();
        resBlock.addClassName("h");
        resBlock.addClassName(STYLE_CENTER);
        return resBlock;
    }

    private DivElement createDayResolutionBlock() {
        DivElement resBlock = createResolutionBlock();
        resBlock.addClassName(STYLE_CENTER);
        return resBlock;
    }

    private DivElement createWeekResolutionBlock() {
        DivElement resBlock = createResolutionBlock();
        resBlock.addClassName("w");
        resBlock.addClassName(STYLE_CENTER);
        return resBlock;
    }

    private boolean isChanged(Resolution resolution, long startDate, long endDate, int firstDayOfWeek,
            int firstDayOfRange, int firstHourOfRange, String locale) {
        boolean resolutionChanged = this.resolution != resolution;
        if (resolutionChanged) {
            minResolutionWidth = -1;
        }

        if (forceUpdateFlag) {
            forceUpdateFlag = false;
            return true;
        }
        return resolutionChanged || this.startDate != startDate || this.endDate != endDate
                || this.firstDayOfWeek != firstDayOfWeek || this.firstDayOfRange != firstDayOfRange
                || this.firstHourOfRange != firstHourOfRange
                || (this.locale == null && locale != null || (this.locale != null && !this.locale.equals(locale)));
    }

    private int calculateResolutionMinWidth() {

        boolean removeResolutionDiv = false;
        if (!resolutionDiv.hasParentElement()) {
            removeResolutionDiv = true;
            getElement().appendChild(resolutionDiv);
        }
        DivElement resBlockMeasure = DivElement.as(DOM.createDiv());
        if (resolution == Resolution.Week) {
            // configurable with '.col.w.measure' selector
            resBlockMeasure.setClassName(STYLE_COL + " " + STYLE_WEEK + " " + STYLE_MEASURE);
        } else {
            // measure for text 'MM'
            resBlockMeasure.setInnerText("MM");
            // configurable with '.col.measure' selector
            resBlockMeasure.setClassName(STYLE_COL + " " + STYLE_MEASURE);
        }
        resolutionDiv.appendChild(resBlockMeasure);
        int width = resBlockMeasure.getClientWidth();
        if (resolution == Resolution.Week) {
            // divide given width by number of days in week
            width = width / DAYS_IN_WEEK;
        }
        width = (width < resolutionWeekDayblockWidth) ? resolutionWeekDayblockWidth : width;
        resBlockMeasure.removeFromParent();
        if (removeResolutionDiv) {
            resolutionDiv.removeFromParent();
        }
        return width;
    }

    private void clear() {
        while (getElement().hasChildNodes()) {
            getElement().getLastChild().removeFromParent();
        }
        spacerBlocks.clear();
        yearRowData.clear();
        monthRowData.clear();
        dayRowData.clear();
    }

    private void showResolutionBlocksOnView() {
        double positionLeftSnapshot = positionLeft;
        double datePos = positionLeftSnapshot;
        firstWeekBlockHidden = false;

        int left = (int) positionLeftSnapshot;
        if (positionLeftSnapshot > 0 && resBlockWidthPx > 0) {
            double overflow = 0.0;
            boolean firstResBlockShort = isFirstResBlockShort();
            overflow = getScrollOverflowForResolutionBlock(positionLeftSnapshot, left, firstResBlockShort);
            left = (int) (positionLeftSnapshot - overflow);
            datePos = adjustLeftPositionForDateDetection(left);
        }
        if (datePos < 0.0) {
            datePos = positionLeftSnapshot;
        }
        long leftDate;
        boolean noticeDst = resolution == Resolution.Hour;
        leftDate = getDateForLeftPosition(datePos, noticeDst);

        double containerWidth = GanttUtil.getBoundingClientRectWidth(getElement().getParentElement());
        fillTimelineForResolution(leftDate,
                Math.min(endDate, getDateForLeftPosition(datePos + containerWidth, noticeDst)), left);

        if (styleElementForLeft != null) {
            StyleInjector.setContents(styleElementForLeft,
                    "." + STYLE_COL + " { position: relative; left: " + left + "px; }");
        }

        GWT.log(getClass().getSimpleName() + " Updated visible timeline elements for horizontal scroll position "
                + left);
    }

    /**
     * Adjust left position for optimal position to detect accurate date with
     * the current resolution.
     */
    private double adjustLeftPositionForDateDetection(int left) {
        double datePos;
        if (resolution == Resolution.Week) {
            // detect date from the center of the first day block inside the
            // week block.
            datePos = left + dayOrHourWidthPx / 2;
        } else {
            // detect date from the center of the block (day/hour)
            datePos = left + resBlockWidthPx / 2;
        }
        return datePos;
    }

    private double getScrollOverflowForResolutionBlock(double positionLeftSnapshot, int left,
            boolean firstResBlockShort) {
        double overflow;
        if (firstResBlockShort && left <= getFirstResolutionElementWidth()) {
            overflow = getScrollOverflowForShortFirstResolutionBlock(positionLeftSnapshot);
        } else {
            overflow = getScrollOverflowForRegularResoultionBlock(positionLeftSnapshot, firstResBlockShort);
        }
        return overflow;
    }

    private double getScrollOverflowForRegularResoultionBlock(double positionLeftSnapshot, boolean firstResBlockShort) {
        double overflow;
        double firstBlockWidth = getFirstResolutionElementWidth();
        double positionLeft = (positionLeftSnapshot - (firstResBlockShort ? firstBlockWidth : 0));
        overflow = positionLeft % resBlockWidthPx;
        if (firstResBlockShort) {
            overflow += firstBlockWidth;
            firstWeekBlockHidden = true;
        }
        return overflow;
    }

    private double getScrollOverflowForShortFirstResolutionBlock(double positionLeftSnapshot) {
        double overflow;
        // need to notice a short resolution block due to timeline's
        // start date which is in middle of a week.
        overflow = positionLeftSnapshot % getFirstResolutionElementWidth();
        if (overflow == 0.0) {
            overflow = getFirstResolutionElementWidth();
        }
        return overflow;
    }

    private void showAllResolutionBlocks() {
        if (styleElementForLeft != null) {
            StyleInjector.setContents(styleElementForLeft, "." + STYLE_COL + " { position: relative; left: 0px; }");
        }
        fillTimelineForResolution(startDate, endDate, 0);
    }

    private int calculateMinimumResolutionBlockWidth() {
        if (resolution == Resolution.Week) {
            return DAYS_IN_WEEK * minResolutionWidth;
        }
        return minResolutionWidth;
    }

    private void createTimelineElementsOnVisibleArea() {
        // create place holder elements that represents weeks/days/hours
        // depending on the resolution in the timeline.
        // Only visible blocks are created, and only once, content will change
        // on scroll.

        // first: detect how many blocks we can fit in the screen
        int blocks = resolutionBlockCount;
        if (isTimelineOverflowingHorizontally()) {
            blocks = (int) (GanttUtil.getBoundingClientRectWidth(getElement().getParentElement())
                    / calculateMinimumResolutionBlockWidth());
            if (resolutionBlockCount < blocks) {
                // blocks need to be scaled up to fit the screen
                blocks = resolutionBlockCount;
            } else {
                blocks += 2;
            }
        }

        DivElement element = null;
        for (int i = 0; i < blocks; i++) {
            switch (resolution) {
            case Hour:
                element = createHourResolutionBlock();
                break;
            case Day:
                element = createDayResolutionBlock();
                break;
            case Week:
                element = createWeekResolutionBlock();
                break;
            }
            resolutionDiv.appendChild(element);
        }

        GWT.log(getClass().getSimpleName() + " Added " + blocks + " visible timeline elements for resolution ."
                + String.valueOf(resolution));
    }

    private boolean isValidChildIndex(int index, int childCount) {
        return (index >= 0) && (index < childCount);
    }

    public static int getWeekNumber(Date d, long timezoneOffset, int firstDayOfWeek) {
        return GanttUtil.getWeekNumber(d, timezoneOffset, firstDayOfWeek);
    }

    public DivElement getResolutionDiv() {
        return resolutionDiv;
    }

    private class BlockRowData {

        private final Map<String, Element> blocks = new LinkedHashMap<String, Element>();
        private final Map<String, Integer> blockLength = new LinkedHashMap<String, Integer>();

        public int size() {
            return blocks.size();
        }

        public Element getBlock(String key) {
            return blocks.get(key);
        }

        public Set<Entry<String, Element>> getBlockEntries() {
            return blocks.entrySet();
        }

        public void setBlock(String key, Element element) {
            blocks.put(key, element);
        }

        public Integer getBlockLength(String key) {
            return blockLength.get(key);
        }

        public void setBlockLength(String key, Integer length) {
            blockLength.put(key, length);
        }

        public void clear() {
            blocks.clear();
            blockLength.clear();
        }
    }

    public void setHour24DateTimeFormat(DateTimeFormat hour24DateTimeFormat) {
        this.hour24DateTimeFormat = hour24DateTimeFormat;
    }

    public void setHour12DateTimeFormat(DateTimeFormat hour12DateTimeFormat) {
        this.hour12DateTimeFormat = hour12DateTimeFormat;
    }

    public void setResolutionWeekDayblockWidth(int resolutionWeekDayblockWidth) {
        this.resolutionWeekDayblockWidth = resolutionWeekDayblockWidth;
    }

    public void setCurrentDateAndTime(boolean showCurrentTime, String currentDate, String currentHour, long timestamp) {
        this.showCurrentTime = showCurrentTime;
        this.currentDate = currentDate;
        this.currentHour = currentHour;
        if (timestamp != this.timestamp) {
            this.timestamp = timestamp;
            browserTimestamp = System.currentTimeMillis();
        }
    }

    /**
     * Get current time epoch.
     * {@link #setCurrentDateAndTime(String, String, long)} must be called once
     * to setup 'clock' properly.
     */
    public long getNow() {
        return timestamp + (System.currentTimeMillis() - browserTimestamp);
    }

}