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

import memoizee from 'memoizee';
import { memoize } from '@indutny/sneequals';
import lodash from 'lodash';
import { createSelector } from 'reselect';
import type { StateType } from '../reducer.preload.ts';
import type { StateSelector } from '../types.std.ts';
import type {
  ConversationLookupType,
  ConversationMessageType,
  ConversationsStateType,
  ConversationType,
  ConversationVerificationData,
  MessageLookupType,
  MessagesByConversationType,
  MessageTimestamps,
  PreJoinConversationType,
} from '../ducks/conversations.preload.ts';
import type {
  StoriesStateType,
  StoryDataType,
} from '../ducks/stories.preload.ts';
import {
  ComposerStep,
  OneTimeModalState,
  ConversationVerificationState,
  type TargetedMessageSource,
} from '../ducks/conversationsEnums.std.ts';
import { getOwn } from '../../util/getOwn.std.ts';
import type { UUIDFetchStateType } from '../../util/uuidFetchState.std.ts';
import { deconstructLookup } from '../../util/deconstructLookup.std.ts';
import type { PropsDataType as TimelinePropsType } from '../../components/conversation/Timeline.dom.tsx';
import { assertDev } from '../../util/assert.std.ts';
import { isConversationUnregistered } from '../../util/isConversationUnregistered.dom.ts';
import { filterAndSortConversations } from '../../util/filterAndSortConversations.std.ts';
import type { ContactNameColorType } from '../../types/Colors.std.ts';
import { ContactNameColors } from '../../types/Colors.std.ts';
import type { AvatarDataType } from '../../types/Avatar.std.ts';
import type { AciString, ServiceIdString } from '../../types/ServiceId.std.ts';
import { normalizeServiceId } from '../../types/ServiceId.std.ts';
import { isInSystemContacts } from '../../util/isInSystemContacts.std.ts';
import { sortByTitle } from '../../util/sortByTitle.std.ts';
import { DurationInSeconds } from '../../util/durations/index.std.ts';
import {
  isDirectConversation,
  isGroupV1,
  isGroupV2,
} from '../../util/whatTypeOfConversation.dom.ts';
import { isGroupInStoryMode } from '../../util/isGroupInStoryMode.std.ts';

import {
  getIntl,
  getRegionCode,
  getUserConversationId,
  getUserNumber,
} from './user.std.ts';
import {
  getBadgeCountMutedConversations,
  getPinnedConversationIds,
  getStoriesEnabled,
} from './items.dom.ts';
import { createLogger } from '../../logging/log.std.ts';
import { TimelineMessageLoadingState } from '../../util/timelineUtil.std.ts';
import { isSignalConversation } from '../../util/isSignalConversation.dom.ts';
import { reduce } from '../../util/iterables.std.ts';
import type { HasStories } from '../../types/Stories.std.ts';
import { getHasStoriesSelector } from './stories2.dom.ts';
import { canEditMessage } from '../../util/canEditMessage.dom.ts';
import { isOutgoing } from '../../messages/helpers.std.ts';
import type {
  AllChatFoldersUnreadStats,
  UnreadStats,
} from '../../util/countUnreadStats.std.ts';
import {
  type ChatFolder,
  isConversationInChatFolder,
} from '../../types/ChatFolder.std.ts';
import {
  getSelectedChatFolder,
  getCurrentChatFolders,
  getStableSelectedConversationIdInChatFolder,
} from './chatFolders.std.ts';
import {
  countAllChatFoldersUnreadStats,
  countAllConversationsUnreadStats,
} from '../../util/countUnreadStats.std.ts';
import type { AllChatFoldersMutedStats } from '../../util/countMutedStats.std.ts';
import { countAllChatFoldersMutedStats } from '../../util/countMutedStats.std.ts';
import { getActiveProfile } from './notificationProfiles.dom.ts';
import type { PinnedMessage } from '../../types/PinnedMessage.std.ts';
import { getPinnedMessagesLimit } from '../../util/pinnedMessages.dom.ts';
import { getSelectedConversationId, getSelectedNavTab } from './nav.std.ts';
import { getCallHistoryUnreadCount } from './callHistory.std.ts';
import { NavTab } from '../../types/Nav.std.ts';
import { ReadStatus } from '../../messages/MessageReadStatus.std.ts';

const { isNumber, pick } = lodash;

const log = createLogger('selectors/conversations');

export type ConversationWithStoriesType = ConversationType & {
  hasStories?: HasStories;
};

let placeholderContact: ConversationType;
export const PLACEHOLDER_CONTACT_ID = 'placeholder-contact';
export const getPlaceholderContact = (): ConversationType => {
  if (placeholderContact) {
    return placeholderContact;
  }

  placeholderContact = {
    acceptedMessageRequest: false,
    badges: [],
    id: PLACEHOLDER_CONTACT_ID,
    type: 'direct',
    title: window.SignalContext.i18n('icu:unknownContact'),
    isMe: false,
  };
  return placeholderContact;
};

export const getConversations = (state: StateType): ConversationsStateType =>
  state.conversations;

export const getPreJoinConversation = createSelector(
  getConversations,
  (state: ConversationsStateType): PreJoinConversationType | undefined => {
    return state.preJoinConversation;
  }
);
export const getConversationLookup = createSelector(
  getConversations,
  (state: ConversationsStateType): ConversationLookupType => {
    return state.conversationLookup;
  }
);

const getConversationsByServiceId = createSelector(
  getConversations,
  (state: ConversationsStateType): ConversationLookupType => {
    return state.conversationsByServiceId;
  }
);

const getConversationsByE164 = createSelector(
  getConversations,
  (state: ConversationsStateType): ConversationLookupType => {
    return state.conversationsByE164;
  }
);

const getConversationsByGroupId = createSelector(
  getConversations,
  (state: ConversationsStateType): ConversationLookupType => {
    return state.conversationsByGroupId;
  }
);

export const getAllConversations = createSelector(
  getConversationLookup,
  (lookup): Array<ConversationType> => Object.values(lookup)
);

export const getSafeConversationWithSameTitle = createSelector(
  getAllConversations,
  (
    _state: StateType,
    {
      possiblyUnsafeConversation,
    }: {
      possiblyUnsafeConversation: ConversationType;
    }
  ) => possiblyUnsafeConversation,
  (conversations, possiblyUnsafeConversation): ConversationType | undefined => {
    const conversationsWithSameTitle = conversations.filter(conversation => {
      return conversation.title === possiblyUnsafeConversation.title;
    });
    assertDev(
      conversationsWithSameTitle.length,
      'Expected at least 1 conversation with the same title (this one)'
    );

    const safeConversation = conversationsWithSameTitle.find(
      otherConversation =>
        otherConversation.acceptedMessageRequest &&
        otherConversation.type === 'direct' &&
        otherConversation.id !== possiblyUnsafeConversation.id
    );

    return safeConversation;
  }
);

export type TargetedMessageType = {
  id: string;
  counter: number;
};
export const getTargetedMessage = createSelector(
  getConversations,
  (state: ConversationsStateType): TargetedMessageType | undefined => {
    if (!state.targetedMessage) {
      return undefined;
    }

    return {
      id: state.targetedMessage,
      counter: state.targetedMessageCounter,
    };
  }
);
export const getTargetedMessageSource = createSelector(
  getConversations,
  (state: ConversationsStateType): TargetedMessageSource | undefined => {
    return state.targetedMessageSource;
  }
);

export const getSelectedMessageIds = createSelector(
  getConversations,
  (state: ConversationsStateType): ReadonlyArray<string> | undefined => {
    return state.selectedMessageIds;
  }
);
export const getLastSelectedMessage = createSelector(
  getConversations,
  (state: ConversationsStateType): MessageTimestamps | undefined => {
    return state.lastSelectedMessage;
  }
);

export const getShowArchived = createSelector(
  getConversations,
  (state: ConversationsStateType): boolean => {
    return state.showArchived;
  }
);

const getComposerState = createSelector(
  getConversations,
  (state: ConversationsStateType) => state.composer
);

export const getComposerStep = createSelector(
  getComposerState,
  (composerState): undefined | ComposerStep => composerState?.step
);

export const hasGroupCreationError = createSelector(
  getComposerState,
  (composerState): boolean => {
    if (composerState?.step === ComposerStep.SetGroupMetadata) {
      return composerState.hasError;
    }
    return false;
  }
);

export const isCreatingGroup = createSelector(
  getComposerState,
  (composerState): boolean =>
    composerState?.step === ComposerStep.SetGroupMetadata &&
    composerState.isCreating
);

export const isEditingAvatar = createSelector(
  getComposerState,
  (composerState): boolean =>
    composerState?.step === ComposerStep.SetGroupMetadata &&
    composerState.isEditingAvatar
);

export const getComposeAvatarData = createSelector(
  getComposerState,
  (composerState): ReadonlyArray<AvatarDataType> =>
    composerState?.step === ComposerStep.SetGroupMetadata
      ? composerState.userAvatarData
      : []
);

export const getMessages = createSelector(
  getConversations,
  (state: ConversationsStateType): MessageLookupType => {
    return state.messagesLookup;
  }
);
export const getMessagesByConversation = createSelector(
  getConversations,
  (state: ConversationsStateType): MessagesByConversationType => {
    return state.messagesByConversation;
  }
);

const getConversationMessages = createSelector(
  getSelectedConversationId,
  getMessagesByConversation,
  (
    conversationId,
    messagesByConversation
  ): ConversationMessageType | undefined => {
    return conversationId ? messagesByConversation[conversationId] : undefined;
  }
);

export const getConversationIsReady: StateSelector<boolean> = createSelector(
  getConversationMessages,
  conversationMessages => {
    return conversationMessages != null;
  }
);

export const getPinnedMessages: StateSelector<ReadonlyArray<PinnedMessage>> =
  createSelector(getConversationMessages, conversationMessages => {
    return conversationMessages?.pinnedMessages ?? [];
  });

export const getPinnedMessagesMessageIds: StateSelector<ReadonlyArray<string>> =
  createSelector(getPinnedMessages, pinnedMessages => {
    return pinnedMessages.map(pinnedMessage => {
      return pinnedMessage.messageId;
    });
  });

export const getHasMaxPinnedMessages: StateSelector<boolean> = createSelector(
  getPinnedMessages,
  pinnedMessages => {
    const pinnedMessagesLimit = getPinnedMessagesLimit();
    const pinnedMessagesCount = pinnedMessages.length;
    return pinnedMessagesCount >= pinnedMessagesLimit;
  }
);

const collator = new Intl.Collator();

// Note: we will probably want to put i18n and regionCode back when we are formatting
//   phone numbers and contacts from scratch here again.
export const _getConversationComparator = () => {
  return (left: ConversationType, right: ConversationType): number => {
    // These two fields can be sorted with each other; they are timestamps
    const leftTimestamp = left.shouldShowDraft
      ? left.draftTimestamp
      : left.lastMessageReceivedAtMs || left.timestamp;
    const rightTimestamp = right.shouldShowDraft
      ? right.draftTimestamp
      : right.lastMessageReceivedAtMs || right.timestamp;

    if (leftTimestamp && !rightTimestamp) {
      return -1;
    }
    if (rightTimestamp && !leftTimestamp) {
      return 1;
    }
    if (leftTimestamp && rightTimestamp && leftTimestamp !== rightTimestamp) {
      return rightTimestamp - leftTimestamp;
    }

    // This field looks like a timestamp, but is actually a counter
    const leftCounter = left.lastMessageReceivedAt;
    const rightCounter = right.lastMessageReceivedAt;
    if (leftCounter && !rightCounter) {
      return -1;
    }
    if (rightCounter && !leftCounter) {
      return 1;
    }
    if (leftCounter && rightCounter && leftCounter !== rightCounter) {
      return rightCounter - leftCounter;
    }

    if (
      typeof left.inboxPosition === 'number' &&
      typeof right.inboxPosition === 'number'
    ) {
      return right.inboxPosition > left.inboxPosition ? -1 : 1;
    }

    if (typeof left.inboxPosition === 'number' && right.inboxPosition == null) {
      return -1;
    }

    if (typeof right.inboxPosition === 'number' && left.inboxPosition == null) {
      return 1;
    }

    return collator.compare(left.title, right.title);
  };
};
const getConversationComparator = createSelector(
  getIntl,
  getRegionCode,
  _getConversationComparator
);

type LeftPaneLists = Readonly<{
  conversations: ReadonlyArray<ConversationType>;
  archivedConversations: ReadonlyArray<ConversationType>;
  pinnedConversations: ReadonlyArray<ConversationType>;
}>;

function _shouldIncludeInChatFolder(
  conversation: ConversationType,
  selectedChatFolder: ChatFolder | null,
  stableSelectedConversationIdInChatFolder: string | null
): boolean {
  if (selectedChatFolder == null) {
    return true;
  }

  // This keeps conversation items from instantly disappearing from the left
  // pane list when you open them and they get marked read
  if (
    stableSelectedConversationIdInChatFolder != null &&
    conversation.id === stableSelectedConversationIdInChatFolder
  ) {
    return true;
  }

  if (isConversationInChatFolder(selectedChatFolder, conversation)) {
    return true;
  }

  return false;
}

type GetLeftPaneListsProps = Readonly<{
  conversationLookup: ConversationLookupType;
  conversationComparator: (
    left: ConversationType,
    right: ConversationType
  ) => number;
  selectedConversationId: string | undefined;
  pinnedConversationIds: ReadonlyArray<string> | null;
  selectedChatFolder: ChatFolder | null;
  stableSelectedConversationIdInChatFolder: string | null;
}>;

export const _getLeftPaneLists = ({
  conversationLookup,
  conversationComparator,
  selectedConversationId,
  pinnedConversationIds,
  selectedChatFolder,
  stableSelectedConversationIdInChatFolder,
}: GetLeftPaneListsProps): LeftPaneLists => {
  const conversations: Array<ConversationType> = [];
  const archivedConversations: Array<ConversationType> = [];
  const pinnedConversations: Array<ConversationType> = [];

  for (let conversation of Object.values(conversationLookup)) {
    if (
      !_shouldIncludeInChatFolder(
        conversation,
        selectedChatFolder,
        stableSelectedConversationIdInChatFolder
      )
    ) {
      continue;
    }

    if (selectedConversationId === conversation.id) {
      conversation = {
        ...conversation,
        isSelected: true,
      };
    }

    // We always show pinned conversations
    if (conversation.isPinned) {
      pinnedConversations.push(conversation);
      continue;
    }

    if (conversation.activeAt) {
      if (conversation.isArchived) {
        archivedConversations.push(conversation);
      } else {
        conversations.push(conversation);
      }
    }
  }

  conversations.sort(conversationComparator);
  archivedConversations.sort(conversationComparator);

  pinnedConversations.sort(
    (a, b) =>
      (pinnedConversationIds || []).indexOf(a.id) -
      (pinnedConversationIds || []).indexOf(b.id)
  );

  return { conversations, archivedConversations, pinnedConversations };
};

export const getLeftPaneLists = createSelector(
  getConversationLookup,
  getConversationComparator,
  getSelectedConversationId,
  getPinnedConversationIds,
  getSelectedChatFolder,
  getStableSelectedConversationIdInChatFolder,
  (
    conversationLookup,
    conversationComparator,
    selectedConversationId,
    pinnedConversationIds,
    selectedChatFolder,
    stableSelectedConversationIdInChatFolder
  ) => {
    return _getLeftPaneLists({
      conversationLookup,
      conversationComparator,
      selectedConversationId,
      pinnedConversationIds,
      selectedChatFolder,
      stableSelectedConversationIdInChatFolder,
    });
  }
);

export const getMaximumGroupSizeModalState = createSelector(
  getComposerState,
  (composerState): OneTimeModalState => {
    switch (composerState?.step) {
      case ComposerStep.ChooseGroupMembers:
      case ComposerStep.SetGroupMetadata:
        return composerState.maximumGroupSizeModalState;
      default:
        assertDev(
          false,
          'Can\'t get the maximum group size modal state in this composer state; returning "never shown"'
        );
        return OneTimeModalState.NeverShown;
    }
  }
);

export const getRecommendedGroupSizeModalState = createSelector(
  getComposerState,
  (composerState): OneTimeModalState => {
    switch (composerState?.step) {
      case ComposerStep.ChooseGroupMembers:
      case ComposerStep.SetGroupMetadata:
        return composerState.recommendedGroupSizeModalState;
      default:
        assertDev(
          false,
          'Can\'t get the recommended group size modal state in this composer state; returning "never shown"'
        );
        return OneTimeModalState.NeverShown;
    }
  }
);

export const getMe = createSelector(
  [getConversationLookup, getUserConversationId],
  (
    lookup: ConversationLookupType,
    ourConversationId: string | undefined
  ): ConversationType => {
    if (!ourConversationId) {
      return getPlaceholderContact();
    }

    return lookup[ourConversationId] || getPlaceholderContact();
  }
);

export const getComposerConversationSearchTerm = createSelector(
  getComposerState,
  (composer): string => {
    if (!composer) {
      assertDev(
        false,
        'getComposerConversationSearchTerm: composer is not open'
      );
      return '';
    }
    if (composer.step === ComposerStep.SetGroupMetadata) {
      assertDev(
        false,
        'getComposerConversationSearchTerm: composer does not have a search term'
      );
      return '';
    }
    return composer.searchTerm;
  }
);

export const getComposerSelectedRegion = createSelector(
  getComposerState,
  (composer): string => {
    if (!composer) {
      assertDev(false, 'getComposerSelectedRegion: composer is not open');
      return '';
    }
    if (composer.step !== ComposerStep.FindByPhoneNumber) {
      assertDev(
        false,
        'getComposerSelectedRegion: composer does not have a selected region'
      );
      return '';
    }
    return composer.selectedRegion;
  }
);

export const getComposerUUIDFetchState = createSelector(
  getComposerState,
  (composer): UUIDFetchStateType => {
    if (!composer) {
      assertDev(false, 'getIsFetchingUsername: composer is not open');
      return {};
    }
    if (
      composer.step !== ComposerStep.StartDirectConversation &&
      composer.step !== ComposerStep.FindByUsername &&
      composer.step !== ComposerStep.FindByPhoneNumber &&
      composer.step !== ComposerStep.ChooseGroupMembers
    ) {
      assertDev(
        false,
        `getComposerUUIDFetchState: step ${composer.step} ` +
          'has no uuidFetchState key'
      );
      return {};
    }
    return composer.uuidFetchState;
  }
);

export const getHasContactSpoofingReview = createSelector(
  getConversations,
  (state: ConversationsStateType): boolean => {
    return state.hasContactSpoofingReview;
  }
);

/**
 * Builds a Set of service IDs that appear in any group's memberships.
 * Used to efficiently check if a contact shares groups with us.
 * Memoized with sneequals to only recompute when group memberships change.
 */
const getServiceIdsInGroups = memoize(
  (conversationLookup: ConversationLookupType): Set<string> => {
    const serviceIds = new Set<string>();
    for (const conversation of Object.values(conversationLookup)) {
      if (
        conversation.type === 'group' &&
        !conversation.left &&
        conversation.memberships
      ) {
        for (const membership of conversation.memberships) {
          serviceIds.add(membership.aci);
        }
      }
    }
    return serviceIds;
  }
);

function isTrusted(
  conversation: ConversationType,
  serviceIdsInGroups: Set<string>
): boolean {
  if (conversation.type === 'group') {
    return true;
  }

  const hasSharedGroups =
    conversation.serviceId != null &&
    serviceIdsInGroups.has(conversation.serviceId);

  return (
    isInSystemContacts(conversation) ||
    hasSharedGroups ||
    conversation.profileSharing ||
    conversation.isMe
  );
}

function hasDisplayInfo(conversation: ConversationType): boolean {
  if (conversation.type === 'group') {
    return Boolean(conversation.name);
  }

  return Boolean(
    conversation.name ||
    conversation.profileName ||
    conversation.phoneNumber ||
    conversation.isMe
  );
}

function canComposeConversation(
  conversation: ConversationType,
  serviceIdsInGroups: Set<string>
): boolean {
  return (
    !isSignalConversation(conversation) &&
    !conversation.isBlocked &&
    !conversation.removalStage &&
    !conversation.terminated &&
    ((isGroupV2(conversation) && !conversation.left) ||
      !isConversationUnregistered(conversation)) &&
    hasDisplayInfo(conversation) &&
    isTrusted(conversation, serviceIdsInGroups)
  );
}

export const getAllComposableConversations = createSelector(
  getConversationLookup,
  (conversationLookup: ConversationLookupType): Array<ConversationType> =>
    Object.values(conversationLookup).filter(
      conversation =>
        !isSignalConversation(conversation) &&
        !conversation.isBlocked &&
        !conversation.removalStage &&
        !conversation.terminated &&
        !conversation.isGroupV1AndDisabled &&
        ((isGroupV2(conversation) && !conversation.left) ||
          !isConversationUnregistered(conversation)) &&
        // All conversation should have a title except in weird cases where
        // they don't, in that case we don't want to show these for Forwarding.
        conversation.titleNoDefault &&
        hasDisplayInfo(conversation)
    )
);

export const getAllGroupsWithInviteAccess = createSelector(
  getConversationLookup,
  (conversationLookup: ConversationLookupType): Array<ConversationType> =>
    Object.values(conversationLookup).filter(conversation => {
      return (
        conversation.type === 'group' &&
        conversation.title &&
        conversation.canAddNewMembers
      );
    })
);

export const getAllConversationsUnreadStats: StateSelector<UnreadStats> =
  createSelector(
    getAllConversations,
    getBadgeCountMutedConversations,
    getActiveProfile,
    (conversations, badgeCountMutedConversations, activeProfile) => {
      return countAllConversationsUnreadStats(conversations, {
        activeProfile,
        includeMuted: badgeCountMutedConversations
          ? 'setting-on'
          : 'setting-off',
      });
    }
  );

export const getAllChatFoldersUnreadStats: StateSelector<AllChatFoldersUnreadStats> =
  createSelector(
    getCurrentChatFolders,
    getAllConversations,
    getBadgeCountMutedConversations,
    getActiveProfile,
    (
      currentChatFolders,
      allConversations,
      badgeCountMutedConversations,
      activeProfile
    ) => {
      return countAllChatFoldersUnreadStats(
        currentChatFolders,
        allConversations,
        {
          activeProfile,
          includeMuted: badgeCountMutedConversations
            ? 'setting-on'
            : 'setting-off',
        }
      );
    }
  );

export const getAllChatFoldersMutedStats: StateSelector<AllChatFoldersMutedStats> =
  createSelector(
    getCurrentChatFolders,
    getAllConversations,
    (currentChatFolders, allConversations) => {
      return countAllChatFoldersMutedStats(
        currentChatFolders,
        allConversations
      );
    }
  );

/**
 * getComposableContacts/getCandidateContactsForNewGroup both return contacts for the
 * composer and group members, a different list from your primary system contacts.
 * This list may include false positives, which is better than missing contacts.
 *
 * Note: the key difference between them:
 *   getComposableContacts includes Note to Self
 *   getCandidateContactsForNewGroup does not include Note to Self
 *
 * Because they filter unregistered contacts and that's (partially) determined by the
 * current time, it's possible for them to return stale contacts that have unregistered
 * if no other conversations change. This should be a rare false positive.
 * @testexport
 */
export const getComposableContacts = createSelector(
  getConversationLookup,
  (conversationLookup: ConversationLookupType): Array<ConversationType> => {
    const serviceIdsInGroups = getServiceIdsInGroups(conversationLookup);
    return Object.values(conversationLookup).filter(
      conversation =>
        conversation.type === 'direct' &&
        canComposeConversation(conversation, serviceIdsInGroups)
    );
  }
);

export const getCandidateContactsForNewGroup = createSelector(
  getConversationLookup,
  (conversationLookup: ConversationLookupType): Array<ConversationType> => {
    const serviceIdsInGroups = getServiceIdsInGroups(conversationLookup);
    return Object.values(conversationLookup).filter(
      conversation =>
        conversation.type === 'direct' &&
        !conversation.isMe &&
        canComposeConversation(conversation, serviceIdsInGroups)
    );
  }
);

export const getComposableGroups = createSelector(
  getConversationLookup,
  (conversationLookup: ConversationLookupType): Array<ConversationType> => {
    const serviceIdsInGroups = getServiceIdsInGroups(conversationLookup);
    return Object.values(conversationLookup).filter(
      conversation =>
        conversation.type === 'group' &&
        canComposeConversation(conversation, serviceIdsInGroups)
    );
  }
);

const getConversationIdsWithStories = createSelector(
  (state: StateType): StoriesStateType => state.stories,
  (stories: StoriesStateType): Set<string> => {
    return new Set(stories.stories.map(({ conversationId }) => conversationId));
  }
);

export const getNonGroupStories = createSelector(
  getComposableGroups,
  getConversationIdsWithStories,
  (
    groups: Array<ConversationType>,
    conversationIdsWithStories: Set<string>
  ): Array<ConversationType> => {
    return groups.filter(
      group => !isGroupInStoryMode(group, conversationIdsWithStories)
    );
  }
);

export const selectMostRecentActiveStoryTimestampByGroupOrDistributionList =
  createSelector(
    (state: StateType): ReadonlyArray<StoryDataType> => state.stories.stories,
    (stories: ReadonlyArray<StoryDataType>): Record<string, number> => {
      return reduce<StoryDataType, Record<string, number>>(
        stories,
        (acc, story) => {
          const distributionListOrConversationId =
            story.storyDistributionListId ?? story.conversationId;
          const cur = acc[distributionListOrConversationId];
          if (cur && story.timestamp < cur) {
            return acc;
          }
          return {
            ...acc,
            [distributionListOrConversationId]: story.timestamp,
          };
        },
        {}
      );
    }
  );

export const getGroupStories = createSelector(
  getConversationLookup,
  getConversationIdsWithStories,
  getHasStoriesSelector,
  (
    conversationLookup: ConversationLookupType,
    conversationIdsWithStories: Set<string>,
    hasStoriesSelector
  ): Array<ConversationWithStoriesType> => {
    return Object.values(conversationLookup)
      .filter(
        conversation =>
          isGroupInStoryMode(conversation, conversationIdsWithStories) &&
          !conversation.left
      )
      .map(conversation => ({
        ...conversation,
        hasStories: hasStoriesSelector(conversation.id),
      }));
  }
);

const getNormalizedComposerConversationSearchTerm = createSelector(
  getComposerConversationSearchTerm,
  (searchTerm: string): string => searchTerm.trim()
);

export const getFilteredComposeContacts = createSelector(
  getNormalizedComposerConversationSearchTerm,
  getComposableContacts,
  getRegionCode,
  (
    searchTerm: string,
    contacts: ReadonlyArray<ConversationType>,
    regionCode: string | undefined
  ): Array<ConversationType> => {
    return filterAndSortConversations(contacts, searchTerm, regionCode);
  }
);

export const getFilteredComposeGroups = createSelector(
  getNormalizedComposerConversationSearchTerm,
  getComposableGroups,
  getRegionCode,
  (
    searchTerm: string,
    groups: ReadonlyArray<ConversationType>,
    regionCode: string | undefined
  ): Array<
    ConversationType & {
      membersCount: number;
      disabledReason: undefined;
      memberships: ReadonlyArray<{
        aci: AciString;
        isAdmin: boolean;
      }>;
    }
  > => {
    return filterAndSortConversations(groups, searchTerm, regionCode).map(
      group => ({
        ...group,
        // we don't disable groups when composing, already filtered
        disabledReason: undefined,
        // should always be populated for a group
        membersCount: group.membersCount ?? 0,
        memberships: group.memberships ?? [],
      })
    );
  }
);

export const getFilteredCandidateContactsForNewGroup = createSelector(
  getCandidateContactsForNewGroup,
  getNormalizedComposerConversationSearchTerm,
  getRegionCode,
  (contacts, searchTerm, regionCode): Array<ConversationType> => {
    return filterAndSortConversations(contacts, searchTerm, regionCode);
  }
);

const getGroupCreationComposerState = createSelector(
  getComposerState,
  (
    composerState
  ): {
    groupName: string;
    groupAvatar: undefined | Uint8Array<ArrayBuffer>;
    groupExpireTimer: DurationInSeconds;
    selectedConversationIds: ReadonlyArray<string>;
  } => {
    switch (composerState?.step) {
      case ComposerStep.ChooseGroupMembers:
      case ComposerStep.SetGroupMetadata:
        return composerState;
      default:
        assertDev(
          false,
          'getSetGroupMetadataComposerState: expected step to be SetGroupMetadata'
        );
        return {
          groupName: '',
          groupAvatar: undefined,
          groupExpireTimer: DurationInSeconds.ZERO,
          selectedConversationIds: [],
        };
    }
  }
);

export const getComposeGroupAvatar = createSelector(
  getGroupCreationComposerState,
  (composerState): undefined | Uint8Array<ArrayBuffer> =>
    composerState.groupAvatar
);

export const getComposeGroupName = createSelector(
  getGroupCreationComposerState,
  (composerState): string => composerState.groupName
);

export const getComposeGroupExpireTimer = createSelector(
  getGroupCreationComposerState,
  (composerState): DurationInSeconds => composerState.groupExpireTimer
);

export const getComposeSelectedContacts = createSelector(
  getConversationLookup,
  getGroupCreationComposerState,
  (conversationLookup, composerState): Array<ConversationType> =>
    deconstructLookup(conversationLookup, composerState.selectedConversationIds)
);

// This is where we will put Conversation selector logic, replicating what
// is currently in models/conversation.getProps()
// What needs to happen to pull that selector logic here?
//   1) contactTypingTimers - that UI-only state needs to be moved to redux
//   2) all of the message selectors need to be reselect-based; today those
//      model-based prop-generation functions expect to get Conversation information
//      directly via ConversationController
function _conversationSelector(
  conversation?: ConversationType
  // regionCode: string,
  // userNumber: string
): ConversationType {
  if (conversation) {
    return conversation;
  }

  return getPlaceholderContact();
}

// A little optimization to reset our selector cache when high-level application data
//   changes: regionCode and userNumber.
type CachedConversationSelectorType = (
  conversation?: ConversationType
) => ConversationType;
const getCachedSelectorForConversation = createSelector(
  getRegionCode,
  getUserNumber,
  (): CachedConversationSelectorType => {
    // Note: memoizee will check all parameters provided, and only run our selector
    //   if any of them have changed.
    return memoizee(_conversationSelector, { max: 2000 });
  }
);

export type GetConversationByAnyIdSelectorType = (
  id?: string
) => ConversationType | undefined;
const getConversationByAnyIdSelector = createSelector(
  getConversationLookup,
  getConversationsByServiceId,
  getConversationsByE164,
  getConversationsByGroupId,
  (
    byId: ConversationLookupType,
    byServiceId: ConversationLookupType,
    byE164: ConversationLookupType,
    byGroupId: ConversationLookupType
  ): GetConversationByAnyIdSelectorType => {
    return (id?: string) => {
      if (!id) {
        return undefined;
      }

      const onGroupId = getOwn(byGroupId, id);
      if (onGroupId) {
        return onGroupId;
      }
      const onServiceId = getOwn(
        byServiceId,
        normalizeServiceId(id, 'getConversationSelector')
      );
      if (onServiceId) {
        return onServiceId;
      }
      const onE164 = getOwn(byE164, id);
      if (onE164) {
        return onE164;
      }
      const onId = getOwn(byId, id);
      if (onId) {
        return onId;
      }

      return undefined;
    };
  }
);

export type GetConversationByIdType = (id?: string) => ConversationType;
export const getConversationSelector = createSelector(
  getCachedSelectorForConversation,
  getConversationByAnyIdSelector,
  (
    selector: CachedConversationSelectorType,
    getById: GetConversationByAnyIdSelectorType
  ): GetConversationByIdType => {
    return (id?: string) => {
      if (!id) {
        return selector(undefined);
      }

      const byId = getById(id);
      if (byId) {
        return selector(byId);
      }

      log.warn(`getConversationSelector: No conversation found for id ${id}`);
      // This will return a placeholder contact
      return selector(undefined);
    };
  }
);

export const getConversationByIdSelector = createSelector(
  getConversationLookup,
  conversationLookup =>
    (id: string): undefined | ConversationType =>
      getOwn(conversationLookup, id)
);

export const getConversationByServiceIdSelector = createSelector(
  getConversationsByServiceId,
  conversationsByServiceId =>
    (serviceId: ServiceIdString): undefined | ConversationType =>
      getOwn(conversationsByServiceId, serviceId)
);

export const getCachedConversationMemberColorsSelector = createSelector(
  getConversationSelector,
  getUserConversationId,
  (
    conversationSelector: GetConversationByIdType,
    ourConversationId: string | undefined
  ) => {
    return memoizee(
      (conversationId: string | undefined) => {
        const contactNameColors = new Map<string, ContactNameColorType>();
        const {
          membersV2 = [],
          type,
          id: theirId,
        } = conversationSelector(conversationId);

        if (type === 'direct') {
          if (ourConversationId) {
            contactNameColors.set(ourConversationId, ContactNameColors[0]);
          }
          contactNameColors.set(theirId, ContactNameColors[0]);
          return contactNameColors;
        }

        [...membersV2]
          .sort((left, right) =>
            String(left.aci) > String(right.aci) ? 1 : -1
          )
          .forEach((member, i) => {
            const conversation = conversationSelector(member.aci);
            if (conversation.id === PLACEHOLDER_CONTACT_ID) {
              return;
            }

            contactNameColors.set(
              conversation.id,
              // oxlint-disable-next-line typescript/no-non-null-assertion
              ContactNameColors[i % ContactNameColors.length]!
            );
          });

        return contactNameColors;
      },
      { max: 100 }
    );
  }
);

export type ContactNameColorSelectorType = (
  conversationId: string,
  contactId: string | undefined
) => ContactNameColorType;

export const getContactNameColorSelector = createSelector(
  getCachedConversationMemberColorsSelector,
  conversationMemberColorsSelector => {
    return (
      conversationId: string,
      contactId: string | undefined
    ): ContactNameColorType => {
      const contactNameColors =
        conversationMemberColorsSelector(conversationId);
      return getContactNameColor(contactNameColors, contactId);
    };
  }
);

export const getContactNameColor = (
  contactNameColors: Map<string, ContactNameColorType>,
  contactId: string | undefined
): ContactNameColorType => {
  if (!contactId) {
    log.warn('No color generated for missing contactId');
    return ContactNameColors[0];
  }

  const color = contactNameColors.get(contactId);
  if (!color) {
    log.warn(`No color generated for contact ${contactId}`);
    return ContactNameColors[0];
  }
  return color;
};

type TimelinePropsWithRawItems = Omit<TimelinePropsType, 'items'> & {
  items: ReadonlyArray<string>;
};
function _conversationMessagesSelector(
  conversation: ConversationMessageType
): TimelinePropsWithRawItems {
  const {
    isNearBottom = null,
    messageChangeCounter,
    messageIds,
    messageLoadingState = null,
    metrics,
    scrollToMessageCounter,
    scrollToMessageId,
  } = conversation;

  const firstId = messageIds[0];
  const lastId =
    messageIds.length === 0 ? undefined : messageIds[messageIds.length - 1];

  const { oldestUnseen } = metrics;

  const haveNewest = !metrics.newest || !lastId || lastId === metrics.newest.id;
  const haveOldest =
    !metrics.oldest || !firstId || firstId === metrics.oldest.id;

  const items = messageIds;

  const oldestUnseenIndex = oldestUnseen
    ? messageIds.findIndex(id => id === oldestUnseen.id)
    : null;
  const scrollToIndex = scrollToMessageId
    ? messageIds.findIndex(id => id === scrollToMessageId)
    : null;
  const { totalUnseen } = metrics;

  return {
    haveNewest,
    haveOldest,
    isNearBottom,
    items,
    messageChangeCounter,
    messageLoadingState,
    oldestUnseenIndex:
      isNumber(oldestUnseenIndex) && oldestUnseenIndex >= 0
        ? oldestUnseenIndex
        : null,
    scrollToIndex:
      isNumber(scrollToIndex) && scrollToIndex >= 0 ? scrollToIndex : null,
    scrollToIndexCounter: scrollToMessageCounter,
    totalUnseen,
  };
}

type CachedConversationMessagesSelectorType = (
  conversation: ConversationMessageType
) => TimelinePropsWithRawItems;
const getCachedSelectorForConversationMessages = createSelector(
  getRegionCode,
  getUserNumber,
  (): CachedConversationMessagesSelectorType => {
    // Note: memoizee will check all parameters provided, and only run our selector
    //   if any of them have changed.
    return memoizee(_conversationMessagesSelector, { max: 50 });
  }
);

export const getConversationMessagesSelector = createSelector(
  getCachedSelectorForConversationMessages,
  getMessagesByConversation,
  (
    conversationMessagesSelector: CachedConversationMessagesSelectorType,
    messagesByConversation: MessagesByConversationType
  ) => {
    return (id: string): TimelinePropsWithRawItems => {
      const conversation = messagesByConversation[id];
      if (!conversation) {
        // TODO: DESKTOP-2340
        return {
          haveNewest: false,
          haveOldest: false,
          messageChangeCounter: 0,
          messageLoadingState: TimelineMessageLoadingState.DoingInitialLoad,
          scrollToIndexCounter: 0,
          totalUnseen: 0,
          items: [],
          isNearBottom: null,
          oldestUnseenIndex: null,
          scrollToIndex: null,
        };
      }

      return conversationMessagesSelector(conversation);
    };
  }
);

export const getInvitedContactsForNewlyCreatedGroup = createSelector(
  getConversationsByServiceId,
  getConversations,
  (
    conversationLookup,
    { invitedServiceIdsForNewlyCreatedGroup = [] }
  ): Array<ConversationType> =>
    deconstructLookup(conversationLookup, invitedServiceIdsForNewlyCreatedGroup)
);

export const getConversationsWithCustomColorSelector = createSelector(
  getAllConversations,
  conversations => {
    return (colorId: string): Array<ConversationType> => {
      return conversations.filter(
        conversation => conversation.customColorId === colorId
      );
    };
  }
);

export function isMissingRequiredProfileSharing(
  conversation: ConversationType
): boolean {
  const doesConversationRequireIt =
    !conversation.isMe &&
    !conversation.left &&
    !conversation.removalStage &&
    !isInSystemContacts(conversation) &&
    (isGroupV1(conversation) || isDirectConversation(conversation));

  return Boolean(
    doesConversationRequireIt &&
    !conversation.profileSharing &&
    conversation.hasMessages
  );
}

export type AdminMembershipType = {
  member: ConversationType;
  labelEmoji: string | undefined;
  labelString: string | undefined;
};
export const getGroupAdminsSelector = createSelector(
  getConversationSelector,
  (conversationSelector: GetConversationByIdType) => {
    return (conversationId: string): Array<AdminMembershipType> => {
      const {
        groupId,
        groupVersion,
        memberships = [],
      } = conversationSelector(conversationId);

      if (
        !isGroupV2({
          groupId,
          groupVersion,
        })
      ) {
        return [];
      }

      const admins: Array<AdminMembershipType> = [];
      memberships.forEach(membership => {
        if (membership.isAdmin) {
          const admin = conversationSelector(membership.aci);
          admins.push({
            member: admin,
            labelEmoji: membership.labelEmoji,
            labelString: membership.labelString,
          });
        }
      });
      return admins;
    };
  }
);

export const getContactSelector = createSelector(
  getConversationSelector,
  conversationSelector => {
    return (contactId: string) =>
      pick(conversationSelector(contactId), 'id', 'title', 'serviceId');
  }
);

export const getConversationVerificationData = createSelector(
  getConversations,
  (
    conversations: Readonly<ConversationsStateType>
  ): Record<string, ConversationVerificationData> =>
    conversations.verificationDataByConversation
);

export const getConversationIdsStoppedForVerification = createSelector(
  getConversationVerificationData,
  (verificationDataByConversation): Array<string> =>
    Object.keys(verificationDataByConversation)
);

export const getConversationServiceIdsStoppingSend = createSelector(
  getConversationVerificationData,
  (pendingData): Array<ServiceIdString> => {
    const result = new Set<ServiceIdString>();
    Object.values(pendingData).forEach(item => {
      if (item.type === ConversationVerificationState.PendingVerification) {
        item.serviceIdsNeedingVerification.forEach(serviceId => {
          result.add(serviceId);
        });

        if (item.byDistributionId) {
          Object.values(item.byDistributionId).forEach(distribution => {
            distribution.serviceIdsNeedingVerification.forEach(serviceId => {
              result.add(serviceId);
            });
          });
        }
      }
    });
    return Array.from(result);
  }
);

export const getConversationsStoppingSend = createSelector(
  getConversationSelector,
  getConversationServiceIdsStoppingSend,
  (
    conversationSelector: GetConversationByIdType,
    serviceIds: ReadonlyArray<ServiceIdString>
  ): Array<ConversationType> => {
    const conversations = serviceIds.map(serviceId =>
      conversationSelector(serviceId)
    );
    return sortByTitle(conversations);
  }
);

export const getHideStoryConversationIds = createSelector(
  getConversationLookup,
  (conversationLookup): Array<string> => {
    const result: Array<string> = [];
    for (const [conversationId, conversation] of Object.entries(
      conversationLookup
    )) {
      if (conversation.hideStory) {
        result.push(conversationId);
      }
    }
    return result;
  }
);

export const getStoriesState = (state: StateType): StoriesStateType =>
  state.stories;

const getStoriesNotificationCount = createSelector(
  getStoriesEnabled,
  getHideStoryConversationIds,
  getStoriesState,
  (
    storiesEnabled,
    hideStoryConversationIds,
    { lastOpenedAtTimestamp, stories }
  ): number => {
    if (!storiesEnabled) {
      return 0;
    }

    const hiddenConversationIds = new Set(hideStoryConversationIds);

    return new Set(
      stories
        .filter(
          story =>
            story.readStatus === ReadStatus.Unread &&
            !story.deletedForEveryone &&
            story.timestamp > (lastOpenedAtTimestamp || 0) &&
            !hiddenConversationIds.has(story.conversationId)
        )
        .map(story => story.conversationId)
    ).size;
  }
);

export const getOtherTabsUnreadStats = createSelector(
  getSelectedNavTab,
  getAllConversationsUnreadStats,
  getCallHistoryUnreadCount,
  getStoriesNotificationCount,
  (
    selectedNavTab,
    conversationsUnreadStats,
    callHistoryUnreadCount,
    storiesNotificationCount
  ): UnreadStats => {
    let unreadCount = 0;
    let unreadMentionsCount = 0;
    let readChatsMarkedUnreadCount = 0;

    if (selectedNavTab !== NavTab.Chats) {
      unreadCount += conversationsUnreadStats.unreadCount;
      unreadMentionsCount += conversationsUnreadStats.unreadMentionsCount;
      readChatsMarkedUnreadCount +=
        conversationsUnreadStats.readChatsMarkedUnreadCount;
    }

    // Note: Conversation unread stats includes the call history unread count.
    if (selectedNavTab !== NavTab.Calls) {
      unreadCount += callHistoryUnreadCount;
    }

    if (selectedNavTab !== NavTab.Stories) {
      unreadCount += storiesNotificationCount;
    }

    return {
      unreadCount,
      unreadMentionsCount,
      readChatsMarkedUnreadCount,
    };
  }
);

// Note that this doesn't take into account max edit count. See canEditMessage.
export const getLastEditableMessageId = createSelector(
  getConversationMessages,
  getMessages,
  (conversationMessages, messagesLookup): string | undefined => {
    if (!conversationMessages) {
      return;
    }

    return conversationMessages.messageIds.findLast(messageId => {
      const message = messagesLookup[messageId];
      return message != null && isOutgoing(message) && canEditMessage(message);
    });
  }
);

export const getPreloadedConversationId = createSelector(
  getConversations,
  ({ preloadData }): string | undefined => preloadData?.conversationId
);

export const getProfileUpdateError = createSelector(
  getConversations,
  ({ hasProfileUpdateError }): boolean => Boolean(hasProfileUpdateError)
);

export const getPendingAvatarDownloadSelector = createSelector(
  getConversations,
  (conversations: ConversationsStateType) => {
    return (conversationId: string): boolean => {
      return Boolean(
        conversations.pendingRequestedAvatarDownload[conversationId]
      );
    };
  }
);
