package org.thoughtcrime.securesms.search;

import android.content.Context;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.database.MergeCursor;
import androidx.annotation.NonNull;
import com.annimon.stream.Stream;
import org.session.libsession.messaging.contacts.Contact;
import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.GroupRecord;
import org.session.libsession.utilities.TextSecurePreferences;
import org.session.libsession.utilities.recipients.Recipient;
import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.contacts.ContactAccessor;
import org.thoughtcrime.securesms.database.CursorList;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.MmsSmsColumns;
import org.thoughtcrime.securesms.database.SearchDatabase;
import org.thoughtcrime.securesms.database.SessionContactDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.search.model.MessageResult;
import org.thoughtcrime.securesms.search.model.SearchResult;
import org.thoughtcrime.securesms.util.Stopwatch;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Executor;
import kotlin.Pair;

// Class to manage 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 {
    // Construct a list containing several ranges of invalid ASCII characters
    // See: https://www.ascii-code.com/
    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 ThreadDatabase         threadDatabase;
  private final GroupDatabase          groupDatabase;
  private final SessionContactDatabase contactDatabase;
  private final ContactAccessor        contactAccessor;
  private final Executor               executor;

  public SearchRepository(@NonNull Context context,
                          @NonNull SearchDatabase searchDatabase,
                          @NonNull ThreadDatabase threadDatabase,
                          @NonNull GroupDatabase groupDatabase,
                          @NonNull SessionContactDatabase contactDatabase,
                          @NonNull ContactAccessor contactAccessor,
                          @NonNull Executor executor)
  {
    this.context          = context.getApplicationContext();
    this.searchDatabase   = searchDatabase;
    this.threadDatabase   = threadDatabase;
    this.groupDatabase    = groupDatabase;
    this.contactDatabase  = contactDatabase;
    this.contactAccessor  = contactAccessor;
    this.executor         = executor;
  }

  public void query(@NonNull String query, @NonNull Callback<SearchResult> callback) {
    // If the sanitized search is empty then abort without search
    String cleanQuery = sanitizeQuery(query).trim();

    executor.execute(() -> {
      Stopwatch timer = new Stopwatch("FtsQuery");
      timer.split("clean");

      Pair<CursorList<Contact>, List<String>> contacts = queryContacts(cleanQuery);
      timer.split("Contacts");

      CursorList<GroupRecord> conversations = queryConversations(cleanQuery, contacts.getSecond());
      timer.split("Conversations");

      CursorList<MessageResult> messages = queryMessages(cleanQuery);
      timer.split("Messages");

      timer.stop(TAG);

      callback.onResult(new SearchResult(cleanQuery, contacts.getFirst(), conversations, messages));
    });
  }

  public void query(@NonNull String query, long threadId, @NonNull Callback<CursorList<MessageResult>> callback) {
    // If the sanitized search query is empty then abort the search
    String cleanQuery = sanitizeQuery(query).trim();
    if (cleanQuery.isEmpty()) {
      callback.onResult(CursorList.emptyList());
      return;
    }

    executor.execute(() -> {
      CursorList<MessageResult> messages = queryMessages(cleanQuery, threadId);
      callback.onResult(messages);
    });
  }

  public Pair<CursorList<Contact>, List<String>> queryContacts(String query) {
    Cursor contacts = contactDatabase.queryContactsByName(query);
    List<Address> contactList = new ArrayList<>();
    List<String> contactStrings = new ArrayList<>();

    while (contacts.moveToNext()) {
      try {
        Contact contact = contactDatabase.contactFromCursor(contacts);
        String contactAccountId = contact.getAccountID();
        Address address = Address.fromSerialized(contactAccountId);
        contactList.add(address);
        contactStrings.add(contactAccountId);
      } catch (Exception e) {
        Log.e("Loki", "Error building Contact from cursor in query", e);
      }
    }

    contacts.close();

    Cursor addressThreads = threadDatabase.searchConversationAddresses(query);
    Cursor individualRecipients = threadDatabase.getFilteredConversationList(contactList);
    if (individualRecipients == null && addressThreads == null) {
      return new Pair<>(CursorList.emptyList(),contactStrings);
    }
    MergeCursor merged = new MergeCursor(new Cursor[]{addressThreads, individualRecipients});

    return new Pair<>(new CursorList<>(merged, new ContactModelBuilder(contactDatabase, threadDatabase)), contactStrings);
  }

  private CursorList<GroupRecord> queryConversations(@NonNull String query, List<String> matchingAddresses) {
    List<String> numbers = contactAccessor.getNumbersForThreadSearchFilter(context, query);
    String localUserNumber = TextSecurePreferences.getLocalNumber(context);
    if (localUserNumber != null) {
      matchingAddresses.remove(localUserNumber);
    }
    Set<Address> addresses = new HashSet<>(Stream.of(numbers).map(number -> Address.fromExternal(context, number)).toList());

    Cursor membersGroupList = groupDatabase.getGroupsFilteredByMembers(matchingAddresses);
    if (membersGroupList != null) {
      GroupDatabase.Reader reader = new GroupDatabase.Reader(membersGroupList);
      while (membersGroupList.moveToNext()) {
        GroupRecord record = reader.getCurrent();
        if (record == null) continue;

        addresses.add(Address.fromSerialized(record.getEncodedId()));
      }
      membersGroupList.close();
    }

    Cursor conversations = threadDatabase.getFilteredConversationList(new ArrayList<>(addresses));
    return conversations != null ? new CursorList<>(conversations, new GroupModelBuilder(threadDatabase, groupDatabase))
            : CursorList.emptyList();
  }

  private CursorList<MessageResult> queryMessages(@NonNull String query) {
    Cursor messages = searchDatabase.queryMessages(query);
    return messages != null ? new CursorList<>(messages, new MessageModelBuilder(context))
                            : CursorList.emptyList();
  }

  private CursorList<MessageResult> queryMessages(@NonNull String query, long threadId) {
    Cursor messages = searchDatabase.queryMessages(query, threadId);
    return messages != null ? new CursorList<>(messages, new MessageModelBuilder(context))
                            : CursorList.emptyList();
  }

  /**
   * 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 static class ContactModelBuilder implements CursorList.ModelBuilder<Contact> {

    private final SessionContactDatabase contactDb;
    private final ThreadDatabase threadDb;

    public ContactModelBuilder(SessionContactDatabase contactDb, ThreadDatabase threadDb) {
      this.contactDb = contactDb;
      this.threadDb = threadDb;
    }

    @Override
    public Contact build(@NonNull Cursor cursor) {
      ThreadRecord threadRecord = threadDb.readerFor(cursor).getCurrent();
      Contact contact = contactDb.getContactWithAccountID(threadRecord.getRecipient().getAddress().serialize());
      if (contact == null) {
        contact = new Contact(threadRecord.getRecipient().getAddress().serialize());
        contact.setThreadID(threadRecord.getThreadId());
      }
      return contact;
    }
  }

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

    private final Context context;

    RecipientModelBuilder(@NonNull Context context) { this.context = context; }

    @Override
    public Recipient build(@NonNull Cursor cursor) {
      Address address = Address.fromExternal(context, cursor.getString(1));
      return Recipient.from(context, address, false);
    }
  }

  private static class GroupModelBuilder implements CursorList.ModelBuilder<GroupRecord> {
    private final ThreadDatabase threadDatabase;
    private final GroupDatabase groupDatabase;

    public GroupModelBuilder(ThreadDatabase threadDatabase, GroupDatabase groupDatabase) {
      this.threadDatabase = threadDatabase;
      this.groupDatabase = groupDatabase;
    }

    @Override
    public GroupRecord build(@NonNull Cursor cursor) {
      ThreadRecord threadRecord = threadDatabase.readerFor(cursor).getCurrent();
      return groupDatabase.getGroup(threadRecord.getRecipient().getAddress().toGroupString()).get();
    }
  }

  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> {

    private final Context context;

    MessageModelBuilder(@NonNull Context context) { this.context = context; }

    @Override
    public MessageResult build(@NonNull Cursor cursor) {
      Address   conversationAddress   = Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.CONVERSATION_ADDRESS)));
      Address   messageAddress        = Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.MESSAGE_ADDRESS)));
      Recipient conversationRecipient = Recipient.from(context, conversationAddress, false);
      Recipient messageRecipient      = Recipient.from(context, messageAddress, false);
      String    body                  = cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.SNIPPET));
      long      sentMs                = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.NORMALIZED_DATE_SENT));
      long      threadId              = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.THREAD_ID));

      return new MessageResult(conversationRecipient, messageRecipient, body, threadId, sentMs);
    }
  }

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