/* 
 * Copyright 2011 Vaadin Ltd.
 * 
 * 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 com.vaadin.ui;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;

import com.vaadin.data.Container;
import com.vaadin.data.util.filter.SimpleStringFilter;
import com.vaadin.event.FieldEvents;
import com.vaadin.event.FieldEvents.BlurEvent;
import com.vaadin.event.FieldEvents.BlurListener;
import com.vaadin.event.FieldEvents.FocusEvent;
import com.vaadin.event.FieldEvents.FocusListener;
import com.vaadin.server.PaintException;
import com.vaadin.server.PaintTarget;
import com.vaadin.server.Resource;
import com.vaadin.shared.ui.combobox.ComboBoxConstants;
import com.vaadin.shared.ui.combobox.FilteringMode;

/**
 * A filtering dropdown single-select. Suitable for newItemsAllowed, but it's
 * turned of by default to avoid mistakes. Items are filtered based on user
 * input, and loaded dynamically ("lazy-loading") from the server. You can turn
 * on newItemsAllowed and change filtering mode (and also turn it off), but you
 * can not turn on multi-select mode.
 * 
 */
@SuppressWarnings("serial")
public class ComboBox extends AbstractSelect implements
        AbstractSelect.Filtering, FieldEvents.BlurNotifier,
        FieldEvents.FocusNotifier {

    private String inputPrompt = null;

    /**
     * Holds value of property pageLength. 0 disables paging.
     */
    protected int pageLength = 10;

    private int columns = 0;

    // Current page when the user is 'paging' trough options
    private int currentPage = -1;

    private FilteringMode filteringMode = FilteringMode.STARTSWITH;

    private String filterstring;
    private String prevfilterstring;

    /**
     * Number of options that pass the filter, excluding the null item if any.
     */
    private int filteredSize;

    /**
     * Cache of filtered options, used only by the in-memory filtering system.
     */
    private List<Object> filteredOptions;

    /**
     * Flag to indicate that request repaint is called by filter request only
     */
    private boolean optionRequest;

    /**
     * True if the container is being filtered temporarily and item set change
     * notifications should be suppressed.
     */
    private boolean filteringContainer;

    /**
     * Flag to indicate whether to scroll the selected item visible (select the
     * page on which it is) when opening the popup or not. Only applies to
     * single select mode.
     * 
     * This requires finding the index of the item, which can be expensive in
     * many large lazy loading containers.
     */
    private boolean scrollToSelectedItem = true;

    /**
     * If text input is not allowed, the ComboBox behaves like a pretty
     * NativeSelect - the user can not enter any text and clicking the text
     * field opens the drop down with options
     */
    private boolean textInputAllowed = true;

    public ComboBox() {
        setNewItemsAllowed(false);
    }

    public ComboBox(String caption, Collection<?> options) {
        super(caption, options);
        setNewItemsAllowed(false);
    }

    public ComboBox(String caption, Container dataSource) {
        super(caption, dataSource);
        setNewItemsAllowed(false);
    }

    public ComboBox(String caption) {
        super(caption);
        setNewItemsAllowed(false);
    }

    /**
     * Gets the current input prompt.
     * 
     * @see #setInputPrompt(String)
     * @return the current input prompt, or null if not enabled
     */
    public String getInputPrompt() {
        return inputPrompt;
    }

    /**
     * Sets the input prompt - a textual prompt that is displayed when the
     * select would otherwise be empty, to prompt the user for input.
     * 
     * @param inputPrompt
     *            the desired input prompt, or null to disable
     */
    public void setInputPrompt(String inputPrompt) {
        this.inputPrompt = inputPrompt;
        markAsDirty();
    }

    @Override
    public void paintContent(PaintTarget target) throws PaintException {
        if (inputPrompt != null) {
            target.addAttribute(ComboBoxConstants.ATTR_INPUTPROMPT, inputPrompt);
        }

        if (!textInputAllowed) {
            target.addAttribute(ComboBoxConstants.ATTR_NO_TEXT_INPUT, true);
        }

        // clear caption change listeners
        getCaptionChangeListener().clear();

        // The tab ordering number
        if (getTabIndex() != 0) {
            target.addAttribute("tabindex", getTabIndex());
        }

        // If the field is modified, but not committed, set modified attribute
        if (isModified()) {
            target.addAttribute("modified", true);
        }

        if (isNewItemsAllowed()) {
            target.addAttribute("allownewitem", true);
        }

        boolean needNullSelectOption = false;
        if (isNullSelectionAllowed()) {
            target.addAttribute("nullselect", true);
            needNullSelectOption = (getNullSelectionItemId() == null);
            if (!needNullSelectOption) {
                target.addAttribute("nullselectitem", true);
            }
        }

        // Constructs selected keys array
        String[] selectedKeys = new String[(getValue() == null
                && getNullSelectionItemId() == null ? 0 : 1)];

        target.addAttribute("pagelength", pageLength);

        target.addAttribute("filteringmode", getFilteringMode().toString());

        // Paints the options and create array of selected id keys
        int keyIndex = 0;

        target.startTag("options");

        if (currentPage < 0) {
            optionRequest = false;
            currentPage = 0;
            filterstring = "";
        }

        boolean nullFilteredOut = filterstring != null
                && !"".equals(filterstring)
                && filteringMode != FilteringMode.OFF;
        // null option is needed and not filtered out, even if not on current
        // page
        boolean nullOptionVisible = needNullSelectOption && !nullFilteredOut;

        // first try if using container filters is possible
        List<?> options = getOptionsWithFilter(nullOptionVisible);
        if (null == options) {
            // not able to use container filters, perform explicit in-memory
            // filtering
            options = getFilteredOptions();
            filteredSize = options.size();
            options = sanitetizeList(options, nullOptionVisible);
        }

        final boolean paintNullSelection = needNullSelectOption
                && currentPage == 0 && !nullFilteredOut;

        if (paintNullSelection) {
            target.startTag("so");
            target.addAttribute("caption", "");
            target.addAttribute("key", "");
            target.endTag("so");
        }

        final Iterator<?> i = options.iterator();
        // Paints the available selection options from data source

        while (i.hasNext()) {

            final Object id = i.next();

            if (!isNullSelectionAllowed() && id != null
                    && id.equals(getNullSelectionItemId()) && !isSelected(id)) {
                continue;
            }

            // Gets the option attribute values
            final String key = itemIdMapper.key(id);
            final String caption = getItemCaption(id);
            final Resource icon = getItemIcon(id);
            getCaptionChangeListener().addNotifierForItem(id);

            // Paints the option
            target.startTag("so");
            if (icon != null) {
                target.addAttribute("icon", icon);
            }
            target.addAttribute("caption", caption);
            if (id != null && id.equals(getNullSelectionItemId())) {
                target.addAttribute("nullselection", true);
            }
            target.addAttribute("key", key);
            if (isSelected(id) && keyIndex < selectedKeys.length) {
                target.addAttribute("selected", true);
                selectedKeys[keyIndex++] = key;
            }
            target.endTag("so");
        }
        target.endTag("options");

        target.addAttribute("totalitems", size()
                + (needNullSelectOption ? 1 : 0));
        if (filteredSize > 0 || nullOptionVisible) {
            target.addAttribute("totalMatches", filteredSize
                    + (nullOptionVisible ? 1 : 0));
        }

        // Paint variables
        target.addVariable(this, "selected", selectedKeys);
        if (isNewItemsAllowed()) {
            target.addVariable(this, "newitem", "");
        }

        target.addVariable(this, "filter", filterstring);
        target.addVariable(this, "page", currentPage);

        currentPage = -1; // current page is always set by client

        optionRequest = true;

    }

    /**
     * Sets whether it is possible to input text into the field or whether the
     * field area of the component is just used to show what is selected. By
     * disabling text input, the comboBox will work in the same way as a
     * {@link NativeSelect}
     * 
     * @see #isTextInputAllowed()
     * 
     * @param textInputAllowed
     *            true to allow entering text, false to just show the current
     *            selection
     */
    public void setTextInputAllowed(boolean textInputAllowed) {
        this.textInputAllowed = textInputAllowed;
        markAsDirty();
    }

    /**
     * Returns true if the user can enter text into the field to either filter
     * the selections or enter a new value if {@link #isNewItemsAllowed()}
     * returns true. If text input is disabled, the comboBox will work in the
     * same way as a {@link NativeSelect}
     * 
     * @return
     */
    public boolean isTextInputAllowed() {
        return textInputAllowed;
    }

    /**
     * Returns the filtered options for the current page using a container
     * filter.
     * 
     * As a size effect, {@link #filteredSize} is set to the total number of
     * items passing the filter.
     * 
     * The current container must be {@link Filterable} and {@link Indexed}, and
     * the filtering mode must be suitable for container filtering (tested with
     * {@link #canUseContainerFilter()}).
     * 
     * Use {@link #getFilteredOptions()} and
     * {@link #sanitetizeList(List, boolean)} if this is not the case.
     * 
     * @param needNullSelectOption
     * @return filtered list of options (may be empty) or null if cannot use
     *         container filters
     */
    protected List<?> getOptionsWithFilter(boolean needNullSelectOption) {
        Container container = getContainerDataSource();

        if (pageLength == 0) {
            // no paging: return all items
            filteredSize = container.size();
            return new ArrayList<Object>(container.getItemIds());
        }

        if (!(container instanceof Filterable)
                || !(container instanceof Indexed)
                || getItemCaptionMode() != ITEM_CAPTION_MODE_PROPERTY) {
            return null;
        }

        Filterable filterable = (Filterable) container;

        Filter filter = buildFilter(filterstring, filteringMode);

        // adding and removing filters leads to extraneous item set
        // change events from the underlying container, but the ComboBox does
        // not process or propagate them based on the flag filteringContainer
        if (filter != null) {
            filteringContainer = true;
            filterable.addContainerFilter(filter);
        }

        // try-finally to ensure that the filter is removed from container even
        // if a exception is thrown...
        try {
            Indexed indexed = (Indexed) container;

            int indexToEnsureInView = -1;

            // if not an option request (item list when user changes page), go
            // to page with the selected item after filtering if accepted by
            // filter
            Object selection = getValue();
            if (isScrollToSelectedItem() && !optionRequest && selection != null) {
                // ensure proper page
                indexToEnsureInView = indexed.indexOfId(selection);
            }

            filteredSize = container.size();
            currentPage = adjustCurrentPage(currentPage, needNullSelectOption,
                    indexToEnsureInView, filteredSize);
            int first = getFirstItemIndexOnCurrentPage(needNullSelectOption,
                    filteredSize);
            int last = getLastItemIndexOnCurrentPage(needNullSelectOption,
                    filteredSize, first);

            // Compute the number of items to fetch from the indexes given or
            // based on the filtered size of the container
            int lastItemToFetch = Math.min(last, filteredSize - 1);
            int nrOfItemsToFetch = (lastItemToFetch + 1) - first;

            List<?> options = indexed.getItemIds(first, nrOfItemsToFetch);

            return options;
        } finally {
            // to the outside, filtering should not be visible
            if (filter != null) {
                filterable.removeContainerFilter(filter);
                filteringContainer = false;
            }
        }
    }

    /**
     * Constructs a filter instance to use when using a Filterable container in
     * the <code>ITEM_CAPTION_MODE_PROPERTY</code> mode.
     * 
     * Note that the client side implementation expects the filter string to
     * apply to the item caption string it sees, so changing the behavior of
     * this method can cause problems.
     * 
     * @param filterString
     * @param filteringMode
     * @return
     */
    protected Filter buildFilter(String filterString,
            FilteringMode filteringMode) {
        Filter filter = null;

        if (null != filterString && !"".equals(filterString)) {
            switch (filteringMode) {
            case OFF:
                break;
            case STARTSWITH:
                filter = new SimpleStringFilter(getItemCaptionPropertyId(),
                        filterString, true, true);
                break;
            case CONTAINS:
                filter = new SimpleStringFilter(getItemCaptionPropertyId(),
                        filterString, true, false);
                break;
            }
        }
        return filter;
    }

    @Override
    public void containerItemSetChange(Container.ItemSetChangeEvent event) {
        if (!filteringContainer) {
            super.containerItemSetChange(event);
        }
    }

    /**
     * Makes correct sublist of given list of options.
     * 
     * If paint is not an option request (affected by page or filter change),
     * page will be the one where possible selection exists.
     * 
     * Detects proper first and last item in list to return right page of
     * options. Also, if the current page is beyond the end of the list, it will
     * be adjusted.
     * 
     * @param options
     * @param needNullSelectOption
     *            flag to indicate if nullselect option needs to be taken into
     *            consideration
     */
    private List<?> sanitetizeList(List<?> options, boolean needNullSelectOption) {

        if (pageLength != 0 && options.size() > pageLength) {

            int indexToEnsureInView = -1;

            // if not an option request (item list when user changes page), go
            // to page with the selected item after filtering if accepted by
            // filter
            Object selection = getValue();
            if (isScrollToSelectedItem() && !optionRequest && selection != null) {
                // ensure proper page
                indexToEnsureInView = options.indexOf(selection);
            }

            int size = options.size();
            currentPage = adjustCurrentPage(currentPage, needNullSelectOption,
                    indexToEnsureInView, size);
            int first = getFirstItemIndexOnCurrentPage(needNullSelectOption,
                    size);
            int last = getLastItemIndexOnCurrentPage(needNullSelectOption,
                    size, first);
            return options.subList(first, last + 1);
        } else {
            return options;
        }
    }

    /**
     * Returns the index of the first item on the current page. The index is to
     * the underlying (possibly filtered) contents. The null item, if any, does
     * not have an index but takes up a slot on the first page.
     * 
     * @param needNullSelectOption
     *            true if a null option should be shown before any other options
     *            (takes up the first slot on the first page, not counted in
     *            index)
     * @param size
     *            number of items after filtering (not including the null item,
     *            if any)
     * @return first item to show on the UI (index to the filtered list of
     *         options, not taking the null item into consideration if any)
     */
    private int getFirstItemIndexOnCurrentPage(boolean needNullSelectOption,
            int size) {
        // Not all options are visible, find out which ones are on the
        // current "page".
        int first = currentPage * pageLength;
        if (needNullSelectOption && currentPage > 0) {
            first--;
        }
        return first;
    }

    /**
     * Returns the index of the last item on the current page. The index is to
     * the underlying (possibly filtered) contents. If needNullSelectOption is
     * true, the null item takes up the first slot on the first page,
     * effectively reducing the first page size by one.
     * 
     * @param needNullSelectOption
     *            true if a null option should be shown before any other options
     *            (takes up the first slot on the first page, not counted in
     *            index)
     * @param size
     *            number of items after filtering (not including the null item,
     *            if any)
     * @param first
     *            index in the filtered view of the first item of the page
     * @return index in the filtered view of the last item on the page
     */
    private int getLastItemIndexOnCurrentPage(boolean needNullSelectOption,
            int size, int first) {
        // page length usable for non-null items
        int effectivePageLength = pageLength
                - (needNullSelectOption && (currentPage == 0) ? 1 : 0);
        return Math.min(size - 1, first + effectivePageLength - 1);
    }

    /**
     * Adjusts the index of the current page if necessary: make sure the current
     * page is not after the end of the contents, and optionally go to the page
     * containg a specific item. There are no side effects but the adjusted page
     * index is returned.
     * 
     * @param page
     *            page number to use as the starting point
     * @param needNullSelectOption
     *            true if a null option should be shown before any other options
     *            (takes up the first slot on the first page, not counted in
     *            index)
     * @param indexToEnsureInView
     *            index of an item that should be included on the page (in the
     *            data set, not counting the null item if any), -1 for none
     * @param size
     *            number of items after filtering (not including the null item,
     *            if any)
     */
    private int adjustCurrentPage(int page, boolean needNullSelectOption,
            int indexToEnsureInView, int size) {
        if (indexToEnsureInView != -1) {
            int newPage = (indexToEnsureInView + (needNullSelectOption ? 1 : 0))
                    / pageLength;
            page = newPage;
        }
        // adjust the current page if beyond the end of the list
        if (page * pageLength > size) {
            page = (size + (needNullSelectOption ? 1 : 0)) / pageLength;
        }
        return page;
    }

    /**
     * Filters the options in memory and returns the full filtered list.
     * 
     * This can be less efficient than using container filters, so use
     * {@link #getOptionsWithFilter(boolean)} if possible (filterable container
     * and suitable item caption mode etc.).
     * 
     * @return
     */
    protected List<?> getFilteredOptions() {
        if (null == filterstring || "".equals(filterstring)
                || FilteringMode.OFF == filteringMode) {
            prevfilterstring = null;
            filteredOptions = new LinkedList<Object>(getItemIds());
            return filteredOptions;
        }

        if (filterstring.equals(prevfilterstring)) {
            return filteredOptions;
        }

        Collection<?> items;
        if (prevfilterstring != null
                && filterstring.startsWith(prevfilterstring)) {
            items = filteredOptions;
        } else {
            items = getItemIds();
        }
        prevfilterstring = filterstring;

        filteredOptions = new LinkedList<Object>();
        for (final Iterator<?> it = items.iterator(); it.hasNext();) {
            final Object itemId = it.next();
            String caption = getItemCaption(itemId);
            if (caption == null || caption.equals("")) {
                continue;
            } else {
                caption = caption.toLowerCase();
            }
            switch (filteringMode) {
            case CONTAINS:
                if (caption.indexOf(filterstring) > -1) {
                    filteredOptions.add(itemId);
                }
                break;
            case STARTSWITH:
            default:
                if (caption.startsWith(filterstring)) {
                    filteredOptions.add(itemId);
                }
                break;
            }
        }

        return filteredOptions;
    }

    /**
     * Invoked when the value of a variable has changed.
     * 
     * @see com.vaadin.ui.AbstractComponent#changeVariables(java.lang.Object,
     *      java.util.Map)
     */
    @Override
    public void changeVariables(Object source, Map<String, Object> variables) {
        // Not calling super.changeVariables due the history of select
        // component hierarchy

        // Selection change
        if (variables.containsKey("selected")) {
            final String[] ka = (String[]) variables.get("selected");

            // Single select mode
            if (ka.length == 0) {

                // Allows deselection only if the deselected item is visible
                final Object current = getValue();
                final Collection<?> visible = getVisibleItemIds();
                if (visible != null && visible.contains(current)) {
                    setValue(null, true);
                }
            } else {
                final Object id = itemIdMapper.get(ka[0]);
                if (id != null && id.equals(getNullSelectionItemId())) {
                    setValue(null, true);
                } else {
                    setValue(id, true);
                }
            }
        }

        String newFilter;
        if ((newFilter = (String) variables.get("filter")) != null) {
            // this is a filter request
            currentPage = ((Integer) variables.get("page")).intValue();
            filterstring = newFilter;
            if (filterstring != null) {
                filterstring = filterstring.toLowerCase();
            }
            requestRepaint();
        } else if (isNewItemsAllowed()) {
            // New option entered (and it is allowed)
            final String newitem = (String) variables.get("newitem");
            if (newitem != null && newitem.length() > 0) {
                getNewItemHandler().addNewItem(newitem);
                // rebuild list
                filterstring = null;
                prevfilterstring = null;
            }
        }

        if (variables.containsKey(FocusEvent.EVENT_ID)) {
            fireEvent(new FocusEvent(this));
        }
        if (variables.containsKey(BlurEvent.EVENT_ID)) {
            fireEvent(new BlurEvent(this));
        }

    }

    @Override
    public void setFilteringMode(FilteringMode filteringMode) {
        this.filteringMode = filteringMode;
    }

    @Override
    public FilteringMode getFilteringMode() {
        return filteringMode;
    }

    @Override
    public void addBlurListener(BlurListener listener) {
        addListener(BlurEvent.EVENT_ID, BlurEvent.class, listener,
                BlurListener.blurMethod);
    }

    /**
     * @deprecated Since 7.0, replaced by {@link #addBlurListener(BlurListener)}
     **/
    @Override
    @Deprecated
    public void addListener(BlurListener listener) {
        addBlurListener(listener);
    }

    @Override
    public void removeBlurListener(BlurListener listener) {
        removeListener(BlurEvent.EVENT_ID, BlurEvent.class, listener);
    }

    /**
     * @deprecated Since 7.0, replaced by
     *             {@link #removeBlurListener(BlurListener)}
     **/
    @Override
    @Deprecated
    public void removeListener(BlurListener listener) {
        removeBlurListener(listener);
    }

    @Override
    public void addFocusListener(FocusListener listener) {
        addListener(FocusEvent.EVENT_ID, FocusEvent.class, listener,
                FocusListener.focusMethod);
    }

    /**
     * @deprecated Since 7.0, replaced by
     *             {@link #addFocusListener(FocusListener)}
     **/
    @Override
    @Deprecated
    public void addListener(FocusListener listener) {
        addFocusListener(listener);
    }

    @Override
    public void removeFocusListener(FocusListener listener) {
        removeListener(FocusEvent.EVENT_ID, FocusEvent.class, listener);
    }

    /**
     * @deprecated Since 7.0, replaced by
     *             {@link #removeFocusListener(FocusListener)}
     **/
    @Override
    @Deprecated
    public void removeListener(FocusListener listener) {
        removeFocusListener(listener);
    }

    /**
     * ComboBox does not support multi select mode.
     * 
     * @deprecated use {@link ListSelect}, {@link OptionGroup} or
     *             {@link TwinColSelect} instead
     * @see com.vaadin.ui.AbstractSelect#setMultiSelect(boolean)
     * @throws UnsupportedOperationException
     *             if trying to activate multiselect mode
     */
    @Deprecated
    @Override
    public void setMultiSelect(boolean multiSelect) {
        if (multiSelect) {
            throw new UnsupportedOperationException("Multiselect not supported");
        }
    }

    /**
     * ComboBox does not support multi select mode.
     * 
     * @deprecated use {@link ListSelect}, {@link OptionGroup} or
     *             {@link TwinColSelect} instead
     * 
     * @see com.vaadin.ui.AbstractSelect#isMultiSelect()
     * 
     * @return false
     */
    @Deprecated
    @Override
    public boolean isMultiSelect() {
        return false;
    }

    /**
     * Sets whether to scroll the selected item visible (directly open the page
     * on which it is) when opening the combo box popup or not. Only applies to
     * single select mode.
     * 
     * This requires finding the index of the item, which can be expensive in
     * many large lazy loading containers.
     * 
     * @param scrollToSelectedItem
     *            true to find the page with the selected item when opening the
     *            selection popup
     */
    public void setScrollToSelectedItem(boolean scrollToSelectedItem) {
        this.scrollToSelectedItem = scrollToSelectedItem;
    }

    /**
     * Returns true if the select should find the page with the selected item
     * when opening the popup (single select combo box only).
     * 
     * @see #setScrollToSelectedItem(boolean)
     * 
     * @return true if the page with the selected item will be shown when
     *         opening the popup
     */
    public boolean isScrollToSelectedItem() {
        return scrollToSelectedItem;
    }

}
