/*
  This file draws heavily from https://github.com/phoenixframework/phoenix/blob/d344ec0a732ab4ee204215b31de69cf4be72e3bf/assets/js/phoenix/presence.js
  License: https://github.com/phoenixframework/phoenix/blob/d344ec0a732ab4ee204215b31de69cf4be72e3bf/LICENSE.md
*/

import type { PresenceOpts, PresenceOnJoinCallback, PresenceOnLeaveCallback } from 'phoenix'
import type RealtimeChannel from './RealtimeChannel'

type Presence<T extends { [key: string]: any } = {}> = {
  presence_ref: string
} & T

export type RealtimePresenceState<T extends { [key: string]: any } = {}> = {
  [key: string]: Presence<T>[]
}

export type RealtimePresenceJoinPayload<T extends { [key: string]: any }> = {
  event: `${REALTIME_PRESENCE_LISTEN_EVENTS.JOIN}`
  key: string
  currentPresences: Presence<T>[]
  newPresences: Presence<T>[]
}

export type RealtimePresenceLeavePayload<T extends { [key: string]: any }> = {
  event: `${REALTIME_PRESENCE_LISTEN_EVENTS.LEAVE}`
  key: string
  currentPresences: Presence<T>[]
  leftPresences: Presence<T>[]
}

export enum REALTIME_PRESENCE_LISTEN_EVENTS {
  SYNC = 'sync',
  JOIN = 'join',
  LEAVE = 'leave',
}

type PresenceDiff = {
  joins: RealtimePresenceState
  leaves: RealtimePresenceState
}

type RawPresenceState = {
  [key: string]: {
    metas: {
      phx_ref?: string
      phx_ref_prev?: string
      [key: string]: any
    }[]
  }
}

type RawPresenceDiff = {
  joins: RawPresenceState
  leaves: RawPresenceState
}

type PresenceChooser<T> = (key: string, presences: Presence[]) => T

export default class RealtimePresence {
  state: RealtimePresenceState = {}
  pendingDiffs: RawPresenceDiff[] = []
  joinRef: string | null = null
  enabled: boolean = false
  caller: {
    onJoin: PresenceOnJoinCallback
    onLeave: PresenceOnLeaveCallback
    onSync: () => void
  } = {
    onJoin: () => {},
    onLeave: () => {},
    onSync: () => {},
  }

  /**
   * Creates a Presence helper that keeps the local presence state in sync with the server.
   *
   * @param channel - The realtime channel to bind to.
   * @param opts - Optional custom event names, e.g. `{ events: { state: 'state', diff: 'diff' } }`.
   *
   * @example
   * ```ts
   * const presence = new RealtimePresence(channel)
   *
   * channel.on('presence', ({ event, key }) => {
   *   console.log(`Presence ${event} on ${key}`)
   * })
   * ```
   */
  constructor(
    public channel: RealtimeChannel,
    opts?: PresenceOpts
  ) {
    const events = opts?.events || {
      state: 'presence_state',
      diff: 'presence_diff',
    }

    this.channel._on(events.state, {}, (newState: RawPresenceState) => {
      const { onJoin, onLeave, onSync } = this.caller

      this.joinRef = this.channel._joinRef()

      this.state = RealtimePresence.syncState(this.state, newState, onJoin, onLeave)

      this.pendingDiffs.forEach((diff) => {
        this.state = RealtimePresence.syncDiff(this.state, diff, onJoin, onLeave)
      })

      this.pendingDiffs = []

      onSync()
    })

    this.channel._on(events.diff, {}, (diff: RawPresenceDiff) => {
      const { onJoin, onLeave, onSync } = this.caller

      if (this.inPendingSyncState()) {
        this.pendingDiffs.push(diff)
      } else {
        this.state = RealtimePresence.syncDiff(this.state, diff, onJoin, onLeave)

        onSync()
      }
    })

    this.onJoin((key, currentPresences, newPresences) => {
      this.channel._trigger('presence', {
        event: 'join',
        key,
        currentPresences,
        newPresences,
      })
    })

    this.onLeave((key, currentPresences, leftPresences) => {
      this.channel._trigger('presence', {
        event: 'leave',
        key,
        currentPresences,
        leftPresences,
      })
    })

    this.onSync(() => {
      this.channel._trigger('presence', { event: 'sync' })
    })
  }

  /**
   * Used to sync the list of presences on the server with the
   * client's state.
   *
   * An optional `onJoin` and `onLeave` callback can be provided to
   * react to changes in the client's local presences across
   * disconnects and reconnects with the server.
   *
   * @internal
   */
  private static syncState(
    currentState: RealtimePresenceState,
    newState: RawPresenceState | RealtimePresenceState,
    onJoin: PresenceOnJoinCallback,
    onLeave: PresenceOnLeaveCallback
  ): RealtimePresenceState {
    const state = this.cloneDeep(currentState)
    const transformedState = this.transformState(newState)
    const joins: RealtimePresenceState = {}
    const leaves: RealtimePresenceState = {}

    this.map(state, (key: string, presences: Presence[]) => {
      if (!transformedState[key]) {
        leaves[key] = presences
      }
    })

    this.map(transformedState, (key, newPresences: Presence[]) => {
      const currentPresences: Presence[] = state[key]

      if (currentPresences) {
        const newPresenceRefs = newPresences.map((m: Presence) => m.presence_ref)
        const curPresenceRefs = currentPresences.map((m: Presence) => m.presence_ref)
        const joinedPresences: Presence[] = newPresences.filter(
          (m: Presence) => curPresenceRefs.indexOf(m.presence_ref) < 0
        )
        const leftPresences: Presence[] = currentPresences.filter(
          (m: Presence) => newPresenceRefs.indexOf(m.presence_ref) < 0
        )

        if (joinedPresences.length > 0) {
          joins[key] = joinedPresences
        }

        if (leftPresences.length > 0) {
          leaves[key] = leftPresences
        }
      } else {
        joins[key] = newPresences
      }
    })

    return this.syncDiff(state, { joins, leaves }, onJoin, onLeave)
  }

  /**
   * Used to sync a diff of presence join and leave events from the
   * server, as they happen.
   *
   * Like `syncState`, `syncDiff` accepts optional `onJoin` and
   * `onLeave` callbacks to react to a user joining or leaving from a
   * device.
   *
   * @internal
   */
  private static syncDiff(
    state: RealtimePresenceState,
    diff: RawPresenceDiff | PresenceDiff,
    onJoin: PresenceOnJoinCallback,
    onLeave: PresenceOnLeaveCallback
  ): RealtimePresenceState {
    const { joins, leaves } = {
      joins: this.transformState(diff.joins),
      leaves: this.transformState(diff.leaves),
    }

    if (!onJoin) {
      onJoin = () => {}
    }

    if (!onLeave) {
      onLeave = () => {}
    }

    this.map(joins, (key, newPresences: Presence[]) => {
      const currentPresences: Presence[] = state[key] ?? []
      state[key] = this.cloneDeep(newPresences)

      if (currentPresences.length > 0) {
        const joinedPresenceRefs = state[key].map((m: Presence) => m.presence_ref)
        const curPresences: Presence[] = currentPresences.filter(
          (m: Presence) => joinedPresenceRefs.indexOf(m.presence_ref) < 0
        )

        state[key].unshift(...curPresences)
      }

      onJoin(key, currentPresences, newPresences)
    })

    this.map(leaves, (key, leftPresences: Presence[]) => {
      let currentPresences: Presence[] = state[key]

      if (!currentPresences) return

      const presenceRefsToRemove = leftPresences.map((m: Presence) => m.presence_ref)
      currentPresences = currentPresences.filter(
        (m: Presence) => presenceRefsToRemove.indexOf(m.presence_ref) < 0
      )

      state[key] = currentPresences

      onLeave(key, currentPresences, leftPresences)

      if (currentPresences.length === 0) delete state[key]
    })

    return state
  }

  /** @internal */
  private static map<T = any>(obj: RealtimePresenceState, func: PresenceChooser<T>): T[] {
    return Object.getOwnPropertyNames(obj).map((key) => func(key, obj[key]))
  }

  /**
   * Remove 'metas' key
   * Change 'phx_ref' to 'presence_ref'
   * Remove 'phx_ref' and 'phx_ref_prev'
   *
   * @example
   * // returns {
   *  abc123: [
   *    { presence_ref: '2', user_id: 1 },
   *    { presence_ref: '3', user_id: 2 }
   *  ]
   * }
   * RealtimePresence.transformState({
   *  abc123: {
   *    metas: [
   *      { phx_ref: '2', phx_ref_prev: '1' user_id: 1 },
   *      { phx_ref: '3', user_id: 2 }
   *    ]
   *  }
   * })
   *
   * @internal
   */
  private static transformState(
    state: RawPresenceState | RealtimePresenceState
  ): RealtimePresenceState {
    state = this.cloneDeep(state)

    return Object.getOwnPropertyNames(state).reduce((newState, key) => {
      const presences = state[key]

      if ('metas' in presences) {
        newState[key] = presences.metas.map((presence) => {
          presence['presence_ref'] = presence['phx_ref']

          delete presence['phx_ref']
          delete presence['phx_ref_prev']

          return presence
        }) as Presence[]
      } else {
        newState[key] = presences
      }

      return newState
    }, {} as RealtimePresenceState)
  }

  /** @internal */
  private static cloneDeep(obj: { [key: string]: any }) {
    return JSON.parse(JSON.stringify(obj))
  }

  /** @internal */
  private onJoin(callback: PresenceOnJoinCallback): void {
    this.caller.onJoin = callback
  }

  /** @internal */
  private onLeave(callback: PresenceOnLeaveCallback): void {
    this.caller.onLeave = callback
  }

  /** @internal */
  private onSync(callback: () => void): void {
    this.caller.onSync = callback
  }

  /** @internal */
  private inPendingSyncState(): boolean {
    return !this.joinRef || this.joinRef !== this.channel._joinRef()
  }
}
