package org.thoughtcrime.securesms.search;

import android.content.Context;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.database.MergeCursor;
import android.text.TextUtils;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import com.annimon.stream.Stream;

import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.contacts.ContactAccessor;
import org.thoughtcrime.securesms.contacts.ContactRepository;
import org.thoughtcrime.securesms.conversationlist.model.MessageResult;
import org.thoughtcrime.securesms.conversationlist.model.SearchResult;
import org.thoughtcrime.securesms.database.CursorList;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MentionDatabase;
import org.thoughtcrime.securesms.database.MentionUtil;
import org.thoughtcrime.securesms.database.MessageDatabase;
import org.thoughtcrime.securesms.database.MmsSmsColumns;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.SearchDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.CursorUtil;
import org.thoughtcrime.securesms.util.Util;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;

import static org.thoughtcrime.securesms.database.SearchDatabase.SNIPPET_WRAP;

/**
 * Manages data retrieval for search.
 */
public class SearchRepository {

  private static final String TAG = SearchRepository.class.getSimpleName();

  private static final Set<Character> BANNED_CHARACTERS = new HashSet<>();
  static {
    // Several ranges of invalid ASCII characters
    for (int i = 33; i <= 47; i++) {
      BANNED_CHARACTERS.add((char) i);
    }
    for (int i = 58; i <= 64; i++) {
      BANNED_CHARACTERS.add((char) i);
    }
    for (int i = 91; i <= 96; i++) {
      BANNED_CHARACTERS.add((char) i);
    }
    for (int i = 123; i <= 126; i++) {
      BANNED_CHARACTERS.add((char) i);
    }
  }

  private final Context           context;
  private final SearchDatabase    searchDatabase;
  private final ContactRepository contactRepository;
  private final ThreadDatabase    threadDatabase;
  private final ContactAccessor   contactAccessor;
  private final Executor          serialExecutor;
  private final ExecutorService   parallelExecutor;
  private final RecipientDatabase recipientDatabase;
  private final MentionDatabase   mentionDatabase;
  private final MessageDatabase   mmsDatabase;

  public SearchRepository() {
    this.context           = ApplicationDependencies.getApplication().getApplicationContext();
    this.searchDatabase    = DatabaseFactory.getSearchDatabase(context);
    this.threadDatabase    = DatabaseFactory.getThreadDatabase(context);
    this.recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
    this.mentionDatabase   = DatabaseFactory.getMentionDatabase(context);
    this.mmsDatabase       = DatabaseFactory.getMmsDatabase(context);
    this.contactRepository = new ContactRepository(context);
    this.contactAccessor   = ContactAccessor.getInstance();
    this.serialExecutor    = SignalExecutors.SERIAL;
    this.parallelExecutor  = SignalExecutors.BOUNDED;
  }

  public void query(@NonNull String query, @NonNull Callback<SearchResult> callback) {
    if (TextUtils.isEmpty(query)) {
      callback.onResult(SearchResult.EMPTY);
      return;
    }

    serialExecutor.execute(() -> {
      String cleanQuery = sanitizeQuery(query);

      Future<List<Recipient>>     contacts        = parallelExecutor.submit(() -> queryContacts(cleanQuery));
      Future<List<ThreadRecord>>  conversations   = parallelExecutor.submit(() -> queryConversations(cleanQuery));
      Future<List<MessageResult>> messages        = parallelExecutor.submit(() -> queryMessages(cleanQuery));
      Future<List<MessageResult>> mentionMessages = parallelExecutor.submit(() -> queryMentions(sanitizeQueryAsTokens(query)));

      try {
        long         startTime = System.currentTimeMillis();
        SearchResult result    = new SearchResult(cleanQuery, contacts.get(), conversations.get(), mergeMessagesAndMentions(messages.get(), mentionMessages.get()));

        Log.d(TAG, "Total time: " + (System.currentTimeMillis() - startTime) + " ms");

        callback.onResult(result);
      } catch (ExecutionException | InterruptedException e) {
        Log.w(TAG, e);
        callback.onResult(SearchResult.EMPTY);
      }
    });
  }

  public void query(@NonNull String query, long threadId, @NonNull Callback<List<MessageResult>> callback) {
    if (TextUtils.isEmpty(query)) {
      callback.onResult(CursorList.emptyList());
      return;
    }

    serialExecutor.execute(() -> {
      long                startTime       = System.currentTimeMillis();
      List<MessageResult> messages        = queryMessages(sanitizeQuery(query), threadId);
      List<MessageResult> mentionMessages = queryMentions(sanitizeQueryAsTokens(query), threadId);

      Log.d(TAG, "[ConversationQuery] " + (System.currentTimeMillis() - startTime) + " ms");

      callback.onResult(mergeMessagesAndMentions(messages, mentionMessages));
    });
  }

  private List<Recipient> queryContacts(String query) {
    Cursor contacts = null;

    try {
      Cursor textSecureContacts = contactRepository.querySignalContacts(query);
      Cursor systemContacts     = contactRepository.queryNonSignalContacts(query);

      contacts = new MergeCursor(new Cursor[]{ textSecureContacts, systemContacts });

      return readToList(contacts, new RecipientModelBuilder(), 250);
    } finally {
      if (contacts != null) {
        contacts.close();
      }
    }
  }

  private @NonNull List<ThreadRecord> queryConversations(@NonNull String query) {
    List<String>      numbers      = contactAccessor.getNumbersForThreadSearchFilter(context, query);
    List<RecipientId> recipientIds = Stream.of(numbers).map(number -> Recipient.external(context, number)).map(Recipient::getId).toList();

    try (Cursor cursor = threadDatabase.getFilteredConversationList(recipientIds)) {
      return readToList(cursor, new ThreadModelBuilder(threadDatabase));
    }
  }

  private @NonNull List<MessageResult> queryMessages(@NonNull String query) {
    List<MessageResult> results;
    try (Cursor cursor = searchDatabase.queryMessages(query)) {
      results = readToList(cursor, new MessageModelBuilder());
    }

    List<Long> messageIds = new LinkedList<>();
    for (MessageResult result : results) {
      if (result.isMms) {
        messageIds.add(result.messageId);
      }
    }

    if (messageIds.isEmpty()) {
      return results;
    }

    Map<Long, List<Mention>> mentions = DatabaseFactory.getMentionDatabase(context).getMentionsForMessages(messageIds);
    if (mentions.isEmpty()) {
      return results;
    }

    List<MessageResult> updatedResults = new ArrayList<>(results.size());
    for (MessageResult result : results) {
      if (result.isMms && mentions.containsKey(result.messageId)) {
        List<Mention> messageMentions = mentions.get(result.messageId);

        //noinspection ConstantConditions
        String updatedBody    = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, result.body, messageMentions).getBody().toString();
        String updatedSnippet = updateSnippetWithDisplayNames(result.body, result.bodySnippet, messageMentions);

        //noinspection ConstantConditions
        updatedResults.add(new MessageResult(result.conversationRecipient, result.messageRecipient, updatedBody, updatedSnippet, result.threadId, result.messageId, result.receivedTimestampMs, result.isMms));
      } else {
        updatedResults.add(result);
      }
    }

    return updatedResults;
  }

  private @NonNull String updateSnippetWithDisplayNames(@NonNull String body, @NonNull String bodySnippet, @NonNull List<Mention> mentions) {
    String cleanSnippet = bodySnippet;
    int    startOffset  = 0;

    if (cleanSnippet.startsWith(SNIPPET_WRAP)) {
      cleanSnippet = cleanSnippet.substring(SNIPPET_WRAP.length());
      startOffset  = SNIPPET_WRAP.length();
    }

    if (cleanSnippet.endsWith(SNIPPET_WRAP)) {
      cleanSnippet = cleanSnippet.substring(0, cleanSnippet.length() - SNIPPET_WRAP.length());
    }

    int startIndex = body.indexOf(cleanSnippet);

    if (startIndex != -1) {
      List<Mention> adjustMentions = new ArrayList<>(mentions.size());
      for (Mention mention : mentions) {
        int adjustedStart = mention.getStart() - startIndex + startOffset;
        if (adjustedStart >= 0 && adjustedStart + mention.getLength() <= cleanSnippet.length()) {
          adjustMentions.add(new Mention(mention.getRecipientId(), adjustedStart, mention.getLength()));
        }
      }

      //noinspection ConstantConditions
      return MentionUtil.updateBodyAndMentionsWithDisplayNames(context, bodySnippet, adjustMentions).getBody().toString();
    }

    return bodySnippet;
  }

  private @NonNull List<MessageResult> queryMessages(@NonNull String query, long threadId) {
    try (Cursor cursor = searchDatabase.queryMessages(query, threadId)) {
      return readToList(cursor, new MessageModelBuilder());
    }
  }

  private @NonNull List<MessageResult> queryMentions(@NonNull List<String> cleanQueries) {
    Set<RecipientId> recipientIds = new HashSet<>();
    for (String cleanQuery : cleanQueries) {
      for (Recipient recipient : recipientDatabase.queryRecipientsForMentions(cleanQuery)) {
        recipientIds.add(recipient.getId());
      }
    }

    Map<Long, List<Mention>> mentionQueryResults = mentionDatabase.getMentionsContainingRecipients(recipientIds, 500);

    if (mentionQueryResults.isEmpty()) {
      return Collections.emptyList();
    }

    List<MessageResult> results = new ArrayList<>();

    try (MessageDatabase.Reader reader = mmsDatabase.getMessages(mentionQueryResults.keySet())) {
      MessageRecord record;
      while ((record = reader.getNext()) != null) {
        List<Mention> mentions = mentionQueryResults.get(record.getId());
        if (Util.hasItems(mentions)) {
          MentionUtil.UpdatedBodyAndMentions updated        = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, record.getBody(), mentions);
          String                             updatedBody    = updated.getBody() != null ? updated.getBody().toString() : record.getBody();
          String                             updatedSnippet = makeSnippet(cleanQueries, updatedBody);

          //noinspection ConstantConditions
          results.add(new MessageResult(threadDatabase.getRecipientForThreadId(record.getThreadId()), record.getRecipient(), updatedBody, updatedSnippet, record.getThreadId(), record.getId(), record.getDateReceived(), true));
        }
      }
    }

    return results;
  }

  private @NonNull List<MessageResult> queryMentions(@NonNull List<String> cleanQueries, long threadId) {
    Set<RecipientId> recipientIds = new HashSet<>();
    for (String cleanQuery : cleanQueries) {
      for (Recipient recipient : recipientDatabase.queryRecipientsForMentions(cleanQuery)) {
        recipientIds.add(recipient.getId());
      }
    }

    Map<Long, List<Mention>> mentionQueryResults = mentionDatabase.getMentionsContainingRecipients(recipientIds, threadId, 500);

    if (mentionQueryResults.isEmpty()) {
      return Collections.emptyList();
    }

    List<MessageResult> results = new ArrayList<>();

    try (MessageDatabase.Reader reader = mmsDatabase.getMessages(mentionQueryResults.keySet())) {
      MessageRecord record;
      while ((record = reader.getNext()) != null) {
        //noinspection ConstantConditions
        results.add(new MessageResult(threadDatabase.getRecipientForThreadId(record.getThreadId()), record.getRecipient(), record.getBody(), record.getBody(), record.getThreadId(), record.getId(), record.getDateReceived(), true));
      }
    }

    return results;
  }

  private @NonNull String makeSnippet(@NonNull List<String> queries, @NonNull String body) {
    if (body.length() < 50) {
      return body;
    }

    String lowerBody = body.toLowerCase();
    for (String query : queries) {
      int foundIndex = lowerBody.indexOf(query.toLowerCase());
      if (foundIndex != -1) {
        int snippetStart = Math.max(0, Math.max(body.lastIndexOf(' ', foundIndex - 5) + 1, foundIndex - 15));
        int lastSpace    = body.indexOf(' ', foundIndex + 30);
        int snippetEnd   = Math.min(body.length(), lastSpace > 0 ? Math.min(lastSpace, foundIndex + 40) : foundIndex + 40);

        return (snippetStart > 0 ? SNIPPET_WRAP : "") + body.substring(snippetStart, snippetEnd) + (snippetEnd < body.length() ? SNIPPET_WRAP : "");
      }
    }
    return body;
  }

  private @NonNull <T> List<T> readToList(@Nullable Cursor cursor, @NonNull CursorList.ModelBuilder<T> builder) {
    return readToList(cursor, builder, -1);
  }

  private @NonNull <T> List<T> readToList(@Nullable Cursor cursor, @NonNull CursorList.ModelBuilder<T> builder, int limit) {
    if (cursor == null) {
      return Collections.emptyList();
    }

    int     i    = 0;
    List<T> list = new ArrayList<>(cursor.getCount());

    while (cursor.moveToNext() && (limit < 0 || i < limit)) {
      list.add(builder.build(cursor));
      i++;
    }

    return list;
  }

  /**
   * Unfortunately {@link DatabaseUtils#sqlEscapeString(String)} is not sufficient for our purposes.
   * MATCH queries have a separate format of their own that disallow most "special" characters.
   *
   * Also, SQLite can't search for apostrophes, meaning we can't normally find words like "I'm".
   * However, if we replace the apostrophe with a space, then the query will find the match.
   */
  private String sanitizeQuery(@NonNull String query) {
    StringBuilder out = new StringBuilder();

    for (int i = 0; i < query.length(); i++) {
      char c = query.charAt(i);
      if (!BANNED_CHARACTERS.contains(c)) {
        out.append(c);
      } else if (c == '\'') {
        out.append(' ');
      }
    }

    return out.toString();
  }

  private @NonNull List<String> sanitizeQueryAsTokens(@NonNull String query) {
    String[] parts = query.split("\\s+");
    if (parts.length > 3) {
      return Collections.emptyList();
    }

    return Stream.of(parts).map(this::sanitizeQuery).toList();
  }

  private static @NonNull List<MessageResult> mergeMessagesAndMentions(@NonNull List<MessageResult> messages, @NonNull List<MessageResult> mentionMessages) {
    Set<Long> includedMmsMessages = new HashSet<>();

    List<MessageResult> combined = new ArrayList<>(messages.size() + mentionMessages.size());
    for (MessageResult result : messages) {
      combined.add(result);
      if (result.isMms) {
        includedMmsMessages.add(result.messageId);
      }
    }

    for (MessageResult result : mentionMessages) {
      if (!includedMmsMessages.contains(result.messageId)) {
        combined.add(result);
      }
    }

    Collections.sort(combined, Collections.reverseOrder((left, right) -> Long.compare(left.receivedTimestampMs, right.receivedTimestampMs)));

    return combined;
  }

  private static class RecipientModelBuilder implements CursorList.ModelBuilder<Recipient> {

    @Override
    public Recipient build(@NonNull Cursor cursor) {
      long recipientId = cursor.getLong(cursor.getColumnIndexOrThrow(ContactRepository.ID_COLUMN));
      return Recipient.resolved(RecipientId.from(recipientId));
    }
  }

  private static class ThreadModelBuilder implements CursorList.ModelBuilder<ThreadRecord> {

    private final ThreadDatabase threadDatabase;

    ThreadModelBuilder(@NonNull ThreadDatabase threadDatabase) {
      this.threadDatabase = threadDatabase;
    }

    @Override
    public ThreadRecord build(@NonNull Cursor cursor) {
      return threadDatabase.readerFor(cursor).getCurrent();
    }
  }

  private static class MessageModelBuilder implements CursorList.ModelBuilder<MessageResult> {

    @Override
    public MessageResult build(@NonNull Cursor cursor) {
      RecipientId conversationRecipientId = RecipientId.from(cursor.getLong(cursor.getColumnIndex(SearchDatabase.CONVERSATION_RECIPIENT)));
      RecipientId messageRecipientId      = RecipientId.from(CursorUtil.requireLong(cursor, SearchDatabase.MESSAGE_RECIPIENT));
      Recipient   conversationRecipient   = Recipient.live(conversationRecipientId).get();
      Recipient   messageRecipient        = Recipient.live(messageRecipientId).get();
      String      body                    = CursorUtil.requireString(cursor, SearchDatabase.BODY);
      String      bodySnippet             = CursorUtil.requireString(cursor, SearchDatabase.SNIPPET);
      long        receivedMs              = CursorUtil.requireLong(cursor, MmsSmsColumns.NORMALIZED_DATE_RECEIVED);
      long        threadId                = CursorUtil.requireLong(cursor, MmsSmsColumns.THREAD_ID);
      int         messageId               = CursorUtil.requireInt(cursor, SearchDatabase.MESSAGE_ID);
      boolean     isMms                   = CursorUtil.requireInt(cursor, SearchDatabase.IS_MMS) == 1;

      return new MessageResult(conversationRecipient, messageRecipient, body, bodySnippet, threadId, messageId, receivedMs, isMms);
    }
  }

  public interface Callback<E> {
    void onResult(@NonNull E result);
  }
}
