import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
import {
  type ChatBskyActorDefs,
  ChatBskyConvoDefs,
  type ChatBskyConvoListConvos,
  moderateProfile,
  type ModerationOpts,
} from '@atproto/api'
import {
  type InfiniteData,
  type Query,
  type QueryClient,
  type QueryKey,
  useInfiniteQuery,
  useQueryClient,
} from '@tanstack/react-query'
import throttle from 'lodash.throttle'

import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {useCurrentConvoId} from '#/state/messages/current-convo-id'
import {useMessagesEventBus} from '#/state/messages/events'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {invalidateJoinLinkPreviewsForConvo} from '#/state/queries/join-links'
import {useAgent, useSession} from '#/state/session'
import {parseConvoView} from '#/components/dms/util'
import {useAgeAssurance} from '#/ageAssurance'
import {type AgeAssuranceFlags} from '#/ageAssurance/types'
import * as bsky from '#/types/bsky'
import {RQKEY as CONVO_KEY} from './conversation'
import {
  type ConvoRequestListQueryData,
  optimisticDelete as optimisticDeleteRequest,
  optimisticDeleteJoinRequest,
  optimisticUpdate as optimisticUpdateRequest,
  RQKEY_ROOT as REQUESTS_RQKEY_ROOT,
} from './list-conversation-requests'
import {listConvoMembersQueryKey} from './list-convo-members'

const DEFAULT_LIMIT = 10
export const UNREAD_LIMIT = 20

export const RQKEY_ROOT = 'convo-list'
export const RQKEY = (
  status: 'accepted' | 'request' | 'all',
  readState: 'all' | 'unread' = 'all',
  kind: 'all' | 'group' | 'direct' = 'all',
  lockStatus:
    | 'unlocked'
    | 'locked'
    | 'locked-permanently'
    | undefined = undefined,
  limit?: number,
) => [RQKEY_ROOT, status, readState, kind, lockStatus, limit] as const

/**
 * Prefix key matching every convo-list query with the given status (and
 * optionally readState), regardless of the remaining params (kind,
 * lockStatus, limit). Only valid with prefix-matching APIs (setQueriesData,
 * getQueriesData, invalidateQueries) - exact-match APIs (getQueryData,
 * setQueryData) hash the full key and will never match a prefix.
 */
export const RQKEY_PARTIAL = (
  status: 'accepted' | 'request' | 'all',
  readState?: 'all' | 'unread',
) => (readState ? [RQKEY_ROOT, status, readState] : [RQKEY_ROOT, status])

/**
 * Whether a convo satisfies the filters encoded in a convo-list query key.
 * Caches are server-filtered, so optimistic inserts must apply the same
 * filters client-side or convos leak into lists that should exclude them.
 */
export function convoMatchesQueryKey(
  convo: ChatBskyConvoDefs.ConvoView,
  queryKey: QueryKey,
): boolean {
  const [, status, readState, kind, lockStatus] = queryKey as ReturnType<
    typeof RQKEY
  >
  if (status !== 'all' && status !== convo.status) return false
  if (readState === 'unread' && convo.unreadCount === 0) return false
  if (ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
    if (kind === 'direct') return false
    if (lockStatus && convo.kind.lockStatus !== lockStatus) return false
  } else {
    if (kind === 'group') return false
    // direct convos are never locked
    if (lockStatus && lockStatus !== 'unlocked') return false
  }
  return true
}

/**
 * Query predicate for optimistically upserting a convo into convo-list
 * caches. Targets caches whose filters the convo satisfies, plus caches the
 * convo is already in - those get updated in place even if the convo no
 * longer matches (e.g. unreadCount dropped to 0), mirroring how read/mute
 * log events update convos in place everywhere.
 */
export function convoListQueryPredicate(convo: ChatBskyConvoDefs.ConvoView) {
  return (query: Query): boolean => {
    const data = query.state.data as ConvoListQueryData | undefined
    if (data && getConvoFromQueryData(convo.id, data)) return true
    return convoMatchesQueryKey(convo, query.queryKey)
  }
}

type RQPageParam = string | undefined

export function useListConvosQuery({
  enabled,
  status,
  readState = 'all',
  kind = 'all',
  limit = DEFAULT_LIMIT,
  lockStatus,
}: {
  enabled?: boolean
  status?: 'request' | 'accepted'
  readState?: 'all' | 'unread'
  kind?: 'all' | 'group' | 'direct'
  limit?: number
  lockStatus?: 'unlocked' | 'locked' | 'locked-permanently'
} = {}) {
  const agent = useAgent()

  return useInfiniteQuery({
    enabled,
    queryKey: RQKEY(status ?? 'all', readState, kind, lockStatus, limit),
    queryFn: async ({pageParam}) => {
      const {data} = await agent.chat.bsky.convo.listConvos(
        {
          limit,
          cursor: pageParam,
          readState: readState === 'unread' ? 'unread' : undefined,
          kind: kind === 'all' ? undefined : kind,
          lockStatus,
          status,
        },
        {headers: DM_SERVICE_HEADERS},
      )
      return data
    },
    initialPageParam: undefined as RQPageParam,
    getNextPageParam: lastPage => lastPage.cursor,
  })
}

const ListConvosContext = createContext<{
  accepted: ChatBskyConvoDefs.ConvoView[]
  request: ChatBskyConvoDefs.ConvoView[]
} | null>(null)
ListConvosContext.displayName = 'ListConvosContext'

export function useListConvos() {
  const ctx = useContext(ListConvosContext)
  if (!ctx) {
    throw new Error('useListConvos must be used within a ListConvosProvider')
  }
  return ctx
}

const empty = {accepted: [], request: []}
export function ListConvosProvider({children}: {children: React.ReactNode}) {
  const {hasSession} = useSession()

  if (!hasSession) {
    return (
      <ListConvosContext.Provider value={empty}>
        {children}
      </ListConvosContext.Provider>
    )
  }

  return <ListConvosProviderInner>{children}</ListConvosProviderInner>
}

export function ListConvosProviderInner({
  children,
}: {
  children: React.ReactNode
}) {
  const aa = useAgeAssurance()
  const {refetch, data} = useListConvosQuery({
    readState: 'unread',
    limit: UNREAD_LIMIT,
    lockStatus: 'unlocked',
    kind: aa.flags.groupChatDisabled ? 'direct' : 'all',
  })
  const messagesBus = useMessagesEventBus()
  const queryClient = useQueryClient()
  const {currentConvoId} = useCurrentConvoId()
  const {currentAccount} = useSession()

  const debouncedRefetch = useMemo(() => {
    const refetchAndInvalidate = () => {
      void refetch()
      void queryClient.invalidateQueries({queryKey: [RQKEY_ROOT]})
      void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]})
    }
    return throttle(refetchAndInvalidate, 500, {
      leading: true,
      trailing: true,
    })
  }, [refetch, queryClient])

  useEffect(() => {
    const unsub = messagesBus.on(
      events => {
        if (events.type !== 'logs') return

        function mutateMembers(
          convoId: string,
          fn: (
            members: ChatBskyActorDefs.ProfileViewBasic[],
          ) => ChatBskyActorDefs.ProfileViewBasic[],
        ) {
          queryClient.setQueryData<ChatBskyActorDefs.ProfileViewBasic[]>(
            listConvoMembersQueryKey(convoId),
            old => {
              if (!old) return // query doesn't exist yet, skip
              return fn(old)
            },
          )
        }

        function updateConvoInAllLists(
          convoId: string,
          fn: (
            convo: ChatBskyConvoDefs.ConvoView,
          ) => ChatBskyConvoDefs.ConvoView,
        ) {
          queryClient.setQueriesData<ConvoListQueryData>(
            {queryKey: [RQKEY_ROOT]},
            old => optimisticUpdate(convoId, old, fn),
          )
          queryClient.setQueriesData<ConvoRequestListQueryData>(
            {queryKey: [REQUESTS_RQKEY_ROOT]},
            old => optimisticUpdateRequest(convoId, old, fn),
          )
        }

        function mutateConvoView(
          convoId: string,
          fn: (
            convo: ChatBskyConvoDefs.ConvoView,
          ) => ChatBskyConvoDefs.ConvoView,
        ) {
          queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>(
            CONVO_KEY(convoId),
            old => (old ? fn(old) : old),
          )
          updateConvoInAllLists(convoId, fn)
        }

        function deleteConvoFromAllLists(convoId: string) {
          queryClient.setQueriesData<ConvoListQueryData>(
            {queryKey: [RQKEY_ROOT]},
            old => optimisticDelete(convoId, old),
          )
          queryClient.setQueriesData<ConvoRequestListQueryData>(
            {queryKey: [REQUESTS_RQKEY_ROOT]},
            old => optimisticDeleteRequest(convoId, old),
          )
        }

        function handleMemberAdded(
          convoId: string,
          did: string,
          relatedProfiles: ChatBskyActorDefs.ProfileViewBasic[],
          rev: string,
        ) {
          const newMember = relatedProfiles.find(r => r.did === did)
          if (!newMember) return
          // If the optimistic add already added them, skip the
          // memberCount bump to avoid double-counting.
          const alreadyKnownMember =
            queryClient
              .getQueryData<
                ChatBskyActorDefs.ProfileViewBasic[]
              >(listConvoMembersQueryKey(convoId))
              ?.some(m => m.did === did) ?? false
          mutateMembers(convoId, list =>
            list.some(m => m.did === did) ? list : list.concat(newMember),
          )
          mutateConvoView(convoId, convo =>
            addMemberToConvoView(convo, newMember, rev, alreadyKnownMember),
          )
        }

        function handleMemberRemoved(
          convoId: string,
          did: string,
          rev: string,
        ) {
          // If the optimistic remove already dropped them from the full
          // list, skip the memberCount decrement to avoid double-counting.
          const alreadyRemovedMember =
            queryClient
              .getQueryData<
                ChatBskyActorDefs.ProfileViewBasic[]
              >(listConvoMembersQueryKey(convoId))
              ?.some(m => m.did === did) === false
          mutateMembers(convoId, list => list.filter(m => m.did !== did))
          mutateConvoView(convoId, convo =>
            removeMemberFromConvoView(convo, did, rev, alreadyRemovedMember),
          )
        }

        for (const log of events.logs) {
          if (ChatBskyConvoDefs.isLogBeginConvo(log)) {
            debouncedRefetch()
          } else if (ChatBskyConvoDefs.isLogLeaveConvo(log)) {
            deleteConvoFromAllLists(log.convoId)
            // The viewer is no longer in this convo (they left on another
            // device, or were removed - removed members receive a
            // logLeaveConvo, not a logRemoveMember). Refetch any cached join
            // link preview so its viewer state reflects the lost membership.
            void invalidateJoinLinkPreviewsForConvo(queryClient, log.convoId)
          } else if (ChatBskyConvoDefs.isLogDeleteMessage(log)) {
            updateConvoInAllLists(log.convoId, convo => {
              if (
                (ChatBskyConvoDefs.isDeletedMessageView(log.message) ||
                  ChatBskyConvoDefs.isMessageView(log.message)) &&
                (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage) ||
                  ChatBskyConvoDefs.isMessageView(convo.lastMessage))
              ) {
                return log.message.id === convo.lastMessage.id
                  ? {
                      ...convo,
                      rev: log.rev,
                      lastMessage: log.message,
                    }
                  : convo
              } else {
                return convo
              }
            })
          } else if (ChatBskyConvoDefs.isLogCreateMessage(log)) {
            // Store in a new var to avoid TS errors due to closures.
            const logRef: ChatBskyConvoDefs.LogCreateMessage = log

            // Get all matching queries
            const queries = queryClient.getQueriesData<ConvoListQueryData>({
              queryKey: [RQKEY_ROOT],
            })

            // Check if convo exists in any query
            let foundConvo: ChatBskyConvoDefs.ConvoView | null = null
            for (const [_key, query] of queries) {
              if (!query) continue
              const convo = getConvoFromQueryData(logRef.convoId, query)
              if (convo) {
                foundConvo = convo
                break
              }
            }

            if (!foundConvo) {
              // Convo not found, trigger refetch
              debouncedRefetch()
              return
            }

            // Update the convo
            const updatedConvo = {
              ...foundConvo,
              rev: logRef.rev,
              lastMessage: logRef.message,
              unreadCount:
                foundConvo.id !== currentConvoId
                  ? (ChatBskyConvoDefs.isMessageView(logRef.message) ||
                      ChatBskyConvoDefs.isDeletedMessageView(logRef.message)) &&
                    logRef.message.sender.did !== currentAccount?.did
                    ? foundConvo.unreadCount + 1
                    : foundConvo.unreadCount
                  : 0,
            }

            function filterConvoFromPage(convo: ChatBskyConvoDefs.ConvoView[]) {
              return convo.filter(c => c.id !== logRef.convoId)
            }

            // Update all matching queries
            function updateFn(old?: ConvoListQueryData) {
              if (!old) return old
              return {
                ...old,
                pages: old.pages.map((page, i) => {
                  if (i === 0) {
                    return {
                      ...page,
                      convos: [
                        updatedConvo,
                        ...filterConvoFromPage(page.convos),
                      ],
                    }
                  }
                  return {
                    ...page,
                    convos: filterConvoFromPage(page.convos),
                  }
                }),
              }
            }
            // always update the unread ones, where the convo qualifies
            queryClient.setQueriesData(
              {
                queryKey: RQKEY_PARTIAL('all', 'unread'),
                predicate: convoListQueryPredicate(updatedConvo),
              },
              (old?: ConvoListQueryData) =>
                old
                  ? updateFn(old)
                  : ({
                      pageParams: [undefined],
                      pages: [{convos: [updatedConvo], cursor: undefined}],
                    } satisfies ConvoListQueryData),
            )
            // update the other ones based on status of the incoming message
            if (updatedConvo.status === 'accepted') {
              queryClient.setQueriesData(
                {
                  queryKey: RQKEY_PARTIAL('accepted'),
                  predicate: convoListQueryPredicate(updatedConvo),
                },
                updateFn,
              )
            } else if (updatedConvo.status === 'request') {
              queryClient.setQueriesData(
                {
                  queryKey: RQKEY_PARTIAL('request'),
                  predicate: convoListQueryPredicate(updatedConvo),
                },
                updateFn,
              )
              // also move-to-top in the new requests cache
              queryClient.setQueriesData<ConvoRequestListQueryData>(
                {queryKey: [REQUESTS_RQKEY_ROOT]},
                old => moveConvoToTopInRequests(updatedConvo, old),
              )
            }
          } else if (ChatBskyConvoDefs.isLogReadMessage(log)) {
            updateConvoInAllLists(log.convoId, convo => ({
              ...convo,
              unreadCount: 0,
              rev: log.rev,
            }))
          } else if (ChatBskyConvoDefs.isLogReadConvo(log)) {
            updateConvoInAllLists(log.convoId, convo => ({
              ...convo,
              unreadCount: 0,
              rev: log.rev,
            }))
          } else if (ChatBskyConvoDefs.isLogAcceptConvo(log)) {
            const requestQueries =
              queryClient.getQueriesData<ConvoListQueryData>({
                queryKey: RQKEY_PARTIAL('request'),
              })
            let foundConvo: ChatBskyConvoDefs.ConvoView | null = null
            for (const [_key, data] of requestQueries) {
              if (!data) continue
              foundConvo = getConvoFromQueryData(log.convoId, data)
              if (foundConvo) break
            }
            if (!foundConvo) {
              debouncedRefetch()
              return
            }
            const acceptedConvo: ChatBskyConvoDefs.ConvoView = {
              ...foundConvo,
              status: 'accepted',
            }
            queryClient.setQueriesData(
              {queryKey: RQKEY_PARTIAL('request')},
              (old?: ConvoListQueryData) => optimisticDelete(log.convoId, old),
            )
            // also remove from the new requests cache
            queryClient.setQueriesData<ConvoRequestListQueryData>(
              {queryKey: [REQUESTS_RQKEY_ROOT]},
              old => optimisticDeleteRequest(log.convoId, old),
            )
            queryClient.setQueriesData(
              {
                queryKey: RQKEY_PARTIAL('accepted'),
                predicate: convoListQueryPredicate(acceptedConvo),
              },
              (old?: ConvoListQueryData) => {
                if (!old) {
                  debouncedRefetch()
                  return old
                }
                return {
                  ...old,
                  pages: old.pages.map((page, i) => {
                    if (i === 0) {
                      return {
                        ...page,
                        convos: [
                          acceptedConvo,
                          ...page.convos.filter(c => c.id !== log.convoId),
                        ],
                      }
                    }
                    return {
                      ...page,
                      convos: page.convos.filter(c => c.id !== log.convoId),
                    }
                  }),
                }
              },
            )
          } else if (ChatBskyConvoDefs.isLogMuteConvo(log)) {
            updateConvoInAllLists(log.convoId, convo => ({
              ...convo,
              muted: true,
              rev: log.rev,
            }))
          } else if (ChatBskyConvoDefs.isLogUnmuteConvo(log)) {
            updateConvoInAllLists(log.convoId, convo => ({
              ...convo,
              muted: false,
              rev: log.rev,
            }))
          } else if (ChatBskyConvoDefs.isLogLockConvo(log)) {
            mutateConvoView(log.convoId, convo => {
              if (ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
                return {
                  ...convo,
                  kind: {...convo.kind, lockStatus: 'locked'},
                  rev: log.rev,
                }
              }
              return {...convo, rev: log.rev}
            })
            // The log event doesn't say whether the lock is forced by a
            // moderation override, so refetch to pick up the flag.
            void queryClient.invalidateQueries({
              queryKey: CONVO_KEY(log.convoId),
            })
          } else if (ChatBskyConvoDefs.isLogUnlockConvo(log)) {
            mutateConvoView(log.convoId, convo => {
              if (ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
                return {
                  ...convo,
                  kind: {
                    ...convo.kind,
                    lockStatus: 'unlocked',
                    // An unlocked convo cannot be moderation-locked.
                    lockStatusModerationOverride: false,
                  },
                  rev: log.rev,
                }
              }
              return {...convo, rev: log.rev}
            })
          } else if (ChatBskyConvoDefs.isLogLockConvoPermanently(log)) {
            mutateConvoView(log.convoId, convo => {
              if (ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
                return {
                  ...convo,
                  kind: {...convo.kind, lockStatus: 'locked-permanently'},
                  rev: log.rev,
                }
              }
              return {...convo, rev: log.rev}
            })
          } else if (
            ChatBskyConvoDefs.isLogCreateJoinLink(log) ||
            ChatBskyConvoDefs.isLogEditJoinLink(log) ||
            ChatBskyConvoDefs.isLogEnableJoinLink(log) ||
            ChatBskyConvoDefs.isLogDisableJoinLink(log)
          ) {
            // Join link data not included in the log event, trigger refetch to get it
            debouncedRefetch()
          } else if (ChatBskyConvoDefs.isLogEditGroup(log)) {
            // Updated group details (name etc.) aren't included in the log
            // event, so refetch to pick them up.
            debouncedRefetch()
          } else if (
            ChatBskyConvoDefs.isLogApproveJoinRequest(log) ||
            ChatBskyConvoDefs.isLogRejectJoinRequest(log)
          ) {
            // Route through mutateConvoView (not updateConvoInAllLists) so the
            // single-convo cache updates too, keeping the in-convo requests
            // banner in sync.
            mutateConvoView(log.convoId, convo =>
              applyJoinRequestCountDelta(convo, log.rev, -1),
            )
          } else if (ChatBskyConvoDefs.isLogIncomingJoinRequest(log)) {
            // Route through mutateConvoView (not updateConvoInAllLists) so the
            // single-convo cache updates too, letting the in-convo requests
            // banner appear live.
            mutateConvoView(log.convoId, convo =>
              applyJoinRequestCountDelta(convo, log.rev, 1),
            )
          } else if (ChatBskyConvoDefs.isLogReadJoinRequests(log)) {
            // The owner marked join requests as read (possibly on another
            // device). Zero the unread count but keep the total, mirroring the
            // useMarkJoinRequestsRead mutation.
            mutateConvoView(log.convoId, convo => {
              if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
                return {...convo, rev: log.rev}
              }
              return {
                ...convo,
                kind: {...convo.kind, unreadJoinRequestCount: 0},
                rev: log.rev,
              }
            })
          } else if (ChatBskyConvoDefs.isLogOutgoingJoinRequest(log)) {
            // Viewer isn't in the chat yet, but the inbox surfaces outgoing
            // requests, so refetch to pick up the new entry.
            debouncedRefetch()
          } else if (ChatBskyConvoDefs.isLogWithdrawIncomingJoinRequest(log)) {
            // A requester rescinded their request to a group the viewer owns.
            // Mirror of isLogIncomingJoinRequest: decrement the counts.
            mutateConvoView(log.convoId, convo =>
              applyJoinRequestCountDelta(convo, log.rev, -1),
            )
          } else if (ChatBskyConvoDefs.isLogWithdrawOutgoingJoinRequest(log)) {
            // The viewer rescinded their own outgoing join request (possibly on
            // another device). Remove it from the requests inbox cache.
            queryClient.setQueriesData<ConvoRequestListQueryData>(
              {queryKey: [REQUESTS_RQKEY_ROOT]},
              old => optimisticDeleteJoinRequest(log.convoId, old),
            )
          } else if (ChatBskyConvoDefs.isLogAddReaction(log)) {
            updateConvoInAllLists(log.convoId, convo => ({
              ...convo,
              lastReaction: {
                $type: 'chat.bsky.convo.defs#messageAndReactionView',
                reaction: log.reaction,
                message: log.message,
              },
              rev: log.rev,
            }))
          } else if (ChatBskyConvoDefs.isLogAddMember(log)) {
            const data = log.message.data
            if (
              bsky.dangerousIsType<ChatBskyConvoDefs.SystemMessageDataAddMember>(
                data,
                ChatBskyConvoDefs.isSystemMessageDataAddMember,
              )
            ) {
              handleMemberAdded(
                log.convoId,
                data.member.did,
                log.relatedProfiles,
                log.rev,
              )
            }
            // Refetch so the server can refresh the curated members list.
            void queryClient.invalidateQueries({
              queryKey: CONVO_KEY(log.convoId),
            })
            debouncedRefetch()
          } else if (ChatBskyConvoDefs.isLogRemoveMember(log)) {
            const data = log.message.data
            if (
              bsky.dangerousIsType<ChatBskyConvoDefs.SystemMessageDataRemoveMember>(
                data,
                ChatBskyConvoDefs.isSystemMessageDataRemoveMember,
              )
            ) {
              handleMemberRemoved(log.convoId, data.member.did, log.rev)
            }
            // Refetch so the server can refill the curated members list.
            void queryClient.invalidateQueries({
              queryKey: CONVO_KEY(log.convoId),
            })
            debouncedRefetch()
          } else if (ChatBskyConvoDefs.isLogMemberJoin(log)) {
            const data = log.message.data
            if (
              bsky.dangerousIsType<ChatBskyConvoDefs.SystemMessageDataMemberJoin>(
                data,
                ChatBskyConvoDefs.isSystemMessageDataMemberJoin,
              )
            ) {
              handleMemberAdded(
                log.convoId,
                data.member.did,
                log.relatedProfiles,
                log.rev,
              )
            }
            void queryClient.invalidateQueries({
              queryKey: CONVO_KEY(log.convoId),
            })
            debouncedRefetch()
          } else if (ChatBskyConvoDefs.isLogMemberLeave(log)) {
            const data = log.message.data
            if (
              bsky.dangerousIsType<ChatBskyConvoDefs.SystemMessageDataMemberLeave>(
                data,
                ChatBskyConvoDefs.isSystemMessageDataMemberLeave,
              )
            ) {
              handleMemberRemoved(log.convoId, data.member.did, log.rev)
            }
            void queryClient.invalidateQueries({
              queryKey: CONVO_KEY(log.convoId),
            })
            debouncedRefetch()
          } else if (ChatBskyConvoDefs.isLogRemoveReaction(log)) {
            queryClient.setQueriesData(
              {queryKey: [RQKEY_ROOT]},
              (old?: ConvoListQueryData) =>
                optimisticUpdate(log.convoId, old, convo => {
                  if (
                    // if the convo is the same
                    log.convoId === convo.id &&
                    ChatBskyConvoDefs.isMessageAndReactionView(
                      convo.lastReaction,
                    ) &&
                    ChatBskyConvoDefs.isMessageView(log.message) &&
                    // ...and the message is the same
                    convo.lastReaction.message.id === log.message.id &&
                    // ...and the reaction is the same
                    convo.lastReaction.reaction.sender.did ===
                      log.reaction.sender.did &&
                    convo.lastReaction.reaction.value === log.reaction.value
                  ) {
                    return {
                      ...convo,
                      // ...remove the reaction. hopefully they didn't react twice in a row!
                      lastReaction: undefined,
                      rev: log.rev,
                    }
                  } else {
                    return convo
                  }
                }),
            )
          }
        }
      },
      {
        // get events for all chats
        convoId: undefined,
      },
    )

    return () => unsub()
  }, [
    messagesBus,
    currentConvoId,
    queryClient,
    currentAccount?.did,
    debouncedRefetch,
  ])

  const ctx = useMemo(() => {
    const convos = data?.pages.flatMap(page => page.convos) ?? []
    return {
      accepted: convos.filter(conv => conv.status === 'accepted'),
      request: convos.filter(conv => conv.status === 'request'),
    }
  }, [data])

  return (
    <ListConvosContext.Provider value={ctx}>
      {children}
    </ListConvosContext.Provider>
  )
}

export function useUnreadMessageCount() {
  const {currentConvoId} = useCurrentConvoId()
  const {currentAccount} = useSession()
  const {accepted, request} = useListConvos()
  const moderationOpts = useModerationOpts()
  const aa = useAgeAssurance()

  return useMemo<{
    count: number
    numUnread?: string
    hasNew: boolean
  }>(() => {
    const acceptedCount = calculateCount(
      accepted,
      currentAccount?.did,
      currentConvoId,
      moderationOpts,
      aa.flags,
    )
    const requestCount = calculateCount(
      request,
      currentAccount?.did,
      currentConvoId,
      moderationOpts,
      aa.flags,
    )
    if (acceptedCount > 0) {
      const total = acceptedCount + Math.min(requestCount, 1)
      return {
        count: total,
        numUnread: total > 10 ? '10+' : String(total),
        // only needed when numUnread is undefined
        hasNew: false,
      }
    } else if (requestCount > 0) {
      return {
        count: 1,
        numUnread: undefined,
        hasNew: true,
      }
    } else {
      return {
        count: 0,
        numUnread: undefined,
        hasNew: false,
      }
    }
  }, [
    accepted,
    request,
    currentAccount?.did,
    currentConvoId,
    moderationOpts,
    aa.flags,
  ])
}

function calculateCount(
  convos: ChatBskyConvoDefs.ConvoView[],
  currentAccountDid: string | undefined,
  currentConvoId: string | undefined,
  moderationOpts: ModerationOpts | undefined,
  flags: AgeAssuranceFlags,
) {
  return (
    convos
      .filter(convo => convo.id !== currentConvoId)
      .reduce((acc, convoView) => {
        const convo = parseConvoView(convoView, currentAccountDid)

        if (!convo || !moderationOpts) return acc

        if (convo.kind === 'group' && flags.groupChatDisabled) return acc

        const shouldIgnore =
          convo.view.muted ||
          !convo.primaryMember ||
          moderateProfile(convo.primaryMember, moderationOpts).blocked ||
          convo.primaryMember.handle === 'missing.invalid' ||
          (convo.kind === 'group' && convo.details.lockStatus !== 'unlocked')
        const unreadJoinRequestCount =
          convo.kind === 'group'
            ? (convo.details.unreadJoinRequestCount ?? 0)
            : 0

        const unreadCount =
          !shouldIgnore &&
          (convo.view.unreadCount > 0 || unreadJoinRequestCount > 0)
            ? 1
            : 0

        return acc + unreadCount
      }, 0) ?? 0
  )
}

export type ConvoListQueryData = {
  pageParams: Array<string | undefined>
  pages: Array<ChatBskyConvoListConvos.OutputSchema>
}

export function useOnMarkAsRead() {
  const queryClient = useQueryClient()

  return useCallback(
    (chatId: string) => {
      queryClient.setQueriesData(
        {queryKey: [RQKEY_ROOT]},
        (old?: ConvoListQueryData) => {
          if (!old) return old
          return optimisticUpdate(chatId, old, convo => ({
            ...convo,
            unreadCount: 0,
          }))
        },
      )
    },
    [queryClient],
  )
}

function optimisticUpdate(
  chatId: string,
  old?: ConvoListQueryData,
  updateFn?: (
    convo: ChatBskyConvoDefs.ConvoView,
  ) => ChatBskyConvoDefs.ConvoView,
) {
  if (!old || !updateFn) return old

  return {
    ...old,
    pages: old.pages.map(page => ({
      ...page,
      convos: page.convos.map(convo =>
        chatId === convo.id ? updateFn(convo) : convo,
      ),
    })),
  }
}

function applyJoinRequestCountDelta(
  convo: ChatBskyConvoDefs.ConvoView,
  rev: string,
  delta: 1 | -1,
): ChatBskyConvoDefs.ConvoView {
  // Join requests are only meaningful for group convos.
  if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
    return {...convo, rev}
  }
  // Bump the total and unread counts together. Both are clamped at 0 and
  // collapse to undefined when empty, matching the server's shape.
  const bump = (current: number | undefined) => {
    const next = Math.max(0, (current ?? 0) + delta)
    return next === 0 ? undefined : next
  }
  return {
    ...convo,
    kind: {
      ...convo.kind,
      joinRequestCount: bump(convo.kind.joinRequestCount),
      unreadJoinRequestCount: bump(convo.kind.unreadJoinRequestCount),
    },
    rev,
  }
}

function moveConvoToTopInRequests(
  updatedConvo: ChatBskyConvoDefs.ConvoView,
  old: ConvoRequestListQueryData | undefined,
): ConvoRequestListQueryData | undefined {
  if (!old) return old
  const typedConvo: ConvoRequestListQueryData['pages'][number]['requests'][number] =
    {
      $type: 'chat.bsky.convo.defs#convoView',
      ...updatedConvo,
    }
  return {
    ...old,
    pages: old.pages.map((page, i) => {
      const filtered = page.requests.filter(
        item =>
          !ChatBskyConvoDefs.isConvoView(item) || item.id !== updatedConvo.id,
      )
      if (i === 0) {
        return {
          ...page,
          requests: [typedConvo, ...filtered],
        }
      }
      return {...page, requests: filtered}
    }),
  }
}

function removeMemberFromConvoView(
  convo: ChatBskyConvoDefs.ConvoView,
  did: string,
  rev: string,
  alreadyRemovedMember: boolean,
): ChatBskyConvoDefs.ConvoView {
  // Member add/remove/join/leave events are only meaningful for group convos.
  if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) return convo
  const nextMembers = convo.members.filter(m => m.did !== did)
  return {
    ...convo,
    rev,
    members: nextMembers,
    kind: {
      ...convo.kind,
      memberCount: alreadyRemovedMember
        ? convo.kind.memberCount
        : Math.max(0, convo.kind.memberCount - 1),
    },
  }
}

function addMemberToConvoView(
  convo: ChatBskyConvoDefs.ConvoView,
  member: ChatBskyActorDefs.ProfileViewBasic,
  rev: string,
  alreadyKnownMember: boolean,
): ChatBskyConvoDefs.ConvoView {
  // Member add/remove/join/leave events are only meaningful for group convos.
  if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) return convo
  const alreadyInCuratedList = convo.members.some(m => m.did === member.did)
  const nextMembers = alreadyInCuratedList
    ? convo.members
    : convo.members.concat(member)
  return {
    ...convo,
    rev,
    members: nextMembers,
    kind: {
      ...convo.kind,
      memberCount: alreadyKnownMember
        ? convo.kind.memberCount
        : convo.kind.memberCount + 1,
    },
  }
}

export function optimisticDelete(chatId: string, old?: ConvoListQueryData) {
  if (!old) return old

  return {
    ...old,
    pages: old.pages.map(page => ({
      ...page,
      convos: page.convos.filter(convo => chatId !== convo.id),
    })),
  }
}

export function getConvoFromQueryData(chatId: string, old: ConvoListQueryData) {
  for (const page of old.pages) {
    for (const convo of page.convos) {
      if (convo.id === chatId) {
        return convo
      }
    }
  }
  return null
}

export function* findAllProfilesInQueryData(
  queryClient: QueryClient,
  did: string,
) {
  const queryDatas = queryClient.getQueriesData<
    InfiniteData<ChatBskyConvoListConvos.OutputSchema>
  >({
    queryKey: [RQKEY_ROOT],
  })
  for (const [_queryKey, queryData] of queryDatas) {
    if (!queryData?.pages) {
      continue
    }

    for (const page of queryData.pages) {
      for (const convo of page.convos) {
        for (const member of convo.members) {
          if (member.did === did) {
            yield member
          }
        }
      }
    }
  }
}
