import { IPushEventEmitter, IPushManager } from './types';
import { ISSEClient } from './SSEClient/types';
import { IMySegmentsSyncTask, IPollingManager, ISegmentsSyncTask } from '../polling/types';
import { objectAssign } from '../../utils/lang/objectAssign';
import { Backoff } from '../../utils/Backoff';
import { SSEHandlerFactory } from './SSEHandler';
import { MySegmentsUpdateWorker } from './UpdateWorkers/MySegmentsUpdateWorker';
import { SegmentsUpdateWorker } from './UpdateWorkers/SegmentsUpdateWorker';
import { DefinitionsUpdateWorker } from './UpdateWorkers/DefinitionsUpdateWorker';
import { authenticateFactory, hashUserKey } from './AuthClient';
import { forOwn } from '../../utils/lang';
import { SSEClient } from './SSEClient';
import { checkIfServerSide, getMatching } from '../../utils/key';
import { MEMBERSHIPS_MS_UPDATE, MEMBERSHIPS_LS_UPDATE, PUSH_NON_RETRYABLE_ERROR, PUSH_SUBSYSTEM_DOWN, SECONDS_BEFORE_EXPIRATION, SEGMENT_UPDATE, SPLIT_KILL, SPLIT_UPDATE, RB_SEGMENT_UPDATE, PUSH_RETRYABLE_ERROR, PUSH_SUBSYSTEM_UP, ControlType } from './constants';
import { STREAMING_FALLBACK, STREAMING_REFRESH_TOKEN, STREAMING_CONNECTING, STREAMING_DISABLED, ERROR_STREAMING_AUTH, STREAMING_DISCONNECTING, STREAMING_RECONNECT, STREAMING_PARSING_MEMBERSHIPS_UPDATE } from '../../logger/constants';
import { IMembershipMSUpdateData, IMembershipLSUpdateData, KeyList, UpdateStrategy } from './SSEHandler/types';
import { getDelay, isInBitmap, parseBitmap, parseCompressedData } from './parseUtils';
import { Hash64, hash64 } from '../../utils/murmur3/murmur3_64';
import { IJwtCredentialV2 } from './AuthClient/types';
import { TOKEN_REFRESH, AUTH_REJECTION } from '../../utils/constants';
import { ISdkFactoryContextSync } from '../../sdkFactory/types';

/**
 * PushManager factory:
 * - for server-side if key is not provided in settings.
 * - for client-side, with support for multiple clients, if key is provided in settings
 */
export function pushManagerFactory(
  params: ISdkFactoryContextSync,
  pollingManager: IPollingManager,
): IPushManager | undefined {

  const { settings, storage, serviceApi, readiness, platform, telemetryTracker } = params;

  // `userKey` is the matching key of main client in client-side SDK.
  // It can be used to check if running on client-side or server-side SDK.
  const userKey = checkIfServerSide(settings) ? undefined : getMatching(settings.core.key);
  const log = settings.log;

  let sseClient: ISSEClient;
  try {
    // `useHeaders` false for client-side, even if the platform EventSource supports headers (e.g., React Native).
    sseClient = new SSEClient(settings, platform);
  } catch (e) {
    log.warn(STREAMING_FALLBACK, [e]);
    return;
  }
  const authenticate = authenticateFactory(serviceApi.fetchAuth);

  // init feedback loop
  const pushEmitter = new platform.EventEmitter() as IPushEventEmitter;
  const sseHandler = SSEHandlerFactory(log, pushEmitter, telemetryTracker);
  sseClient.setEventHandler(sseHandler);

  // init workers
  // MySegmentsUpdateWorker (client-side) are initiated in `add` method
  const segmentsUpdateWorker = userKey ? undefined : SegmentsUpdateWorker(log, pollingManager.segmentsSyncTask as ISegmentsSyncTask, storage.segments);
  // For server-side we pass the segmentsSyncTask, used by DefinitionsUpdateWorker to fetch new segments
  const definitionsUpdateWorker = DefinitionsUpdateWorker(log, storage, pollingManager.definitionsSyncTask, readiness.definitions, telemetryTracker);

  // [Only for client-side] map of hashes to user keys, to dispatch membership update events to the corresponding MySegmentsUpdateWorker
  const userKeyHashes: Record<string, string> = {};
  // [Only for client-side] map of user keys to their corresponding hash64 and MySegmentsUpdateWorkers.
  // Hash64 is used to process membership update events and dispatch actions to the corresponding MySegmentsUpdateWorker.
  const clients: Record<string, { hash64: Hash64, worker: ReturnType<typeof MySegmentsUpdateWorker> }> = {};

  // [Only for client-side] variable to flag that a new client was added. It is needed to reconnect streaming.
  let connectForNewClient = false;

  // flag that indicates if `stop/disconnectPush` was called, either by the SyncManager, when the client is destroyed, or due to a PUSH_NON_RETRYABLE_ERROR error.
  // It is used to halt the `connectPush` process if it was in progress.
  let disconnected: boolean | undefined;
  // flag that indicates a PUSH_NON_RETRYABLE_ERROR, condition with which starting pushManager again is ignored.
  // true if STREAMING_DISABLED control event, or 'pushEnabled: false', or non-recoverable SSE or Auth errors.
  let disabled: boolean | undefined; // `disabled` implies `disconnected === true`

  /** PushManager functions related to initialization */

  const connectPushRetryBackoff = new Backoff(connectPush, settings.scheduler.pushRetryBackoffBase);

  let timeoutIdTokenRefresh: ReturnType<typeof setTimeout>;
  let timeoutIdSseOpen: ReturnType<typeof setTimeout>;

  function scheduleTokenRefreshAndSse(authData: IJwtCredentialV2) {
    // clear scheduled tasks if exist
    if (timeoutIdTokenRefresh) clearTimeout(timeoutIdTokenRefresh);
    if (timeoutIdSseOpen) clearTimeout(timeoutIdSseOpen);

    // Set token refresh 10 minutes before `expirationTime - issuedAt`
    const decodedToken = authData.decodedToken;
    const refreshTokenDelay = decodedToken.exp - decodedToken.iat - SECONDS_BEFORE_EXPIRATION;
    // Default connDelay of 60 secs
    const connDelay = typeof authData.connDelay === 'number' && authData.connDelay >= 0 ? authData.connDelay : 60;

    log.info(STREAMING_REFRESH_TOKEN, [refreshTokenDelay, connDelay]);

    timeoutIdTokenRefresh = setTimeout(connectPush, refreshTokenDelay * 1000);

    timeoutIdSseOpen = setTimeout(() => {
      // halt if disconnected
      if (disconnected) return;
      sseClient.open(authData);
    }, connDelay * 1000);

    telemetryTracker.streamingEvent(TOKEN_REFRESH, decodedToken.exp);
  }

  function connectPush() {
    // Guard condition in case `stop/disconnectPush` has been called (e.g., calling SDK destroy, or app signal close/background)
    if (disconnected) return;
    // @TODO distinguish log for 'Connecting' (1st time) and 'Re-connecting'
    log.info(STREAMING_CONNECTING);
    disconnected = false;

    const userKeys = userKey ? Object.keys(clients) : undefined;
    authenticate(userKeys).then(
      function (authData) {
        if (disconnected) return;

        // 'pushEnabled: false' is handled as a PUSH_NON_RETRYABLE_ERROR instead of PUSH_SUBSYSTEM_DOWN, in order to
        // close the sseClient in case the org has been bloqued while the instance was connected to streaming
        if (!authData.pushEnabled) {
          log.info(STREAMING_DISABLED);
          pushEmitter.emit(PUSH_NON_RETRYABLE_ERROR);
          return;
        }

        // [Only for client-side] don't open SSE connection if a new shared client was added, since it means that a new authentication is taking place
        if (userKeys && userKeys.length < Object.keys(clients).length) return;

        // Schedule SSE connection and refresh token
        scheduleTokenRefreshAndSse(authData);
      }
    ).catch(
      function (error) {
        if (disconnected) return;

        log.error(ERROR_STREAMING_AUTH, [error.message]);

        // Handle 4XX HTTP errors: 401 (invalid SDK Key) or 400 (using incorrect SDK Key, i.e., client-side SDK Key on server-side)
        if (error.statusCode >= 400 && error.statusCode < 500) {
          telemetryTracker.streamingEvent(AUTH_REJECTION);
          pushEmitter.emit(PUSH_NON_RETRYABLE_ERROR);
          return;
        }

        // Handle other HTTP and network errors as recoverable errors
        pushEmitter.emit(PUSH_RETRYABLE_ERROR);
      }
    );
  }

  // close SSE connection and cancel scheduled tasks
  function disconnectPush() {
    // Halt disconnecting, just to avoid redundant logs if called multiple times
    if (disconnected) return;
    disconnected = true;

    sseClient.close();
    log.info(STREAMING_DISCONNECTING);

    if (timeoutIdTokenRefresh) clearTimeout(timeoutIdTokenRefresh);
    if (timeoutIdSseOpen) clearTimeout(timeoutIdSseOpen);
    connectPushRetryBackoff.reset();

    stopWorkers();
  }

  // cancel scheduled fetch retries of Splits, Segments, and MySegments Update Workers
  function stopWorkers() {
    definitionsUpdateWorker.stop();
    if (userKey) forOwn(clients, ({ worker }) => worker.stop());
    else segmentsUpdateWorker!.stop();
  }

  pushEmitter.on(PUSH_SUBSYSTEM_DOWN, stopWorkers);

  // Only required when streaming connects after a PUSH_RETRYABLE_ERROR.
  // Otherwise it is unnecessary (e.g, STREAMING_RESUMED).
  pushEmitter.on(PUSH_SUBSYSTEM_UP, () => {
    connectPushRetryBackoff.reset();
  });

  /** Fallback to polling without retry due to: STREAMING_DISABLED control event, or 'pushEnabled: false', or non-recoverable SSE and Authentication errors */

  pushEmitter.on(PUSH_NON_RETRYABLE_ERROR, function handleNonRetryableError() {
    disabled = true;
    // Note: `stopWorkers` is been called twice, but it is not harmful
    disconnectPush();
    pushEmitter.emit(PUSH_SUBSYSTEM_DOWN); // no harm if polling already
  });

  /** Fallback to polling with retry due to recoverable SSE and Authentication errors */

  pushEmitter.on(PUSH_RETRYABLE_ERROR, function handleRetryableError() { // HTTP or network error in SSE connection
    // SSE connection is closed to avoid repeated errors due to retries
    sseClient.close();

    // retry streaming reconnect with backoff algorithm
    let delayInMillis = connectPushRetryBackoff.scheduleCall();

    log.info(STREAMING_RECONNECT, [delayInMillis / 1000]);

    pushEmitter.emit(PUSH_SUBSYSTEM_DOWN); // no harm if polling already
  });

  /** STREAMING_RESET notification. Unlike a PUSH_RETRYABLE_ERROR, it doesn't emit PUSH_SUBSYSTEM_DOWN to fallback polling */

  pushEmitter.on(ControlType.STREAMING_RESET, function handleStreamingReset() {
    if (disconnected) return; // should never happen

    // Minimum required clean-up.
    // `disconnectPush` cannot be called because it sets `disconnected` and thus `connectPush` will not execute
    if (timeoutIdTokenRefresh) clearTimeout(timeoutIdTokenRefresh);

    connectPush();
  });

  /** Functions related to synchronization (Queues and Workers in the spec) */

  pushEmitter.on(SPLIT_KILL, definitionsUpdateWorker.killDefinition);
  pushEmitter.on(SPLIT_UPDATE, definitionsUpdateWorker.put);
  pushEmitter.on(RB_SEGMENT_UPDATE, definitionsUpdateWorker.put);

  function handleMySegmentsUpdate(parsedData: IMembershipMSUpdateData | IMembershipLSUpdateData) {
    switch (parsedData.u) {
      case UpdateStrategy.BoundedFetchRequest: {
        let bitmap: Uint8Array;
        try {
          bitmap = parseBitmap(parsedData.d!, parsedData.c!);
        } catch (e) {
          log.warn(STREAMING_PARSING_MEMBERSHIPS_UPDATE, ['BoundedFetchRequest', e]);
          break;
        }

        forOwn(clients, ({ hash64, worker }, matchingKey) => {
          if (isInBitmap(bitmap, hash64.hex)) {
            worker.put(parsedData, undefined, getDelay(parsedData, matchingKey));
          }
        });
        return;
      }
      case UpdateStrategy.KeyList: {
        let keyList: KeyList, added: Set<string>, removed: Set<string>;
        try {
          keyList = parseCompressedData<KeyList>(parsedData.d!, parsedData.c!);
          added = new Set(keyList.a);
          removed = new Set(keyList.r);
        } catch (e) {
          log.warn(STREAMING_PARSING_MEMBERSHIPS_UPDATE, ['KeyList', e]);
          break;
        }

        if (!parsedData.n || !parsedData.n.length) {
          log.warn(STREAMING_PARSING_MEMBERSHIPS_UPDATE, ['KeyList', 'No segment name was provided']);
          break;
        }

        forOwn(clients, ({ hash64, worker }) => {
          const add = added.has(hash64.dec) ? true : removed.has(hash64.dec) ? false : undefined;
          if (add !== undefined) {
            worker.put(parsedData, {
              added: add ? [parsedData.n![0]] : [],
              removed: add ? [] : [parsedData.n![0]]
            });
          }
        });
        return;
      }
      case UpdateStrategy.SegmentRemoval:
        if (!parsedData.n || !parsedData.n.length) {
          log.warn(STREAMING_PARSING_MEMBERSHIPS_UPDATE, ['SegmentRemoval', 'No segment name was provided']);
          break;
        }

        forOwn(clients, ({ worker }) => {
          worker.put(parsedData, {
            added: [],
            removed: parsedData.n!
          });
        });
        return;
    }

    // `UpdateStrategy.UnboundedFetchRequest` and fallbacks of other cases
    forOwn(clients, ({ worker }, matchingKey) => {
      worker.put(parsedData, undefined, getDelay(parsedData, matchingKey));
    });
  }

  if (userKey) {
    pushEmitter.on(MEMBERSHIPS_MS_UPDATE, handleMySegmentsUpdate);
    pushEmitter.on(MEMBERSHIPS_LS_UPDATE, handleMySegmentsUpdate);
  } else {
    pushEmitter.on(SEGMENT_UPDATE, segmentsUpdateWorker!.put);
  }

  return objectAssign(
    // Expose Event Emitter functionality and Event constants
    Object.create(pushEmitter),
    {
      // Stop/pause push mode.
      // It doesn't emit events. Neither PUSH_SUBSYSTEM_DOWN to start polling.
      stop() {
        disconnectPush(); // `handleNonRetryableError` cannot be used as `stop`, because it emits PUSH_SUBSYSTEM_DOWN event, which starts polling.
        if (userKey) this.remove(userKey); // Necessary to properly resume streaming in client-side (e.g., RN SDK transition to foreground).
      },

      // Start/resume push mode.
      // It eventually emits PUSH_SUBSYSTEM_DOWN, that starts polling, or PUSH_SUBSYSTEM_UP, that executes a syncAll
      start() {
        // Guard condition to avoid calling `connectPush` again if the `start` method is called multiple times or if push has been disabled.
        if (disabled || disconnected === false) return;
        disconnected = false;

        if (userKey) this.add(userKey, pollingManager.segmentsSyncTask); // client-side
        else setTimeout(connectPush); // server-side runs in next cycle as in client-side, for consistency with client-side
      },

      // true/false if start or stop was called last respectively
      isRunning() {
        return disconnected === false;
      },

      // [Only for client-side]
      add(userKey: string, mySegmentsSyncTask: IMySegmentsSyncTask) {
        const hash = hashUserKey(userKey);

        if (!userKeyHashes[hash]) {
          userKeyHashes[hash] = userKey;
          clients[userKey] = {
            hash64: hash64(userKey),
            worker: MySegmentsUpdateWorker(log, storage, mySegmentsSyncTask, telemetryTracker)
          };
          connectForNewClient = true; // we must reconnect on start, to listen the channel for the new user key

          // Reconnects in case of a new client.
          // Run in next event-loop cycle to save authentication calls
          // in case multiple clients are created in the current cycle.
          if (this.isRunning()) {
            setTimeout(function checkForReconnect() {
              if (connectForNewClient) {
                connectForNewClient = false;
                connectPush();
              }
            }, 0);
          }
        }
      },
      // [Only for client-side]
      remove(userKey: string) {
        const hash = hashUserKey(userKey);
        delete userKeyHashes[hash];
        delete clients[userKey];
      }
    }
  );
}
