/*
 * Copyright 2026 Signal Messenger, LLC
 * SPDX-License-Identifier: AGPL-3.0-only
 */

package org.thoughtcrime.securesms.contacts.paged

import android.content.Context
import android.text.SpannableStringBuilder
import android.text.style.ForegroundColorSpan
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.widget.AppCompatImageView
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.google.android.material.button.MaterialButton
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
import org.signal.core.ui.compose.FastScrollCharacterProvider
import org.signal.core.util.BreakIteratorCompat
import org.signal.core.util.requireDrawable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatar
import org.thoughtcrime.securesms.avatar.view.AvatarView
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.FromTextView
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.database.model.StoryViewState
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.SearchUtil
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProvider
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProviderBuilder
import org.thoughtcrime.securesms.util.visible
import java.util.Locale
import org.signal.core.ui.R as CoreUiR

/**
 * Holds the [MappingModel]s and [MappingViewHolder]s used by [ContactSearchAdapter], along with
 * helpers for registering them on a [MappingAdapter] (RecyclerView) or building a
 * [MappingEntryProvider] (Compose).
 */
object ContactSearchModels {

  fun registerStoryItems(
    mappingAdapter: MappingAdapter,
    displayCheckBox: Boolean = false,
    storyListener: ContactSearchAdapter.OnClickedCallback<ContactSearchData.Story>,
    storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks? = null,
    showStoryRing: Boolean = false
  ) {
    mappingAdapter.registerFactory(
      StoryModel::class.java,
      LayoutFactory({ StoryViewHolder(it, displayCheckBox, storyListener, storyContextMenuCallbacks, showStoryRing) }, R.layout.contact_search_story_item)
    )
  }

  fun registerKnownRecipientItems(
    mappingAdapter: MappingAdapter,
    fixedContacts: Set<ContactSearchKey>,
    displayOptions: ContactSearchAdapter.DisplayOptions,
    recipientListener: ContactSearchAdapter.OnClickedCallback<ContactSearchData.KnownRecipient>,
    recipientLongClickCallback: ContactSearchAdapter.OnLongClickedCallback<ContactSearchData.KnownRecipient>,
    recipientCallButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks
  ) {
    mappingAdapter.registerFactory(
      RecipientModel::class.java,
      LayoutFactory({
        KnownRecipientViewHolder(it, fixedContacts, displayOptions, recipientListener, recipientLongClickCallback, recipientCallButtonClickCallbacks)
      }, R.layout.contact_search_item)
    )
  }

  fun registerUnknownRecipientItems(
    mappingAdapter: MappingAdapter,
    onClick: ContactSearchAdapter.OnClickedCallback<ContactSearchData.UnknownRecipient>,
    displayCheckBox: Boolean
  ) {
    mappingAdapter.registerFactory(
      UnknownRecipientModel::class.java,
      LayoutFactory({ UnknownRecipientViewHolder(it, onClick, displayCheckBox) }, R.layout.contact_search_unknown_item)
    )
  }

  fun registerHeaders(mappingAdapter: MappingAdapter) {
    mappingAdapter.registerFactory(
      HeaderModel::class.java,
      LayoutFactory({ HeaderViewHolder(it) }, R.layout.contact_search_section_header)
    )
  }

  fun registerExpands(mappingAdapter: MappingAdapter, expandListener: (ContactSearchData.Expand) -> Unit) {
    mappingAdapter.registerFactory(
      ExpandModel::class.java,
      LayoutFactory({ ExpandViewHolder(it, expandListener) }, R.layout.contacts_expand_item)
    )
  }

  fun registerChatTypeItems(mappingAdapter: MappingAdapter, chatTypeRowListener: ContactSearchAdapter.OnClickedCallback<ContactSearchData.ChatTypeRow>) {
    mappingAdapter.registerFactory(
      ChatTypeModel::class.java,
      LayoutFactory({ ChatTypeViewHolder(it, chatTypeRowListener) }, R.layout.contact_search_chat_type_item)
    )
  }

  /**
   * Returns a [MappingEntryProvider] containing the same set of view holders registered by the
   * adapter-side `register*` methods, suitable for use with a Compose `MappingLazyColumn`.
   * Compose-side callers can merge this with their own provider via
   * [MappingEntryProviderBuilder.provider].
   */
  fun composeEntries(
    fixedContacts: Set<ContactSearchKey>,
    displayOptions: ContactSearchAdapter.DisplayOptions,
    callbacks: ContactSearchAdapter.ClickCallbacks,
    longClickCallbacks: ContactSearchAdapter.LongClickCallbacks,
    storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks? = null,
    callButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks
  ): MappingEntryProvider<Any> {
    return MappingEntryProviderBuilder<Any>().apply {
      viewHolder<StoryModel>(
        key = { model -> "StoryModel:${model.story.recipient.id}:${model.story.privacyMode}" }
      ) { ctx ->
        LayoutFactory(
          { view -> StoryViewHolder(view, displayOptions.displayCheckBox, callbacks::onStoryClicked, storyContextMenuCallbacks, displayOptions.displayStoryRing) },
          R.layout.contact_search_story_item
        ).createViewHolder(FrameLayout(ctx))
      }
      entry<RecipientModel>(
        key = { model -> "${model.knownRecipient.sectionKey}:${model.knownRecipient.recipient.id}" }
      ) { model ->
        Column(modifier = Modifier.fillMaxWidth()) {
          val letter = model.knownRecipient.headerLetter
          if (letter != null) {
            Text(
              text = letter,
              color = colorResource(R.color.signal_text_primary),
              fontFamily = FontFamily.SansSerif,
              fontWeight = FontWeight.Medium,
              fontSize = 16.sp,
              modifier = Modifier
                .fillMaxWidth()
                .padding(
                  start = dimensionResource(R.dimen.dsl_settings_gutter),
                  end = dimensionResource(R.dimen.dsl_settings_gutter),
                  top = 16.dp,
                  bottom = 12.dp
                )
            )
          }
          var viewHolder: MappingViewHolder<RecipientModel>? by remember { mutableStateOf(null) }
          AndroidView(
            factory = { ctx ->
              val holder = LayoutFactory(
                { view -> KnownRecipientViewHolder(view, fixedContacts, displayOptions, callbacks::onKnownRecipientClicked, longClickCallbacks::onKnownRecipientLongClick, callButtonClickCallbacks) },
                R.layout.contact_search_item
              ).createViewHolder(FrameLayout(ctx))
              viewHolder = holder
              holder.itemView
            },
            update = {
              viewHolder?.bind(model)
            }
          )
        }
      }
      viewHolder<UnknownRecipientModel>(
        key = { model -> "Unknown:${model.data.sectionKey}:${model.data.mode}:${model.data.query}" }
      ) { ctx ->
        LayoutFactory(
          { view -> UnknownRecipientViewHolder(view, callbacks::onUnknownRecipientClicked, displayOptions.displayCheckBox) },
          R.layout.contact_search_unknown_item
        ).createViewHolder(FrameLayout(ctx))
      }
      viewHolder<HeaderModel>(
        key = { model -> "HEADER${model.header.sectionKey}" }
      ) { ctx ->
        LayoutFactory(
          { view -> HeaderViewHolder(view) },
          R.layout.contact_search_section_header
        ).createViewHolder(FrameLayout(ctx))
      }
      viewHolder<ExpandModel>(
        key = { model -> "EXPAND${model.expand.sectionKey}" }
      ) { ctx ->
        LayoutFactory(
          { view -> ExpandViewHolder(view, callbacks::onExpandClicked) },
          R.layout.contacts_expand_item
        ).createViewHolder(FrameLayout(ctx))
      }
      viewHolder<ChatTypeModel>(
        key = { model -> "ChatType${model.data.chatType}" }
      ) { ctx ->
        LayoutFactory(
          { view -> ChatTypeViewHolder(view, callbacks::onChatTypeClicked) },
          R.layout.contact_search_chat_type_item
        ).createViewHolder(FrameLayout(ctx))
      }
      entry<EmptyModel>(
        key = { "EmptyModel" }
      ) { model ->
        Text(
          text = if (model.empty.query.isNullOrEmpty()) stringResource(R.string.SearchFragment_no_results_empty) else stringResource(R.string.SearchFragment_no_results, model.empty.query),
          color = MaterialTheme.colorScheme.onSurface,
          textAlign = TextAlign.Center,
          modifier = Modifier
            .fillMaxWidth()
            .padding(vertical = 16.dp)
        )
      }
    }.build()
  }

  fun toMappingModelList(contactSearchData: List<ContactSearchData?>, selection: Set<ContactSearchKey>, arbitraryRepository: ArbitraryRepository?): MappingModelList {
    return MappingModelList(
      contactSearchData.filterNotNull().map {
        when (it) {
          is ContactSearchData.Story -> StoryModel(it, selection.contains(it.contactSearchKey), SignalStore.story.userHasBeenNotifiedAboutStories)
          is ContactSearchData.KnownRecipient -> RecipientModel(it, selection.contains(it.contactSearchKey), it.shortSummary)
          is ContactSearchData.Expand -> ExpandModel(it)
          is ContactSearchData.Header -> HeaderModel(it)
          is ContactSearchData.TestRow -> error("This row exists for testing only.")
          is ContactSearchData.Arbitrary -> arbitraryRepository?.getMappingModel(it) ?: error("This row must be handled manually")
          is ContactSearchData.Message -> MessageModel(it)
          is ContactSearchData.Thread -> ThreadModel(it)
          is ContactSearchData.Empty -> EmptyModel(it)
          is ContactSearchData.GroupWithMembers -> GroupWithMembersModel(it)
          is ContactSearchData.UnknownRecipient -> UnknownRecipientModel(it)
          is ContactSearchData.ChatTypeRow -> ChatTypeModel(it, selection.contains(it.contactSearchKey))
        }
      }
    )
  }

  /**
   * Story Model
   */
  class StoryModel(val story: ContactSearchData.Story, val isSelected: Boolean, val hasBeenNotified: Boolean) : MappingModel<StoryModel> {

    override fun areItemsTheSame(newItem: StoryModel): Boolean {
      return newItem.story == story
    }

    override fun areContentsTheSame(newItem: StoryModel): Boolean {
      return story.recipient.hasSameContent(newItem.story.recipient) &&
        isSelected == newItem.isSelected &&
        hasBeenNotified == newItem.hasBeenNotified
    }

    override fun getChangePayload(newItem: StoryModel): Any? {
      return if (story.recipient.hasSameContent(newItem.story.recipient) &&
        hasBeenNotified == newItem.hasBeenNotified &&
        newItem.isSelected != isSelected
      ) {
        0
      } else {
        null
      }
    }
  }

  private class StoryViewHolder(
    itemView: View,
    val displayCheckBox: Boolean,
    val onClick: ContactSearchAdapter.OnClickedCallback<ContactSearchData.Story>,
    private val storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks?,
    private val showStoryRing: Boolean = false
  ) : MappingViewHolder<StoryModel>(itemView) {

    val avatar: AvatarView = itemView.findViewById(R.id.contact_photo_image)
    val badge: BadgeImageView = itemView.findViewById(R.id.contact_badge)
    val checkbox: CheckBox = itemView.findViewById(R.id.check_box)
    val name: FromTextView = itemView.findViewById(R.id.name)
    val number: TextView = itemView.findViewById(R.id.number)
    val groupStoryIndicator: AppCompatImageView = itemView.findViewById(R.id.group_story_indicator)
    var storyViewState: Observable<StoryViewState>? = null
    var storyDisposable: Disposable? = null

    override fun bind(model: StoryModel) {
      itemView.setOnClickListener { onClick.onClicked(avatar, getData(model), isSelected(model)) }
      bindLongPress(model)

      bindCheckbox(model)

      if (payload.isNotEmpty()) {
        return
      }

      storyViewState = if (showStoryRing) StoryViewState.getForRecipientId(getRecipient(model).id) else null
      avatar.setStoryRingFromState(StoryViewState.NONE)
      groupStoryIndicator.isActivated = false

      name.setText(getRecipient(model))
      badge.setBadgeFromRecipient(getRecipient(model))

      bindAvatar(model)
      bindNumberField(model)
    }

    fun isSelected(model: StoryModel): Boolean = model.isSelected
    fun getData(model: StoryModel): ContactSearchData.Story = model.story
    fun getRecipient(model: StoryModel): Recipient = model.story.recipient

    fun bindNumberField(model: StoryModel) {
      number.visible = true

      val count = if (model.story.recipient.isGroup) {
        model.story.recipient.participantIds.size
      } else {
        model.story.count
      }

      if (model.story.recipient.isMyStory && !model.hasBeenNotified) {
        number.setText(R.string.ContactSearchItems__tap_to_choose_your_viewers)
        number.setSingleLine(false)
      } else {
        number.setSingleLine(true)
        number.text = when {
          model.story.recipient.isGroup -> context.resources.getQuantityString(R.plurals.ContactSearchItems__group_story_d_viewers, count, count)
          model.story.recipient.isMyStory -> {
            if (model.story.privacyMode == DistributionListPrivacyMode.ALL_EXCEPT) {
              context.resources.getQuantityString(R.plurals.ContactSearchItems__my_story_s_dot_d_excluded, count, presentPrivacyMode(DistributionListPrivacyMode.ALL), count)
            } else {
              context.resources.getQuantityString(R.plurals.ContactSearchItems__my_story_s_dot_d_viewers, count, presentPrivacyMode(model.story.privacyMode), count)
            }
          }

          else -> context.resources.getQuantityString(R.plurals.ContactSearchItems__custom_story_d_viewers, count, count)
        }
      }
    }

    fun bindCheckbox(model: StoryModel) {
      checkbox.visible = displayCheckBox
      checkbox.isChecked = isSelected(model)
    }

    fun bindAvatar(model: StoryModel) {
      if (model.story.recipient.isMyStory) {
        avatar.setFallbackAvatarProvider(MyStoryFallbackAvatarProvider)
        avatar.displayProfileAvatar(Recipient.self())
      } else {
        avatar.setFallbackAvatarProvider(null)
        avatar.displayChatAvatar(getRecipient(model))
      }
      groupStoryIndicator.visible = showStoryRing && model.story.recipient.isGroup
    }

    fun bindLongPress(model: StoryModel) {
      if (storyContextMenuCallbacks == null) {
        return
      }

      itemView.setOnLongClickListener {
        val actions: List<ActionItem> = when {
          model.story.recipient.isMyStory -> getMyStoryContextMenuActions(model, storyContextMenuCallbacks)
          model.story.recipient.isGroup -> getGroupStoryContextMenuActions(model, storyContextMenuCallbacks)
          model.story.recipient.isDistributionList -> getPrivateStoryContextMenuActions(model, storyContextMenuCallbacks)
          else -> error("Unsupported story target. Not a group or distribution list.")
        }

        SignalContextMenu.Builder(itemView, itemView.rootView as ViewGroup)
          .offsetX(context.resources.getDimensionPixelSize(R.dimen.dsl_settings_gutter))
          .show(actions)

        true
      }
    }

    private fun getMyStoryContextMenuActions(model: StoryModel, callbacks: ContactSearchAdapter.StoryContextMenuCallbacks): List<ActionItem> {
      return listOf(
        ActionItem(CoreUiR.drawable.symbol_settings_android_24, context.getString(R.string.ContactSearchItems__story_settings)) {
          callbacks.onOpenStorySettings(model.story)
        }
      )
    }

    private fun getGroupStoryContextMenuActions(model: StoryModel, callbacks: ContactSearchAdapter.StoryContextMenuCallbacks): List<ActionItem> {
      return listOf(
        ActionItem(R.drawable.symbol_minus_circle_24, context.getString(R.string.ContactSearchItems__remove_story)) {
          callbacks.onRemoveGroupStory(model.story, model.isSelected)
        }
      )
    }

    private fun getPrivateStoryContextMenuActions(model: StoryModel, callbacks: ContactSearchAdapter.StoryContextMenuCallbacks): List<ActionItem> {
      return listOf(
        ActionItem(CoreUiR.drawable.symbol_settings_android_24, context.getString(R.string.ContactSearchItems__story_settings)) {
          callbacks.onOpenStorySettings(model.story)
        },
        ActionItem(CoreUiR.drawable.symbol_trash_24, context.getString(R.string.ContactSearchItems__delete_story), CoreUiR.color.signal_colorError) {
          callbacks.onDeletePrivateStory(model.story, model.isSelected)
        }
      )
    }

    private fun presentPrivacyMode(privacyMode: DistributionListPrivacyMode): String {
      return when (privacyMode) {
        DistributionListPrivacyMode.ONLY_WITH -> context.getString(R.string.ContactSearchItems__only_share_with)
        DistributionListPrivacyMode.ALL_EXCEPT -> context.getString(R.string.ChooseInitialMyStoryMembershipFragment__all_except)
        DistributionListPrivacyMode.ALL -> context.getString(R.string.ChooseInitialMyStoryMembershipFragment__all_signal_connections)
      }
    }

    private object MyStoryFallbackAvatarProvider : AvatarImageView.FallbackAvatarProvider {
      override fun getFallbackAvatar(recipient: Recipient): FallbackAvatar {
        if (recipient.isSelf) {
          return FallbackAvatar.Resource.Person(recipient.avatarColor)
        }

        return super.getFallbackAvatar(recipient)
      }
    }

    override fun onAttachedToWindow() {
      storyDisposable = storyViewState?.observeOn(AndroidSchedulers.mainThread())?.subscribe {
        avatar.setStoryRingFromState(it)
        when (it) {
          StoryViewState.UNVIEWED -> groupStoryIndicator.isActivated = true
          else -> groupStoryIndicator.isActivated = false
        }
      }
    }

    override fun onDetachedFromWindow() {
      storyDisposable?.dispose()
    }
  }

  /**
   * Recipient model
   */
  class RecipientModel(
    val knownRecipient: ContactSearchData.KnownRecipient,
    val isSelected: Boolean,
    val shortSummary: Boolean
  ) : MappingModel<RecipientModel>, FastScrollCharacterProvider {

    override fun getFastScrollCharacter(context: Context): CharSequence {
      val name = if (knownRecipient.recipient.isSelf && knownRecipient.showSelfAsYou) {
        context.getString(R.string.Recipient_you)
      } else if (knownRecipient.recipient.isSelf) {
        context.getString(R.string.note_to_self)
      } else {
        knownRecipient.recipient.getDisplayName(context)
      }

      val letter: CharSequence = BreakIteratorCompat.getInstance()
        .apply { setText(name) }
        .asSequence()
        .map { charSequence -> charSequence.trim { it <= ' ' } }
        .filter { it.isNotEmpty() }
        .mapNotNull {
          when {
            EmojiUtil.isEmoji(it.toString()) -> it
            Character.isLetterOrDigit(it[0]) -> it[0].uppercaseChar().toString()
            else -> null
          }
        }
        .firstOrNull() ?: "#"

      return letter
    }

    override fun areItemsTheSame(newItem: RecipientModel): Boolean {
      return newItem.knownRecipient == knownRecipient
    }

    override fun areContentsTheSame(newItem: RecipientModel): Boolean {
      return knownRecipient.recipient.hasSameContent(newItem.knownRecipient.recipient) && isSelected == newItem.isSelected
    }

    override fun getChangePayload(newItem: RecipientModel): Any? {
      return if (knownRecipient.recipient.hasSameContent(newItem.knownRecipient.recipient) && newItem.isSelected != isSelected) {
        0
      } else {
        null
      }
    }
  }

  class UnknownRecipientModel(val data: ContactSearchData.UnknownRecipient) : MappingModel<UnknownRecipientModel> {
    override fun areItemsTheSame(newItem: UnknownRecipientModel): Boolean = true

    override fun areContentsTheSame(newItem: UnknownRecipientModel): Boolean = data == newItem.data
  }

  private class UnknownRecipientViewHolder(
    itemView: View,
    private val onClick: ContactSearchAdapter.OnClickedCallback<ContactSearchData.UnknownRecipient>,
    private val displayCheckBox: Boolean
  ) : MappingViewHolder<UnknownRecipientModel>(itemView) {

    private val checkbox: CheckBox = itemView.findViewById(R.id.check_box)
    private val name: FromTextView = itemView.findViewById(R.id.name)
    private val number: TextView = itemView.findViewById(R.id.number)
    private val headerGroup: View = itemView.findViewById(R.id.contact_header)
    private val headerText: TextView = itemView.findViewById(R.id.section_header)

    override fun bind(model: UnknownRecipientModel) {
      checkbox.visible = displayCheckBox
      checkbox.isSelected = false
      val nameText = when (model.data.mode) {
        ContactSearchConfiguration.NewRowMode.NEW_CALL -> R.string.contact_selection_list__new_call
        ContactSearchConfiguration.NewRowMode.NEW_CONVERSATION -> -1
        ContactSearchConfiguration.NewRowMode.BLOCK -> R.string.contact_selection_list__unknown_contact_block
        ContactSearchConfiguration.NewRowMode.ADD_TO_GROUP -> R.string.contact_selection_list__unknown_contact_add_to_group
      }

      if (nameText > 0) {
        name.setText(nameText)
        number.text = model.data.query
        number.visible = true
      } else {
        name.text = model.data.query
        number.visible = false
      }

      if (model.data.mode == ContactSearchConfiguration.NewRowMode.NEW_CONVERSATION) {
        headerGroup.visible = true
        headerText.setText(
          if (model.data.sectionKey == ContactSearchConfiguration.SectionKey.PHONE_NUMBER) {
            R.string.FindByActivity__find_by_phone_number
          } else {
            R.string.FindByActivity__find_by_username
          }
        )
      } else {
        headerGroup.visible = false
      }

      itemView.setOnClickListener {
        onClick.onClicked(itemView, model.data, false)
      }
    }
  }

  private class KnownRecipientViewHolder(
    itemView: View,
    private val fixedContacts: Set<ContactSearchKey>,
    displayOptions: ContactSearchAdapter.DisplayOptions,
    onClick: ContactSearchAdapter.OnClickedCallback<ContactSearchData.KnownRecipient>,
    private val onLongClick: ContactSearchAdapter.OnLongClickedCallback<ContactSearchData.KnownRecipient>,
    callButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks
  ) : BaseRecipientViewHolder<RecipientModel, ContactSearchData.KnownRecipient>(itemView, displayOptions, onClick, callButtonClickCallbacks), LetterHeaderDecoration.LetterHeaderItem {

    private var headerLetter: String? = null

    override fun isSelected(model: RecipientModel): Boolean = model.isSelected
    override fun getData(model: RecipientModel): ContactSearchData.KnownRecipient = model.knownRecipient
    override fun getRecipient(model: RecipientModel): Recipient = model.knownRecipient.recipient
    override fun showSelfAsYou(model: RecipientModel): Boolean = model.knownRecipient.showSelfAsYou

    override fun bindAvatar(model: RecipientModel) {
      if (model.knownRecipient.showSelfAsYou) {
        avatar.setAvatarUsingProfile(getRecipient(model))
      } else {
        super.bindAvatar(model)
      }
    }

    override fun bindNumberField(model: RecipientModel) {
      val recipient = getRecipient(model)
      if (model.knownRecipient.sectionKey == ContactSearchConfiguration.SectionKey.GROUP_MEMBERS && displayOptions.displaySecondaryInformation != ContactSearchAdapter.DisplaySecondaryInformation.NEVER) {
        number.text = model.knownRecipient.groupsInCommon.toDisplayText(context, displayGroupsLimit = 2)
        number.visible = true
      } else if (model.knownRecipient.sectionKey == ContactSearchConfiguration.SectionKey.GROUP_MEMBERS) {
        number.visible = false
      } else if (model.shortSummary && recipient.isGroup) {
        val count = recipient.participantIds.size
        number.text = context.resources.getQuantityString(R.plurals.ContactSearchItems__group_d_members, count, count)
        number.visible = true
      } else if (displayOptions.displaySecondaryInformation == ContactSearchAdapter.DisplaySecondaryInformation.ALWAYS && recipient.combinedAboutAndEmoji != null) {
        number.text = recipient.combinedAboutAndEmoji
        number.visible = true
      } else if (displayOptions.displaySecondaryInformation == ContactSearchAdapter.DisplaySecondaryInformation.ALWAYS && recipient.hasE164) {
        number.visible = false
      } else {
        super.bindNumberField(model)
      }

      headerLetter = model.knownRecipient.headerLetter
    }

    override fun bindCheckbox(model: RecipientModel) {
      super.bindCheckbox(model)

      if (fixedContacts.contains(model.knownRecipient.contactSearchKey)) {
        checkbox.isChecked = true
      }
      checkbox.isEnabled = !fixedContacts.contains(model.knownRecipient.contactSearchKey)
    }

    override fun bindLabelField(model: RecipientModel) {
      adminLabel.visible = model.knownRecipient.showAdminLabel
    }

    override fun isEnabled(model: RecipientModel): Boolean {
      return !fixedContacts.contains(model.knownRecipient.contactSearchKey)
    }

    override fun getHeaderLetter(): String? {
      return headerLetter
    }

    override fun bindLongPress(model: RecipientModel) {
      itemView.setOnLongClickListener { onLongClick.onLongClicked(itemView, model.knownRecipient) }
    }

    override fun getHighlightQuery(model: RecipientModel): String? {
      return model.knownRecipient.query
    }
  }

  /**
   * Base Recipient View Holder
   */
  abstract class BaseRecipientViewHolder<T : MappingModel<T>, D : ContactSearchData>(
    itemView: View,
    val displayOptions: ContactSearchAdapter.DisplayOptions,
    val onClick: ContactSearchAdapter.OnClickedCallback<D>,
    val onCallButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks
  ) : MappingViewHolder<T>(itemView) {

    protected val avatar: AvatarImageView = itemView.findViewById(R.id.contact_photo_image)
    protected val badge: BadgeImageView = itemView.findViewById(R.id.contact_badge)
    protected val checkbox: CheckBox = itemView.findViewById(R.id.check_box)
    protected val name: FromTextView = itemView.findViewById(R.id.name)
    protected val number: TextView = itemView.findViewById(R.id.number)
    protected val label: TextView = itemView.findViewById(R.id.label)
    protected val adminLabel: TextView = itemView.findViewById(R.id.admin_label)
    private val startAudio: View = itemView.findViewById(R.id.start_audio)
    private val startVideo: View = itemView.findViewById(R.id.start_video)
    private val searchStyleFactory = SearchUtil.StyleFactory { arrayOf(ForegroundColorSpan(ContextCompat.getColor(context, CoreUiR.color.signal_colorOnSurface)), SpanUtil.getBoldSpan()) }

    override fun bind(model: T) {
      if (isEnabled(model)) {
        itemView.setOnClickListener { onClick.onClicked(avatar, getData(model), isSelected(model)) }
        bindLongPress(model)
      } else {
        itemView.setOnClickListener(null)
      }

      bindCheckbox(model)

      if (payload.isNotEmpty()) {
        return
      }

      val recipient = getRecipient(model)
      val suffix: CharSequence? = if (recipient.isSystemContact && !recipient.showVerified) {
        SpannableStringBuilder().apply {
          val drawable = context.requireDrawable(CoreUiR.drawable.symbol_person_circle_24).apply {
            setTint(ContextCompat.getColor(context, CoreUiR.color.signal_colorOnSurface))
          }
          SpanUtil.appendCenteredImageSpan(this, drawable, 16, 16)
        }
      } else {
        null
      }
      val query = getHighlightQuery(model)
      val displayName: CharSequence = if (!query.isNullOrBlank()) {
        SearchUtil.getHighlightedSpan(Locale.getDefault(), searchStyleFactory, recipient.getDisplayName(context), query, SearchUtil.MATCH_ALL)
      } else {
        recipient.getDisplayName(context)
      }

      name.setText(recipient, displayName, suffix, true, showSelfAsYou(model))

      badge.setBadgeFromRecipient(getRecipient(model))

      bindAvatar(model)
      bindNumberField(model)
      bindLabelField(model)
      bindCallButtons(model)
    }

    protected open fun bindCheckbox(model: T) {
      checkbox.visible = displayOptions.displayCheckBox
      checkbox.isChecked = isSelected(model)
    }

    protected open fun isEnabled(model: T): Boolean = true
    protected open fun showSelfAsYou(model: T): Boolean = false
    protected open fun getHighlightQuery(model: T): String? = null

    protected open fun bindAvatar(model: T) {
      avatar.setAvatar(getRecipient(model))
    }

    protected open fun bindNumberField(model: T) {
      number.visible = getRecipient(model).isGroup
      if (getRecipient(model).isGroup) {
        number.text = getRecipient(model).participantIds
          .take(10)
          .map { id ->
            val recipient = Recipient.resolved(id)
            RecipientDisplayName(
              recipient = recipient,
              displayName = if (recipient.isSelf) {
                context.getString(R.string.ConversationTitleView_you)
              } else {
                recipient.getShortDisplayName(context)
              }
            )
          }
          .sortedWith(compareBy({ it.recipient.isUnregistered }, { it.recipient.isSelf }, { it.displayName }))
          .joinToString(", ") { it.displayName }
      }
    }

    protected open fun bindLabelField(model: T) {
      label.visible = false
    }

    protected open fun bindLongPress(model: T) = Unit

    private fun bindCallButtons(model: T) {
      val recipient = getRecipient(model)
      if (displayOptions.displayCallButtons && (recipient.isPushGroup || recipient.isRegistered)) {
        startVideo.visible = true
        startAudio.visible = !recipient.isPushGroup

        startVideo.setOnClickListener {
          onCallButtonClickCallbacks.onVideoCallButtonClicked(recipient)
        }

        startAudio.setOnClickListener {
          onCallButtonClickCallbacks.onAudioCallButtonClicked(recipient)
        }
      } else {
        startVideo.visible = false
        startAudio.visible = false
      }
    }

    abstract fun isSelected(model: T): Boolean
    abstract fun getData(model: T): D
    abstract fun getRecipient(model: T): Recipient
  }

  /**
   * Mapping Model for section headers
   */
  class HeaderModel(val header: ContactSearchData.Header) : MappingModel<HeaderModel> {
    override fun areItemsTheSame(newItem: HeaderModel): Boolean {
      return header.sectionKey == newItem.header.sectionKey
    }

    override fun areContentsTheSame(newItem: HeaderModel): Boolean {
      return areItemsTheSame(newItem) &&
        header.action?.icon == newItem.header.action?.icon &&
        header.action?.label == newItem.header.action?.label
    }
  }

  /**
   * Mapping Model for messages
   */
  class MessageModel(val message: ContactSearchData.Message) : MappingModel<MessageModel> {
    override fun areItemsTheSame(newItem: MessageModel): Boolean = message.contactSearchKey == newItem.message.contactSearchKey

    override fun areContentsTheSame(newItem: MessageModel): Boolean {
      return message == newItem.message
    }
  }

  /**
   * Mapping Model for threads
   */
  class ThreadModel(val thread: ContactSearchData.Thread) : MappingModel<ThreadModel> {
    override fun areItemsTheSame(newItem: ThreadModel): Boolean = thread.contactSearchKey == newItem.thread.contactSearchKey
    override fun areContentsTheSame(newItem: ThreadModel): Boolean {
      return thread == newItem.thread
    }
  }

  class EmptyModel(val empty: ContactSearchData.Empty) : MappingModel<EmptyModel> {
    override fun areItemsTheSame(newItem: EmptyModel): Boolean = true
    override fun areContentsTheSame(newItem: EmptyModel): Boolean = newItem.empty == empty
  }

  /**
   * Mapping Model for [ContactSearchData.GroupWithMembers]
   */
  class GroupWithMembersModel(val groupWithMembers: ContactSearchData.GroupWithMembers) : MappingModel<GroupWithMembersModel> {
    override fun areContentsTheSame(newItem: GroupWithMembersModel): Boolean = newItem.groupWithMembers == groupWithMembers

    override fun areItemsTheSame(newItem: GroupWithMembersModel): Boolean = newItem.groupWithMembers.contactSearchKey == groupWithMembers.contactSearchKey
  }

  /**
   * View Holder for section headers
   */
  private class HeaderViewHolder(itemView: View) : MappingViewHolder<HeaderModel>(itemView) {

    private val headerTextView: TextView = itemView.findViewById(R.id.section_header)
    private val headerActionView: MaterialButton = itemView.findViewById(R.id.section_header_action)

    override fun bind(model: HeaderModel) {
      headerTextView.setText(
        when (model.header.sectionKey) {
          ContactSearchConfiguration.SectionKey.STORIES -> R.string.ContactsCursorLoader_my_stories
          ContactSearchConfiguration.SectionKey.RECENTS -> R.string.ContactsCursorLoader_recent_chats
          ContactSearchConfiguration.SectionKey.INDIVIDUALS -> R.string.ContactsCursorLoader_contacts
          ContactSearchConfiguration.SectionKey.GROUPS -> R.string.ContactsCursorLoader_groups
          ContactSearchConfiguration.SectionKey.GROUP_MEMBERS -> R.string.ContactsCursorLoader_group_members
          ContactSearchConfiguration.SectionKey.CHATS -> R.string.ContactsCursorLoader__chats
          ContactSearchConfiguration.SectionKey.MESSAGES -> R.string.ContactsCursorLoader__messages
          ContactSearchConfiguration.SectionKey.GROUPS_WITH_MEMBERS -> R.string.ContactsCursorLoader_group_members
          ContactSearchConfiguration.SectionKey.CONTACTS_WITHOUT_THREADS -> R.string.ContactsCursorLoader_contacts
          ContactSearchConfiguration.SectionKey.CHAT_TYPES -> R.string.ContactsCursorLoader__chat_types
          else -> error("This section does not support HEADER")
        }
      )

      if (model.header.action != null) {
        headerActionView.visible = true
        headerActionView.setIconResource(model.header.action.icon)
        headerActionView.setText(model.header.action.label)
        headerActionView.setOnClickListener { model.header.action.action.run() }
      } else {
        headerActionView.visible = false
      }
    }
  }

  /**
   * Mapping Model for expandable content rows.
   */
  class ExpandModel(val expand: ContactSearchData.Expand) : MappingModel<ExpandModel> {
    override fun areItemsTheSame(newItem: ExpandModel): Boolean {
      return expand.contactSearchKey == newItem.expand.contactSearchKey
    }

    override fun areContentsTheSame(newItem: ExpandModel): Boolean {
      return areItemsTheSame(newItem)
    }
  }

  /**
   * View Holder for expandable content rows.
   */
  private class ExpandViewHolder(itemView: View, private val expandListener: (ContactSearchData.Expand) -> Unit) : MappingViewHolder<ExpandModel>(itemView) {
    override fun bind(model: ExpandModel) {
      itemView.setOnClickListener { expandListener.invoke(model.expand) }
    }
  }

  /**
   * Mapping Model for chat types.
   */
  class ChatTypeModel(val data: ContactSearchData.ChatTypeRow, val isSelected: Boolean) : MappingModel<ChatTypeModel> {
    override fun areItemsTheSame(newItem: ChatTypeModel): Boolean = data == newItem.data
    override fun areContentsTheSame(newItem: ChatTypeModel): Boolean = data == newItem.data && isSelected == newItem.isSelected
  }

  /**
   * View Holder for chat types
   */
  private class ChatTypeViewHolder(
    itemView: View,
    val onClick: ContactSearchAdapter.OnClickedCallback<ContactSearchData.ChatTypeRow>
  ) : MappingViewHolder<ChatTypeModel>(itemView) {

    val image: ImageView = itemView.findViewById(R.id.image)
    val name: TextView = itemView.findViewById(R.id.name)
    val checkbox: CheckBox = itemView.findViewById(R.id.check_box)

    override fun bind(model: ChatTypeModel) {
      itemView.setOnClickListener { onClick.onClicked(itemView, model.data, model.isSelected) }

      image.setImageResource(model.data.imageResId)

      if (model.data.chatType == ChatType.INDIVIDUAL) {
        name.text = context.getString(R.string.ChatFoldersFragment__one_on_one_chats)
      }
      if (model.data.chatType == ChatType.GROUPS) {
        name.text = context.getString(R.string.ChatFoldersFragment__groups)
      }

      checkbox.isChecked = model.isSelected
    }
  }

  private data class RecipientDisplayName(val recipient: Recipient, val displayName: String)
}
