/*
 * Copyright (C) 2013 The Android Open Source Project
 *
 * 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.android.documentsui.queries;

import static com.android.documentsui.base.SharedMinimal.DEBUG;
import static com.android.documentsui.base.State.ACTION_GET_CONTENT;
import static com.android.documentsui.base.State.ACTION_OPEN;
import static com.android.documentsui.base.State.ActionType;

import android.content.Intent;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.provider.DocumentsContract;
import android.text.TextUtils;
import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
import android.view.MenuItem.OnActionExpandListener;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.View.OnFocusChangeListener;
import android.view.ViewGroup;

import androidx.annotation.GuardedBy;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.appcompat.widget.SearchView;
import androidx.appcompat.widget.SearchView.OnQueryTextListener;
import androidx.fragment.app.FragmentManager;

import com.android.documentsui.MetricConsts;
import com.android.documentsui.Metrics;
import com.android.documentsui.R;
import com.android.documentsui.base.DocumentInfo;
import com.android.documentsui.base.DocumentStack;
import com.android.documentsui.base.EventHandler;
import com.android.documentsui.base.RootInfo;
import com.android.documentsui.base.Shared;
import com.android.documentsui.base.State;
import com.android.modules.utils.build.SdkLevel;

import java.util.Timer;
import java.util.TimerTask;

/**
 * Manages searching UI behavior.
 */
public class SearchViewManager implements
        SearchView.OnCloseListener, OnQueryTextListener, OnClickListener, OnFocusChangeListener,
        OnActionExpandListener {

    private static final String TAG = "SearchManager";

    // How long we wait after the user finishes typing before kicking off a search.
    public static final int SEARCH_DELAY_MS = 750;

    private final SearchManagerListener mListener;
    private final EventHandler<String> mCommandProcessor;
    private final SearchChipViewManager mChipViewManager;
    private final Timer mTimer;
    private final Handler mUiHandler;

    private final Object mSearchLock;
    @GuardedBy("mSearchLock")
    private @Nullable Runnable mQueuedSearchRunnable;
    @GuardedBy("mSearchLock")
    private @Nullable TimerTask mQueuedSearchTask;
    private @Nullable String mCurrentSearch;
    private String mQueryContentFromIntent;
    private boolean mSearchExpanded;
    private boolean mIgnoreNextClose;
    private boolean mFullBar;
    private boolean mIsHistorySearch;
    private boolean mShowSearchBar;

    private @Nullable Menu mMenu;
    private @Nullable MenuItem mMenuItem;
    private @Nullable SearchView mSearchView;
    private @Nullable FragmentManager mFragmentManager;

    public SearchViewManager(
            SearchManagerListener listener,
            EventHandler<String> commandProcessor,
            ViewGroup chipGroup,
            @Nullable Bundle savedState) {
        this(listener, commandProcessor, new SearchChipViewManager(chipGroup), savedState,
                new Timer(), new Handler(Looper.getMainLooper()));
    }

    @VisibleForTesting
    protected SearchViewManager(
            SearchManagerListener listener,
            EventHandler<String> commandProcessor,
            SearchChipViewManager chipViewManager,
            @Nullable Bundle savedState,
            Timer timer,
            Handler handler) {
        assert (listener != null);
        assert (commandProcessor != null);

        mSearchLock = new Object();
        mListener = listener;
        mCommandProcessor = commandProcessor;
        mTimer = timer;
        mUiHandler = handler;
        mChipViewManager = chipViewManager;
        mChipViewManager.setSearchChipViewManagerListener(this::onChipCheckedStateChanged);

        if (savedState != null) {
            mCurrentSearch = savedState.getString(Shared.EXTRA_QUERY);
            mChipViewManager.restoreCheckedChipItems(savedState);
        } else {
            mCurrentSearch = null;
        }
    }

    private void onChipCheckedStateChanged(View v) {
        mListener.onSearchChipStateChanged(v);
        performSearch(mCurrentSearch);
    }

    /**
     * Parse the query content from Intent. If the action is not {@link State#ACTION_GET_CONTENT}
     * or {@link State#ACTION_OPEN}, don't perform search.
     * @param intent the intent to parse.
     * @param action the action to check.
     * @return True, if get the query content from the intent. Otherwise, false.
     */
    public boolean parseQueryContentFromIntent(Intent intent, @ActionType int action) {
        if (action == ACTION_OPEN || action == ACTION_GET_CONTENT) {
            final String queryString = intent.getStringExtra(Intent.EXTRA_CONTENT_QUERY);
            if (!TextUtils.isEmpty(queryString)) {
                mQueryContentFromIntent = queryString;
                return true;
            }
        }
        return false;
    }

    /**
     * Build the bundle of query arguments.
     * Example: search string and mime types
     *
     * @return the bundle of query arguments
     */
    public Bundle buildQueryArgs() {
        final Bundle queryArgs = mChipViewManager.getCheckedChipQueryArgs();
        if (!TextUtils.isEmpty(mCurrentSearch)) {
            queryArgs.putString(DocumentsContract.QUERY_ARG_DISPLAY_NAME, mCurrentSearch);
        } else if (isExpanded() && isSearching()) {
            // The existence of the DocumentsContract.QUERY_ARG_DISPLAY_NAME constant is used to
            // determine if this is a text search (as opposed to simply filtering from within a
            // non-searching view), so ensure the argument exists when searching.
            queryArgs.putString(DocumentsContract.QUERY_ARG_DISPLAY_NAME, "");
        }

        return queryArgs;
    }

    /**
     * Initialize the search chips base on the acceptMimeTypes.
     *
     * @param acceptMimeTypes use to filter chips
     */
    public void initChipSets(String[] acceptMimeTypes) {
        mChipViewManager.initChipSets(acceptMimeTypes);
    }

    /**
     * Update the search chips base on the acceptMimeTypes.
     * If the count of matched chips is less than two, we will
     * hide the chip row.
     *
     * @param acceptMimeTypes use to filter chips
     */
    public void updateChips(String[] acceptMimeTypes) {
        mChipViewManager.updateChips(acceptMimeTypes);
    }

    /**
     * Bind chip data in ChipViewManager on other view groups
     *
     * @param chipGroup target view group for bind ChipViewManager data
     */
    public void bindChips(ViewGroup chipGroup) {
        mChipViewManager.bindMirrorGroup(chipGroup);
    }

    /**
     * Click behavior when chip in synced chip group click.
     *
     * @param data SearchChipData synced in mirror group
     */
    public void onMirrorChipClick(SearchChipData data) {
        mChipViewManager.onMirrorChipClick(data);
        mSearchView.clearFocus();
    }

    /**
     * Initailize search view by option menu.
     *
     * @param menu the menu include search view
     * @param isFullBarSearch whether hide other menu when search view expand
     * @param isShowSearchBar whether replace collapsed search view by search hint text
     */
    public void install(Menu menu, boolean isFullBarSearch, boolean isShowSearchBar) {
        mMenu = menu;
        mMenuItem = mMenu.findItem(R.id.option_menu_search);
        mSearchView = (SearchView) mMenuItem.getActionView();

        mSearchView.setOnQueryTextListener(this);
        mSearchView.setOnCloseListener(this);
        mSearchView.setOnSearchClickListener(this);
        mSearchView.setOnQueryTextFocusChangeListener(this);
        final View clearButton = mSearchView.findViewById(androidx.appcompat.R.id.search_close_btn);
        if (clearButton != null) {
            clearButton.setPadding(clearButton.getPaddingStart() + getPixelForDp(4),
                    clearButton.getPaddingTop(), clearButton.getPaddingEnd() + getPixelForDp(4),
                    clearButton.getPaddingBottom());
            clearButton.setOnClickListener(v -> {
                mSearchView.setQuery("", false);
                mSearchView.requestFocus();
                mListener.onSearchViewClearClicked();
            });
        }
        if (SdkLevel.isAtLeastU()) {
            final View textView = mSearchView.findViewById(androidx.appcompat.R.id.search_src_text);
            if (textView != null) {
                try {
                    textView.setIsHandwritingDelegate(true);
                } catch (LinkageError e) {
                    // Running on a device with an older build of Android U
                    // TODO(b/274154553): Remove try/catch block after Android U Beta 1 is released
                }
            }
        }

        mFullBar = isFullBarSearch;
        mShowSearchBar = isShowSearchBar;
        mSearchView.setMaxWidth(Integer.MAX_VALUE);
        mMenuItem.setOnActionExpandListener(this);

        restoreSearch(true);
    }

    public void setFragmentManager(FragmentManager fragmentManager) {
        mFragmentManager = fragmentManager;
    }

    /**
     * Used to hide menu icons, when the search is being restored. Needed because search restoration
     * is done before onPrepareOptionsMenu(Menu menu) that is overriding the icons visibility.
     */
    public void updateMenu() {
        if (mMenu != null && isExpanded() && mFullBar) {
            mMenu.setGroupVisible(R.id.group_hide_when_searching, false);
        }
    }

    /**
     * @param stack New stack.
     */
    public void update(DocumentStack stack) {
        if (mMenuItem == null || mSearchView == null) {
            if (DEBUG) {
                Log.d(TAG, "update called before Search MenuItem installed.");
            }
            return;
        }

        if (mCurrentSearch != null) {
            mMenuItem.expandActionView();

            mSearchView.setIconified(false);
            mSearchView.clearFocus();
            mSearchView.setQuery(mCurrentSearch, false);
        } else {
            mSearchView.clearFocus();
            if (!mSearchView.isIconified()) {
                mIgnoreNextClose = true;
                mSearchView.setIconified(true);
            }

            if (mMenuItem.isActionViewExpanded()) {
                mMenuItem.collapseActionView();
            }
        }

        showMenu(stack);
    }

    public void showMenu(@Nullable DocumentStack stack) {
        final DocumentInfo cwd = stack != null ? stack.peek() : null;

        boolean supportsSearch = true;

        // Searching in archives is not enabled, as archives are backed by
        // a different provider than the root provider.
        if (cwd != null && cwd.isInArchive()) {
            supportsSearch = false;
        }

        final RootInfo root = stack != null ? stack.getRoot() : null;
        if (root == null || !root.supportsSearch()) {
            supportsSearch = false;
        }

        if (mMenuItem == null) {
            if (DEBUG) {
                Log.d(TAG, "showMenu called before Search MenuItem installed.");
            }
            return;
        }

        if (!supportsSearch) {
            mCurrentSearch = null;
        }

        // Recent root show open search bar, do not show duplicate search icon.
        mMenuItem.setVisible(supportsSearch && (!stack.isRecents() || !mShowSearchBar));

        mChipViewManager.setChipsRowVisible(supportsSearch && root.supportsMimeTypesSearch());
    }

    /**
     * Cancels current search operation. Triggers clearing and collapsing the SearchView.
     *
     * @return True if it cancels search. False if it does not operate search currently.
     */
    public boolean cancelSearch() {
        if (mSearchView != null && (isExpanded() || isSearching())) {
            cancelQueuedSearch();

            if (mFullBar) {
                onClose();
            } else {
                // Causes calling onClose(). onClose() is triggering directory content update.
                mSearchView.setIconified(true);
            }

            return true;
        }
        return false;
    }

    private int getPixelForDp(int dp) {
        final float scale = mSearchView.getContext().getResources().getDisplayMetrics().density;
        return (int) (dp * scale + 0.5f);
    }

    private void cancelQueuedSearch() {
        synchronized (mSearchLock) {
            if (mQueuedSearchTask != null) {
                mQueuedSearchTask.cancel();
            }
            mQueuedSearchTask = null;
            mUiHandler.removeCallbacks(mQueuedSearchRunnable);
            mQueuedSearchRunnable = null;
            mIsHistorySearch = false;
        }
    }

    /**
     * Sets search view into the searching state. Used to restore state after device orientation
     * change.
     */
    public void restoreSearch(boolean keepFocus) {
        if (mSearchView == null) {
            return;
        }

        if (isTextSearching()) {
            onSearchBarClicked();
            mSearchView.setQuery(mCurrentSearch, false);

            if (keepFocus) {
                mSearchView.requestFocus();
            } else {
                mSearchView.clearFocus();
            }
        }
    }

    public void onSearchBarClicked() {
        if (mMenuItem == null) {
            return;
        }

        mMenuItem.expandActionView();
        onSearchExpanded();
    }

    private void onSearchExpanded() {
        mSearchExpanded = true;
        if (mFullBar && mMenu != null) {
            mMenu.setGroupVisible(R.id.group_hide_when_searching, false);
        }

        mListener.onSearchViewChanged(true);
    }

    /**
     * Clears the search. Triggers refreshing of the directory content.
     *
     * @return True if the default behavior of clearing/dismissing SearchView should be overridden.
     *         False otherwise.
     */
    @Override
    public boolean onClose() {
        mSearchExpanded = false;
        if (mIgnoreNextClose) {
            mIgnoreNextClose = false;
            return false;
        }

        // Refresh the directory if a search was done
        if (mCurrentSearch != null || mChipViewManager.hasCheckedItems()) {
            // Make sure SearchFragment was dismissed.
            if (mFragmentManager != null) {
                SearchFragment.dismissFragment(mFragmentManager);
            }

            // Clear checked chips
            mChipViewManager.clearCheckedChips();
            mCurrentSearch = null;
            mListener.onSearchChanged(mCurrentSearch);
        }

        if (mFullBar && mMenuItem != null) {
            mMenuItem.collapseActionView();
        }
        mListener.onSearchFinished();

        mListener.onSearchViewChanged(false);

        return false;
    }

    /**
     * Called when owning activity is saving state to be used to restore state during creation.
     *
     * @param state Bundle to save state too
     */
    public void onSaveInstanceState(Bundle state) {
        if (mSearchView != null && mSearchView.hasFocus() && mCurrentSearch == null) {
            // Restore focus even if no text was input before screen rotation.
            mCurrentSearch = "";
        }
        state.putString(Shared.EXTRA_QUERY, mCurrentSearch);
        mChipViewManager.onSaveInstanceState(state);
    }

    /**
     * Sets mSearchExpanded. Called when search icon is clicked to start search for both search view
     * modes.
     */
    @Override
    public void onClick(View v) {
        onSearchExpanded();
    }

    @Override
    public boolean onQueryTextSubmit(String query) {

        if (mCommandProcessor.accept(query)) {
            mSearchView.setQuery("", false);
        } else {
            cancelQueuedSearch();
            // Don't kick off a search if we've already finished it.
            if (!TextUtils.equals(mCurrentSearch, query)) {
                mCurrentSearch = query;
                mListener.onSearchChanged(mCurrentSearch);
            }
            recordHistory();
            mSearchView.clearFocus();
        }

        return true;
    }

    /**
     * Used to detect and handle back button pressed event when search is expanded.
     */
    @Override
    public void onFocusChange(View v, boolean hasFocus) {
        if (!hasFocus && !mChipViewManager.hasCheckedItems()) {
            if (mSearchView != null && mCurrentSearch == null) {
                mSearchView.setIconified(true);
            } else if (TextUtils.isEmpty(getSearchViewText())) {
                cancelSearch();
            }
        }
        mListener.onSearchViewFocusChanged(hasFocus);
    }

    @VisibleForTesting
    protected TimerTask createSearchTask(String newText) {
        return new TimerTask() {
            @Override
            public void run() {
                // Do the actual work on the main looper.
                synchronized (mSearchLock) {
                    mQueuedSearchRunnable = () -> {
                        mCurrentSearch = newText;
                        if (mCurrentSearch != null && mCurrentSearch.isEmpty()) {
                            mCurrentSearch = null;
                        }
                        logTextSearchMetric();
                        mListener.onSearchChanged(mCurrentSearch);
                    };
                    mUiHandler.post(mQueuedSearchRunnable);
                }
            }
        };
    }

    @Override
    public boolean onQueryTextChange(String newText) {
        //Skip first search when search expanded
        if (mCurrentSearch == null && newText.isEmpty()) {
            return true;
        }

        performSearch(newText);
        if (mFragmentManager != null) {
            if (!newText.isEmpty()) {
                SearchFragment.dismissFragment(mFragmentManager);
            } else {
                SearchFragment.showFragment(mFragmentManager, "");
            }
        }
        return true;
    }

    private void performSearch(String newText) {
        cancelQueuedSearch();
        synchronized (mSearchLock) {
            mQueuedSearchTask = createSearchTask(newText);

            mTimer.schedule(mQueuedSearchTask, SEARCH_DELAY_MS);
        }
    }

    @Override
    public boolean onMenuItemActionCollapse(MenuItem item) {
        mMenu.setGroupVisible(R.id.group_hide_when_searching, true);

        // Handles case when search view is collapsed by using the arrow on the left of the bar
        if (isExpanded() || isSearching()) {
            cancelSearch();
            return false;
        }
        return true;
    }

    @Override
    public boolean onMenuItemActionExpand(MenuItem item) {
        return true;
    }

    public String getCurrentSearch() {
        return mCurrentSearch;
    }

    /**
     * Get current text on search view.
     *
     * @return  Current string on search view
     */
    public String getSearchViewText() {
        if (mSearchView == null) {
            return null;
        }

        return mSearchView.getQuery().toString();
    }

    /**
     * Record current search for history.
     */
    public void recordHistory() {
        if (TextUtils.isEmpty(mCurrentSearch)) {
            return;
        }

        recordHistoryInternal();
    }

    protected void recordHistoryInternal() {
        if (mSearchView == null) {
            Log.w(TAG, "Search view is null, skip record history this time");
            return;
        }

        SearchHistoryManager.getInstance(
                mSearchView.getContext().getApplicationContext()).addHistory(mCurrentSearch);
    }

    /**
     * Remove specific text item in history list.
     *
     * @param history target string for removed.
     */
    public void removeHistory(String history) {
        if (mSearchView == null) {
            Log.w(TAG, "Search view is null, skip remove history this time");
            return;
        }

        SearchHistoryManager.getInstance(
                mSearchView.getContext().getApplicationContext()).deleteHistory(history);
    }

    private void logTextSearchMetric() {
        if (isTextSearching()) {
            Metrics.logUserAction(mIsHistorySearch
                    ? MetricConsts.USER_ACTION_SEARCH_HISTORY : MetricConsts.USER_ACTION_SEARCH);
            Metrics.logSearchType(mIsHistorySearch
                    ? MetricConsts.TYPE_SEARCH_HISTORY : MetricConsts.TYPE_SEARCH_STRING);
            mIsHistorySearch = false;
        }
    }

    /**
     * Get the query content from intent.
     * @return If has query content, return the query content. Otherwise, return null
     * @see #parseQueryContentFromIntent(Intent, int)
     */
    public String getQueryContentFromIntent() {
        return mQueryContentFromIntent;
    }

    public void setCurrentSearch(String queryString) {
        mCurrentSearch = queryString;
    }

    /**
     * Set next search type is history search.
     */
    public void setHistorySearch() {
        mIsHistorySearch = true;
    }

    public boolean isSearching() {
        return mCurrentSearch != null || mChipViewManager.hasCheckedItems();
    }

    public boolean isTextSearching() {
        return mCurrentSearch != null;
    }

    public boolean hasCheckedChip() {
        return mChipViewManager.hasCheckedItems();
    }

    public boolean isExpanded() {
        return mSearchExpanded;
    }

    public interface SearchManagerListener {
        void onSearchChanged(@Nullable String query);

        void onSearchFinished();

        void onSearchViewChanged(boolean opened);

        void onSearchChipStateChanged(View v);

        void onSearchViewFocusChanged(boolean hasFocus);

        /**
         * Call back when search view clear button clicked
         */
        void onSearchViewClearClicked();
    }
}
