import {
  type $Typed,
  type AppBskyEmbedRecord,
  type AtpAgent,
  type ChatBskyActorDefs,
  ChatBskyConvoDefs,
  type ChatBskyConvoGetLog,
  type ChatBskyConvoSendMessage,
  type ChatBskyEmbedJoinLink,
  type ChatBskyGroupDefs,
} from '@atproto/api'
import {XRPCError} from '@atproto/api'
import {EventEmitter} from 'eventemitter3'
import {nanoid} from 'nanoid/non-secure'

import {networkRetry} from '#/lib/async/retry'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {
  isErrorMaybeAppPasswordPermissions,
  isNetworkError,
} from '#/lib/strings/errors'
import {Logger} from '#/logger'
import {
  isProfileShadowApplied,
  listenProfileShadowUpdate,
  mergeShadow,
  type ProfileShadow,
} from '#/state/cache/profile-shadow'
import {
  ACTIVE_POLL_INTERVAL,
  BACKGROUND_POLL_INTERVAL,
  INACTIVE_TIMEOUT,
  NETWORK_FAILURE_STATUSES,
} from '#/state/messages/convo/const'
import {
  type ConvoDispatch,
  ConvoDispatchEvent,
  type ConvoError,
  ConvoErrorCode,
  type ConvoEvent,
  type ConvoItem,
  ConvoItemError,
  type ConvoParams,
  type ConvoState,
  ConvoStatus,
} from '#/state/messages/convo/types'
import {type MessagesEventBus} from '#/state/messages/events/agent'
import {type MessagesEventBusError} from '#/state/messages/events/types'
import {
  type ConvoWithDetails,
  type GroupConvoMember,
  parseConvoView,
} from '#/components/dms/util'
import {IS_NATIVE} from '#/env'

const logger = Logger.create(Logger.Context.ConversationAgent)

export function isConvoItemMessage(
  item: ConvoItem,
): item is ConvoItem & {type: 'message'} {
  if (!item) return false
  return (
    item.type === 'message' ||
    item.type === 'deleted-message' ||
    item.type === 'pending-message'
  )
}

function toSystemMessageView(
  ev: ChatBskyConvoGetLog.OutputSchema['logs'][number],
): ChatBskyConvoDefs.SystemMessageView | null {
  const isSystem =
    ChatBskyConvoDefs.isLogAddMember(ev) ||
    ChatBskyConvoDefs.isLogRemoveMember(ev) ||
    ChatBskyConvoDefs.isLogMemberJoin(ev) ||
    ChatBskyConvoDefs.isLogMemberLeave(ev) ||
    ChatBskyConvoDefs.isLogLockConvo(ev) ||
    ChatBskyConvoDefs.isLogUnlockConvo(ev) ||
    ChatBskyConvoDefs.isLogLockConvoPermanently(ev) ||
    ChatBskyConvoDefs.isLogEditGroup(ev) ||
    ChatBskyConvoDefs.isLogCreateJoinLink(ev) ||
    ChatBskyConvoDefs.isLogEditJoinLink(ev) ||
    ChatBskyConvoDefs.isLogEnableJoinLink(ev) ||
    ChatBskyConvoDefs.isLogDisableJoinLink(ev)
  if (!isSystem) return null
  return ev.message
}

export class Convo {
  private id: string

  private agent: AtpAgent
  private events: MessagesEventBus
  private senderUserDid: string

  private status: ConvoStatus = ConvoStatus.Uninitialized
  private error: ConvoError | undefined
  private oldestRev: string | undefined | null = undefined
  private isFetchingHistory = false
  private latestRev: string | undefined = undefined

  private pastMessages: Map<
    string,
    | ChatBskyConvoDefs.MessageView
    | ChatBskyConvoDefs.DeletedMessageView
    | ChatBskyConvoDefs.SystemMessageView
  > = new Map()
  private newMessages: Map<
    string,
    | ChatBskyConvoDefs.MessageView
    | ChatBskyConvoDefs.DeletedMessageView
    | ChatBskyConvoDefs.SystemMessageView
  > = new Map()
  private pendingMessages: Map<
    string,
    {
      id: string
      message: ChatBskyConvoSendMessage.InputSchema['message']
      optimisticEmbedView?:
        | $Typed<AppBskyEmbedRecord.View>
        | $Typed<ChatBskyEmbedJoinLink.View>
    }
  > = new Map()
  private deletedMessages: Set<string> = new Set()
  private relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic> =
    new Map()
  /**
   * Accumulated profile shadow state, keyed by did. The profiles this agent
   * holds come from direct service fetches, so they are invisible to the
   * shadow cache's react-query scan. We keep our own overlay and re-apply it
   * whenever server data overwrites `relatedProfiles` or `convo.members`,
   * otherwise refreshes would revert optimistic state (e.g. a block) until
   * the server catches up.
   */
  private profileShadows: Map<string, Partial<ProfileShadow>> = new Map()

  private isProcessingPendingMessages = false

  private lastActiveTimestamp: number | undefined

  private emitter = new EventEmitter<{event: [ConvoEvent]}>()

  convoId: string
  convo: ConvoWithDetails | undefined
  sender: ChatBskyActorDefs.ProfileViewBasic | undefined
  recipients: ChatBskyActorDefs.ProfileViewBasic[] | undefined
  snapshot: ConvoState | undefined

  constructor(params: ConvoParams) {
    this.id = nanoid(3)
    this.convoId = params.convoId
    this.agent = params.agent
    this.events = params.events
    this.senderUserDid = params.agent.assertDid

    if (params.placeholderData) {
      this.setupPlaceholderData(params.placeholderData)
    }

    this.setConvo = this.setConvo.bind(this)
    this.subscribe = this.subscribe.bind(this)
    this.getSnapshot = this.getSnapshot.bind(this)
    this.sendMessage = this.sendMessage.bind(this)
    this.deleteMessage = this.deleteMessage.bind(this)
    this.fetchMessageHistory = this.fetchMessageHistory.bind(this)
    this.ingestFirehose = this.ingestFirehose.bind(this)
    this.onFirehoseConnect = this.onFirehoseConnect.bind(this)
    this.onFirehoseError = this.onFirehoseError.bind(this)
    this.markConvoAccepted = this.markConvoAccepted.bind(this)
    this.addReaction = this.addReaction.bind(this)
    this.removeReaction = this.removeReaction.bind(this)
    this.updateGroupName = this.updateGroupName.bind(this)
    this.updateGroupMembers = this.updateGroupMembers.bind(this)
    this.updateJoinLink = this.updateJoinLink.bind(this)
    this.updateLockStatus = this.updateLockStatus.bind(this)
  }

  private commit() {
    this.snapshot = undefined
    this.subscribers.forEach(subscriber => subscriber())
  }

  private subscribers: (() => void)[] = []

  subscribe(subscriber: () => void) {
    if (this.subscribers.length === 0) {
      this.cleanupProfileShadowListener = listenProfileShadowUpdate(
        ({did, shadow}) => {
          this.mergeProfileShadow(did, shadow)
        },
      )
      this.init()
    }

    this.subscribers.push(subscriber)

    return () => {
      this.subscribers = this.subscribers.filter(s => s !== subscriber)
      if (this.subscribers.length === 0) {
        this.cleanupProfileShadowListener?.()
        this.cleanupProfileShadowListener = undefined
        this.suspend()
      }
    }
  }

  private cleanupProfileShadowListener: (() => void) | undefined

  getSnapshot(): ConvoState {
    if (!this.snapshot) this.snapshot = this.generateSnapshot()
    // logger.debug('snapshotted', {})
    return this.snapshot
  }

  private generateSnapshot(): ConvoState {
    const shared = {
      isFetchingHistory: this.isFetchingHistory,
      // Explicit null check since the value is initially undefined.
      hasAllHistory: this.oldestRev === null,
    }

    const methods = {
      deleteMessage: this.deleteMessage,
      sendMessage: this.sendMessage,
      fetchMessageHistory: this.fetchMessageHistory,
      markConvoAccepted: this.markConvoAccepted,
      addReaction: this.addReaction,
      removeReaction: this.removeReaction,
    }

    const emptyMethods = {
      deleteMessage: undefined,
      sendMessage: undefined,
      fetchMessageHistory: undefined,
      markConvoAccepted: undefined,
      addReaction: undefined,
      removeReaction: undefined,
    }

    switch (this.status) {
      case ConvoStatus.Initializing: {
        return {
          status: ConvoStatus.Initializing,
          items: [],
          convo: this.convo,
          error: undefined,
          ...shared,
          ...emptyMethods,
        }
      }
      case ConvoStatus.Disabled: {
        return {
          status: this.status,
          items: this.getItems(),
          convo: this.convo!,
          relatedProfiles: this.relatedProfiles,
          error: undefined,
          ...shared,
          ...methods,
        }
      }
      case ConvoStatus.Suspended: {
        return {
          status: this.status,
          items: this.getItems(),
          convo: this.convo!,
          relatedProfiles: this.relatedProfiles,
          error: undefined,
          ...shared,
          ...methods,
        }
      }
      case ConvoStatus.Backgrounded: {
        return {
          status: this.status,
          items: this.getItems(),
          convo: this.convo!,
          relatedProfiles: this.relatedProfiles,
          error: undefined,
          ...shared,
          ...methods,
        }
      }
      case ConvoStatus.Ready: {
        return {
          status: this.status,
          items: this.getItems(),
          convo: this.convo!,
          relatedProfiles: this.relatedProfiles,
          error: undefined,
          ...shared,
          ...methods,
        }
      }
      case ConvoStatus.Error: {
        return {
          status: ConvoStatus.Error,
          items: [],
          convo: undefined,
          error: this.error!,
          isFetchingHistory: false,
          hasAllHistory: false,
          ...emptyMethods,
        }
      }
      default: {
        return {
          status: ConvoStatus.Uninitialized,
          items: [],
          convo: this.convo,
          error: undefined,
          isFetchingHistory: false,
          // Explicit null check since the value is initially undefined.
          hasAllHistory: this.oldestRev === null,
          ...emptyMethods,
        }
      }
    }
  }

  dispatch(action: ConvoDispatch) {
    const prevStatus = this.status

    switch (this.status) {
      case ConvoStatus.Uninitialized: {
        switch (action.event) {
          case ConvoDispatchEvent.Init: {
            this.status = ConvoStatus.Initializing
            void this.setup()
            this.setupFirehose()
            this.requestPollInterval(ACTIVE_POLL_INTERVAL)
            break
          }
        }
        break
      }
      case ConvoStatus.Initializing: {
        switch (action.event) {
          case ConvoDispatchEvent.Ready: {
            this.status = ConvoStatus.Ready
            void this.fetchMessageHistory()
            break
          }
          case ConvoDispatchEvent.Background: {
            this.status = ConvoStatus.Backgrounded
            void this.fetchMessageHistory()
            this.requestPollInterval(BACKGROUND_POLL_INTERVAL)
            break
          }
          case ConvoDispatchEvent.Suspend: {
            this.status = ConvoStatus.Suspended
            this.cleanupFirehoseConnection?.()
            this.withdrawRequestedPollInterval()
            break
          }
          case ConvoDispatchEvent.Error: {
            this.status = ConvoStatus.Error
            this.error = action.payload
            this.cleanupFirehoseConnection?.()
            this.withdrawRequestedPollInterval()
            break
          }
          case ConvoDispatchEvent.Disable: {
            this.status = ConvoStatus.Disabled
            void this.fetchMessageHistory() // finish init
            this.cleanupFirehoseConnection?.()
            this.withdrawRequestedPollInterval()
            break
          }
        }
        break
      }
      case ConvoStatus.Ready: {
        switch (action.event) {
          case ConvoDispatchEvent.Resume: {
            void this.refreshConvo()
            this.requestPollInterval(ACTIVE_POLL_INTERVAL)
            break
          }
          case ConvoDispatchEvent.Background: {
            this.status = ConvoStatus.Backgrounded
            this.requestPollInterval(BACKGROUND_POLL_INTERVAL)
            break
          }
          case ConvoDispatchEvent.Suspend: {
            this.status = ConvoStatus.Suspended
            this.cleanupFirehoseConnection?.()
            this.withdrawRequestedPollInterval()
            break
          }
          case ConvoDispatchEvent.Error: {
            this.status = ConvoStatus.Error
            this.error = action.payload
            this.cleanupFirehoseConnection?.()
            this.withdrawRequestedPollInterval()
            break
          }
          case ConvoDispatchEvent.Disable: {
            this.status = ConvoStatus.Disabled
            this.cleanupFirehoseConnection?.()
            this.withdrawRequestedPollInterval()
            break
          }
        }
        break
      }
      case ConvoStatus.Backgrounded: {
        switch (action.event) {
          case ConvoDispatchEvent.Resume: {
            if (this.wasChatInactive()) {
              this.reset()
            } else {
              if (this.convo) {
                this.status = ConvoStatus.Ready
                void this.refreshConvo()
                this.maybeRecoverFromNetworkError()
              } else {
                this.status = ConvoStatus.Initializing
                void this.setup()
              }
              this.requestPollInterval(ACTIVE_POLL_INTERVAL)
            }
            break
          }
          case ConvoDispatchEvent.Suspend: {
            this.status = ConvoStatus.Suspended
            this.cleanupFirehoseConnection?.()
            this.withdrawRequestedPollInterval()
            break
          }
          case ConvoDispatchEvent.Error: {
            this.status = ConvoStatus.Error
            this.error = action.payload
            this.cleanupFirehoseConnection?.()
            this.withdrawRequestedPollInterval()
            break
          }
          case ConvoDispatchEvent.Disable: {
            this.status = ConvoStatus.Disabled
            this.cleanupFirehoseConnection?.()
            this.withdrawRequestedPollInterval()
            break
          }
        }
        break
      }
      case ConvoStatus.Suspended: {
        switch (action.event) {
          case ConvoDispatchEvent.Init: {
            this.reset()
            break
          }
          case ConvoDispatchEvent.Resume: {
            this.reset()
            break
          }
          case ConvoDispatchEvent.Error: {
            this.status = ConvoStatus.Error
            this.error = action.payload
            break
          }
          case ConvoDispatchEvent.Disable: {
            this.status = ConvoStatus.Disabled
            break
          }
        }
        break
      }
      case ConvoStatus.Error: {
        switch (action.event) {
          case ConvoDispatchEvent.Init: {
            this.reset()
            break
          }
          case ConvoDispatchEvent.Resume: {
            this.reset()
            break
          }
          case ConvoDispatchEvent.Suspend: {
            this.status = ConvoStatus.Suspended
            break
          }
          case ConvoDispatchEvent.Error: {
            this.status = ConvoStatus.Error
            this.error = action.payload
            break
          }
          case ConvoDispatchEvent.Disable: {
            this.status = ConvoStatus.Disabled
            break
          }
        }
        break
      }
      case ConvoStatus.Disabled: {
        // can't do anything
        break
      }
      default:
        break
    }

    logger.debug(`dispatch '${action.event}'`, {
      id: this.id,
      prev: prevStatus,
      next: this.status,
    })

    this.updateLastActiveTimestamp()
    this.commit()
  }

  private reset() {
    this.convo = undefined
    this.snapshot = undefined

    this.status = ConvoStatus.Uninitialized
    this.error = undefined
    this.oldestRev = undefined
    this.latestRev = undefined

    this.pastMessages = new Map()
    this.newMessages = new Map()
    this.pendingMessages = new Map()
    this.deletedMessages = new Set()
    this.relatedProfiles = new Map()
    // Shadow updates fired while suspended are missed, so the overlay may be
    // stale - drop it and trust the from-scratch refetch.
    this.profileShadows = new Map()

    this.pendingMessageFailure = null
    this.fetchMessageHistoryError = undefined
    this.firehoseError = undefined

    this.dispatch({event: ConvoDispatchEvent.Init})
  }

  maybeRecoverFromNetworkError() {
    if (this.firehoseError) {
      this.firehoseError.retry()
      this.firehoseError = undefined
      this.commit()
    } else {
      void this.batchRetryPendingMessages()
    }

    if (this.fetchMessageHistoryError) {
      this.fetchMessageHistoryError.retry()
      this.fetchMessageHistoryError = undefined
      this.commit()
    }
  }

  private setConvo(convo: ChatBskyConvoDefs.ConvoView) {
    this.convo = parseConvoView(convo, this.senderUserDid) ?? this.convo
    if (this.convo) {
      for (const member of this.convo.members) {
        this.relatedProfiles.set(member.did, member)
      }
    }
    this.applyProfileShadows()
  }

  private updateConvo(convo: Partial<ChatBskyConvoDefs.ConvoView>) {
    if (this.convo) {
      this.convo =
        parseConvoView({...this.convo.view, ...convo}, this.senderUserDid) ??
        this.convo
      for (const member of this.convo.members) {
        this.relatedProfiles.set(member.did, member)
      }
      this.applyProfileShadows()
    }
  }

  /**
   * Initialises the convo with placeholder data, if provided. We still refetch it before rendering the convo,
   * but this allows us to render the convo header immediately.
   */
  private setupPlaceholderData(
    data: NonNullable<ConvoParams['placeholderData']>,
  ) {
    this.setConvo(data.convo)
  }

  private async setup() {
    try {
      const {convo} = await this.fetchConvo()

      this.setConvo(convo)

      /*
       * Some validation prior to `Ready` status
       */
      if (!this.convo) {
        throw new Error('could not find convo')
      }

      const self = this.convo.members.find(m => m.did === this.senderUserDid)

      if (!self) {
        throw new Error('could not find self in convo')
      }

      const userIsDisabled = Boolean(self.chatDisabled)

      if (userIsDisabled) {
        this.dispatch({event: ConvoDispatchEvent.Disable})
      } else {
        this.dispatch({event: ConvoDispatchEvent.Ready})
      }
    } catch (err) {
      const e = err as Error
      if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
        logger.error('setup failed', {
          safeMessage: e.message,
        })
      }

      this.dispatch({
        event: ConvoDispatchEvent.Error,
        payload: {
          exception: e,
          code: ConvoErrorCode.InitFailed,
          retry: () => {
            this.reset()
          },
        },
      })
      this.commit()
    }
  }

  init() {
    this.dispatch({event: ConvoDispatchEvent.Init})
  }

  resume() {
    this.dispatch({event: ConvoDispatchEvent.Resume})
  }

  background() {
    this.dispatch({event: ConvoDispatchEvent.Background})
  }

  suspend() {
    this.dispatch({event: ConvoDispatchEvent.Suspend})
  }

  /**
   * Called on any state transition, like when the chat is backgrounded. This
   * value is then checked on background -> foreground transitions.
   */
  private updateLastActiveTimestamp() {
    this.lastActiveTimestamp = Date.now()
  }
  private wasChatInactive() {
    if (!this.lastActiveTimestamp) return true
    return Date.now() - this.lastActiveTimestamp > INACTIVE_TIMEOUT
  }

  private requestedPollInterval: (() => void) | undefined
  private requestPollInterval(interval: number) {
    this.withdrawRequestedPollInterval()
    this.requestedPollInterval = this.events.requestPollInterval(interval)
  }
  private withdrawRequestedPollInterval() {
    if (this.requestedPollInterval) {
      this.requestedPollInterval()
    }
  }

  private pendingFetchConvo:
    | Promise<{convo: ChatBskyConvoDefs.ConvoView}>
    | undefined
  async fetchConvo() {
    if (this.pendingFetchConvo) return this.pendingFetchConvo

    // non-blocking
    void this.fetchMemberList()

    this.pendingFetchConvo = (async () => {
      try {
        const response = await networkRetry(2, () => {
          return this.agent.chat.bsky.convo.getConvo(
            {convoId: this.convoId},
            {headers: DM_SERVICE_HEADERS},
          )
        })

        const convo = response.data.convo

        return {
          convo,
        }
      } finally {
        this.pendingFetchConvo = undefined
      }
    })()

    return this.pendingFetchConvo
  }

  async refreshConvo() {
    try {
      void this.fetchMemberList()
      const {convo} = await this.fetchConvo()
      // throw new Error('UNCOMMENT TO TEST REFRESH FAILURE')
      this.setConvo(convo)
    } catch (err) {
      const e = err as Error
      if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
        logger.error(`failed to refresh convo`, {
          safeMessage: e.message,
        })
      }
    }
  }

  // purely for populating `this.relatedProfiles` - we do not pipe it
  // into the ConvoWithDetails. If you want to drive UI based on the member list,
  // use `useListConvoMembersQuery`
  // we shouldn't also block loading off of this - the UI should be resilient
  async fetchMemberList() {
    let cursor: string | undefined
    do {
      const result = await networkRetry(2, () => {
        return this.agent.chat.bsky.convo.getConvoMembers(
          {
            convoId: this.convoId,
            limit: 50,
            cursor,
          },
          {headers: DM_SERVICE_HEADERS},
        )
      })
      cursor = result.data.cursor

      for (const member of result.data.members) {
        this.relatedProfiles.set(member.did, member)
      }
    } while (cursor)
    this.applyProfileShadows()
  }

  private fetchMessageHistoryError: {retry: () => void} | undefined
  async fetchMessageHistory() {
    logger.debug('fetch message history', {})

    /*
     * If oldestRev is null, we've fetched all history.
     * Needs to explicitly check for `null` since this is initially `undefined`.
     */
    if (this.oldestRev === null) return

    /*
     * Don't fetch again if a fetch is already in progress
     */
    if (this.isFetchingHistory) return

    /*
     * If we've rendered a retry state for history fetching, exit. Upon retry,
     * this will be removed and we'll try again.
     */
    if (this.fetchMessageHistoryError) return

    try {
      this.isFetchingHistory = true
      this.commit()

      const nextCursor = this.oldestRev // for TS
      const response = await networkRetry(2, () => {
        return this.agent.chat.bsky.convo.getMessages(
          {
            cursor: nextCursor,
            convoId: this.convoId,
            limit: IS_NATIVE ? 30 : 60,
          },
          {headers: DM_SERVICE_HEADERS},
        )
      })
      const {cursor, messages, relatedProfiles} = response.data

      this.oldestRev = cursor ?? null

      if (relatedProfiles) {
        for (const profile of relatedProfiles) {
          this.relatedProfiles.set(profile.did, profile)
        }
        this.applyProfileShadows()
      }

      /*
       * If the response contained fewer messages than the limit, we know
       * there are no more pages, regardless of whether a cursor was returned.
       */
      if (messages.length < (IS_NATIVE ? 30 : 60)) {
        this.oldestRev = null
      }

      for (const message of messages) {
        if (
          ChatBskyConvoDefs.isMessageView(message) ||
          ChatBskyConvoDefs.isDeletedMessageView(message) ||
          ChatBskyConvoDefs.isSystemMessageView(message)
        ) {
          /*
           * If this message is already in new messages, it was added by the
           * firehose ingestion, and we can safely overwrite it. This trusts
           * the server on ordering, and keeps it in sync.
           */
          if (this.newMessages.has(message.id)) {
            this.newMessages.delete(message.id)
          }
          this.pastMessages.set(message.id, message)
        }
      }
    } catch (err) {
      const e = err as Error
      if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
        logger.error('failed to fetch message history', {
          safeMessage: e.message,
        })
      }

      this.fetchMessageHistoryError = {
        retry: () => {
          void this.fetchMessageHistory()
        },
      }
    } finally {
      this.isFetchingHistory = false
      this.commit()
    }
  }

  private cleanupFirehoseConnection: (() => void) | undefined
  private setupFirehose() {
    // remove old listeners, if exist
    this.cleanupFirehoseConnection?.()

    // reconnect
    this.cleanupFirehoseConnection = this.events.on(
      event => {
        switch (event.type) {
          case 'connect': {
            this.onFirehoseConnect()
            break
          }
          case 'error': {
            this.onFirehoseError(event.error)
            break
          }
          case 'logs': {
            this.ingestFirehose(event.logs)
            break
          }
        }
      },
      /*
       * This is VERY important — we only want events for this convo.
       */
      {convoId: this.convoId},
    )
  }

  private firehoseError: MessagesEventBusError | undefined

  onFirehoseConnect() {
    this.firehoseError = undefined
    void this.batchRetryPendingMessages()
    this.commit()
  }

  onFirehoseError(error?: MessagesEventBusError) {
    this.firehoseError = error
    this.commit()
  }

  ingestFirehose(events: ChatBskyConvoGetLog.OutputSchema['logs']) {
    let needsCommit = false

    for (const ev of events) {
      /*
       * If there's a rev, we should handle it. If there's not a rev, we don't
       * know what it is.
       */
      if ('rev' in ev && typeof ev.rev === 'string') {
        const isUninitialized = !this.latestRev
        const isNewEvent = this.latestRev && ev.rev > this.latestRev

        /*
         * We received an event prior to fetching any history, so we can safely
         * use this as the initial history cursor
         */
        if (this.oldestRev === undefined && isUninitialized) {
          this.oldestRev = ev.rev
        }

        /*
         * We only care about new events
         */
        if (isNewEvent || isUninitialized) {
          /*
           * Update rev regardless of if it's a ev type we care about or not
           */
          this.latestRev = ev.rev

          if ('relatedProfiles' in ev && Array.isArray(ev.relatedProfiles)) {
            for (const profile of ev.relatedProfiles) {
              this.relatedProfiles.set(profile.did, profile)
            }
            this.applyProfileShadows()
          }

          if (
            ChatBskyConvoDefs.isLogCreateMessage(ev) &&
            ChatBskyConvoDefs.isMessageView(ev.message)
          ) {
            /*
             * If this message is already in past messages, the initial
             * history fetch raced this log event and already returned it.
             * Update in place rather than inserting a duplicate into new
             * messages.
             */
            if (this.pastMessages.has(ev.message.id)) {
              this.pastMessages.set(ev.message.id, ev.message)
            } else {
              /**
               * If this message is already in new messages, it was added by our
               * sending logic, and is based on client-ordering. When we receive
               * the "committed" event from the log, we should replace this
               * reference and re-insert in order to respect the order we received
               * from the log.
               */
              if (this.newMessages.has(ev.message.id)) {
                this.newMessages.delete(ev.message.id)
              }
              this.newMessages.set(ev.message.id, ev.message)
            }
            needsCommit = true
          } else if (
            ChatBskyConvoDefs.isLogDeleteMessage(ev) &&
            ChatBskyConvoDefs.isDeletedMessageView(ev.message)
          ) {
            /*
             * Update if we have this in state. If we don't, don't worry about it.
             */
            if (
              this.pastMessages.has(ev.message.id) ||
              this.newMessages.has(ev.message.id)
            ) {
              this.pastMessages.delete(ev.message.id)
              this.newMessages.delete(ev.message.id)
              this.deletedMessages.delete(ev.message.id)
              needsCommit = true
            }
          } else if (
            (ChatBskyConvoDefs.isLogAddReaction(ev) ||
              ChatBskyConvoDefs.isLogRemoveReaction(ev)) &&
            ChatBskyConvoDefs.isMessageView(ev.message)
          ) {
            /*
             * Update if we have this in state - replace message wholesale. If we don't, don't worry about it.
             */
            if (this.pastMessages.has(ev.message.id)) {
              this.pastMessages.set(ev.message.id, ev.message)
              needsCommit = true
            }
            if (this.newMessages.has(ev.message.id)) {
              this.newMessages.set(ev.message.id, ev.message)
              needsCommit = true
            }
          } else {
            const systemView = toSystemMessageView(ev)
            if (systemView) {
              // same as above: avoid duplicating if history fetch won the race
              if (this.pastMessages.has(systemView.id)) {
                this.pastMessages.set(systemView.id, systemView)
              } else {
                this.newMessages.set(systemView.id, systemView)
              }
              needsCommit = true
            }
          }
        }
      }
    }

    if (needsCommit) {
      this.commit()
    }
  }

  private pendingMessageFailure: 'recoverable' | 'unrecoverable' | null = null

  sendMessage(
    message: ChatBskyConvoSendMessage.InputSchema['message'],
    optimisticEmbedView?:
      | $Typed<AppBskyEmbedRecord.View>
      | $Typed<ChatBskyEmbedJoinLink.View>,
  ) {
    // Ignore empty messages for now since they have no other purpose atm
    if (!message.text.trim() && !message.embed) return

    logger.debug('send message', {})

    const tempId = nanoid()

    this.pendingMessageFailure = null
    this.pendingMessages.set(tempId, {
      id: tempId,
      message,
      optimisticEmbedView,
    })
    if (this.convo?.view.status === 'request') {
      this.updateConvo({
        status: 'accepted',
      })
    }
    this.commit()

    if (!this.isProcessingPendingMessages && !this.pendingMessageFailure) {
      void this.processPendingMessages()
    }
  }

  markConvoAccepted() {
    this.updateConvo({
      status: 'accepted',
    })

    this.commit()
  }

  updateMuted(muted: boolean) {
    this.updateConvo({
      muted,
    })

    this.commit()
  }

  updateGroupName(name: string) {
    if (this.convo?.kind !== 'group') {
      throw new Error('updateGroupName can only be called on group convo')
    }

    this.updateConvo({
      kind: {
        ...this.convo.details,
        name,
      },
    })

    this.commit()
  }

  updateGroupMembers(members: GroupConvoMember[], memberCount: number) {
    if (this.convo?.kind !== 'group') {
      throw new Error('updateGroupMembers can only be called on group convo')
    }

    this.updateConvo({
      members,
      kind: {
        ...this.convo.details,
        memberCount,
      },
    })

    this.commit()
  }

  updateJoinLink(joinLink: ChatBskyGroupDefs.JoinLinkView | undefined) {
    if (this.convo?.kind !== 'group') {
      throw new Error('updateJoinLink can only be called on group convo')
    }

    this.updateConvo({
      kind: {
        ...this.convo.details,
        joinLink,
      },
    })

    this.commit()
  }

  updateLockStatus(
    lockStatus: ChatBskyConvoDefs.ConvoLockStatus,
    lockStatusModerationOverride: boolean,
  ) {
    if (this.convo?.kind !== 'group') {
      throw new Error('updateLockStatus can only be called on group convo')
    }

    this.updateConvo({
      kind: {
        ...this.convo.details,
        lockStatus,
        lockStatusModerationOverride,
      },
    })

    this.commit()
  }

  async processPendingMessages() {
    logger.debug(
      `processing messages (${this.pendingMessages.size} remaining)`,
      {},
    )

    const pendingMessage = Array.from(this.pendingMessages.values()).shift()

    /*
     * If there are no pending messages, we're done.
     */
    if (!pendingMessage) {
      this.isProcessingPendingMessages = false
      return
    }

    try {
      this.isProcessingPendingMessages = true

      const {id, message} = pendingMessage

      const response = await this.agent.chat.bsky.convo.sendMessage(
        {
          convoId: this.convoId,
          message,
        },
        {encoding: 'application/json', headers: DM_SERVICE_HEADERS},
      )
      const res = response.data

      // remove from queue
      this.pendingMessages.delete(id)

      /*
       * Insert into `newMessages` as soon as we have a real ID. That way, when
       * we get an event log back, we can replace in situ.
       */
      this.newMessages.set(res.id, {
        ...res,
        $type: 'chat.bsky.convo.defs#messageView',
      })
      // render new message state, prior to firehose
      this.commit()

      // continue queue processing
      await this.processPendingMessages()
    } catch (err) {
      const e = err as Error
      this.handleSendMessageFailure(e)
      this.isProcessingPendingMessages = false
    }
  }

  private handleSendMessageFailure(e: Error | XRPCError) {
    if (e instanceof XRPCError) {
      if (NETWORK_FAILURE_STATUSES.includes(e.status)) {
        this.pendingMessageFailure = 'recoverable'
      } else {
        this.pendingMessageFailure = 'unrecoverable'

        switch (e.message) {
          case 'block between recipient and sender':
            this.emitter.emit('event', {
              type: 'invalidate-block-state',
              accountDids: [
                this.senderUserDid,
                ...this.recipients!.map(r => r.did),
              ],
            })
            break
          case 'Account is disabled':
            this.dispatch({event: ConvoDispatchEvent.Disable})
            break
          case 'Convo not found':
          case 'Account does not exist':
          case 'recipient does not exist':
          case 'recipient requires incoming messages to come from someone they follow':
          case 'recipient has disabled incoming messages':
            break
          default:
            if (!isNetworkError(e)) {
              logger.warn(`handleSendMessageFailure could not handle error`, {
                status: e.status,
                message: e.message,
              })
            }
            break
        }
      }
    } else {
      this.pendingMessageFailure = 'unrecoverable'

      if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
        logger.error(`handleSendMessageFailure received unknown error`, {
          safeMessage: e.message,
        })
      }
    }

    this.commit()
  }

  async batchRetryPendingMessages() {
    if (this.pendingMessageFailure === null) return

    const messageArray = Array.from(this.pendingMessages.values())
    if (messageArray.length === 0) return

    this.pendingMessageFailure = null
    this.commit()

    logger.debug(
      `batch retrying ${this.pendingMessages.size} pending messages`,
      {},
    )

    try {
      const {data} = await this.agent.chat.bsky.convo.sendMessageBatch(
        {
          items: messageArray.map(({message}) => ({
            convoId: this.convoId,
            message,
          })),
        },
        {encoding: 'application/json', headers: DM_SERVICE_HEADERS},
      )
      const {items} = data

      /*
       * Insert into `newMessages` as soon as we have a real ID. That way, when
       * we get an event log back, we can replace in situ.
       */
      for (const item of items) {
        this.newMessages.set(item.id, {
          ...item,
          $type: 'chat.bsky.convo.defs#messageView',
        })
      }

      for (const pendingMessage of messageArray) {
        this.pendingMessages.delete(pendingMessage.id)
      }

      this.commit()

      logger.debug(`sent ${this.pendingMessages.size} pending messages`, {})
    } catch (err) {
      const e = err as Error
      this.handleSendMessageFailure(e)
    }
  }

  async deleteMessage(messageId: string) {
    logger.debug('delete message', {})

    this.deletedMessages.add(messageId)
    this.commit()

    try {
      await networkRetry(2, () => {
        return this.agent.chat.bsky.convo.deleteMessageForSelf(
          {
            convoId: this.convoId,
            messageId,
          },
          {encoding: 'application/json', headers: DM_SERVICE_HEADERS},
        )
      })
    } catch (err) {
      const e = err as Error
      if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
        logger.error(`failed to delete message`, {
          safeMessage: e.message,
        })
      }
      this.deletedMessages.delete(messageId)
      this.commit()
      throw e
    }
  }

  on(handler: (event: ConvoEvent) => void) {
    this.emitter.on('event', handler)

    return () => {
      this.emitter.off('event', handler)
    }
  }

  /*
   * Items in reverse order, since FlatList inverts
   */
  getItems(): ConvoItem[] {
    const items: ConvoItem[] = []

    this.pastMessages.forEach(m => {
      if (ChatBskyConvoDefs.isMessageView(m)) {
        items.unshift({
          type: 'message',
          key: m.id,
          message: m,
        })
      } else if (ChatBskyConvoDefs.isDeletedMessageView(m)) {
        items.unshift({
          type: 'deleted-message',
          key: m.id,
          message: m,
        })
      } else if (ChatBskyConvoDefs.isSystemMessageView(m)) {
        items.unshift({
          type: 'system-message',
          key: m.id,
          message: m,
        })
      }
    })

    if (this.fetchMessageHistoryError) {
      items.unshift({
        type: 'error',
        code: ConvoItemError.HistoryFailed,
        key: ConvoItemError.HistoryFailed,
        retry: () => {
          this.maybeRecoverFromNetworkError()
        },
      })
    }

    this.newMessages.forEach(m => {
      if (ChatBskyConvoDefs.isMessageView(m)) {
        items.push({
          type: 'message',
          key: m.id,
          message: m,
        })
      } else if (ChatBskyConvoDefs.isDeletedMessageView(m)) {
        items.push({
          type: 'deleted-message',
          key: m.id,
          message: m,
        })
      } else if (ChatBskyConvoDefs.isSystemMessageView(m)) {
        items.push({
          type: 'system-message',
          key: m.id,
          message: m,
        })
      }
    })

    this.pendingMessages.forEach(m => {
      items.push({
        type: 'pending-message',
        key: m.id,
        message: {
          ...m.message,
          embed: m.optimisticEmbedView,
          $type: 'chat.bsky.convo.defs#messageView',
          id: nanoid(),
          rev: '__fake__',
          sentAt: new Date().toISOString(),
          sender: {
            $type: 'chat.bsky.convo.defs#messageViewSender',
            did: this.senderUserDid,
          },
        },
        failed: this.pendingMessageFailure !== null,
        retry:
          this.pendingMessageFailure === 'recoverable'
            ? () => {
                this.maybeRecoverFromNetworkError()
              }
            : undefined,
      })
    })

    if (this.firehoseError) {
      items.push({
        type: 'error',
        code: ConvoItemError.FirehoseFailed,
        key: ConvoItemError.FirehoseFailed,
        retry: () => {
          this.firehoseError?.retry()
        },
      })
    }

    return items.filter(item => {
      if (isConvoItemMessage(item)) {
        return !this.deletedMessages.has(item.message.id)
      }
      return true
    })
  }

  /**
   * Add an emoji reaction to a message
   *
   * @param messageId - the id of the message to add the reaction to
   * @param emoji - must be one grapheme
   */
  async addReaction(messageId: string, emoji: string) {
    const optimisticReaction = {
      value: emoji,
      sender: {did: this.senderUserDid},
      createdAt: new Date().toISOString(),
    }
    let restore: null | (() => void) = null
    if (this.pastMessages.has(messageId)) {
      const prevMessage = this.pastMessages.get(messageId)
      if (
        ChatBskyConvoDefs.isMessageView(prevMessage) &&
        // skip optimistic update if reaction already exists
        !prevMessage.reactions?.find(
          reaction =>
            reaction.sender.did === this.senderUserDid &&
            reaction.value === emoji,
        )
      ) {
        if (prevMessage.reactions) {
          if (
            prevMessage.reactions.filter(
              reaction => reaction.sender.did === this.senderUserDid,
            ).length >= 5
          ) {
            throw new Error('Maximum reactions reached')
          }
        }
        this.pastMessages.set(messageId, {
          ...prevMessage,
          reactions: [...(prevMessage.reactions ?? []), optimisticReaction],
        })
        this.commit()
        restore = () => {
          this.pastMessages.set(messageId, prevMessage)
          this.commit()
        }
      }
    } else if (this.newMessages.has(messageId)) {
      const prevMessage = this.newMessages.get(messageId)
      if (
        ChatBskyConvoDefs.isMessageView(prevMessage) &&
        !prevMessage.reactions?.find(reaction => reaction.value === emoji)
      ) {
        if (prevMessage.reactions && prevMessage.reactions.length >= 5)
          throw new Error('Maximum reactions reached')
        this.newMessages.set(messageId, {
          ...prevMessage,
          reactions: [...(prevMessage.reactions ?? []), optimisticReaction],
        })
        this.commit()
        restore = () => {
          this.newMessages.set(messageId, prevMessage)
          this.commit()
        }
      }
    }

    try {
      logger.debug(`Adding reaction ${emoji} to message ${messageId}`)
      const {data} = await this.agent.chat.bsky.convo.addReaction(
        {messageId, value: emoji, convoId: this.convoId},
        {encoding: 'application/json', headers: DM_SERVICE_HEADERS},
      )
      if (ChatBskyConvoDefs.isMessageView(data.message)) {
        if (this.pastMessages.has(messageId)) {
          this.pastMessages.set(messageId, data.message)
          this.commit()
        } else if (this.newMessages.has(messageId)) {
          this.newMessages.set(messageId, data.message)
          this.commit()
        }
      }
    } catch (error) {
      if (restore) restore()
      throw error
    }
  }

  /*
   * Remove a reaction from a message.
   *
   * @param messageId - The ID of the message to remove the reaction from.
   * @param emoji - The emoji to remove.
   */
  async removeReaction(messageId: string, emoji: string) {
    let restore: null | (() => void) = null
    if (this.pastMessages.has(messageId)) {
      const prevMessage = this.pastMessages.get(messageId)
      if (ChatBskyConvoDefs.isMessageView(prevMessage)) {
        this.pastMessages.set(messageId, {
          ...prevMessage,
          reactions: prevMessage.reactions?.filter(
            reaction =>
              reaction.value !== emoji ||
              reaction.sender.did !== this.senderUserDid,
          ),
        })
        this.commit()
        restore = () => {
          this.pastMessages.set(messageId, prevMessage)
          this.commit()
        }
      }
    } else if (this.newMessages.has(messageId)) {
      const prevMessage = this.newMessages.get(messageId)
      if (ChatBskyConvoDefs.isMessageView(prevMessage)) {
        this.newMessages.set(messageId, {
          ...prevMessage,
          reactions: prevMessage.reactions?.filter(
            reaction =>
              reaction.value !== emoji ||
              reaction.sender.did !== this.senderUserDid,
          ),
        })
        this.commit()
        restore = () => {
          this.newMessages.set(messageId, prevMessage)
          this.commit()
        }
      }
    }

    try {
      logger.debug(`Removing reaction ${emoji} from message ${messageId}`)
      await this.agent.chat.bsky.convo.removeReaction(
        {messageId, value: emoji, convoId: this.convoId},
        {encoding: 'application/json', headers: DM_SERVICE_HEADERS},
      )
    } catch (error) {
      if (restore) restore()
      throw error
    }
  }

  mergeProfileShadow(did: string, shadow: Partial<ProfileShadow>) {
    // Accumulate even if the did isn't held yet - the profile may arrive
    // later via message history or the member list, and must get the shadow.
    this.profileShadows.set(did, {
      ...this.profileShadows.get(did),
      ...shadow,
    })
    if (this.applyProfileShadow(did, shadow)) {
      this.commit()
    }
  }

  /**
   * Re-applies all accumulated shadows. Must be called after any server data
   * lands in `relatedProfiles` or `this.convo`, since raw server profiles
   * would otherwise clobber optimistic state.
   */
  private applyProfileShadows() {
    for (const [did, shadow] of this.profileShadows) {
      this.applyProfileShadow(did, shadow)
    }
  }

  private applyProfileShadow(
    did: string,
    shadow: Partial<ProfileShadow>,
  ): boolean {
    let changed = false

    const related = this.relatedProfiles.get(did)
    if (related && !isProfileShadowApplied(related, shadow)) {
      this.relatedProfiles.set(did, mergeShadow(related, shadow))
      changed = true
    }

    if (this.convo) {
      const next = applyShadowToConvo(this.convo, did, shadow)
      if (next) {
        this.convo = next
        changed = true
      }
    }

    return changed
  }
}

/**
 * Returns a new convo with the shadow merged into the matching member (and
 * `primaryMember`, if it's the same profile), or null if nothing changed.
 */
function applyShadowToConvo(
  convo: ConvoWithDetails,
  did: string,
  shadow: Partial<ProfileShadow>,
): ConvoWithDetails | null {
  const i = convo.members.findIndex(m => m.did === did)
  if (i === -1) return null
  if (isProfileShadowApplied(convo.members[i], shadow)) return null

  // The branches are identical, but narrowing the union is what lets the
  // member arrays keep their per-kind types.
  if (convo.kind === 'group') {
    const members = convo.members.slice()
    members[i] = mergeShadow(members[i], shadow)
    return {
      ...convo,
      members,
      primaryMember:
        convo.primaryMember?.did === did ? members[i] : convo.primaryMember,
    }
  } else {
    const members = convo.members.slice()
    members[i] = mergeShadow(members[i], shadow)
    return {
      ...convo,
      members,
      primaryMember:
        convo.primaryMember.did === did ? members[i] : convo.primaryMember,
    }
  }
}
