import { PubNubSharedWorkerRequestEvents } from './custom-events/request-processing-event';
import {
  SubscriptionStateChangeEvent,
  SubscriptionStateInvalidateEvent,
} from './custom-events/subscription-state-event';
import { SubscribeRequest } from './subscribe-request';
import { Payload } from '../../../core/types/api';
import { PubNubClient } from './pubnub-client';
import { LeaveRequest } from './leave-request';
import { AccessToken } from './access-token';
import { leaveRequest } from './helpers';

export class SubscriptionStateChange {
  // --------------------------------------------------------
  // ---------------------- Information ---------------------
  // --------------------------------------------------------
  // region Information

  /**
   * Timestamp when batched changes has been modified before.
   */
  private static previousChangeTimestamp = 0;

  /**
   * Timestamp when subscription change has been enqueued.
   */
  private readonly _timestamp: number;
  // endregion

  // --------------------------------------------------------
  // --------------------- Constructor ----------------------
  // --------------------------------------------------------
  // region Constructor

  /**
   * Squash changes to exclude repetitive removal and addition of the same requests in a single change transaction.
   *
   * @param changes - List of changes that should be analyzed and squashed if possible.
   * @returns List of changes that doesn't have self-excluding change requests.
   */
  static squashedChanges(changes: SubscriptionStateChange[]) {
    if (!changes.length || changes.length === 1) return changes;

    // Sort changes in order in which they have been created (original `changes` is Set).
    const sortedChanges = changes.sort((lhc, rhc) => lhc.timestamp - rhc.timestamp);

    // Remove changes which first add and then remove same request (removes both addition and removal change entry).
    const requestAddChange = sortedChanges.filter((change) => !change.remove);
    requestAddChange.forEach((addChange) => {
      for (let idx = 0; idx < requestAddChange.length; idx++) {
        const change = requestAddChange[idx];
        if (!change.remove || change.request.identifier !== addChange.request.identifier) continue;
        sortedChanges.splice(idx, 1);
        sortedChanges.splice(sortedChanges.indexOf(addChange), 1);
        break;
      }
    });

    // Filter out old `add` change entries for the same client.
    const addChangePerClient: Record<string, SubscriptionStateChange> = {};
    requestAddChange.forEach((change) => {
      if (addChangePerClient[change.clientIdentifier]) {
        const changeIdx = sortedChanges.indexOf(change);
        if (changeIdx >= 0) sortedChanges.splice(changeIdx, 1);
      }
      addChangePerClient[change.clientIdentifier] = change;
    });

    return sortedChanges;
  }

  /**
   * Create subscription state batched change entry.
   *
   * @param clientIdentifier - Identifier of the {@link PubNubClient|PubNub} client that provided data for subscription
   * state change.
   * @param request - Request that should be used during batched subscription state modification.
   * @param remove - Whether provided {@link request} should be removed from `subscription` state or not.
   * @param sendLeave - Whether the {@link PubNubClient|client} should send a presence `leave` request for _free_
   * channels and groups or not.
   * @param [clientInvalidate=false] - Whether the `subscription` state change was caused by the
   * {@link PubNubClient|PubNub} client invalidation (unregister) or not.
   */
  constructor(
    public readonly clientIdentifier: string,
    public readonly request: SubscribeRequest,
    public readonly remove: boolean,
    public readonly sendLeave: boolean,
    public readonly clientInvalidate = false,
  ) {
    this._timestamp = this.timestampForChange();
  }
  // endregion

  // --------------------------------------------------------
  // --------------------- Properties -----------------------
  // --------------------------------------------------------
  // region Properties

  /**
   * Retrieve subscription change enqueue timestamp.
   *
   * @returns Subscription change enqueue timestamp.
   */
  get timestamp() {
    return this._timestamp;
  }
  // endregion

  // --------------------------------------------------------
  // ----------------------- Helpers ------------------------
  // --------------------------------------------------------
  // region Helpers

  /**
   * Serialize object for easier representation in logs.
   *
   * @returns Stringified `subscription` state object.
   */
  toString() {
    return `SubscriptionStateChange { timestamp: ${this.timestamp}, client: ${
      this.clientIdentifier
    }, request: ${this.request.toString()}, remove: ${this.remove ? "'remove'" : "'do not remove'"}, sendLeave: ${
      this.sendLeave ? "'send'" : "'do not send'"
    } }`;
  }

  /**
   * Serialize the object to a "typed" JSON string.
   *
   * @returns "Typed" JSON string.
   */
  toJSON() {
    return this.toString();
  }

  /**
   * Retrieve timestamp when change has been added to the batch.
   *
   * Non-repetitive timestamp required for proper changes sorting and identification of requests which has been removed
   * and added during single batch.
   *
   * @returns Non-repetitive timestamp even for burst changes.
   */
  private timestampForChange() {
    const timestamp = Date.now();

    if (timestamp <= SubscriptionStateChange.previousChangeTimestamp) {
      SubscriptionStateChange.previousChangeTimestamp++;
    } else SubscriptionStateChange.previousChangeTimestamp = timestamp;

    return SubscriptionStateChange.previousChangeTimestamp;
  }
  // endregion
}

/**
 * Aggregated subscription state.
 *
 * State object responsible for keeping in sync and optimization of `client`-provided {@link SubscribeRequest|requests}
 * by attaching them to already existing or new aggregated `service`-provided {@link SubscribeRequest|requests} to
 * reduce number of concurrent connections.
 */
export class SubscriptionState extends EventTarget {
  // --------------------------------------------------------
  // ---------------------- Information ---------------------
  // --------------------------------------------------------
  // region Information

  /**
   * Map of `client`-provided request identifiers to the subscription state listener abort controller.
   */
  private requestListenersAbort: Record<string, AbortController> = {};

  /**
   * Map of {@link PubNubClient|client} identifiers to their portion of data which affects subscription state.
   *
   * **Note:** This information is removed only with the {@link SubscriptionState.removeClient|removeClient} function
   * call.
   */
  private clientsState: Record<
    string,
    { channels: Set<string>; channelGroups: Set<string>; state?: Record<string, Payload> }
  > = {};

  /**
   * Map of explicitly set `userId` presence state.
   *
   * This is the final source of truth, which is applied on the aggregated `state` object.
   *
   * **Note:** This information is removed only with the {@link SubscriptionState.removeClient|removeClient} function
   * call.
   */
  private clientsPresenceState: Record<string, { update: number; state: Record<string, Payload> }> = {};

  /**
   * Map of {@link PubNubClient|client} to its {@link SubscribeRequest|request} that already received response/error
   * or has been canceled.
   */
  private lastCompletedRequest: Record<string, SubscribeRequest> = {};

  /**
   * List of identifiers of the {@link PubNubClient|PubNub} clients that should be invalidated when it will be
   * possible.
   */
  private clientsForInvalidation: string[] = [];

  /**
   * Map of {@link PubNubClient|client} to its {@link SubscribeRequest|request} which is pending for
   * `service`-provided {@link SubscribeRequest|request} processing results.
   */
  private requests: Record<string, SubscribeRequest> = {};

  /**
   * Aggregated/modified {@link SubscribeRequest|subscribe} requests which is used to call PubNub REST API.
   *
   * **Note:** There could be multiple requests to handle the situation when similar {@link PubNubClient|PubNub} clients
   * have subscriptions but with different timetokens (if requests have intersecting lists of channels and groups they
   * can be merged in the future if a response on a similar channel will be received and the same `timetoken` will be
   * used for continuation).
   */
  private serviceRequests: SubscribeRequest[] = [];

  /**
   * Cached list of channel groups used with recent aggregation service requests.
   *
   * **Note:** Set required to have the ability to identify which channel groups have been added/removed with recent
   * {@link SubscriptionStateChange|changes} list processing.
   */
  private channelGroups: Set<string> = new Set();

  /**
   * Cached list of channels used with recent aggregation service requests.
   *
   * **Note:** Set required to have the ability to identify which channels have been added/removed with recent
   * {@link SubscriptionStateChange|changes} list processing.
   */
  private channels: Set<string> = new Set();

  /**
   * Reference to the most suitable access token to access {@link SubscriptionState#channels|channels} and
   * {@link SubscriptionState#channelGroups|channelGroups}.
   */
  private accessToken?: AccessToken;
  // endregion

  // --------------------------------------------------------
  // --------------------- Constructor ----------------------
  // --------------------------------------------------------
  // region Constructor

  /**
   * Create subscription state management object.
   *
   * @param identifier -  Similar {@link SubscribeRequest|subscribe} requests aggregation identifier.
   */
  constructor(public readonly identifier: string) {
    super();
  }
  // endregion

  // --------------------------------------------------------
  // ---------------------- Accessors -----------------------
  // --------------------------------------------------------
  // region Accessors

  /**
   * Check whether subscription state contain state for specific {@link PubNubClient|PubNub} client.
   *
   * @param client - Reference to the {@link PubNubClient|PubNub} client for which state should be checked.
   * @returns `true` if there is state related to the {@link PubNubClient|client}.
   */
  hasStateForClient(client: PubNubClient) {
    return !!this.clientsState[client.identifier];
  }

  /**
   * Retrieve portion of subscription state which is unique for the {@link PubNubClient|client}.
   *
   * Function will return list of channels and groups which has been introduced by the client into the state (no other
   * clients have them).
   *
   * @param client - Reference to the {@link PubNubClient|PubNub} client for which unique elements should be retrieved
   * from the state.
   * @param channels - List of client's channels from subscription state.
   * @param channelGroups - List of client's channel groups from subscription state.
   * @returns State with channels and channel groups unique for the {@link PubNubClient|client}.
   */
  uniqueStateForClient(
    client: PubNubClient,
    channels: string[],
    channelGroups: string[],
  ): {
    channels: string[];
    channelGroups: string[];
  } {
    let uniqueChannelGroups = [...channelGroups];
    let uniqueChannels = [...channels];

    Object.entries(this.clientsState).forEach(([identifier, state]) => {
      if (identifier === client.identifier) return;
      uniqueChannelGroups = uniqueChannelGroups.filter((channelGroup) => !state.channelGroups.has(channelGroup));
      uniqueChannels = uniqueChannels.filter((channel) => !state.channels.has(channel));
    });

    return { channels: uniqueChannels, channelGroups: uniqueChannelGroups };
  }

  /**
   * Retrieve ongoing `client`-provided {@link SubscribeRequest|subscribe} request for the {@link PubNubClient|client}.
   *
   * @param client - Reference to the {@link PubNubClient|PubNub} client for which requests should be retrieved.
   * @param [invalidated=false] - Whether receiving request for invalidated (unregistered) {@link PubNubClient|PubNub}
   * client.
   * @returns A `client`-provided {@link SubscribeRequest|subscribe} request if it has been sent by
   * {@link PubNubClient|client}.
   */
  requestForClient(client: PubNubClient, invalidated = false): SubscribeRequest | undefined {
    return this.requests[client.identifier] ?? (invalidated ? this.lastCompletedRequest[client.identifier] : undefined);
  }
  // endregion

  // --------------------------------------------------------
  // --------------------- Aggregation ----------------------
  // --------------------------------------------------------
  // region Aggregation

  /**
   * Update access token for the client which should be used with next subscribe request.
   *
   * @param accessToken - Access token for next subscribe REST API call.
   */
  updateClientAccessToken(accessToken: AccessToken) {
    if (!this.accessToken || accessToken.isNewerThan(this.accessToken)) this.accessToken = accessToken;
  }

  /**
   * Update presence associated with `client`'s `userId` with channels and groups.
   * @param client - Reference to the {@link PubNubClient|PubNub} client for which `userId` presence state has been
   * changed.
   * @param state - Payloads that are associated with `userId` at specified (as keys) channels and groups.
   */
  updateClientPresenceState(client: PubNubClient, state: Record<string, Payload>) {
    const presenceState = this.clientsPresenceState[client.identifier];
    state ??= {};

    if (!presenceState) this.clientsPresenceState[client.identifier] = { update: Date.now(), state };
    else {
      Object.assign(presenceState.state, state);
      presenceState.update = Date.now();
    }
  }

  /**
   * Mark specific client as suitable for state invalidation when it will be appropriate.
   *
   * @param client - Reference to the {@link PubNubClient|PubNub} client which should be invalidated when will be
   * possible.
   */
  invalidateClient(client: PubNubClient) {
    if (this.clientsForInvalidation.includes(client.identifier)) return;
    this.clientsForInvalidation.push(client.identifier);
  }

  /**
   * Process batched subscription state change.
   *
   * @param changes - List of {@link SubscriptionStateChange|changes} made from requests received from the core
   * {@link PubNubClient|PubNub} client modules.
   */
  processChanges(changes: SubscriptionStateChange[]) {
    if (changes.length) changes = SubscriptionStateChange.squashedChanges(changes);
    if (!changes.length) return;

    let stateRefreshRequired = this.channelGroups.size === 0 && this.channels.size === 0;
    if (!stateRefreshRequired)
      stateRefreshRequired = changes.some((change) => change.remove || change.request.requireCachedStateReset);

    // Update list of PubNub client requests.
    const appliedRequests = this.applyChanges(changes);

    let stateChanges: ReturnType<typeof this.subscriptionStateChanges>;
    if (stateRefreshRequired) stateChanges = this.refreshInternalState();

    // Identify and dispatch subscription state change event with service requests for cancellation and start.
    this.handleSubscriptionStateChange(
      changes,
      stateChanges,
      appliedRequests.initial,
      appliedRequests.continuation,
      appliedRequests.removed,
    );

    // Check whether subscription state for all registered clients has been removed or not.
    if (!Object.keys(this.clientsState).length) this.dispatchEvent(new SubscriptionStateInvalidateEvent());
  }

  /**
   * Make changes to the internal state.
   *
   * Categorize changes by grouping requests (into `initial`, `continuation`, and `removed` groups) and update internal
   * state to reflect those changes (add/remove `client`-provided requests).
   *
   * @param changes - Final subscription state changes list.
   * @returns Subscribe request separated by different subscription loop stages.
   */
  private applyChanges(changes: SubscriptionStateChange[]): {
    initial: SubscribeRequest[];
    continuation: SubscribeRequest[];
    removed: SubscribeRequest[];
  } {
    const continuationRequests: SubscribeRequest[] = [];
    const initialRequests: SubscribeRequest[] = [];
    const removedRequests: SubscribeRequest[] = [];

    changes.forEach((change) => {
      const { remove, request, clientIdentifier, clientInvalidate } = change;

      if (!remove) {
        if (request.isInitialSubscribe) initialRequests.push(request);
        else continuationRequests.push(request);

        this.requests[clientIdentifier] = request;
        this.addListenersForRequestEvents(request);
      }

      if (remove && (!!this.requests[clientIdentifier] || !!this.lastCompletedRequest[clientIdentifier])) {
        if (clientInvalidate) {
          delete this.clientsPresenceState[clientIdentifier];
          delete this.lastCompletedRequest[clientIdentifier];
          delete this.clientsState[clientIdentifier];
        }

        delete this.requests[clientIdentifier];
        removedRequests.push(request);
      }
    });

    return { initial: initialRequests, continuation: continuationRequests, removed: removedRequests };
  }

  /**
   * Process changes in subscription state.
   *
   * @param changes - Final subscription state changes list.
   * @param stateChanges - Changes to the subscribed channels and groups in aggregated requests.
   * @param initialRequests - List of `client`-provided handshake {@link SubscribeRequest|subscribe} requests.
   * @param continuationRequests - List of `client`-provided subscription loop continuation
   * {@link SubscribeRequest|subscribe} requests.
   * @param removedRequests - List of `client`-provided {@link SubscribeRequest|subscribe} requests that should be
   * removed from the state.
   */
  private handleSubscriptionStateChange(
    changes: SubscriptionStateChange[],
    stateChanges: ReturnType<typeof this.subscriptionStateChanges>,
    initialRequests: SubscribeRequest[],
    continuationRequests: SubscribeRequest[],
    removedRequests: SubscribeRequest[],
  ) {
    // Retrieve list of active (not completed or canceled) `service`-provided requests.
    const serviceRequests = this.serviceRequests.filter((request) => !request.completed && !request.canceled);
    const requestsWithInitialResponse: { request: SubscribeRequest; timetoken: string; region: string }[] = [];
    const newContinuationServiceRequests: SubscribeRequest[] = [];
    const newInitialServiceRequests: SubscribeRequest[] = [];
    const cancelledServiceRequests: SubscribeRequest[] = [];
    let serviceLeaveRequest: LeaveRequest | undefined;

    // Identify token override for initial requests.
    let timetokenOverrideRefreshTimestamp: number | undefined;
    let decidedTimetokenRegionOverride: string | undefined;
    let decidedTimetokenOverride: string | undefined;

    const cancelServiceRequest = (serviceRequest: SubscribeRequest) => {
      cancelledServiceRequests.push(serviceRequest);

      const rest = serviceRequest
        .dependentRequests<SubscribeRequest>()
        .filter((dependantRequest) => !removedRequests.includes(dependantRequest));

      if (rest.length === 0) return;

      rest.forEach((dependantRequest) => (dependantRequest.serviceRequest = undefined));
      (serviceRequest.isInitialSubscribe ? initialRequests : continuationRequests).push(...rest);
    };

    // --------------------------------------------------
    // Identify ongoing `service`-provided requests which should be canceled because channels/channel groups has been
    // added/removed.
    //

    if (stateChanges) {
      if (stateChanges.channels.added || stateChanges.channelGroups.added) {
        for (const serviceRequest of serviceRequests) cancelServiceRequest(serviceRequest);
        serviceRequests.length = 0;
      } else if (stateChanges.channels.removed || stateChanges.channelGroups.removed) {
        const channelGroups = stateChanges.channelGroups.removed ?? [];
        const channels = stateChanges.channels.removed ?? [];

        for (let serviceRequestIdx = serviceRequests.length - 1; serviceRequestIdx >= 0; serviceRequestIdx--) {
          const serviceRequest = serviceRequests[serviceRequestIdx];
          if (!serviceRequest.hasAnyChannelsOrGroups(channels, channelGroups)) continue;

          cancelServiceRequest(serviceRequest);
          serviceRequests.splice(serviceRequestIdx, 1);
        }
      }
    }

    continuationRequests = this.squashSameClientRequests(continuationRequests);
    initialRequests = this.squashSameClientRequests(initialRequests);

    // --------------------------------------------------
    // Searching for optimal timetoken, which should be used for `service`-provided request (will override response with
    // new timetoken to make it possible to aggregate on next subscription loop with already ongoing `service`-provided
    // long-poll request).
    //

    (initialRequests.length ? continuationRequests : []).forEach((request) => {
      let shouldSetPreviousTimetoken = !decidedTimetokenOverride;
      if (!shouldSetPreviousTimetoken && request.timetoken !== '0') {
        if (decidedTimetokenOverride === '0') shouldSetPreviousTimetoken = true;
        else if (request.timetoken < decidedTimetokenOverride!)
          shouldSetPreviousTimetoken = request.creationDate > timetokenOverrideRefreshTimestamp!;
      }

      if (shouldSetPreviousTimetoken) {
        timetokenOverrideRefreshTimestamp = request.creationDate;
        decidedTimetokenOverride = request.timetoken;
        decidedTimetokenRegionOverride = request.region;
      }
    });

    // --------------------------------------------------
    // Try to attach `initial` and `continuation` `client`-provided requests to ongoing `service`-provided requests.
    //

    // Separate continuation requests by next subscription loop timetoken.
    // This prevents possibility that some subscribe requests will be aggregated into one with much newer timetoken and
    // miss messages as result.
    const continuationByTimetoken: Record<string, SubscribeRequest[]> = {};
    continuationRequests.forEach((request) => {
      if (!continuationByTimetoken[request.timetoken]) continuationByTimetoken[request.timetoken] = [request];
      else continuationByTimetoken[request.timetoken].push(request);
    });

    this.attachToServiceRequest(serviceRequests, initialRequests);
    for (let initialRequestIdx = initialRequests.length - 1; initialRequestIdx >= 0; initialRequestIdx--) {
      const request = initialRequests[initialRequestIdx];

      serviceRequests.forEach((serviceRequest) => {
        if (!request.isSubsetOf(serviceRequest) || serviceRequest.isInitialSubscribe) return;

        const { region, timetoken } = serviceRequest;
        requestsWithInitialResponse.push({ request, timetoken, region: region! });
        initialRequests.splice(initialRequestIdx, 1);
      });
    }
    if (initialRequests.length) {
      let aggregationRequests: SubscribeRequest[];

      if (continuationRequests.length) {
        decidedTimetokenOverride = Object.keys(continuationByTimetoken).sort().pop()!;
        const requests = continuationByTimetoken[decidedTimetokenOverride];
        decidedTimetokenRegionOverride = requests[0].region!;
        delete continuationByTimetoken[decidedTimetokenOverride];

        requests.forEach((request) => request.resetToInitialRequest());
        aggregationRequests = [...initialRequests, ...requests];
      } else aggregationRequests = initialRequests;

      // Create handshake service request (if possible)
      this.createAggregatedRequest(
        aggregationRequests,
        newInitialServiceRequests,
        decidedTimetokenOverride,
        decidedTimetokenRegionOverride,
      );
    }

    // Handle case when `initial` requests are supersets of continuation requests.
    Object.values(continuationByTimetoken).forEach((requestsByTimetoken) => {
      // Set `initial` `service`-provided requests as service requests for those continuation `client`-provided requests
      // that are a _subset_ of them.
      this.attachToServiceRequest(newInitialServiceRequests, requestsByTimetoken);
      // Set `ongoing` `service`-provided requests as service requests for those continuation `client`-provided requests
      // that are a _subset_ of them (if any still available).
      this.attachToServiceRequest(serviceRequests, requestsByTimetoken);

      // Create continuation `service`-provided request (if possible).
      this.createAggregatedRequest(requestsByTimetoken, newContinuationServiceRequests);
    });

    // --------------------------------------------------
    // Identify channels and groups for which presence `leave` should be generated.
    //

    const channelGroupsForLeave = new Set<string>();
    const channelsForLeave = new Set<string>();

    if (
      stateChanges &&
      removedRequests.length &&
      (stateChanges.channels.removed || stateChanges.channelGroups.removed)
    ) {
      const channelGroups = stateChanges.channelGroups.removed ?? [];
      const channels = stateChanges.channels.removed ?? [];
      const client = removedRequests[0].client;

      changes
        .filter((change) => change.remove && change.sendLeave)
        .forEach((change) => {
          const { channels: requestChannels, channelGroups: requestChannelsGroups } = change.request;
          channelGroups.forEach((group) => requestChannelsGroups.includes(group) && channelGroupsForLeave.add(group));
          channels.forEach((channel) => requestChannels.includes(channel) && channelsForLeave.add(channel));
        });
      serviceLeaveRequest = leaveRequest(client, [...channelsForLeave], [...channelGroupsForLeave]);
    }

    if (
      requestsWithInitialResponse.length ||
      newInitialServiceRequests.length ||
      newContinuationServiceRequests.length ||
      cancelledServiceRequests.length ||
      serviceLeaveRequest
    ) {
      this.dispatchEvent(
        new SubscriptionStateChangeEvent(
          requestsWithInitialResponse,
          [...newInitialServiceRequests, ...newContinuationServiceRequests],
          cancelledServiceRequests,
          serviceLeaveRequest,
        ),
      );
    }
  }

  /**
   * Refresh the internal subscription's state.
   */
  private refreshInternalState() {
    const channelGroups = new Set<string>();
    const channels = new Set<string>();

    // Aggregate channels and groups from active requests.
    Object.entries(this.requests).forEach(([clientIdentifier, request]) => {
      const presenceState = this.clientsPresenceState[clientIdentifier];
      const cachedPresenceStateKeys = presenceState ? Object.keys(presenceState.state) : [];
      const clientState = (this.clientsState[clientIdentifier] ??= { channels: new Set(), channelGroups: new Set() });

      request.channelGroups.forEach((group) => {
        clientState.channelGroups.add(group);
        channelGroups.add(group);
      });

      request.channels.forEach((channel) => {
        clientState.channels.add(channel);
        channels.add(channel);
      });

      if (presenceState && cachedPresenceStateKeys.length) {
        cachedPresenceStateKeys.forEach((key) => {
          if (!request.channels.includes(key) && !request.channelGroups.includes(key)) delete presenceState.state[key];
        });

        if (Object.keys(presenceState.state).length === 0) delete this.clientsPresenceState[clientIdentifier];
      }
    });

    const changes = this.subscriptionStateChanges(channels, channelGroups);

    // Update state information.
    this.channelGroups = channelGroups;
    this.channels = channels;

    // Identify most suitable access token.
    const sortedTokens = Object.values(this.requests)
      .flat()
      .filter((request) => !!request.accessToken)
      .map((request) => request.accessToken!)
      .sort(AccessToken.compare);
    if (sortedTokens && sortedTokens.length > 0) {
      const latestAccessToken = sortedTokens.pop();
      if (!this.accessToken || (latestAccessToken && latestAccessToken.isNewerThan(this.accessToken)))
        this.accessToken = latestAccessToken;
    }

    return changes;
  }
  // endregion

  // --------------------------------------------------------
  // ------------------- Event Handlers ---------------------
  // --------------------------------------------------------
  // region Event handlers

  private addListenersForRequestEvents(request: SubscribeRequest) {
    const abortController = (this.requestListenersAbort[request.identifier] = new AbortController());

    const cleanUpCallback = () => {
      this.removeListenersFromRequestEvents(request);
      if (!request.isServiceRequest) {
        if (this.requests[request.client.identifier]) {
          this.lastCompletedRequest[request.client.identifier] = request;
          delete this.requests[request.client.identifier];

          const clientIdx = this.clientsForInvalidation.indexOf(request.client.identifier);
          if (clientIdx > 0) {
            this.clientsForInvalidation.splice(clientIdx, 1);
            delete this.clientsPresenceState[request.client.identifier];
            delete this.lastCompletedRequest[request.client.identifier];
            delete this.clientsState[request.client.identifier];

            // Check whether subscription state for all registered clients has been removed or not.
            if (!Object.keys(this.clientsState).length) this.dispatchEvent(new SubscriptionStateInvalidateEvent());
          }
        }

        return;
      }

      const requestIdx = this.serviceRequests.indexOf(request);
      if (requestIdx >= 0) this.serviceRequests.splice(requestIdx, 1);
    };

    request.addEventListener(PubNubSharedWorkerRequestEvents.Success, cleanUpCallback, {
      signal: abortController.signal,
      once: true,
    });
    request.addEventListener(PubNubSharedWorkerRequestEvents.Error, cleanUpCallback, {
      signal: abortController.signal,
      once: true,
    });
    request.addEventListener(PubNubSharedWorkerRequestEvents.Canceled, cleanUpCallback, {
      signal: abortController.signal,
      once: true,
    });
  }

  private removeListenersFromRequestEvents(request: SubscribeRequest) {
    if (!this.requestListenersAbort[request.request.identifier]) return;

    this.requestListenersAbort[request.request.identifier].abort();
    delete this.requestListenersAbort[request.request.identifier];
  }
  // endregion

  // --------------------------------------------------------
  // ----------------------- Helpers ------------------------
  // --------------------------------------------------------
  // region Helpers

  /**
   * Identify changes to the channels and groups.
   *
   * @param channels - Set with channels which has been left after client requests list has been changed.
   * @param channelGroups - Set with channel groups which has been left after client requests list has been changed.
   * @returns Objects with names of channels and groups which has been added and removed from the current subscription
   * state.
   */
  private subscriptionStateChanges(
    channels: Set<string>,
    channelGroups: Set<string>,
  ):
    | {
        channelGroups: { removed?: string[]; added?: string[] };
        channels: { removed?: string[]; added?: string[] };
      }
    | undefined {
    const stateIsEmpty = this.channelGroups.size === 0 && this.channels.size === 0;
    const changes = { channelGroups: {}, channels: {} };
    const removedChannelGroups: string[] = [];
    const addedChannelGroups: string[] = [];
    const removedChannels: string[] = [];
    const addedChannels: string[] = [];

    for (const group of channelGroups) if (!this.channelGroups.has(group)) addedChannelGroups.push(group);
    for (const channel of channels) if (!this.channels.has(channel)) addedChannels.push(channel);

    if (!stateIsEmpty) {
      for (const group of this.channelGroups) if (!channelGroups.has(group)) removedChannelGroups.push(group);
      for (const channel of this.channels) if (!channels.has(channel)) removedChannels.push(channel);
    }

    if (addedChannels.length || removedChannels.length) {
      changes.channels = {
        ...(addedChannels.length ? { added: addedChannels } : {}),
        ...(removedChannels.length ? { removed: removedChannels } : {}),
      };
    }

    if (addedChannelGroups.length || removedChannelGroups.length) {
      changes.channelGroups = {
        ...(addedChannelGroups.length ? { added: addedChannelGroups } : {}),
        ...(removedChannelGroups.length ? { removed: removedChannelGroups } : {}),
      };
    }

    return Object.keys(changes.channelGroups).length === 0 && Object.keys(changes.channels).length === 0
      ? undefined
      : changes;
  }

  /**
   * Squash list of provided requests to represent latest request for each client.
   *
   * @param requests - List with potentially repetitive or multiple {@link SubscribeRequest|subscribe} requests for the
   * same {@link PubNubClient|PubNub} client.
   * @returns List of latest {@link SubscribeRequest|subscribe} requests for corresponding {@link PubNubClient|PubNub}
   * clients.
   */
  private squashSameClientRequests(requests: SubscribeRequest[]) {
    if (!requests.length || requests.length === 1) return requests;

    // Sort requests in order in which they have been created.
    const sortedRequests = requests.sort((lhr, rhr) => lhr.creationDate - rhr.creationDate);
    return Object.values(
      sortedRequests.reduce(
        (acc, value) => {
          acc[value.client.identifier] = value;
          return acc;
        },
        {} as Record<string, SubscribeRequest>,
      ),
    );
  }

  /**
   * Attach `client`-provided requests to the compatible ongoing `service`-provided requests.
   *
   * @param serviceRequests - List of ongoing `service`-provided subscribe requests.
   * @param requests - List of `client`-provided requests that should try to hook for service response using existing
   * ongoing `service`-provided requests.
   */
  private attachToServiceRequest(serviceRequests: SubscribeRequest[], requests: SubscribeRequest[]) {
    if (!serviceRequests.length || !requests.length) return;

    [...requests].forEach((request) => {
      for (const serviceRequest of serviceRequests) {
        // Check whether continuation request is actually a subset of the `service`-provided request or not.
        // Note: Second condition handled in the function which calls `attachToServiceRequest`.
        if (
          !!request.serviceRequest ||
          !request.isSubsetOf(serviceRequest) ||
          (request.isInitialSubscribe && !serviceRequest.isInitialSubscribe)
        )
          continue;

        // Attach to the matching `service`-provided request.
        request.serviceRequest = serviceRequest;

        // There is no need to aggregate attached request.
        const requestIdx = requests.indexOf(request);
        requests.splice(requestIdx, 1);
        break;
      }
    });
  }

  /**
   * Create aggregated `service`-provided {@link SubscribeRequest|subscribe} request.
   *
   * @param requests - List of `client`-provided {@link SubscribeRequest|subscribe} requests which should be sent with
   * as single `service`-provided request.
   * @param serviceRequests - List with created `service`-provided {@link SubscribeRequest|subscribe} requests.
   * @param timetokenOverride - Timetoken that should replace the initial response timetoken.
   * @param regionOverride - Timetoken region that should replace the initial response timetoken region.
   */
  private createAggregatedRequest(
    requests: SubscribeRequest[],
    serviceRequests: SubscribeRequest[],
    timetokenOverride?: string,
    regionOverride?: string,
  ) {
    if (requests.length === 0) return;
    let targetState: Record<string, Payload> | undefined;

    // Apply aggregated presence state in proper order.
    if ((requests[0].request.queryParameters!.tt ?? '0') === '0' && Object.keys(this.clientsPresenceState).length) {
      targetState = {};

      requests.forEach(
        (request) => Object.keys(request.state ?? {}).length && Object.assign(targetState!, request.state),
      );
      Object.values(this.clientsPresenceState)
        .sort((lhs, rhs) => lhs.update - rhs.update)
        .forEach(({ state }) => Object.assign(targetState!, state));
    }

    const serviceRequest = SubscribeRequest.fromRequests(
      requests,
      this.accessToken,
      timetokenOverride,
      regionOverride,
      targetState,
    );
    this.addListenersForRequestEvents(serviceRequest);

    requests.forEach((request) => (request.serviceRequest = serviceRequest));
    this.serviceRequests.push(serviceRequest);
    serviceRequests.push(serviceRequest);
  }
  // endregion
}
