/**
 * Copyright (c) 2025 SoftCorporation LLC. All rights reserved.
 *
 * The Software License, Version 1.0
 *
 * SoftCorporation LLC. grants you ("Licensee") a non-exclusive, royalty free,
 * license to use, modify and redistribute this software in source and binary
 * code form, provided that the following conditions are met:
 *
 * 1. The end-user documentation included with the redistribution,
 *    if any, must include the following acknowledgment:
 *       "This product includes software developed by the
 *        SoftCorporation LLC. (http://www.softcorporation.com)."
 *    Alternately, this acknowledgment may appear in the software itself,
 *    if and wherever such third-party acknowledgments normally appear.
 *
 * 2. This software and SoftCorporation name must not be used to
 *    promote products derived from the software without prior
 *    written permission. For written permission, please contact
 *    info@softcorporation.com.
 *
 * This software is provided "AS IS," without a warranty of any kind.
 * ALL EXPRESS OR IMPLIED CONDITIONS, REPRESENTATIONS AND WARRANTIES,
 * INCLUDING ANY IMPLIED WARRANTY OF MERCHANTABILITY, FITNESS FOR A
 * PARTICULAR PURPOSE OR NON-INFRINGEMENT, ARE HEREBY EXCLUDED.
 * IN NO EVENT SHALL THE SOFTCORPORATION BE LIABLE FOR ANY DIRECT,
 * INDIRECT, INCIDENTAL OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
 * USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION).
 *
 */
package com.softcorporation.sandi.search.service;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

import org.json.JSONArray;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;

import com.softcorporation.sandi.base.model.Client;
import com.softcorporation.sandi.base.model.TextChunk;
import com.softcorporation.sandi.base.service.ClientService;
import com.softcorporation.sandi.base.service.LanguageModelService;
import com.softcorporation.sandi.base.service.SynonymService;
import com.softcorporation.sandi.base.util.Constants;
import com.softcorporation.sandi.base.util.Utils;
import com.softcorporation.sandi.search.model.SearchRequest;
import com.softcorporation.sandi.search.model.SearchResponse;
import com.softcorporation.sandi.search.model.SearchResult;
import com.softcorporation.sandi.search.util.SearchException;
import com.softcorporation.util.StringUtil;

public abstract class SearchService {

	private static final Logger LOGGER = LoggerFactory.getLogger(SearchService.class);
	
	/**
	 * Highlight tags
	 */
    @Value("${sandi.search.highlight.tags:<em>,</em>}")
    protected String defaultHighlightTags;
	
	/**
	 * Weight of legacy query
	 */
    @Value("${sandi.search.weight.legacy:0.4}")
    protected float weightLegacyQuery;
	
	/**
	 * Weight of vector query
	 */
    @Value("${sandi.search.weight.vector:0.8}")
    protected float weightVectorQuery;
	
	/**
	 * Weight of vector high
	 */
    @Value("${sandi.search.weight.vector.high:0.5}")
    protected float weightVectorHigh;
	
    /**
	 * Weight of vector low
	 */
    @Value("${sandi.search.weight.vector.low:0.3}")
    protected float weightVectorLow;
	
	/**
	 * Weight of vector content
	 */
    @Value("${sandi.search.weight.vector.content:0.1}")
    protected float weightVectorContent;

	/**
	 * Weight of legacy text high
	 */
    @Value("${sandi.search.weight.legacy.high:0.6}")
    protected float weightLegacyHigh;
	
	/**
	 * Weight of legacy text low
	 */
    @Value("${sandi.search.weight.legacy.low:0.3}")
    protected float weightLegacyLow;
	
	/**
	 * Weight of legacy text content
	 */
    @Value("${sandi.search.weight.legacy.content:0.1}")
    protected float weightLegacyContent;
	
	/**
	 * Weight of vector query words = 0.02
	 */
    @Value("${sandi.search.weight.vector.words:0.02}")
    protected float weightVectorWords;
	
	/**
	 * Weight of vector query synonyms = 0.01
	 */
    @Value("${sandi.search.weight.vector.synonyms:0.01}")
    protected float weightVectorSynonyms;
	
	/**
	 * Weight of legacy query words = 0.2
	 */
    @Value("${sandi.search.weight.legacy.words:0.2}")
    protected float weightLegacyWords;
	
	/**
	 * Weight of legacy query synonyms = 0.1
	 */
    @Value("${sandi.search.weight.legacy.synonyms:0.1}")
    protected float weightLegacySynonyms;
	
	/**
	 * Search score keywods min (to remove from scoring)
	 */
    @Value("${sandi.search.score.keywords.min:0.3}")
    protected double searchScoreKeywordsMin;
	
	/**
	 * Search results score rag min
	 */
    @Value("${sandi.search.score.rag.min:0.8}")
    protected double searchScoreRagMin;
	
	/**
	 * Search results score bad
	 */
    @Value("${sandi.search.score.results.bad:0.4}")
    protected double searchScoreResultsBad;
	
	/**
	 * Search results top K
	 */
    @Value("${sandi.search.results.topk:100}")
    protected int searchResultsTopK;
	
	/**
	 * Search results rag max
	 */
    @Value("${sandi.search.results.rag.max:10}")
    protected int searchResultsRagMax;
	
	/**
	 * Search results rerank max
	 */
    @Value("${sandi.search.results.rerank.max:20}")
    protected int searchResultsRerankMax;
	
	/**
	 * Search results chunks max
	 */
    @Value("${sandi.search.results.chunks.max:3}")
    protected int searchResultsChunksMax;
	
	/**
	 * 1500 max chunk length for EN 512 tokens
	 */
    @Value("${sandi.embedding.chunk.length.max:1500}")
    private int embeddingChunkLengthMax;
    
	/**
	 * overlap chunk length
	 */
    @Value("${sandi.embedding.chunk.length.overlap:200}")
    private int embeddingChunkLengthOverlap;
	
	/**
	 * min chunk length
	 */
    @Value("${sandi.embedding.chunk.length.min:100}")
    private int embeddingChunkLengthMin;
	
    @Value("${sandi.synonyms.similarity.score:0.75}")
    private float synonymSimilarityScore;
    
    @Value("${sandi.synonyms.number.max:4}")
    private int synonymNumberMax;
    
    /**
     * clientSearchUrl String
     */
    @Value("${sandi.service.client.search.url:}")
    private String clientSearchUrl;
    
    @Autowired
    protected ClientService clientService;
    
    @Autowired
    protected SynonymService synonymService;
    
    @Autowired
	protected LanguageModelService languageModelService;
	
	public abstract SearchResponse search(SearchRequest searchRequest) throws SearchException;

    /**
     * Initialize search request
     * 
     * @param searchRequest
     * @throws SearchException
     */
	public void init(SearchRequest searchRequest) throws SearchException {
        if (searchRequest.isLegacy() == null) {
        	searchRequest.setLegacy(false);
        }
        if (searchRequest.isSynonyms() == null) {
        	searchRequest.setSynonyms(false);
        }
        if (searchRequest.isExpand() == null) {
        	searchRequest.setExpand(false);
        } else if (searchRequest.isExpand() && searchRequest.isLegacy()) {
            throw new SearchException("Legacy search does not support Expansion");
        }        	
        if (searchRequest.isRerank() == null) {
        	searchRequest.setRerank(false);
        } else if (searchRequest.isRerank() && searchRequest.isLegacy()) {
//            throw new SearchException("Legacy search does not support Reranking");
        }        	
        if (searchRequest.getPrecision() == null || searchRequest.getPrecision().equalsIgnoreCase(Constants.PRECISION_MEDIUM)) {
    	    searchRequest.setPrecision(Constants.PRECISION_MEDIUM);
        } else if (searchRequest.getPrecision().equalsIgnoreCase(Constants.PRECISION_HIGH)) {
    	    searchRequest.setPrecision(Constants.PRECISION_HIGH);
        } else if (searchRequest.getPrecision().equalsIgnoreCase(Constants.PRECISION_LOW)) {
            searchRequest.setPrecision(Constants.PRECISION_LOW);
        } else {
            throw new SearchException("Invalid precision: " + searchRequest.getPrecision());
        }
        if (searchRequest.isCollapse() == null) {
        	searchRequest.setCollapse(true);
        }	
        if (searchRequest.getGroupFields() != null) {
        	if (searchRequest.getGroupFields().trim().isEmpty()) {
                searchRequest.setGroupFields(null);
        	}
        }
        if (searchRequest.getFacetFields() != null) {
        	if (searchRequest.getFacetFields().trim().isEmpty()) {
                searchRequest.setFacetFields(null);
        	}
        }
        if (searchRequest.getHighlightFields() != null) {
        	if (searchRequest.getHighlightFields().trim().isEmpty()) {
                searchRequest.setHighlightFields(null);
        	}
        }
        if (searchRequest.getHighlightTags() == null) {
            searchRequest.setHighlightTags(defaultHighlightTags);
        }
        if (searchRequest.isExact() == null) {
        	searchRequest.setExact(false);
        } else {
            throw new SearchException("Exact not implemented!");
        }
        if (searchRequest.isDym() == null) {
        	searchRequest.setDym(false);
        }
        if (searchRequest.isRag() == null) {
        	searchRequest.setRag(false);
        } else if (searchRequest.isRag() && searchRequest.isLegacy()) {
            throw new SearchException("Legacy search does not support RAG");
        }
        if (searchRequest.isDebug() == null) {
        	searchRequest.setDebug(false);
        }
    }
	
    /**
     * Clean query from spaces, remove special characters and fix quotes
     * 
     * @param query
     *          The query
     * @return Cleaned query
     */
    public static String clean(final String query) {
        return fixQuotes(mergeSpaces(removeSpecialChars(query)));
    }

    /**
     * Wrap query with dashes in quotes
     * 
     * @param query
     *          The query
     * @return Wrapped in quotes query
     */
    public static String quoteDash(final String query) {
        StringBuilder buffer = new StringBuilder();
        boolean inQuotes = false;
        boolean doQuotes = false;
        int pos = -1;
        int shift = 0;
        for (int i = 0; i < query.length(); i++) {
            char chr = query.charAt(i);
            if (inQuotes) {
                if (chr == '"') {
                    inQuotes = false;
                    doQuotes = false;
                }
            } else {
                if (chr == '"') {
                    inQuotes = true;
                    doQuotes = false;
                } else if (isSpaceOrParentheses(chr)) {
                    if (doQuotes) {
                        if (pos < i - 1) {
                            buffer.insert(pos + 1, '\"');
                            buffer.append('\"');
                            shift += 2;
                        }
                        doQuotes = false;
                    }
                    pos = i + shift;
                } else if (isDashAndPosition(pos, i, chr)) {
                        doQuotes = true;
//                    chr = '-'; // moved to solr schema
                }                
            }
            buffer.append(chr);
        }
        if (doQuotes) {
            buffer.insert(pos + 1, '\"');
            buffer.append('\"');
        }
        return buffer.toString();
    }

	/**
	 * This validates dash and postion.
	 * @param pos int
	 * @param index int
	 * @param chr char
	 * @return boolean valid
	 */
    public static boolean isDashAndPosition(final int pos, final int index, final char chr) {
		return isDash(chr) && (pos < index - 1);
	}

    /**
     * Check if space or parentheses.
     * 
     * @param chr
     *          The character
     * @return true if match
     */
	public static boolean isSpaceOrParentheses(final char chr) {
        boolean output;
        if (chr == ' ' || chr == '\t' || chr == 160) {
            output = true;
        } else if (chr == '(' || chr == ')') {
            output = true;
        } else {
            output = false;
        }
        return output;
    }

    /**
     * Check if dash/hyphen like.
     * 
     * @param chr
     *          The character
     * @return true if match
     */
    public static boolean isDash(final char chr) {
        boolean output;
        if (chr == '-') {
            output = true;
        } else if (chr == 30 || chr == 31 || chr == 9472) {
            output = true;
        } else if (chr == 8211 || chr == 8212 || chr == 8722) { 
            output = true;
        } else {
            output = false;
        }
        return output;
    }

    /**
     * Merge query extra spaces
     * 
     * @param query
     *          The query
     * @return Merged query
     */
    public static String mergeSpaces(final String query) {
        return query.replaceAll("\\s+", " ").trim();
    }

    /**
     * Escape single middle double quote
     * 
     * @param searchText 
     *        The search text
     * @return Escaped search text
     */
    public static String fixQuotes(final String searchText) {
    	String output = searchText;
    	output = output.replaceAll("\\\\\\\\", "XX");
    	output = output.replaceAll("\\\\\"", "YY");
    	int position = -1;
    	int position1 = -1;
    	int occurences = 0;
        for (int i = 0; i < output.length(); i++) {
			char letter = output.charAt(i);
			if (letter == '"') {
	        	position1 = position;
				position = i;
				occurences++;
			}
		}
        output = searchText;
        if (position >= 0 && (occurences % 2) != 0) {
        	if (position1 > 0) {
        		position = position1;
        	}
//   			output = output.substring(0, position) + "\\" + output.substring(position); // escape single quote
   			output = output.substring(0, position) + output.substring(position + 1);  // remove single quote
        }    	
    	return output;
    }

	/**
	 * Check spelling
	 * 
	 */
	protected String spellCheck(final Client client, final String searchQuery) {
		if (!("*".equals(searchQuery))) {
			String correctedQuery = languageModelService.spellCheck(client, searchQuery);
			if (correctedQuery != null && correctedQuery.length() > 0) {
				if (!(searchQuery.equalsIgnoreCase(correctedQuery))) {
					return correctedQuery;
				}
			}
		}
		return null;
	}

    /**
     * Process synonyms
     */
    protected void processSynonyms(final SearchRequest searchRequest, final String cleanSearchQuery, final JSONArray queryKeywords) {
        Client client = searchRequest.getClient();
        String[] synonymSets = client.getSynonyms();
        if (synonymSets == null || synonymSets.length == 0) {
            LOGGER.debug("[{}] Client {} has no synonyms", searchRequest.getRequestId(), client);
        	return;
        }
        List<String> synonymNames = Arrays.asList(synonymSets);
        
        List<String> ngramWords = new ArrayList<>();
        Map<String, List<String>> ngramWordsMap = new TreeMap<>();
        String[] tokens = cleanSearchQuery.split("\\s+");
        for (int i = 0; i < queryKeywords.length(); i++) {
        	JSONObject keyword = queryKeywords.getJSONObject(i);
        	String word = keyword.getString(Constants.TEXT);
        	String type = keyword.getString(Constants.TYPE);
        	if (Constants.KEYWORD.equals(type) || Constants.ENTITY.equals(type)) {
        		if (!ngramWordsMap.containsKey(word)) {
        			ngramWords.add(word);
        	        List<String> ngrams = getNgrams(tokens, word, 5);
        			ngramWordsMap.put(word, ngrams);
        			for (String ngram : ngrams) {
        				if (!ngramWords.contains(ngram)) {
        					ngramWords.add(ngram);
            			}
        			}
                }
            }
        }

    	try {
    		Map<String, List<List<String>>> synonymMap = synonymService.getSynonyms(ngramWords, synonymNames);
    		List<String> synonymEntries = new ArrayList<>();
            for (String ngramWord : ngramWords) {
            	List<List<String>> synonymLists = synonymMap.get(ngramWord);
            	if (synonymLists != null) {
                	for (List<String> synonyms : synonymLists) {
                		StringBuilder sb = new StringBuilder();
                		for (String synonym : synonyms) {
                			if (sb.length() > 0) {
                				sb.append(", ");
                			}
                			sb.append(synonym);
                		}
                		synonymEntries.add(sb.toString());
                	}
            	}
            }

            // TODO add weight to exact match between ngramword and found synonym word
            List<Double> similarities = languageModelService.getSimilarity(client, cleanSearchQuery, synonymEntries);

    		Map<String, JSONObject> bestSynonymMap = new TreeMap<>();
            int j = 0;
            for (String ngramWord : ngramWords) {
            	List<List<String>> synonymLists = synonymMap.get(ngramWord);
            	if (synonymLists != null) {
                	double maxScore = 0;
                	List<String> bestSynonyms = new ArrayList<>();
                	for (List<String> synonyms : synonymLists) {
                		Double score = similarities.get(j++);
                		if (score > maxScore) {
                			bestSynonyms = synonyms;
                			maxScore = score;
                		}
                	}
                	JSONObject jsonSynonym = new JSONObject();
                	jsonSynonym.put(Constants.WORD, ngramWord);
                	jsonSynonym.put(Constants.SYNONYMS, new JSONArray(bestSynonyms));
                	jsonSynonym.put(Constants.SCORE, maxScore);
                	bestSynonymMap.put(ngramWord, jsonSynonym);
                }
            }

            for (int i = 0; i < queryKeywords.length(); i++) {
            	JSONObject keyword = queryKeywords.getJSONObject(i);
            	String word = keyword.getString(Constants.TEXT);
//            	String type = keyword.getString(Constants.TYPE
            	double maxScore = 0;
            	List<String> ngrams = ngramWordsMap.get(word);
            	if (ngrams != null) {
                	for (String ngram : ngrams) {
                		JSONObject jsonSynonym = bestSynonymMap.get(ngram);
                		if (jsonSynonym != null) {
                        	String synonym = jsonSynonym.getString(Constants.WORD);
                        	JSONArray synonyms = jsonSynonym.getJSONArray(Constants.SYNONYMS);
                        	double score = jsonSynonym.getDouble(Constants.SCORE);
                        	if (score > maxScore) {
                        		maxScore = score;
                        		if (!word.equals(synonym)) {
//                        			synonyms.put(synonym);
                        		}
                            	keyword.put(Constants.SYNONYMS, synonyms);
                        	}
                		}
                	}
            	}
            }
            
            LOGGER.debug("[{}] Query Keywords with Synonyms: {}", searchRequest.getRequestId(), queryKeywords);

		} catch (Exception e) {
			LOGGER.error("[{}] Keyword synonyms processing exception: {}", searchRequest.getRequestId(), e.getMessage());
		}
    }
    
    /**
     * Get all n-grams from a query string that contain word.
     * 
     * @param query input text
     * @param word - ngram must contain this word
     * @param maxLen maximum number of words in an ngram
     * @return list of all matching n-grams
     */
    private static List<String> getNgrams(final String[] tokens, final String word, final int maxLen) {
        if (tokens == null || tokens.length == 0) {
            return Collections.emptyList();
        }
        List<String> result = new ArrayList<>();
        for (int size = 1; size <= Math.min(maxLen, tokens.length); size++) {
            for (int start = 0; start <= tokens.length - size; start++) {
                String[] slice = Arrays.copyOfRange(tokens, start, start + size);
                for (String token : slice) {
                    if (token.equals(word)) {
                        result.add(String.join(" ", slice));
                        break;
                    }
                }
            }
        }
        return result;
    }
    
	/**
	 * Get RAG answer
	 * 
     * @param searchQuery
     * @param searchResults
     * @return RAG answer
	 */
	protected String getRagAnswer(final Client client, final String searchQuery, final List<SearchResult> searchResults) {
		if ("*".equals(searchQuery)) {
			return "";
		}
	    LOGGER.debug("RAG processing for query: {}", searchQuery);

	    // TODO Add chain
	    
		StringBuilder sbText = new StringBuilder();
		for (int i = 0; i < searchResults.size() && i < searchResultsRagMax; i++) {
			SearchResult searchResult = searchResults.get(i);
			if (searchResult.getScore() > 0 && searchResult.getScore() < searchScoreRagMin) {
				if (i == 0) {
					if (searchResult.getScore() <= searchScoreResultsBad) {
						return Constants.NO_GREAT_MATCHES;
					}
					return "";
				}
//				break;
			}
			sbText.append("\n\nSearch result ");
			sbText.append(i + 1);
			sbText.append(":\n");
			int count = 0;
			List<TextChunk> chunks = searchResult.getChunks();
			if (chunks != null) {
				for (TextChunk chunk : chunks) {
					if (Constants.TYPE_HIGH.equals(chunk.getType())) {
						if (sbText.length() > 0) {
			       			sbText.append(", ");
						}
			    		sbText.append(chunk.getText());
			    		if (++count >= searchResultsChunksMax) {
			    			break;
			    		}
					}
				}
       			sbText.append("\n\n");
       			count = 0;
       			for (TextChunk chunk : chunks) {
					if (Constants.TYPE_CONTENT.equals(chunk.getType())) {
						if (sbText.length() > 0) {
			       			sbText.append("\n\n");
						}
			    		sbText.append(chunk.getText());
			    		if (++count >= searchResultsChunksMax) {
			    			break;
			    		}
					}
				}
//       			sbText.append("\n\n");
//       			count = 0;
//       			for (TextChunk chunk : chunks) {
//					if (Constants.TYPE_LOW.equals(chunk.getType())) {
//						if (sbText.length() > 0) {
//			       			sbText.append(", ");
//						}
//			    		sbText.append(chunk.getText());
//			    		if (++count >= searchResultsChunksMax) {
//			    			break;
//			    		}
//					}
//				}
			}
		}
		String ragContent = sbText.toString();
		String answer;
		if (ragContent.length() > embeddingChunkLengthMin) { //TODO
			answer = languageModelService.getRAGAnswer(client, searchQuery, ragContent);
		} else {
			answer = null;
		}
	    LOGGER.debug("RAG query: {} RAG answer: {}", searchQuery, answer);
    	return answer;
	}

	/**
	 * Rerank search results using the external reranker followed by
	 * Reciprocal Rank Fusion (RRF) to combine the original search ranking
	 * with the reranker ranking.
	 *
	 * RRF score: rrf(doc) = 1/(k + origRank) + 1/(k + rerankRank)
	 * where k = 60, ranks are 1-based, and origRank is the position in the
	 * incoming list before reranking. Documents beyond searchResultsRerankMax
	 * are not sent to the reranker and receive only the original rank term.
	 */
	protected void rerankSearchResults(final Client client, final String searchQuery, final List<SearchResult> searchResults) {
		if ("*".equals(searchQuery)) {
			return;
		}
	    LOGGER.debug("Reranking results for query: {}", searchQuery);

	    final int rerankCount = Math.min(searchResults.size(), searchResultsRerankMax);

	    List<String> texts = new ArrayList<>(rerankCount);
		for (int i = 0; i < rerankCount; i++) {
			SearchResult searchResult = searchResults.get(i);
			StringBuilder sbText = new StringBuilder();
			int count = 0;
			List<TextChunk> chunks = searchResult.getChunks();
			if (chunks != null) {
				for (TextChunk chunk : chunks) {
					if (Constants.TYPE_HIGH.equals(chunk.getType())) {
						if (sbText.length() > 0) {
			       			sbText.append(", ");
						}
			    		sbText.append(chunk.getText());
			    		if (++count >= searchResultsChunksMax) {
			    			break;
			    		}
					}
				}
       			sbText.append("\n\n");
       			count = 0;
				for (TextChunk chunk : chunks) {
					if (Constants.TYPE_CONTENT.equals(chunk.getType())) {
						if (sbText.length() > 0) {
			       			sbText.append("\n\n");
						}
			    		sbText.append(chunk.getText());
			    		if (++count >= searchResultsChunksMax) {
			    			break;
			    		}
					}
				}
       			sbText.append("\n\n");
       			count = 0;
				for (TextChunk chunk : chunks) {
					if (Constants.TYPE_LOW.equals(chunk.getType())) {
						if (sbText.length() > 0) {
			       			sbText.append(", ");
						}
			    		sbText.append(chunk.getText());
			    		if (++count >= searchResultsChunksMax) {
			    			break;
			    		}
					}
				}
			}
    		String text = sbText.toString();
    		text = Utils.getSubText(text, 0, 1000); // TODO
    		texts.add(text);
		}

		List<Double> scores = languageModelService.rerankSearchResults(client, searchQuery, texts);
        if (scores == null) {
        	return;
        }

        // Derive 1-based reranker ranks from the reranker scores.
        // Sort indices of the reranked subset by score descending to get their ranks.
        Integer[] rerankIndices = new Integer[rerankCount];
        for (int i = 0; i < rerankCount; i++) {
        	rerankIndices[i] = i;
        }
        Arrays.sort(rerankIndices, (a, b) -> Double.compare(scores.get(b), scores.get(a)));
        // rerankRank[i] = 1-based reranker rank of the document at original position i
        int[] rerankRank = new int[rerankCount];
        for (int rank = 0; rank < rerankCount; rank++) {
        	rerankRank[rerankIndices[rank]] = rank + 1;
        }

        // Compute RRF score for every result.
        // Reranked documents fuse two rank lists; the rest use original rank only.
        final int k = 60;
        for (int i = 0; i < searchResults.size(); i++) {
        	int origRank = i + 1;                        // 1-based position in the incoming list
        	double rrf = 1.0 / (k + origRank);           // contribution from original ranking
        	if (i < rerankCount) {
        		rrf += 1.0 / (k + rerankRank[i]);        // contribution from reranker ranking
        	}
        	searchResults.get(i).setScore(rrf);
        }

        // Sort all results by RRF score descending
		Collections.sort(searchResults, new Comparator<SearchResult>() {
		    @Override
		    public int compare(SearchResult sr1, SearchResult sr2) {
		        return Double.compare(sr2.getScore(), sr1.getScore()); // descending
		    }
		});
	}

	/**
	 * Clean query special chars and spaces
	 * 
	 * @param query The query
	 * @return Clean query
	 */
	public static String cleanSearchQuery(final String query) {
		return StringUtil.cleanSpaces(fixQuotes(removeSpecialChars(query)));
	}

    /**
     * Remove query special chars 
     * except "&'*-+,.;
     * 
     * @param query The query
     * @return Removed chars query
     */
    public static String removeSpecialChars(final String query) {
        return query.replaceAll("[!#$%()_/: <=>@\\[\\]\\\\^`\\?{|}~]", " ");
    }

	public Object clientProcessing(Client client, JSONObject jsonDocument) throws SearchException {
		if (client.isClientSearchProcessing()) {
			JSONObject request = new JSONObject();
			request.put(Constants.CLIENT, client.getClientId());
			request.put(Constants.DATA, jsonDocument);
			try {
				String response = Utils.post(clientSearchUrl, request.toString()).trim();
				if (response.length() == 0) {
			        LOGGER.info("Client {} ignore document: {}", client.getClientId(),
			        		StringUtil.formatForLogging(jsonDocument.toString()));
			        return null;
				} else if (response.startsWith("{") && response.endsWith("}")) {
					return new JSONObject(response);
				} else if (response.startsWith("[") && response.endsWith("]")) {
					return new JSONArray(response);
				} else {
			        throw new SearchException("Invalid response from client processing:" + response);
				}
			} catch (IOException e) {
	            throw new SearchException("Client processing failed", e);
			}
		}
		return jsonDocument;
	}
    
}