/**
 * Subscription REST API module.
 */

import { createMalformedResponseError, createValidationError, PubNubError } from '../../errors/pubnub-error';
import { TransportResponse } from '../types/transport-response';
import { ICryptoModule } from '../interfaces/crypto-module';
import { encodeNames, messageFingerprint } from '../utils';
import * as Subscription from '../types/api/subscription';
import { AbstractRequest } from '../components/request';
import * as FileSharing from '../types/api/file-sharing';
import RequestOperation from '../constants/operations';
import * as AppContext from '../types/api/app-context';
import { KeySet, Payload, Query } from '../types/api';

// --------------------------------------------------------
// ---------------------- Defaults ------------------------
// --------------------------------------------------------
// region Defaults

/**
 * Whether should subscribe to channels / groups presence announcements or not.
 */
const WITH_PRESENCE = false;

// endregion

// --------------------------------------------------------
// ------------------------ Types -------------------------
// --------------------------------------------------------
// region Types

/**
 * PubNub-defined event types by payload.
 */
export enum PubNubEventType {
  /**
   * Presence change event.
   */
  Presence = -2,

  /**
   * Regular message event.
   *
   * **Note:** This is default type assigned for non-presence events if `e` field is missing.
   */
  Message = -1,

  /**
   * Signal data event.
   */
  Signal = 1,

  /**
   * App Context object event.
   */
  AppContext,

  /**
   * Message reaction event.
   */
  MessageAction,

  /**
   * Files event.
   */
  Files,
}

/**
 * Time cursor.
 *
 * Cursor used by subscription loop to identify point in time after which updates will be
 * delivered.
 */
type SubscriptionCursor = {
  /**
   * PubNub high-precision timestamp.
   *
   * Aside of specifying exact time of receiving data / event this token used to catchup /
   * follow on real-time updates.
   */
  t: string;

  /**
   * Data center region for which `timetoken` has been generated.
   */
  r: number;
};

// endregion

// region Presence service response
/**
 * Periodical presence change service response.
 */
type PresenceIntervalData = {
  /**
   * Periodical subscribed channels and groups presence change announcement.
   */
  action: 'interval';

  /**
   * Unix timestamp when presence event has been triggered.
   */
  timestamp: number;

  /**
   * The current occupancy after the presence change is updated.
   */
  occupancy: number;

  /**
   * The list of unique user identifiers that `joined` the channel since the last interval
   * presence update.
   */
  join?: string[];

  /**
   * The list of unique user identifiers that `left` the channel since the last interval
   * presence update.
   */
  leave?: string[];

  /**
   * The list of unique user identifiers that `timeout` the channel since the last interval
   * presence update.
   */
  timeout?: string[];

  /**
   * Indicates whether presence should be requested manually using {@link PubNubCore.hereNow hereNow()}
   * or not.
   *
   * Depending on from the presence activity, the resulting interval update can be too large to be
   * returned as a presence event with subscribe REST API response. The server will set this flag to
   * `true` in this case.
   */
  hereNowRefresh: boolean;

  /**
   * Indicates whether presence should be requested manually or not.
   *
   * **Warning:** This is internal property which will be removed after processing.
   *
   * @internal
   */
  here_now_refresh?: boolean;
};

/**
 * Subscribed user presence information change service response.
 */
type PresenceChangeData = {
  /**
   * Change if user's presence.
   *
   * User's presence may change between: `join`, `leave` and `timeout`.
   */
  action: 'join' | 'leave' | 'timeout';

  /**
   * Unix timestamp when presence event has been triggered.
   */
  timestamp: number;

  /**
   * Unique identification of the user for whom presence information changed.
   */
  uuid: string;

  /**
   * The current occupancy after the presence change is updated.
   */
  occupancy: number;

  /**
   * The user's state associated with the channel has been updated.
   *
   * @deprecated Use set state methods to specify associated user's data instead of passing to
   * subscribe.
   */
  data?: { [p: string]: Payload };
};

/**
 * Associated user presence state change service response.
 */
type PresenceStateChangeData = {
  /**
   * Subscribed user associated presence state change.
   */
  action: 'state-change';

  /**
   * Unix timestamp when presence event has been triggered.
   */
  timestamp: number;

  /**
   * Unique identification of the user for whom associated presence state has been changed.
   */
  uuid: string;

  /**
   * The user's state associated with the channel has been updated.
   */
  state: { [p: string]: Payload };
};

/**
 * Channel presence service response.
 */
export type PresenceData = PresenceIntervalData | PresenceChangeData | PresenceStateChangeData;
// endregion

// region Message Actions service response
/**
 * Message reaction change service response.
 */
export type MessageActionData = {
  /**
   * The type of event that happened during the message action update.
   *
   * Possible values are:
   * - `added` - action has been added to the message
   * - `removed` - action has been removed from message
   */
  event: 'added' | 'removed';

  /**
   * Information about message action for which update has been generated.
   */
  data: {
    /**
     * Timetoken of message for which action has been added / removed.
     */
    messageTimetoken: string;

    /**
     * Timetoken of message action which has been added / removed.
     */
    actionTimetoken: string;

    /**
     * Message action type.
     */
    type: string;

    /**
     * Value associated with message action {@link type}.
     */
    value: string;
  };

  /**
   * Name of service which generated update for message action.
   */
  source: string;

  /**
   * Version of service which generated update for message action.
   */
  version: string;
};
// endregion

// region App Context service data
/**
 * VSP Objects change events.
 */
type AppContextVSPEvents = 'updated' | 'removed';

/**
 * App Context Objects change events.
 */
type AppContextEvents = 'set' | 'delete';

/**
 * Common real-time App Context Object service response.
 */
type ObjectData<Event extends string, Type extends string, AppContextObject> = {
  /**
   * The type of event that happened during the object update.
   */
  event: Event;

  /**
   * App Context object type.
   */
  type: Type;

  /**
   * App Context object information.
   *
   * App Context object can be one of:
   * - `channel` / `space`
   * - `uuid` / `user`
   * - `membership`
   */
  data: AppContextObject;

  /**
   * Name of service which generated update for object.
   */
  source: string;

  /**
   * Version of service which generated update for object.
   */
  version: string;
};

/**
 * `Channel` object change real-time service response.
 */
type ChannelObjectData = ObjectData<
  AppContextEvents,
  'channel',
  AppContext.ChannelMetadataObject<AppContext.CustomData>
>;

/**
 * `Space` object change real-time service response.
 */
export type SpaceObjectData = ObjectData<
  AppContextVSPEvents,
  'space',
  AppContext.ChannelMetadataObject<AppContext.CustomData>
>;

/**
 * `Uuid` object change real-time service response.
 */
type UuidObjectData = ObjectData<AppContextEvents, 'uuid', AppContext.UUIDMetadataObject<AppContext.CustomData>>;

/**
 * `User` object change real-time service response.
 */
export type UserObjectData = ObjectData<
  AppContextVSPEvents,
  'user',
  AppContext.UUIDMetadataObject<AppContext.CustomData>
>;

/**
 * `Membership` object change real-time service response.
 */
type MembershipObjectData = ObjectData<
  AppContextEvents,
  'membership',
  Omit<AppContext.ObjectData<AppContext.CustomData>, 'id'> & {
    /**
     * User membership status.
     */
    status?: string;

    /**
     * User membership type.
     */
    type?: string;

    /**
     * `Uuid` object which has been used to create relationship with `channel`.
     */
    uuid: {
      /**
       * Unique `user` object identifier.
       */
      id: string;
    };

    /**
     * `Channel` object which has been used to create relationship with `uuid`.
     */
    channel: {
      /**
       * Unique `channel` object identifier.
       */
      id: string;
    };
  }
>;

/**
 * VSP `Membership` object change real-time service response.
 */
export type VSPMembershipObjectData = ObjectData<
  AppContextVSPEvents,
  'membership',
  Omit<AppContext.ObjectData<AppContext.CustomData>, 'id'> & {
    /**
     * `User` object which has been used to create relationship with `space`.
     */
    user: {
      /**
       * Unique `user` object identifier.
       */
      id: string;
    };

    /**
     * `Space` object which has been used to create relationship with `user`.
     */
    space: {
      /**
       * Unique `channel` object identifier.
       */
      id: string;
    };
  }
>;

/**
 * App Context service response.
 */
export type AppContextObjectData = ChannelObjectData | UuidObjectData | MembershipObjectData;
// endregion

// region File service response
/**
 * File service response.
 */
export type FileData = {
  /**
   * Message which has been associated with uploaded file.
   */
  message?: Payload;

  /**
   * Information about uploaded file.
   */
  file: {
    /**
     * Unique identifier of uploaded file.
     */
    id: string;

    /**
     * Actual name with which file has been stored.
     */
    name: string;
  };
};
// endregion

/**
 * Service response data envelope.
 *
 * Each entry from `m` list wrapped into this object.
 *
 * @internal
 */
type Envelope = {
  /**
   * Shard number on which the event has been stored.
   */
  a: string;

  /**
   * A numeric representation of enabled debug flags.
   */
  f: number;

  /**
   * PubNub defined event type.
   */
  e?: PubNubEventType;

  /**
   * Identifier of client which sent message (set only when Publish REST API endpoint called with
   * `uuid`).
   */
  i?: string;

  /**
   * Sequence number (set only when Publish REST API endpoint called with `seqn`).
   */
  s?: number;

  /**
   * Event "publish" time.
   *
   * This is the time when message has been received by {@link https://www.pubnub.com|PubNub} network.
   */
  p: SubscriptionCursor;

  /**
   * User-defined (local) "publish" time.
   */
  o?: SubscriptionCursor;

  /**
   * Name of channel where update received.
   */
  c: string;

  /**
   * Event payload.
   *
   * **Note:** One more type not mentioned here to keep type system working ({@link Payload}).
   */
  d: PresenceData | MessageActionData | AppContextObjectData | FileData | string;

  /**
   * Actual name of subscription through which event has been delivered.
   *
   * PubNub client can be used to subscribe to the group of channels to receive updates and
   * (group name will be set for field). With this approach there will be no need to separately
   * add *N* number of channels to `subscribe` method call.
   */
  b?: string;

  /**
   * User-provided metadata during `publish` method usage.
   */
  u?: { [p: string]: Payload };

  /**
   * User-provided message type (set only when `publish` called with `type`).
   */
  cmt?: string;

  /**
   * Identifier of space into which message has been published (set only when `publish` called
   * with `space_id`).
   */
  si?: string;
};

/**
 * Subscribe REST API service success response.
 *
 * @internal
 */
type ServiceResponse = {
  /**
   * Next subscription cursor.
   *
   * The cursor contains information about the start of the next real-time update timeframe.
   */
  t: SubscriptionCursor;

  /**
   * List of updates.
   *
   * Contains list of real-time updates received using previous subscription cursor.
   */
  m: Envelope[];
};

/**
 * Request configuration parameters.
 *
 * @internal
 */
export type SubscribeRequestParameters = Subscription.SubscribeParameters & {
  /**
   * Timetoken's region identifier.
   */
  region?: number;

  /**
   * Subscriber `userId` presence timeout.
   *
   * For how long (in seconds) user will be `online` without sending any new subscribe or
   * heartbeat requests.
   */
  heartbeat?: number;

  /**
   * Real-time events filtering expression.
   */
  filterExpression?: string | null;

  /**
   * PubNub REST API access key set.
   */
  keySet: KeySet;

  /**
   * Received data decryption module.
   */
  crypto?: ICryptoModule;

  /**
   * File download Url generation function.
   *
   * @param id - Unique identifier of the file which should be downloaded.
   * @param name - Name with which file has been stored.
   * @param channel - Name of the channel from which file should be downloaded.
   */
  getFileUrl: (parameters: FileSharing.FileUrlParameters) => string;

  /**
   * Whether request has been created on user demand or not.
   */
  onDemand?: boolean;
};
// endregion

/**
 * Base subscription request implementation.
 *
 * Subscription request used in small variations in two cases:
 * - subscription manager
 * - event engine
 *
 * @internal
 */
export class BaseSubscribeRequest extends AbstractRequest<Subscription.SubscriptionResponse, ServiceResponse> {
  constructor(protected readonly parameters: SubscribeRequestParameters) {
    super({ cancellable: true });

    // Apply default request parameters.
    this.parameters.withPresence ??= WITH_PRESENCE;
    this.parameters.channelGroups ??= [];
    this.parameters.channels ??= [];
  }

  operation(): RequestOperation {
    return RequestOperation.PNSubscribeOperation;
  }

  validate(): string | undefined {
    const {
      keySet: { subscribeKey },
      channels,
      channelGroups,
    } = this.parameters;

    if (!subscribeKey) return 'Missing Subscribe Key';
    if (!channels && !channelGroups) return '`channels` and `channelGroups` both should not be empty';
  }

  async parse(response: TransportResponse): Promise<Subscription.SubscriptionResponse> {
    let serviceResponse: ServiceResponse | undefined;
    let responseText: string | undefined;

    try {
      responseText = AbstractRequest.decoder.decode(response.body);
      const parsedJson = JSON.parse(responseText);
      serviceResponse = parsedJson as ServiceResponse;
    } catch (error) {
      console.error('Error parsing JSON response:', error);
    }

    if (!serviceResponse) {
      throw new PubNubError(
        'Service response error, check status for details',
        createMalformedResponseError(responseText, response.status),
      );
    }

    const events: Subscription.SubscriptionResponse['messages'] = serviceResponse.m
      .filter((envelope) => {
        const subscribable = envelope.b === undefined ? envelope.c : envelope.b;
        return (
          (this.parameters.channels && this.parameters.channels.includes(subscribable)) ||
          (this.parameters.channelGroups && this.parameters.channelGroups.includes(subscribable))
        );
      })
      .map((envelope) => {
        let { e: eventType } = envelope;

        // Resolve missing event type.
        eventType ??= envelope.c.endsWith('-pnpres') ? PubNubEventType.Presence : PubNubEventType.Message;
        const pn_mfp = messageFingerprint(envelope.d);

        // Check whether payload is string (potentially encrypted data).
        if (eventType != PubNubEventType.Signal && typeof envelope.d === 'string') {
          if (eventType == PubNubEventType.Message) {
            return {
              type: PubNubEventType.Message,
              data: this.messageFromEnvelope(envelope),
              pn_mfp,
            };
          }

          return {
            type: PubNubEventType.Files,
            data: this.fileFromEnvelope(envelope),
            pn_mfp,
          };
        } else if (eventType == PubNubEventType.Message) {
          return {
            type: PubNubEventType.Message,
            data: this.messageFromEnvelope(envelope),
            pn_mfp,
          };
        } else if (eventType === PubNubEventType.Presence) {
          return {
            type: PubNubEventType.Presence,
            data: this.presenceEventFromEnvelope(envelope),
            pn_mfp,
          };
        } else if (eventType == PubNubEventType.Signal) {
          return {
            type: PubNubEventType.Signal,
            data: this.signalFromEnvelope(envelope),
            pn_mfp,
          };
        } else if (eventType === PubNubEventType.AppContext) {
          return {
            type: PubNubEventType.AppContext,
            data: this.appContextFromEnvelope(envelope),
            pn_mfp,
          };
        } else if (eventType === PubNubEventType.MessageAction) {
          return {
            type: PubNubEventType.MessageAction,
            data: this.messageActionFromEnvelope(envelope),
            pn_mfp,
          };
        }

        return {
          type: PubNubEventType.Files,
          data: this.fileFromEnvelope(envelope),
          pn_mfp,
        };
      });

    return {
      cursor: { timetoken: serviceResponse.t.t, region: serviceResponse.t.r },
      messages: events,
    };
  }

  protected get headers(): Record<string, string> | undefined {
    return { ...(super.headers ?? {}), accept: 'text/javascript' };
  }

  // --------------------------------------------------------
  // ------------------ Envelope parsing --------------------
  // --------------------------------------------------------
  // region Envelope parsing

  private presenceEventFromEnvelope(envelope: Envelope): Subscription.Presence {
    const { d: payload } = envelope;
    const [channel, subscription] = this.subscriptionChannelFromEnvelope(envelope);

    // Clean up channel and subscription name from presence suffix.
    const trimmedChannel = channel.replace('-pnpres', '');

    // Backward compatibility with deprecated properties.
    const actualChannel = subscription !== null ? trimmedChannel : null;
    const subscribedChannel = subscription !== null ? subscription : trimmedChannel;

    if (typeof payload !== 'string') {
      if ('data' in payload) {
        // @ts-expect-error This is `state-change` object which should have `state` field.
        payload['state'] = payload.data;
        delete payload.data;
      } else if ('action' in payload && payload.action === 'interval') {
        payload.hereNowRefresh = payload.here_now_refresh ?? false;
        delete payload.here_now_refresh;
      }
    }

    return {
      channel: trimmedChannel,
      subscription,
      actualChannel,
      subscribedChannel,
      timetoken: envelope.p.t,
      ...(payload as PresenceData),
    };
  }

  private messageFromEnvelope(envelope: Envelope): Subscription.Message {
    const [channel, subscription] = this.subscriptionChannelFromEnvelope(envelope);
    const [message, decryptionError] = this.decryptedData<Payload>(envelope.d);

    // Backward compatibility with deprecated properties.
    const actualChannel = subscription !== null ? channel : null;
    const subscribedChannel = subscription !== null ? subscription : channel;

    // Basic message event payload.
    const event: Subscription.Message = {
      channel,
      subscription,
      actualChannel,
      subscribedChannel,
      timetoken: envelope.p.t,
      publisher: envelope.i,
      message,
    };

    if (envelope.u) event.userMetadata = envelope.u;
    if (envelope.cmt) event.customMessageType = envelope.cmt;
    if (decryptionError) event.error = decryptionError;

    return event;
  }

  private signalFromEnvelope(envelope: Envelope): Subscription.Signal {
    const [channel, subscription] = this.subscriptionChannelFromEnvelope(envelope);

    const event: Subscription.Signal = {
      channel,
      subscription,
      timetoken: envelope.p.t,
      publisher: envelope.i,
      message: envelope.d,
    };

    if (envelope.u) event.userMetadata = envelope.u;
    if (envelope.cmt) event.customMessageType = envelope.cmt;

    return event;
  }

  private messageActionFromEnvelope(envelope: Envelope): Subscription.MessageAction {
    const [channel, subscription] = this.subscriptionChannelFromEnvelope(envelope);
    const action = envelope.d as MessageActionData;

    return {
      channel,
      subscription,
      timetoken: envelope.p.t,
      publisher: envelope.i,
      event: action.event,
      data: {
        ...action.data,
        uuid: envelope.i!,
      },
    };
  }

  private appContextFromEnvelope(envelope: Envelope): Subscription.AppContextObject {
    const [channel, subscription] = this.subscriptionChannelFromEnvelope(envelope);
    const object = envelope.d as AppContextObjectData;

    return {
      channel,
      subscription,
      timetoken: envelope.p.t,
      message: object,
    };
  }

  private fileFromEnvelope(envelope: Envelope): Subscription.File {
    const [channel, subscription] = this.subscriptionChannelFromEnvelope(envelope);
    const [file, decryptionError] = this.decryptedData<Subscription.File | string>(envelope.d);
    let errorMessage = decryptionError;

    // Basic file event payload.
    const event: Subscription.File = {
      channel,
      subscription,
      timetoken: envelope.p.t,
      publisher: envelope.i,
    };

    if (envelope.u) event.userMetadata = envelope.u;
    if (!file) errorMessage ??= `File information payload is missing.`;
    else if (typeof file === 'string') errorMessage ??= `Unexpected file information payload data type.`;
    else {
      event.message = file.message;
      if (file.file) {
        event.file = {
          id: file.file.id,
          name: file.file.name,
          url: this.parameters.getFileUrl({ id: file.file.id, name: file.file.name, channel }),
        };
      }
    }

    if (envelope.cmt) event.customMessageType = envelope.cmt;
    if (errorMessage) event.error = errorMessage;

    return event;
  }
  // endregion

  private subscriptionChannelFromEnvelope(envelope: Envelope): [string, string | null] {
    return [envelope.c, envelope.b === undefined ? envelope.c : envelope.b];
  }

  /**
   * Decrypt provided `data`.
   *
   * @param [data] - Message or file information which should be decrypted if possible.
   *
   * @returns Tuple with decrypted data and decryption error (if any).
   */
  private decryptedData<T extends Payload = Payload>(data: Payload): [T, string | undefined] {
    if (!this.parameters.crypto || typeof data !== 'string') return [data as T, undefined];

    let payload: Payload | null;
    let error: string | undefined;

    try {
      const decryptedData = this.parameters.crypto.decrypt(data);
      payload =
        decryptedData instanceof ArrayBuffer
          ? JSON.parse(SubscribeRequest.decoder.decode(decryptedData))
          : decryptedData;
    } catch (err) {
      payload = null;
      error = `Error while decrypting message content: ${(err as Error).message}`;
    }

    return [(payload ?? data) as T, error];
  }
}

/**
 * Subscribe request.
 *
 * @internal
 */
export class SubscribeRequest extends BaseSubscribeRequest {
  protected get path(): string {
    const {
      keySet: { subscribeKey },
      channels,
    } = this.parameters;

    return `/v2/subscribe/${subscribeKey}/${encodeNames(channels?.sort() ?? [], ',')}/0`;
  }

  protected get queryParameters(): Query {
    const { channelGroups, filterExpression, heartbeat, state, timetoken, region, onDemand } = this.parameters;
    const query: Query = {};

    if (onDemand) query['on-demand'] = 1;
    if (channelGroups && channelGroups.length > 0) query['channel-group'] = channelGroups.sort().join(',');
    if (filterExpression && filterExpression.length > 0) query['filter-expr'] = filterExpression;
    if (heartbeat) query.heartbeat = heartbeat;
    if (state && Object.keys(state).length > 0) query['state'] = JSON.stringify(state);
    if (timetoken !== undefined && typeof timetoken === 'string') {
      if (timetoken.length > 0 && timetoken !== '0') query['tt'] = timetoken;
    } else if (timetoken !== undefined && timetoken > 0) query['tt'] = timetoken;

    if (region) query['tr'] = region;

    return query;
  }
}
