/*
 * Copyright 2023 Palantir Technologies, Inc. All rights reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import WebSocket from "isomorphic-ws";
import invariant from "tiny-invariant";
import { ExponentialBackoff } from "../util/exponentialBackoff.js";
const MINIMUM_RECONNECT_DELAY_MS = 5 * 1000;
const EXPONENTIAL_BACKOFF_INITIAL_DELAY_MS = 1000;
const EXPONENTIAL_BACKOFF_MAX_DELAY_MS = 60000;
const EXPONENTIAL_BACKOFF_MULTIPLIER = 2;
const EXPONENTIAL_BACKOFF_JITTER_FACTOR = 0.3;
const WEBSOCKET_IDLE_DISCONNECT_DELAY_MS = 15000;
const WEBSOCKET_HEARTBEAT_INTERVAL_MS = 45 * 1000;

/** Noop function to reduce conditional checks */
function doNothing() {}

/**
 * Converts an ObjectSetListener to one where all the functions are defined.
 */
function fillOutListener({
  onChange = doNothing,
  onError = doNothing,
  onOutOfDate = doNothing,
  onSuccessfulSubscription = doNothing
}) {
  return {
    onChange,
    onError,
    onOutOfDate,
    onSuccessfulSubscription
  };
}
function isReady(sub) {
  return sub.isReady != null;
}
function subscriptionIsDone(sub) {
  return sub.status === "done" || sub.status === "error";
}

/** @internal */
export class ObjectSetListenerWebsocket {
  static #instances = new WeakMap();
  // FIXME
  static getInstance(client) {
    let instance = ObjectSetListenerWebsocket.#instances.get(client.clientCacheKey);
    if (instance == null) {
      instance = new ObjectSetListenerWebsocket(client);
      ObjectSetListenerWebsocket.#instances.set(client.clientCacheKey, instance);
    }
    return instance;
  }
  #ws;
  #lastWsConnect = 0;
  #client;
  #backoff;
  #isFirstConnection = true;
  #logger;

  /**
   * map of requestId to all active subscriptions at the time of the request
   */
  #pendingSubscriptions = new Map();

  /**
   * Map of subscriptionId to Subscription. Note: the subscriptionId may be
   * temporary and not the actual subscriptionId from the server.
   */
  #subscriptions = new Map();
  #endedSubscriptions = new Set();
  #maybeDisconnectTimeout;
  #heartbeatInterval;

  // DO NOT CONSTRUCT DIRECTLY. ONLY EXPOSED AS A TESTING SEAM
  constructor(client, {
    minimumReconnectDelayMs = MINIMUM_RECONNECT_DELAY_MS
  } = {}) {
    this.MINIMUM_RECONNECT_DELAY_MS = minimumReconnectDelayMs;
    this.#client = client;
    this.#backoff = new ExponentialBackoff({
      initialDelayMs: EXPONENTIAL_BACKOFF_INITIAL_DELAY_MS,
      maxDelayMs: EXPONENTIAL_BACKOFF_MAX_DELAY_MS,
      multiplier: EXPONENTIAL_BACKOFF_MULTIPLIER,
      jitterFactor: EXPONENTIAL_BACKOFF_JITTER_FACTOR
    });
    this.#logger = client.logger?.child({}, {
      msgPrefix: "<OSW> "
    });
    !(client.baseUrl.startsWith("https://") || client.baseUrl.startsWith("http://")) ? process.env.NODE_ENV !== "production" ? invariant(false, "Stack must be a URL") : invariant(false) : void 0;
  }
  async subscribe(objectType, objectSet, listener, properties = [], shouldLoadRids = false) {
    const objOrInterfaceDef = objectType.type === "object" ? await this.#client.ontologyProvider.getObjectDefinition(objectType.apiName) : await this.#client.ontologyProvider.getInterfaceDefinition(objectType.apiName);
    let objectProperties = [];
    let referenceProperties = [];
    if (properties.length === 0) {
      properties = Object.keys(objOrInterfaceDef.properties);
    }
    objectProperties = properties.filter(p => objOrInterfaceDef.properties[p].type !== "geotimeSeriesReference");
    referenceProperties = properties.filter(p => objOrInterfaceDef.properties[p].type === "geotimeSeriesReference");
    const sub = {
      listener: fillOutListener(listener),
      objectSet,
      primaryKeyPropertyName: objOrInterfaceDef.type === "interface" ? undefined : objOrInterfaceDef.primaryKeyApiName,
      requestedProperties: objectProperties,
      requestedReferenceProperties: referenceProperties,
      status: "preparing",
      // Since we don't have a real subscription id yet but we need to keep
      // track of this reference, we can just use a random uuid.
      subscriptionId: `TMP-${nextUuid()}}`,
      interfaceApiName: objOrInterfaceDef.type === "object" ? undefined : objOrInterfaceDef.apiName,
      loadRids: shouldLoadRids
    };
    this.#subscriptions.set(sub.subscriptionId, sub);

    // actually prepares the subscription, ensures the ws is ready, and sends
    // a subscribe message. We don't want to block on this.
    void this.#initiateSubscribe(sub);
    return () => {
      this.#unsubscribe(sub);
    };
  }

  /**
   * Called at least once for every subscription.
   *
   * - Resets pending expiry
   * - Recreates temporary object set
   * - Triggers a full subscribe message
   *
   * @returns
   */
  async #initiateSubscribe(sub) {
    if (process.env.NODE_ENV !== "production") {
      this.#logger?.debug("#initiateSubscribe()");
    }
    try {
      await this.#ensureWebsocket();

      // the consumer may have already unsubscribed before we are ready to request a subscription
      // so we have to acquire the pendingSubscription after the await.
      if (subscriptionIsDone(sub)) {
        return;
      }
      sub.isReady = true;
      // if we aren't open, then this happens after we #onConnect
      if (this.#ws?.readyState === WebSocket.OPEN) {
        this.#sendSubscribeMessage();
      }
    } catch (error) {
      this.#logger?.error(error, "Error in #initiateSubscribe");
      this.#tryCatchOnError(sub, true, error);
    }
  }
  #sendSubscribeMessage() {
    if (process.env.NODE_ENV !== "production") {
      this.#logger?.debug("#sendSubscribeMessage()");
    }
    // If two calls to `.subscribe()` happen at once (or if the connection is reset),
    // we may have multiple subscriptions that don't have a subscriptionId yet,
    // so we filter those out.
    const readySubs = [...this.#subscriptions.values()].filter(isReady);
    const id = nextUuid();
    // responses come back as an array of subIds, so we need to know the sources
    this.#pendingSubscriptions.set(id, readySubs);

    // every subscribe message "overwrites" the previous ones that are not
    // re-included, so we have to reconstitute the entire list of subscriptions
    const subscribe = {
      id,
      requests: readySubs.map(({
        objectSet,
        requestedProperties,
        requestedReferenceProperties,
        interfaceApiName
      }) => {
        return {
          objectSet: objectSet,
          propertySet: requestedProperties,
          referenceSet: requestedReferenceProperties,
          objectLoadingResponseOptions: {
            shouldLoadObjectRids: true
          }
        };
      })
    };
    if (process.env.NODE_ENV !== "production") {
      this.#logger?.debug({
        payload: subscribe
      }, "sending subscribe message");
    }
    this.#ws?.send(JSON.stringify(subscribe));
  }
  #unsubscribe(sub, newStatus = "done") {
    if (subscriptionIsDone(sub)) {
      // if we are already done, we don't need to do anything
      return;
    }
    sub.status = newStatus;

    // make sure listeners do nothing now
    sub.listener = fillOutListener({});
    this.#subscriptions.delete(sub.subscriptionId);
    this.#endedSubscriptions.add(sub.subscriptionId);
    this.#sendSubscribeMessage();

    // If we have no more subscriptions, we can disconnect the websocket
    // however we should wait a bit to see if we get any more subscriptions.
    // For example, when switching between react views, you may unsubscribe
    // in the old view and subscribe in the new view. We don't need to re-establish
    // the websocket connection in that case.
    if (this.#maybeDisconnectTimeout) {
      // We reset the timeout on every unsubscribe so its always at least 15s from
      // the last time we are empty. E.g.:
      //   - 0s: Subscribe(A)
      //   - 10s: Unsubscribe(A)
      //   - 11s: Subscribe(B)
      //   - 20s: Unsubscribe(B)
      // If we do not clear out the timeout we would disconnect at 25s but that would only be
      // 5s after the last subscription was removed instead of at 35s for the desired 15s.
      clearTimeout(this.#maybeDisconnectTimeout);
    }
    this.#maybeDisconnectTimeout = setTimeout(() => {
      this.#maybeDisconnectTimeout = undefined;
      if (this.#subscriptions.size === 0) {
        this.#cycleWebsocket();
      }
    }, WEBSOCKET_IDLE_DISCONNECT_DELAY_MS);
  }
  async #ensureWebsocket() {
    if (this.#ws == null) {
      const {
        baseUrl,
        tokenProvider
      } = this.#client;
      const url = constructWebsocketUrl(baseUrl, await this.#client.ontologyRid);
      const token = await tokenProvider();

      // tokenProvider is async, there could potentially be a race to create the websocket.
      // Only the first call to reach here will find a null this.#ws, the rest will bail out
      if (this.#ws == null) {
        // Only apply exponential backoff delay on reconnection attempts, not the first connection
        if (!this.#isFirstConnection) {
          const delay = this.#backoff.calculateDelay();
          if (process.env.NODE_ENV !== "production") {
            this.#logger?.debug({
              delay,
              attempt: this.#backoff.getAttempt()
            }, "Waiting before reconnect");
          }
          await new Promise(resolve => {
            setTimeout(resolve, delay);
          });
        }
        this.#lastWsConnect = Date.now();

        // we again may have lost the race after our minimum backoff time
        if (this.#ws == null) {
          if (process.env.NODE_ENV !== "production") {
            this.#logger?.debug("Creating websocket");
          }
          this.#ws = new WebSocket(url, [`Bearer-${token}`]);
          this.#ws.addEventListener("close", this.#onClose);
          this.#ws.addEventListener("message", this.#onMessage);
          this.#ws.addEventListener("open", this.#onOpen);
        }
      }
      // Allow await-ing the websocket open event if it isn't open already.
      // This needs to happen even for callers that didn't just create this.#ws
      if (this.#ws.readyState === WebSocket.CONNECTING) {
        const ws = this.#ws;
        return new Promise((resolve, reject) => {
          function cleanup() {
            ws.removeEventListener("open", open);
            ws.removeEventListener("error", error);
            ws.removeEventListener("close", cleanup);
          }
          function open() {
            cleanup();
            resolve();
          }
          function error(evt) {
            cleanup();
            reject(new Error(String(evt)));
          }
          ws.addEventListener("open", open);
          ws.addEventListener("error", error);
          ws.addEventListener("close", cleanup);
        });
      }
    }
  }
  #onOpen = () => {
    // Mark that we've successfully connected at least once
    this.#isFirstConnection = false;
    // Reset backoff on successful connection
    this.#backoff.reset();
    // resubscribe all of the listeners
    this.#sendSubscribeMessage();

    // Start heartbeat to keep connection alive
    if (this.#heartbeatInterval) {
      clearInterval(this.#heartbeatInterval);
    }
    this.#heartbeatInterval = setInterval(() => {
      if (this.#ws?.readyState === WebSocket.OPEN) {
        this.#sendSubscribeMessage();
      }
    }, WEBSOCKET_HEARTBEAT_INTERVAL_MS);
  };
  #onMessage = async message => {
    const data = JSON.parse(String(message.data));
    if (process.env.NODE_ENV !== "production") {
      this.#logger?.debug({
        payload: data
      }, "received message from ws");
    }
    switch (data.type) {
      case "objectSetChanged":
        await this.#handleMessage_objectSetChanged(data);
        return;
      case "refreshObjectSet":
        this.#handleMessage_refreshObjectSet(data);
        return;
      case "subscribeResponses":
        this.#handleMessage_subscribeResponses(data);
        return;
      case "subscriptionClosed":
        {
          this.#handleMessage_subscriptionClosed(data);
          return;
        }
      default:
        process.env.NODE_ENV !== "production" ? invariant(false, "Unexpected message type") : invariant(false);
    }
  };
  #handleMessage_objectSetChanged = async payload => {
    const sub = this.#subscriptions.get(payload.id);
    if (sub == null) return;
    const objectUpdates = payload.updates.filter(update => update.type === "object");
    const referenceUpdates = payload.updates.filter(update => update.type === "reference");
    const osdkObjectsWithReferenceUpdates = await Promise.all(referenceUpdates.map(async o => {
      const osdkObjectArray = await this.#client.objectFactory2(this.#client, [{
        __apiName: o.objectType,
        __primaryKey: sub.primaryKeyPropertyName != null ? o.primaryKey[sub.primaryKeyPropertyName] : undefined,
        ...o.primaryKey,
        [o.property]: o.value
      }], sub.interfaceApiName, {}, undefined, false, undefined, false, await this.#fetchInterfaceMapping(o.objectType, sub.interfaceApiName));
      const singleOsdkObject = osdkObjectArray[0] ?? undefined;
      return singleOsdkObject != null ? {
        object: singleOsdkObject,
        state: "ADDED_OR_UPDATED"
      } : undefined;
    }));
    for (const update of osdkObjectsWithReferenceUpdates) {
      if (update != null) {
        try {
          sub.listener.onChange?.(update);
        } catch (error) {
          this.#logger?.error(error, "Error in onChange callback");
          this.#tryCatchOnError(sub, false, error);
        }
      }
    }
    const osdkObjects = await Promise.all(objectUpdates.map(async o => {
      const keysToDelete = Object.keys(o.object).filter(key => sub.requestedReferenceProperties.includes(key));
      for (const key of keysToDelete) {
        delete o.object[key];
      }
      const osdkObjectArray = await this.#client.objectFactory2(this.#client, [o.object], sub.interfaceApiName, {}, undefined, false, undefined, false, await this.#fetchInterfaceMapping(o.object.__apiName, sub.interfaceApiName));
      const singleOsdkObject = osdkObjectArray[0] ?? undefined;
      const rid = singleOsdkObject["$rid"];
      return singleOsdkObject != null ? rid === undefined ? {
        object: singleOsdkObject,
        state: o.state
      } : {
        object: singleOsdkObject,
        state: o.state,
        rid: rid
      } : undefined;
    }));
    for (const osdkObject of osdkObjects) {
      if (osdkObject != null) {
        try {
          sub.listener.onChange?.(osdkObject);
        } catch (error) {
          this.#logger?.error(error, "Error in onChange callback");
          this.#tryCatchOnError(sub, false, error);
        }
      }
    }
  };
  async #fetchInterfaceMapping(objectTypeApiName, interfaceApiName) {
    if (interfaceApiName == null) return {};
    const interfaceMap = (await this.#client.ontologyProvider.getObjectDefinition(objectTypeApiName)).interfaceMap;
    return {
      [interfaceApiName]: {
        [objectTypeApiName]: interfaceMap[interfaceApiName]
      }
    };
  }
  #handleMessage_refreshObjectSet = payload => {
    const sub = this.#subscriptions.get(payload.id);
    !sub ? process.env.NODE_ENV !== "production" ? invariant(false, `Expected subscription id ${payload.id}`) : invariant(false) : void 0;
    try {
      sub.listener.onOutOfDate();
    } catch (error) {
      this.#logger?.error(error, "Error in onOutOfDate callback");
      this.#tryCatchOnError(sub, false, error);
    }
  };
  #handleMessage_subscribeResponses = payload => {
    const {
      id,
      responses
    } = payload;
    const subs = this.#pendingSubscriptions.get(id);
    !subs ? process.env.NODE_ENV !== "production" ? invariant(false, `should have a pending subscription for ${id}`) : invariant(false) : void 0;
    this.#pendingSubscriptions.delete(id);
    for (let i = 0; i < responses.length; i++) {
      const sub = subs[i];
      const response = responses[i];
      switch (response.type) {
        case "error":
          this.#tryCatchOnError(sub, true, response.errors);
          this.#unsubscribe(sub, "error");
          break;
        case "qos":
          // the server has requested that we tear down our websocket and reconnect to help load balance
          this.#cycleWebsocket();
          break;
        case "success":
          // `"preparing"` should only be the status on an initial subscribe.
          const shouldFireOutOfDate = sub.status === "expired" || sub.status === "reconnecting";
          if (process.env.NODE_ENV !== "production") {
            this.#logger?.debug({
              shouldFireOutOfDate
            }, "success");
          }
          sub.status = "subscribed";
          if (sub.subscriptionId !== response.id) {
            // might be the temporary one
            this.#subscriptions.delete(sub.subscriptionId);
            sub.subscriptionId = response.id;
            this.#subscriptions.set(sub.subscriptionId, sub); // future messages come by this subId
          }
          try {
            if (shouldFireOutOfDate) sub.listener.onOutOfDate();else sub.listener.onSuccessfulSubscription();
          } catch (error) {
            this.#logger?.error(error, "Error in onOutOfDate or onSuccessfulSubscription callback");
            this.#tryCatchOnError(sub, false, error);
          }
          break;
        default:
          this.#tryCatchOnError(sub, true, response);
      }
    }
  };
  #handleMessage_subscriptionClosed(payload) {
    const sub = this.#subscriptions.get(payload.id);
    if (sub == null && this.#endedSubscriptions.has(payload.id)) return;
    !sub ? process.env.NODE_ENV !== "production" ? invariant(false, `Expected subscription id ${payload.id}`) : invariant(false) : void 0;
    this.#tryCatchOnError(sub, true, payload.cause);
    this.#unsubscribe(sub, "error");
  }
  #onClose = event => {
    if (process.env.NODE_ENV !== "production") {
      this.#logger?.debug({
        event
      }, "Received close event from ws", event);
    }
    this.#cycleWebsocket();
  };
  #cycleWebsocket = () => {
    // Clear heartbeat interval
    if (this.#heartbeatInterval) {
      clearInterval(this.#heartbeatInterval);
      this.#heartbeatInterval = undefined;
    }
    if (this.#ws) {
      this.#ws.removeEventListener("open", this.#onOpen);
      this.#ws.removeEventListener("message", this.#onMessage);
      this.#ws.removeEventListener("close", this.#onClose);
      if (this.#ws.readyState !== WebSocket.CLOSING && this.#ws.readyState !== WebSocket.CLOSED) {
        this.#ws.close();
      }
      this.#ws = undefined;
    }

    // if we have any listeners that are still depending on us, go ahead and reopen the websocket
    if (this.#subscriptions.size > 0) {
      if (process.env.NODE_ENV !== "production") {
        for (const s of this.#subscriptions.values()) {
          !(s.status !== "done" && s.status !== "error") ? process.env.NODE_ENV !== "production" ? invariant(false, "should not have done/error subscriptions still") : invariant(false) : void 0;
        }
      }
      for (const s of this.#subscriptions.values()) {
        if (s.status === "subscribed") s.status = "reconnecting";
      }

      // we don't care about the result of this (we want cycleWebsocket to be fire and forget)
      // just that it happens
      void this.#ensureWebsocket();
    }
  };
  #tryCatchOnError = (sub, subscriptionClosed, error) => {
    try {
      sub.listener.onError({
        subscriptionClosed: subscriptionClosed,
        error
      });
    } catch (onErrorError) {
      // eslint-disable-next-line no-console
      console.error(`Error encountered in an onError callback for an OSDK subscription`, onErrorError);
      // eslint-disable-next-line no-console
      console.error(`This onError call was triggered by an error in another callback`, error);
      // eslint-disable-next-line no-console
      console.error(`The subscription has been closed.`, error);
      if (!subscriptionClosed) {
        this.#logger?.error(error, "Error in onError callback");
        this.#unsubscribe(sub, "error");
        this.#tryCatchOnError(sub, true, onErrorError);
      }
    }
  };
}

/** @internal */
export function constructWebsocketUrl(baseUrl, ontologyRid) {
  const base = new URL(baseUrl);
  const url = new URL(`api/v2/ontologySubscriptions/ontologies/${ontologyRid}/streamSubscriptions`, base);
  url.protocol = url.protocol.replace("https", "wss");
  return url;
}
let uuidCounter = 0;
function nextUuid() {
  return `00000000-0000-0000-0000-${(uuidCounter++).toString().padStart(12, "0")}`;
}
//# sourceMappingURL=ObjectSetListenerWebsocket.js.map