/*
 * This file is part of muCommander, http://www.mucommander.com
 *
 * muCommander is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 3 of the License, or
 * (at your option) any later version.
 *
 * muCommander is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package com.mucommander.ui.quicksearch;

import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;

import javax.swing.JComponent;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * This class contains 'quick search' common functionality - selection of rows that match
 * the user's keyboard input.
 * This class is abstract, and should be inherited by subclasses that define 'quick search' 
 * functionality for specific components. 
 * 
 * @author Arik Hadas
 */
public abstract class QuickSearch<T> extends KeyAdapter implements Runnable {
	private static final Logger LOGGER = LoggerFactory.getLogger(QuickSearch.class);
	
	/** Quick search string */
    private String searchString;

	/** Timestamp of the last search string change, used when quick search is active */
    private long lastSearchStringChange;

    /** Thread that's responsible for canceling the quick search on timeout,
     * has a null value when quick search is not active */
    private Thread timeoutThread;

	/** Quick search timeout in ms */
    private final static int QUICK_SEARCH_TIMEOUT = 2000;

    /** Icon that is used to indicate in the status bar that quick search has failed */
    protected final static String QUICK_SEARCH_KO_ICON = "quick_search_ko.png";

    /** Icon that is used to indicate in the status bar that quick search has found a match */
    protected final static String QUICK_SEARCH_OK_ICON = "quick_search_ok.png";
    
    private JComponent component;
    
    protected QuickSearch(JComponent component) {
    	this.component = component;
    	
    	// Listener to key events to start quick search or update search string when it is active
    	component.addKeyListener(this);
    }
    
    /**
     * Turns on quick search mode. This method has no effect if the quick search is already active.
     * {@link #isActive() isActive()} will return <code>true</code> after this call, and until the quick search has
     * timed out or has been cancelled by user.
     */
    protected synchronized void start() {
        if(!isActive()) {
            // Reset search string
            searchString = "";
            // Start the thread that's responsible for canceling the quick search on timeout
            timeoutThread = new Thread(this, "QuickSearch timeout thread");
            timeoutThread.start();
            lastSearchStringChange = System.currentTimeMillis();

            searchStarted();
        }
    }

    /**
     * Stops the current quick search. This method has no effect if the quick search is not currently active.
     */
    public synchronized void stop() {
        if(isActive()) {
            timeoutThread = null;

            searchStopped();
        }
    }

    /**
     * Returns <code>true</code> if a quick search is being performed.
     *
     * @return true if a quick search is being performed
     */
    public synchronized boolean isActive() {
        return timeoutThread != null;
    }


    /**
     * Returns <code>true</code> if the current quick search string matches the given string.
     * Always returns <code>false</code> when the quick search is inactive.
     *
     * @param string the string to test against the quick search string
     * @return true if the current quick search string matches the given string
     */
    public boolean matches(String string) {
        return isActive() && string.toLowerCase().indexOf(searchString.toLowerCase())!=-1;
    }


    /**
     * Returns <code>true</code> if the given <code>KeyEvent</code> corresponds to a valid quick search input,
     * <code>false</code> in any of the following cases:
     *
     * <ul>
     *   <li>has any of the Alt, Ctrl or Meta modifier keys down (Shift is OK)</li>
     *   <li>is an ASCII control character (<32 or ==127)</li>
     *   <li>is not a valid Unicode character</li>
     * </ul>
     *
     * @param e the KeyEvent to test
     * @return true if the given <code>KeyEvent</code> corresponds to a valid quick search input
     */
    protected boolean isValidQuickSearchInput(KeyEvent e) {
        if((e.getModifiersEx()&(KeyEvent.ALT_DOWN_MASK|KeyEvent.CTRL_DOWN_MASK|KeyEvent.META_DOWN_MASK))!=0)
            return false;

        char keyChar = e.getKeyChar();
        return keyChar>=32 && keyChar!=127 && Character.isDefined(keyChar);
    }
    
    /**
     * Setter for the last search string change time
     * 
     * @param lastSearchStringChange - the time of the last change made to the search string
     */
	protected void setLastSearchStringChange(long lastSearchStringChange) {
		this.lastSearchStringChange = lastSearchStringChange;
	}

	protected boolean isSearchStringEmpty() {
		return searchString.length() == 0;
	}
	
	protected void removeLastCharacterFromSearchString() {
		// Remove last character from the search string
        // Since the search string has been updated, match information has changed as well
        // and we need to repaint the table.
        // Note that we only repaint if the search string is not empty: if it's empty,
        // the cancel() method will be called, and repainting twice would result in an
        // unpleasant graphical artifact.
        searchString = searchString.substring(0, searchString.length()-1);
        if(searchString.length() != 0)
            component.repaint();
	}
	
	protected void appendCharacterToSearchString(char keyChar) {
		// Update search string with the key that has just been typed
        // Since the search string has been updated, match information has changed as well
        // and we need to repaint the table.
        searchString += keyChar;
        component.repaint();
	}
	
	/**
     * Finds a match (if any) for the current quick search string and selects the corresponding row.
     *
     * @param startRow first row to be tested
     * @param descending specifies whether rows should be tested in ascending or descending order
     * @param findBestMatch if <code>true</code>, all rows will be tested in the specified order, looking for the best match. If not, it will stop to the first match (not necessarily the best).
     */
    protected void findMatch(int startRow, boolean descending, boolean findBestMatch) {
        LOGGER.trace("startRow="+startRow+" descending="+descending+" findMatch="+findBestMatch);

        // If search string is empty, update status bar without any icon and return
        if(searchString.length()==0) {
            searchStringBecameEmpty(searchString);
        }
        else {
        	int bestMatch = getBestMatch(startRow, descending, findBestMatch);

            if (bestMatch != -1)
                matchFound(bestMatch, searchString);
            else
                matchNotFound(searchString);
        }
    }
	
	private int getBestMatch(int startRow, boolean descending, boolean findBestMatch) {
    	String searchStringLC = searchString.toLowerCase();
    	int searchStringLen = searchString.length();
        int startsWithCaseMatch = -1;
        int startsWithNoCaseMatch = -1;
        int containsCaseMatch = -1;
        int containsNoCaseMatch = -1;
        int nbRows = getNumOfItems();

        // Iterate on rows and look the first strings to match one of the following tests,
        // in the following order of importance :
        // - search string matches the beginning of the string with the same case
        // - search string matches the beginning of the string with a different case
        // - string contains search string with the same case
        // - string contains search string with a different case
        for(int i=startRow; descending?i<nbRows:i>=0; i=descending?i+1:i-1) {
            // if findBestMatch was not specified, stop to the first match
            if(!findBestMatch && (startsWithCaseMatch!=-1 || startsWithNoCaseMatch!=-1 || containsCaseMatch!=-1 || containsNoCaseMatch!=-1))
                break;

            String item = getItemString(i);
            int itemLen = item.length();

            // No need to compare strings if quick search string is longer than compared string,
            // they won't match
            if(itemLen<searchStringLen)
                continue;

            // Compare quick search string against
            if (item.startsWith(searchString)) {
                // We've got the best match we could ever have, let's get out of this loop!
                startsWithCaseMatch = i;
                break;
            }

            // If we already have a match on this test case, let's skip to the next string
            if(startsWithNoCaseMatch!=-1)
                continue;

            String itemLC = item.toLowerCase();
            if(itemLC.startsWith(searchStringLC)) {
                // We've got a match, let's see if we can find a better match on the next string
                startsWithNoCaseMatch = i;
            }

            // No need to check if the compared string contains search string if both size are equal,
            // in the case startsWith test yields the same result
            if(itemLen==searchStringLen)
                continue;

            // If we already have a match on this test case, let's skip to the next string
            if(containsCaseMatch!=-1)
                continue;

            if(item.indexOf(searchString)!=-1) {
                // We've got a match, let's see if we can find a better match on the next string
                containsCaseMatch = i;
                continue;
            }

            // If we already have a match on this test case, let's skip to the next string
            if(containsNoCaseMatch!=-1)
                continue;

            if(itemLC.indexOf(searchStringLC)!=-1) {
                // We've got a match, let's see if we can find a better match on the next string
                containsNoCaseMatch = i;
                continue;
            }
        }
    	
        // Determines what the best match is, based on all the matches we found
        int bestMatch = startsWithCaseMatch!=-1?startsWithCaseMatch
            :startsWithNoCaseMatch!=-1?startsWithNoCaseMatch
            :containsCaseMatch!=-1?containsCaseMatch
            :containsNoCaseMatch!=-1?containsNoCaseMatch
            :-1;
        
        LOGGER.trace("startsWithCaseMatch="+startsWithCaseMatch+" containsCaseMatch="+containsCaseMatch+" startsWithNoCaseMatch="+startsWithNoCaseMatch+" containsNoCaseMatch="+containsNoCaseMatch);
        LOGGER.trace("bestMatch="+bestMatch);
        
        return bestMatch;
    }

	//////////////////////
	// Abstract methods //
	//////////////////////

	/**
	 * Hook that is called after the search is started
	 */
	protected abstract void searchStarted();
	
	/**
	 * Hook that is called after the search is stopped
	 */
	protected abstract void searchStopped();
	
	/**
	 * Return number of items to be searched in
	 * 
	 * @return number of items
	 */
	protected abstract int getNumOfItems();
	
	/**
	 * Return item at a given index as String
	 * 
	 * @param index - index of item
	 * @return item at index as String
	 */
	protected abstract String getItemString(int index);
	
	/**
	 * Hook that is called after a search was done for an empty string
	 * 
	 * @param searchString
	 */
	protected abstract void searchStringBecameEmpty(String searchString);
	
	/**
	 * Hook that is called after a search was done and an item was found
	 * 
	 * @param row - the row of the item that was found
	 * @param searchString - the string that was being searched
	 */
	protected abstract void matchFound(int row, String searchString);
	
	/**
	 * Hood that is called after a search was done and no item was found
	 * 
	 * @param searchString - the string that was being searched
	 */
	protected abstract void matchNotFound(String searchString);

    //////////////////////
    // Runnable methods //
    //////////////////////

    public void run() {
        do {
            try { Thread.sleep(100); }
            catch(InterruptedException e) {
                // No problemo
            }

            synchronized(this) {
                if(timeoutThread!=null && System.currentTimeMillis()-lastSearchStringChange >= QUICK_SEARCH_TIMEOUT) {
                    stop();
                }
            }
        }
        while(timeoutThread!=null);
    }

    ///////////////////////////////
    // KeyAdapter implementation //
    ///////////////////////////////
    
    @Override
    public synchronized void keyReleased(KeyEvent e) {
        // Cancel quick search if backspace key has been pressed and search string is empty.
        // This check is done on key release, so that if backspace key is maintained pressed
        // to remove all the search string, it does not trigger the JComponent's back action 
    	// which is mapped on backspace too
        if(isActive() && e.getKeyCode()==KeyEvent.VK_BACK_SPACE && searchString.equals("")) {
            e.consume();
            stop();
        }
    }
}
