import { Tooltip } from '@mui/material';
import { action, computed, makeObservable, observable } from 'mobx';
import { observer } from 'mobx-react';
import * as React from 'react';
import { ClientUtils } from '../../../ClientUtils';
import { Doc, DocListCastAsync, Field, FieldType } from '../../../fields/Doc';
import { DirectLinks, DocData } from '../../../fields/DocSymbols';
import { Id } from '../../../fields/FieldSymbols';
import { DocCast, StrCast } from '../../../fields/Types';
import { DocUtils } from '../../documents/DocUtils';
import { DocumentType } from '../../documents/DocumentTypes';
import { Docs } from '../../documents/Documents';
import { SearchUtil } from '../../util/SearchUtil';
import { SnappingManager } from '../../util/SnappingManager';
import { undoBatch } from '../../util/UndoManager';
import { ViewBoxBaseComponent } from '../DocComponent';
import { ObservableReactComponent } from '../ObservableReactComponent';
import { CollectionDockingView } from '../collections/CollectionDockingView';
// import { IRecommendation, Recommendation } from '../newlightbox/components';
// import { fetchRecommendations } from '../newlightbox/utils';
import { DocumentView } from '../nodes/DocumentView';
import { FieldView, FieldViewProps } from '../nodes/FieldView';
import './SearchBox.scss';

const DAMPENING_FACTOR = 0.9;
const MAX_ITERATIONS = 25;
const ERROR = 0.03;

export interface SearchBoxItemProps {
    Doc: Doc;
    searchString: string;
    isLinkSearch: boolean;
    matchedKeys: string[];
    className: string;
    linkFrom: Doc | undefined;
    selectItem: (doc: Doc) => void;
    linkCreateAnchor?: () => Doc | undefined;
    linkCreated?: (link: Doc) => void;
}
@observer
export class SearchBoxItem extends ObservableReactComponent<SearchBoxItemProps> {
    constructor(props: SearchBoxItemProps) {
        super(props);
        makeObservable(this);
    }

    /**
     * @param {Doc} doc - doc to be selected
     *
     * This method selects a doc by either jumping to it (centering/zooming in on it)
     * or opening it in a new tab.
     */
    selectElement = (doc: Doc, finishFunc: () => void) => DocumentView.showDocument(doc, { willPan: true }, finishFunc);

    /**
     * @param {Doc} doc - doc of the search result that has been clicked on
     *
     * This method is called when the user clicks on a search result. The _selectedResult is
     * updated accordingly and the doc is highlighted with the selectElement method.
     */
    onResultClick = action(async (doc: Doc) => {
        this._props.selectItem(doc);
        this.selectElement(doc, () => DocumentView.getFirstDocumentView(doc)?.ComponentView?.search?.(this._props.searchString, undefined, false));
    });

    componentWillUnmount(): void {
        const doc = this._props.Doc;
        DocumentView.getFirstDocumentView(doc)?.ComponentView?.search?.('', undefined, true);
    }

    @undoBatch
    makeLink = action((linkTo: Doc) => {
        const linkFrom = this._props.linkCreateAnchor?.();
        if (linkFrom) {
            const link = DocUtils.MakeLink(linkFrom, linkTo, {});
            link && this._props.linkCreated?.(link);
        }
    });

    render() {
        // eslint-disable-next-line no-use-before-define
        const formattedType = SearchBox.formatType(StrCast(this._props.Doc.type), StrCast(this._props.Doc.type_collection));
        const { title } = this._props.Doc;

        return (
            <Tooltip placement="right" title={<div className="dash-tooltip">{title as string}</div>}>
                <div
                    onClick={
                        this._props.isLinkSearch
                            ? () => this.makeLink(this._props.Doc)
                            : e => {
                                  this.onResultClick(this._props.Doc);
                                  e.stopPropagation();
                              }
                    }
                    style={{
                        fontWeight: Doc.Links(this._props.linkFrom).find(
                            link => Doc.AreProtosEqual(Doc.getOppositeAnchor(link, this._props.linkFrom!), this._props.Doc) || Doc.AreProtosEqual(DocCast(Doc.getOppositeAnchor(link, this._props.linkFrom!)?.annotationOn), this._props.Doc)
                        )
                            ? 'bold'
                            : '',
                    }}
                    className={this._props.className}>
                    <div className="searchBox-result-title">{title as string}</div>
                    <div className="searchBox-result-type" style={{ color: SnappingManager.userVariantColor }}>
                        {formattedType}
                    </div>
                    <div className="searchBox-result-keys" style={{ color: SnappingManager.userVariantColor }}>
                        {this._props.matchedKeys.join(', ')}
                    </div>
                </div>
            </Tooltip>
        );
    }
}

export interface SearchBoxProps extends FieldViewProps {
    linkSearch: boolean;
    linkFrom?: (() => Doc | undefined) | undefined;
    linkCreateAnchor?: () => Doc | undefined;
    linkCreated?: (link: Doc) => void;
}

/**
 * This is the SearchBox component. It represents the search box input and results in
 * the search panel on the left side of the screen.
 */
@observer
export class SearchBox extends ViewBoxBaseComponent<SearchBoxProps>() {
    public static LayoutString(fieldKey: string) {
        return FieldView.LayoutString(SearchBox, fieldKey);
    }
    // eslint-disable-next-line no-use-before-define
    public static Instance: SearchBox;

    private _inputRef = React.createRef<HTMLInputElement>();

    @observable _searchString = '';
    @observable _docTypeString = 'all';
    @observable _results: Map<Doc, string[]> = new Map<Doc, string[]>();
    // @observable _recommendations: IRecommendation[] = [];
    @observable _pageRanks: Map<Doc, number> = new Map<Doc, number>();
    @observable _linkedDocsOut: Map<Doc, Set<Doc>> = new Map<Doc, Set<Doc>>();
    @observable _linkedDocsIn: Map<Doc, Set<Doc>> = new Map<Doc, Set<Doc>>();
    @observable _selectedResult: Doc | undefined = undefined;
    @observable _deletedDocsStatus: boolean = false;
    @observable _onlyEmbeddings: boolean = true;

    constructor(props: SearchBoxProps) {
        super(props);
        makeObservable(this);
        SearchBox.Instance = this;
    }

    /**
     * This method is called when the SearchBox component is first mounted. When the user opens
     * the search panel, the search input box is automatically selected. This allows the user to
     * type in the search input box immediately, without needing clicking on it first.
     */
    componentDidMount() {
        if (this._inputRef.current) {
            this._inputRef.current.focus();
        }
    }

    /**
     * This method is called when the SearchBox component is about to be unmounted. When the user
     * closes the search panel, the search and its results are reset.
     */
    componentWillUnmount() {
        this.resetSearch();
    }

    /**
     * This method is called when the text in the search input box is modified by the user. The
     * _searchString is updated to the new value of the text in the input box and submitSearch
     * is called to update the search results accordingly.
     *
     * (Note: There is no longer a need to press enter to submit a search. Any update to the input
     * causes a search to be submitted automatically.)
     */
    _timeout: NodeJS.Timeout | undefined = undefined;
    onInputChange = action((e: React.ChangeEvent<HTMLInputElement>) => {
        this._searchString = e.target.value;
        this._timeout && clearTimeout(this._timeout);
        this._timeout = setTimeout(() => this.submitSearch(), 300);
    });

    /**
     * This method is called when the option in the select drop-down menu is changed. The
     * _docTypeString is updated to the new value of the option in the drop-down menu. This
     * is used to filter the results of the search to documents of a specific type.
     *
     * (Note: This doesn't affect the results array, so there is no need to submit a new
     * search here. The results of the search on the _searchString query are simply filtered
     * by type directly before rendering them.)
     */
    onSelectChange = action((e: React.ChangeEvent<HTMLSelectElement>) => {
        this._docTypeString = e.target.value;
    });

    /**
     * @param {Doc[]} docs - docs to be searched through recursively
     * @param {number, Doc => void} func - function to be called on each doc
     *
     * This method iterates asynchronously through an array of docs and all docs within those
     * docs, calling the function func on each doc.
     */
    static async foreachRecursiveDocAsync(docsIn: Doc[], func: (depth: number, doc: Doc) => void) {
        let docs = docsIn;
        let newarray: Doc[] = [];
        let depth = 0;
        while (docs.length > 0) {
            newarray = [];
            // eslint-disable-next-line no-await-in-loop
            await Promise.all(
                docs
                    .filter(d => d)
                    // eslint-disable-next-line no-loop-func
                    .map(async d => {
                        const fieldKey = Doc.LayoutFieldKey(d);
                        const annos = !Field.toString(Doc.LayoutField(d) as FieldType).includes('CollectionView');
                        const data = d[annos ? fieldKey + '_annotations' : fieldKey];
                        const dataDocs = await DocListCastAsync(data);
                        dataDocs && newarray.push(...dataDocs);
                        func(depth, d);
                    })
            );
            docs = newarray;
            depth++;
        }
    }

    /**
     * @param {String} type - string representing the type of a doc
     *
     * This method converts a doc type string of any length to a 3-letter doc type string in
     * which the first letter is capitalized. This is used when displaying the type on the
     * right side of each search result.
     */
    static formatType(type: string, colType: string): string {
        switch (type) {
            case DocumentType.PDF : return 'PDF';
            case DocumentType.IMG : return 'Img';
            case DocumentType.RTF : return 'Rtf';
            case DocumentType.COL : return 'Col:'+colType.substring(0,3);
            default:
        } // prettier-ignore

        return type.charAt(0).toUpperCase() + type.substring(1, 3);
    }

    /**
     * @param {String} query - search query string
     *
     * This method searches the CollectionDockingView instance for a certain query and puts
     * the matching results in the results array. Docs are considered to be matching results
     * when the query is a substring of many different pieces of its metadata (title, text,
     * author, etc).
     */
    @action
    searchCollection(query: string) {
        this._selectedResult = undefined;
        this._results = SearchUtil.SearchCollection(CollectionDockingView.Instance?.Document, query, this._docTypeString === 'keys');

        this.computePageRanks();
    }

    /**
     * This method initializes the page rank of every document to the reciprocal
     * of the number of documents in the collection.
     */
    @action
    initializePageRanks() {
        this._pageRanks.clear();
        this._linkedDocsOut.clear();

        this._results.forEach((_, doc) => {
            this._linkedDocsIn.set(doc, new Set());
        });

        this._results.forEach((_, doc) => {
            this._pageRanks.set(doc, 1.0 / this._results.size);

            if (doc[DocData][DirectLinks].size === 0) {
                this._linkedDocsOut.set(doc, new Set(this._results.keys()));

                this._results.forEach((__, linkedDoc) => {
                    this._linkedDocsIn.get(linkedDoc)?.add(doc);
                });
            } else {
                const linkedDocSet = new Set<Doc>();

                doc[DocData][DirectLinks].forEach(link => {
                    const d1 = link?.link_anchor_1 as Doc;
                    const d2 = link?.link_anchor_2 as Doc;
                    if (doc === d1 && this._results.has(d2)) {
                        linkedDocSet.add(d2);
                        this._linkedDocsIn.get(d2)?.add(doc);
                    } else if (doc === d2 && this._results.has(d1)) {
                        linkedDocSet.add(d1);
                        this._linkedDocsIn.get(d1)?.add(doc);
                    }
                });

                this._linkedDocsOut.set(doc, linkedDocSet);
            }
        });
    }

    /**
     * This method runs one complete iteration of the page rank algorithm. It
     * returns true iff all page ranks have converged (i.e. changed by less than
     * the _error value), which means that the algorithm should terminate.
     *
     * @return true if page ranks have converged; false otherwise
     */
    @action
    pageRankIteration(): boolean {
        let converged = true;
        const pageRankFromAll = (1 - DAMPENING_FACTOR) / this._results.size;
        const nextPageRanks = new Map<Doc, number>();

        this._results.forEach((_, doc) => {
            let nextPageRank = pageRankFromAll;

            this._linkedDocsIn.get(doc)?.forEach(linkedDoc => {
                nextPageRank += (DAMPENING_FACTOR * (this._pageRanks.get(linkedDoc) ?? 0)) / (this._linkedDocsOut.get(linkedDoc)?.size ?? 1);
            });

            nextPageRanks.set(doc, nextPageRank);

            if (Math.abs(nextPageRank - (this._pageRanks.get(doc) ?? 0)) > ERROR) {
                converged = false;
            }
        });

        this._pageRanks = nextPageRanks;

        return converged;
    }

    /**
     * This method performs the page rank algorithm on the graph of documents
     * that match the search query. Vertices are documents and edges are links
     * between documents.
     */
    @action
    computePageRanks() {
        this.initializePageRanks();

        for (let i = 0; i < MAX_ITERATIONS; i++) {
            if (this.pageRankIteration()) {
                break;
            }
        }
    }

    /**
     * This method submits a search with the _searchString as its query and updates
     * the results array accordingly.
     */
    @action
    submitSearch = async () => {
        this.resetSearch();

        const query = StrCast(this._searchString);
        Doc.SetSearchQuery(query);
        if (!this._props.linkSearch) Array.from(this._results.keys()).forEach(doc => DocumentView.getFirstDocumentView(doc)?.ComponentView?.search?.(this._searchString, undefined, true));
        this._results.clear();

        if (query) {
            this.searchCollection(query);
            // const response = await fetchRecommendations('', query, [], true);
            // const recs = response.recommendations as any[];
            // const recommendations: IRecommendation[] = [];
            // recs.forEach(rec => {
            //     const { title, url, type, text, transcript, previewUrl, embedding, distance, source, related_concepts: relatedConcepts, doc_id: docId } = rec;
            //     recommendations.push({
            //         title,
            //         data: url,
            //         type,
            //         text,
            //         transcript,
            //         previewUrl,
            //         embedding,
            //         distance: Math.round(distance * 100) / 100,
            //         source: source,
            //         related_concepts: relatedConcepts,
            //         docId,
            //     });
            // });
            // const setRecommendations = action(() => {
            //     this._recommendations = recommendations;
            // });
            // setRecommendations();
        }
    };

    /**
     * This method resets the search by iterating through each result and removing all
     * brushes and highlights. All search matches are cleared as well.
     */
    resetSearch = action(() => {
        this._timeout && clearTimeout(this._timeout);
        this._timeout = undefined;
        this._results.forEach((_, doc) => {
            DocumentView.getFirstDocumentView(doc)?.ComponentView?.search?.('', undefined, true);
            Doc.UnBrushDoc(doc);
            Doc.UnHighlightDoc(doc);
            Doc.ClearSearchMatches();
        });
    });

    /**
     * This method returns a JSX list of the options in the select drop-down menu, which
     * is used to filter the types of documents that appear in the search results.
     */
    @computed
    public get selectOptions() {
        const selectValues = ['all', DocumentType.RTF, DocumentType.IMG, DocumentType.PDF, DocumentType.WEB, DocumentType.VID, DocumentType.AUDIO, DocumentType.COL, 'keys'];

        return selectValues.map(value => (
            <option key={value} value={value}>
                {ClientUtils.cleanDocumentTypeExt(value as DocumentType)}
            </option>
        ));
    }

    /**
     * This method renders the search input box, select drop-down menu, and search results.
     */
    render() {
        const isLinkSearch: boolean = this._props.linkSearch;
        const sortedResults = Array.from(this._results.entries()).sort((a, b) => (this._pageRanks.get(b[0]) ?? 0) - (this._pageRanks.get(a[0]) ?? 0)); // sorted by page rank
        const resultsJSX = [] as JSX.Element[];
        const linkFrom = this._props.linkFrom?.();

        let validResults = 0;
        sortedResults.forEach(([Document, matchedKeys]) => {
            let className = 'searchBox-results-scroll-view-result';

            if (this._selectedResult === Document) {
                className += ' searchBox-results-scroll-view-result-selected';
            }

            if (this._docTypeString === 'keys' || this._docTypeString === 'all' || this._docTypeString === Document.type) {
                validResults++;
                resultsJSX.push(
                    <SearchBoxItem
                        key={Document[Id]}
                        Doc={Document}
                        selectItem={action((doc: Doc) => {
                            this._selectedResult = doc;
                        })}
                        isLinkSearch={isLinkSearch}
                        searchString={this._searchString}
                        matchedKeys={matchedKeys}
                        linkFrom={linkFrom}
                        className={className}
                        linkCreateAnchor={this._props.linkCreateAnchor}
                        linkCreated={this._props.linkCreated}
                    />
                );
            }
        });

        const recommendationsJSX: JSX.Element[] = []; // this._recommendations.map(props => <Recommendation {...props} />);

        return (
            <div className="searchBox-container" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }}>
                <div className="searchBox-bar">
                    {isLinkSearch ? null : (
                        <select name="type" id="searchBox-type" className="searchBox-type" onChange={this.onSelectChange}>
                            {this.selectOptions}
                        </select>
                    )}
                    <input
                        defaultValue=""
                        autoComplete="off"
                        onChange={this.onInputChange}
                        onKeyDown={e => {
                            e.key === 'Enter' ? this.submitSearch() : null;
                            e.stopPropagation();
                        }}
                        type="text"
                        placeholder="Search..."
                        id="search-input"
                        className="searchBox-input"
                        style={{ width: isLinkSearch ? '100%' : undefined, borderRadius: isLinkSearch ? '5px' : undefined }}
                        ref={this._inputRef}
                    />
                </div>
                {resultsJSX.length > 0 && (
                    <div className="searchBox-results-container">
                        <div className="section-header" style={{ background: SnappingManager.userVariantColor }}>
                            <div className="section-title">Results</div>
                            <div className="section-subtitle">{`${validResults} result` + (validResults === 1 ? '' : 's')}</div>
                        </div>
                        <div className="searchBox-results-view">{resultsJSX}</div>
                    </div>
                )}
                {recommendationsJSX.length > 0 && (
                    <div className="searchBox-recommendations-container">
                        <div className="section-header" style={{ background: SnappingManager.userVariantColor }}>
                            <div className="section-title">Recommendations</div>
                            <div className="section-subtitle">{`${validResults} result` + (validResults === 1 ? '' : 's')}</div>
                        </div>
                        <div className="searchBox-recommendations-view">{recommendationsJSX}</div>
                    </div>
                )}
            </div>
        );
    }
}

Docs.Prototypes.TemplateMap.set(DocumentType.SEARCH, {
    layout: { view: SearchBox, dataField: 'data' },
    options: { acl: '', _width: 400 },
});
