// Wrapper around lunr index searching system for pydoctor API objects 
//      and function to format search results items into rederable HTML elements.
// This file is meant to be used as a library for the pydoctor search bar (search.js) as well as
//      provide a hackable inferface to integrate API docs searching into other platforms, i.e. provide a 
//      "Search in API docs" option from Read The Docs search page.
// Depends on ajax.js, bundled with pydoctor. 
// Other required ressources like lunr.js, searchindex.json and all-documents.html are passed as URL
//      to functions. This makes the code reusable outside of pydoctor build directory.    
// Implementation note: Searches are designed to be launched synchronously, if lunrSearch() is called sucessively (while already running),
// old promise will never resolves and the search worker will be restarted.

// Hacky way to make the worker code inline with the rest of the source file handling the search.
// Worker message params are the following: 
// - query: string
// - indexJSONData: dict
// - defaultFields: list of strings
let _lunrWorkerCode = `

// The lunr.js code will be inserted here.

onmessage = (message) => {
    if (!message.data.query) {
        throw new Error('No search query provided.');
    }
    if (!message.data.indexJSONData) {
        throw new Error('No index data provided.');
    }
    if (!message.data.defaultFields) {
        throw new Error('No default fields provided.');
    }

    // Create index
    let index = lunr.Index.load(message.data.indexJSONData);

    // Declare query function building 
    function _queryfn(_query){ // _query is the Query object
        // Edit the parsed query clauses that are applicable for all fields (default) in order
        // to remove the field 'kind' from the clause since this it's only useful when specifically requested.
        var parser = new lunr.QueryParser(message.data.query, _query)
        parser.parse()

        _query.clauses.forEach(clause => {
            if (clause.fields == _query.allFields){
                // we change the query fields when they are applicable to all fields
                // to a list of predefined fields because we might include additional filters (like kind:)
                // which should not be matched by default.
                clause.fields = message.data.defaultFields;
            }

        });
        // Auto wilcard feature, see issue https://github.com/twisted/pydoctor/issues/648
        var new_clauses = [];

        _query.clauses.forEach(clause => {
            if (clause.presence === 1) { // ignore clauses that have explicit presence (+/-)
                // Setting clause.wildcard is useless, and clause.wildcard is actually always NONE 
                // due to https://github.com/olivernn/lunr.js/issues/495
                // But this works...
                if (clause.term.slice(-1) != '*'){
                    let new_clause = {...clause}
                    new_clause.term = new_clause.term + '*'
                    clause.boost = 2
                    new_clause.boost = 1
                    new_clauses.push(new_clause)
                }
                
                // Adding a leading wildcard if the dot is included as well.
                // This should only apply to terms that are applicable to name-like fields. 
                // so we refer to the default fields
                if (clause.term.indexOf('.') != -1) {
                    if (clause.term.slice(0,1) != '*'){
                        let second_new_clause = {...clause}
                        second_new_clause.boost = 1
                        if (clause.term.slice(0,1) != '.'){ 
                            second_new_clause.term = '.' + second_new_clause.term
                        }
                        second_new_clause.term = '*' + second_new_clause.term
                        if (clause.term.slice(-1) != '*'){
                            second_new_clause.term = second_new_clause.term + '*'
                        }
                        new_clauses.push(second_new_clause)
                    }
                }
            }
        });

        new_clauses.forEach(clause => {
            _query.clauses.push(clause)
        });
        console.log('Parsed query:')
        console.dir(_query.clauses)
    }

    // Launch the search
    var results = index.query(_queryfn)

    // Post message with results
    postMessage({'results':results});
};
`;

// Adapted from https://stackoverflow.com/a/44137284
// Override worker methods to detect termination and count message posting and restart() method.
// This allows some optimizations since the worker doesn't need to be restarted when it hasn't been used.
function _makeWorkerSmart(workerURL) {
    // make normal worker
    var worker = new Worker(workerURL);
    // assume that it's running from the start
    worker.terminated = false;
    worker.postMessageCount = 0;
    // count the number of times postMessage() is called
    worker.postMessage = function() {
        this.postMessageCount = this.postMessageCount + 1;
        // normal post message
        return Worker.prototype.postMessage.apply(this, arguments);
    }
    // sets terminated to true
    worker.terminate = function() {
        if (this.terminated===true){return;}
        this.terminated = true;
        // normal terminate
        return Worker.prototype.terminate.apply(this, arguments);
    }
    // creates NEW WORKER with the same URL as itself, terminate worker first.
    worker.restart = function() {
        this.terminate();
        return _makeWorkerSmart(workerURL);
    }
    return worker;
}

var _searchWorker = null

/**
 * The searchEventsEnv Document variable let's let caller register a event listener "searchStarted" for sending
 * a signal when the search actually starts, could be up to 0.2 or 0.3 secs ater user finished typing.
 */
let searchEventsEnv = document.implementation.createHTMLDocument(
    'This is a document to popagate search related events, we avoid using "document" for performance reasons.');

// there is a difference in abortSearch() vs restartSearchWorker().
// abortSearch() triggers a abortSearch event, which have a effect on searches that are not yet running in workers.
// whereas restartSearchWorker() which kills the worker if it's in use, but does not abort search that is not yet posted to the worker.
function abortSearch(){
    searchEventsEnv.dispatchEvent(new CustomEvent('abortSearch', {}));
}
// Kills and restarts search worker (if needed).
function restartSearchWorker() {
    var w = _searchWorker;
    if (w!=null){
        if (w.postMessageCount>0){
            // the worker has been used, it has to be restarted
            // TODO: Actually it needs to be restarted only if it's running a search right now.
            // Otherwise we can reuse the same worker, but that's not a very big deal in this context.
            w = w.restart();
        } 
        // Else, the worker has never been used, it can be returned as is. 
        // This can happens when typing fast with a very large index JSON to load.
    }
    _searchWorker = w;
}

function _getWorkerPromise(lunJsSourceCode){ // -> Promise of a fresh worker to run a query.
    let promise = new Promise((resolve, reject) => {
        // Do the search business, wrap the process inside an inline Worker.
        // This is a hack such that the UI can refresh during the search.
        if (_searchWorker===null){
            // Create only one blob and URL.
            let lunrWorkerCode = lunJsSourceCode + _lunrWorkerCode;
            let _workerBlob = new Blob([lunrWorkerCode], {type: 'text/javascript'});
            let _workerObjectURL = window.URL.createObjectURL(_workerBlob);
            _searchWorker = _makeWorkerSmart(_workerObjectURL)
        }
        else{
            restartSearchWorker();
        }
        resolve(_searchWorker);
    });
    return promise
}

/**
 * Launch a search and get a promise of results. One search can be lauch at a time only.
 * Old promise never resolves if calling lunrSearch() again while already running.
 * @param query: Query string.
 * @param indexURL: URL pointing to the Lunr search index, generated by pydoctor.
 * @param defaultFields: List of strings: default fields to apply to query clauses when none is specified. ["name", "names", "qname"] for instance.
 * @param lunrJsURL: URL pointing to a copy of lunr.js.
 * @param searchDelay: Number of miliseconds to wait before actually launching the query. This is useful to set for "search as you type" kind of search box
 *                     because it let a chance to users to continue typing without triggering useless searches (because previous search is aborted on launching a new one).
*/
function lunrSearch(query, indexURL, defaultFields, lunrJsURL, searchDelay){
    // Abort ongoing search
    abortSearch();

    // Register abort procedure.
    var _aborted = false;
    searchEventsEnv.addEventListener('abortSearch', (ev) => {
        _aborted = true;
        searchEventsEnv.removeEventListener('abortSearch', this);
    });

    // Perf:
    // Because this function can be called a lot of times in a very few moments, 
    // Actually launch search after a delay to let a chance to users to continue typing,
    // which would trigger a search abort event, which would avoid wasting a worker 
    // for a search that is not wanted anymore.
    return new Promise((_resolve, _reject) => {
        setTimeout(() => {
        _resolve(
        _getIndexDataPromise(indexURL).then((lunrIndexData) => {
        // Include lunr.js source inside the worker such that it has no dependencies.
        return httpGetPromise(lunrJsURL).then((responseText) => {
        // Do the search business, wrap the process inside an inline Worker.
        // This is a hack such that the UI can refresh during the search.
        return _getWorkerPromise(responseText).then((worker) => {
            let promise = new Promise((resolve, reject) => {
                worker.onmessage = (message) => {
                    if (!message.data.results){
                        reject("No data received from worker");
                    }
                    else{
                        console.log("Got result from worker:")
                        console.dir(message.data.results)
                        resolve(message.data.results)
                    }
                }
                worker.onerror = function(error) {
                    reject(error);
                };
            });
            let _msgData = {
                'query': query,
                'indexJSONData': lunrIndexData,
                'defaultFields': defaultFields,
            }
            
            if (!_aborted){
                console.log(`Posting query "${query}" to worker:`)
                console.dir(_msgData)
                worker.postMessage(_msgData);
                searchEventsEnv.dispatchEvent(
                    new CustomEvent("searchStarted", {'query':query})
                );
            }

            return promise
        });
        });
        })
        );}, searchDelay);
    });
}

/** 
* @param results: list of lunr.Index~Result.
* @param allDocumentsURL: URL pointing to all-documents.html, generated by pydoctor.
* @returns: Promise of a list of HTMLElement corresponding to the all-documents.html
*   list elements matching your search results.
*/
function fetchResultsData(results, allDocumentsURL){
    return _getAllDocumentsPromise(allDocumentsURL).then((allDocuments) => {
        // Look for results data in parsed all-documents.html
        return _asyncFor(results, (result) => {
            // Find the result model row data.
            var dobj = allDocuments.getElementById(result.ref);
            if (!dobj){
                throw new Error("Cannot find document ID: " + result.ref);
            }
            // Return result data
            return dobj;
        })
    })
}

/**
 * Transform list item as in all-documents.html into a formatted search result row.
 */
function buildSearchResult(dobj) {

    // Build one result item
    var tr = document.createElement('tr'),
        kindtd = document.createElement('td'),
        contenttd = document.createElement('td'),
        article = document.createElement('article'),
        header = document.createElement('header'),
        section = document.createElement('section'),
        code = document.createElement('code'),
        a = document.createElement('a'),
        p = document.createElement('p');
  
    p.innerHTML = dobj.querySelector('.summary').innerHTML;
    a.setAttribute('href', dobj.querySelector('.url').innerHTML);
    a.setAttribute('class', 'internal-link');
    a.innerHTML = dobj.querySelector('.fullName').innerHTML;
    
    let kind_value = dobj.querySelector('.kind').innerHTML;
    let type_value = dobj.querySelector('.type').innerHTML;
  
    // Adding '()' on functions and methods
    if (type_value.endsWith("Function")){
        a.innerHTML = a.innerHTML + '()';
    }
  
    kindtd.innerHTML = kind_value;
    
    // Putting everything together
    tr.appendChild(kindtd);
    tr.appendChild(contenttd);
    contenttd.appendChild(article);
    article.appendChild(header);
    article.appendChild(section);
    header.appendChild(code);
    code.appendChild(a);
    section.appendChild(p);
  
    // Set kind as the CSS class of the kind td tag
    let ob_css_class = dobj.querySelector('.kind').innerHTML.toLowerCase().replace(' ', '');
    kindtd.setAttribute('class', ob_css_class);
  
    // Set private
    if (dobj.querySelector('.privacy').innerHTML.includes('PRIVATE')){
      tr.setAttribute('class', 'private');
    }
    
    return tr;
}


// This gives the UI the opportunity to refresh while we're iterating over a large list.
function _asyncFor(iterable, callback) { // -> Promise of List of results returned by callback
    const promise_global = new Promise((resolve_global, reject_global) => {
      let promises = [];
      iterable.forEach((element) => {
          promises.push(new Promise((resolve, _reject) => {
            setTimeout(() => {
                try{ resolve(callback(element)); }
                catch (error){ _reject(error); }
            }, 0);
          }));
      }); 
      Promise.all(promises).then((results) =>{
        resolve_global(results);
      }).catch((err) => {
          reject_global(err);
      });
    });
    return promise_global;
  }

// Cache indexes JSON data since it takes a little bit of time to load JSON into stuctured data
var _indexDataCache = {};
function _getIndexDataPromise(indexURL) { // -> Promise of a structured data for the lunr Index.
    if (!_indexDataCache[indexURL]){
        return httpGetPromise(indexURL).then((responseText) => {
            _indexDataCache[indexURL] = JSON.parse(responseText)
            return (_indexDataCache[indexURL]);
        });
    }
    else{
        return new Promise((_resolve, _reject) => {
            _resolve(_indexDataCache[indexURL]);
        });
    }
}

// Cache Document object
var _allDocumentsCache = {};
function _getAllDocumentsPromise(allDocumentsURL) { // -> Promise of the all-documents.html Document object.
    if (!_allDocumentsCache[allDocumentsURL]){
        return httpGetPromise(allDocumentsURL).then((responseText) => {
            let _parser = new self.DOMParser();
            _allDocumentsCache[allDocumentsURL] = _parser.parseFromString(responseText, "text/html");
            return (_allDocumentsCache[allDocumentsURL]);
        });
    }
    else{
        return new Promise((_resolve, _reject) => {
            _resolve(_allDocumentsCache[allDocumentsURL]);
        });
    }
}
