import pRetry from "p-retry";
import {
  nullThrows,
  prefixError,
  reduce,
  InitData,
  Expression,
  Query,
  Value,
  ObjectValueWithVariables,
  UpdateListener,
  DehydratedState,
  DeepPartial,
  hash,
  stableStringify,
  LogLevel,
  uniqueId,
  InitDataProvider,
  ObjectValue,
  getSplitsAndCommitConfigForExpression,
  GetHashDataFunction,
  GetInitDataFunction,
  defaultRetries,
  UpdateTrigger,
  InitQuery,
  FieldQuery,
  Logs,
  HashData,
} from "../shared";
import Logger from "./Logger";
import { isBrowser } from "./environment";
import LRUCache from "../shared/helpers/LRUCache";
import getMetadata from "../shared/helpers/getMetadata";

/** @internal: Not part of the Hypertune public API */
export default class Context {
  protected readonly initDataProvider: InitDataProvider | null;
  protected readonly initDataRefreshIntervalMs: number;
  protected readonly shouldSkipInitDataUpdateOnRefresh: boolean;
  protected readonly query: Query<ObjectValueWithVariables> | null;
  protected readonly initQuery: InitQuery;
  protected variableValues: ObjectValue;
  protected readonly updateListeners: Map<UpdateListener, boolean>;
  protected shouldClose = false;
  protected updateTimeout: NodeJS.Timeout | null = null;
  // @internal
  public readonly logger: Logger;
  // @internal
  public initData: InitData | null = null;
  // @internal
  public lastInitDataRefreshTime: number | null = null;
  // @internal
  public readonly getFieldCache: LRUCache<Expression> | null = null;
  // @internal
  public readonly getItemsCache: LRUCache<Expression[]> | null = null;
  // @internal
  public readonly evaluateCache: LRUCache<{
    value: Value;
    logs: Logs;
    path: string;
    args: { [path: string]: ObjectValue };
    shouldLogEvaluation: boolean;
  }> | null = null;
  // @internal
  public override: DeepPartial<ObjectValue> | null = null;
  // @internal

  // eslint-disable-next-line max-params
  constructor({
    traceId,
    initData,
    lastInitDataRefreshTime,
    initDataProvider,
    initDataRefreshIntervalMs,
    shouldRefreshInitData,
    shouldRefreshInitDataOnCreate,
    shouldSkipInitDataUpdateOnRefresh,
    query,
    initQuery,
    variableValues,
    logger,
    cacheSize,
    override,
  }: {
    traceId: string;
    initData: InitData | null;
    lastInitDataRefreshTime: number | null;
    initDataProvider: InitDataProvider | null;
    initDataRefreshIntervalMs: number;
    shouldRefreshInitData: boolean;
    shouldRefreshInitDataOnCreate: boolean;
    shouldSkipInitDataUpdateOnRefresh: boolean;
    query: Query<ObjectValueWithVariables> | null;
    initQuery: InitQuery;
    variableValues: ObjectValue;
    logger: Logger;
    cacheSize: number;
    override: object | null;
  }) {
    this.initDataProvider = initDataProvider;
    this.initDataRefreshIntervalMs = initDataRefreshIntervalMs;
    this.shouldSkipInitDataUpdateOnRefresh = shouldSkipInitDataUpdateOnRefresh;
    this.query = query;
    this.initQuery = initQuery;
    this.variableValues = variableValues;
    this.updateListeners = new Map();
    this.logger = logger;
    this.override = override;

    if (cacheSize > 0) {
      this.getFieldCache = new LRUCache(cacheSize);
      this.getItemsCache = new LRUCache(cacheSize);
      this.evaluateCache = new LRUCache(cacheSize);
    }

    if (initData) {
      this.log(traceId, LogLevel.Info, "Initializing from snapshot data...");
      this.updateInitData(
        traceId,
        "snapshot",
        initData,
        lastInitDataRefreshTime
      );
    }
    this.initAndStartIntervals(
      traceId,
      shouldRefreshInitDataOnCreate,
      shouldRefreshInitData
    );

    if (isBrowser) {
      window.addEventListener("beforeunload", async () => {
        if (this.shouldClose) {
          return;
        }
        await this.close(/* traceId */ uniqueId());
      });
    }
  }

  // eslint-disable-next-line max-params
  private updateInitData(
    traceId: string,
    initSourceName: string,
    newInitData: InitData,
    newDataProviderInitTime: number | null
  ): boolean {
    try {
      const currentCommitId = this.initData?.commitId ?? -1;
      const currentCommitHash = this.initData?.hash ?? "";

      if (newInitData.commitId < currentCommitId) {
        this.log(
          traceId,
          LogLevel.Info,
          `Skipped initialization from ${initSourceName} data as commit with id "${newInitData.commitId}" isn't newer than "${currentCommitId}".`
        );
        return false;
      }
      if (
        newInitData.commitId === currentCommitId &&
        newInitData.hash === currentCommitHash
      ) {
        this.updateLastInitDataRefreshTime(newDataProviderInitTime);
        this.log(
          traceId,
          LogLevel.Info,
          `Skipped initialization from ${initSourceName} data as commit id "${newInitData.commitId}" with hash "${newInitData.hash}" is already active.`
        );
        return false;
      }

      // If initializing from Hypertune Edge, the expression should already be
      // reduced given the query and variables. If initializing from the
      // fallback, it should already be reduced given the query but not the
      // variables. If initializing from Vercel Edge Config, it won't be reduced
      // at all. In all cases, we reduce the returned expression again anyway
      // given the query and variables.
      const reducedExpression = prefixError(
        () =>
          reduce(
            newInitData.splits,
            newInitData.commitConfig,
            this.query,
            this.variableValues,
            newInitData.reducedExpression,
            /* allowMissingVariables */ false
          ),
        "Reduction Error: "
      );

      this.initData = { ...newInitData, reducedExpression };
      this.updateLastInitDataRefreshTime(newDataProviderInitTime);
      this.log(
        traceId,
        LogLevel.Info,
        `Initialized successfully from ${initSourceName} data.`,
        { commitId: newInitData.commitId, hash: newInitData.hash }
      );

      this.getFieldCache?.purge();
      this.getItemsCache?.purge();
      this.evaluateCache?.purge();

      return true;
    } catch (error) {
      this.log(
        traceId,
        LogLevel.Error,
        `Error initializing from ${initSourceName} data.`,
        getMetadata(error)
      );
      return false;
    }
  }

  // @internal
  initIfNeeded(traceId: string, retries: number): Promise<void> {
    const {
      lastInitDataRefreshTime,
      initDataProvider,
      initDataRefreshIntervalMs,
    } = this;

    if (!initDataProvider) {
      this.log(
        traceId,
        LogLevel.Info,
        "Not initializing from data provider as it's null."
      );
      return Promise.resolve();
    }

    if (lastInitDataRefreshTime) {
      const msSinceLastInitDataRefresh = Date.now() - lastInitDataRefreshTime;
      if (msSinceLastInitDataRefresh < initDataRefreshIntervalMs) {
        this.log(
          traceId,
          LogLevel.Debug,
          `Not initializing from data provider as already did ${msSinceLastInitDataRefresh}ms ago which is less than the update interval of ${initDataRefreshIntervalMs}ms.`
        );
        return Promise.resolve();
      }
    }

    return this.initFromDataProvider(traceId, initDataProvider, retries);
  }

  private initFromDataProvider(
    traceId: string,
    initDataProvider: InitDataProvider,
    retries: number
  ): Promise<void> {
    return this.withUpdateNotificationAsync(async () => {
      let checkingHash = true;
      const initSourceName = initDataProvider.getName();

      try {
        let newInitData: InitData | null = null;

        // If already initialized, first get the latest commit hash
        // to check if we need to update
        if (this.initData) {
          let hashData: HashData;

          if (initDataProvider.getHashData) {
            hashData = await this.getHashData(
              traceId,
              initDataProvider.getHashData.bind(initDataProvider),
              retries
            );
          } else {
            newInitData = await this.getInitData(
              traceId,
              initSourceName,
              initDataProvider.getInitData.bind(initDataProvider),
              retries
            );
            hashData = {
              hash: newInitData.hash,
              commitId: newInitData.commitId,
            };
          }

          if (this.initData.hash === hashData.hash) {
            // Log this as latest init time as verifying that we already have
            // the latest commit is equivalent to initialization from server.
            this.log(traceId, LogLevel.Debug, "Commit hash is already latest.");
            this.updateLastInitDataRefreshTime(Date.now());
            return {
              updateTrigger: "initDataProvider",
              notify: false,
              hasUpdated: false,
            };
          }
          if (hashData.commitId < this.initData.commitId) {
            // This happens in the unlikely case when the hash data is for an
            // older commit than the init data.
            this.log(
              traceId,
              LogLevel.Info,
              `Skipped initialization from ${initSourceName} as hash data is for commit with ID "${hashData.commitId}" which isn't newer than "${this.initData.commitId}".`
            );
            return {
              updateTrigger: "initDataProvider",
              notify: false,
              hasUpdated: false,
            };
          }
          this.log(
            traceId,
            LogLevel.Info,
            `Commit hash (${this.initData.hash}) is not latest (${hashData.hash}).`
          );
          if (this.shouldSkipInitDataUpdateOnRefresh) {
            return {
              updateTrigger: "initDataProvider",
              notify: true,
              hasUpdated: false,
            };
          }
        }
        checkingHash = false;

        this.log(
          traceId,
          LogLevel.Info,
          `Initializing from ${initSourceName}...`
        );
        if (!newInitData) {
          newInitData = await this.getInitData(
            traceId,
            initSourceName,
            initDataProvider.getInitData.bind(initDataProvider),
            retries
          );
        }

        const hasUpdated = this.updateInitData(
          traceId,
          initSourceName,
          newInitData,
          Date.now()
        );
        return {
          updateTrigger: "initDataProvider",
          notify: hasUpdated,
          hasUpdated,
        };
      } catch (error) {
        this.log(
          traceId,
          LogLevel.Error,
          `All attempts to ${checkingHash ? "check for updates" : "initialize"} from ${initSourceName} failed.`,
          getMetadata(error)
        );
        return {
          updateTrigger: "initDataProvider",
          notify: false,
          hasUpdated: false,
        };
      }
    });
  }

  private async getHashData(
    traceId: string,
    getInitDataHash: GetHashDataFunction,
    retries: number
  ): Promise<HashData> {
    this.log(traceId, LogLevel.Debug, "Getting latest commit hash...");

    const latestInitDataHash = await pRetry(
      (attemptNumber) => {
        this.log(
          traceId,
          LogLevel.Debug,
          `Attempt ${attemptNumber} to get latest commit hash...`
        );
        return getInitDataHash({
          traceId,
          initQuery: this.initQuery,
          variableValues: this.variableValues,
        });
      },
      {
        retries,
        maxTimeout: 6000,
        onFailedAttempt: (error) => {
          this.log(
            traceId,
            LogLevel.Debug,
            `Attempt ${error.attemptNumber} to get latest commit hash failed. There are ${error.retriesLeft} retries left.`,
            getMetadata(error)
          );
          if (this.shouldClose) {
            throw new pRetry.AbortError(
              `Stopped trying to get latest commit hash.`
            );
          }
        },
      }
    );

    return latestInitDataHash;
  }

  private getInitData(
    traceId: string,
    initSourceName: string,
    getInitData: GetInitDataFunction,
    retries: number
  ): Promise<InitData> {
    return pRetry(
      (attemptNumber) => {
        this.log(
          traceId,
          LogLevel.Debug,
          `Attempt ${attemptNumber} to initialize from ${initSourceName}...`
        );
        return getInitData({
          traceId,
          initQuery: this.initQuery,
          variableValues: this.variableValues,
        });
      },
      {
        retries,
        maxTimeout: 6000,
        onFailedAttempt: (error) => {
          this.log(
            traceId,
            LogLevel.Debug,
            `Attempt ${error.attemptNumber} to initialize from ${initSourceName} failed. There are ${error.retriesLeft} retries left.`,
            getMetadata(error)
          );
          if (this.shouldClose) {
            throw new pRetry.AbortError(
              `Stopped trying to initialize from ${initSourceName}.`
            );
          }
        },
      }
    );
  }

  private initAndStartIntervals(
    initTraceId: string,
    shouldRefreshInitDataOnCreate: boolean,
    shouldRefreshInitData: boolean
  ): void {
    if (!this.initDataProvider) {
      this.log(initTraceId, LogLevel.Info, "Not checking for updates.");
      return;
    }

    const providerName = this.initDataProvider.getName();

    // eslint-disable-next-line func-style
    const update = (traceId = uniqueId()): void => {
      if (this.shouldClose) {
        // No need to get updates when the SDK is shutting down.
        this.updateTimeout = null;
        this.log(
          traceId,
          LogLevel.Debug,
          `Stopped checking for updates from ${providerName}.`
        );
        return;
      }

      this.initIfNeeded(traceId, defaultRetries)
        .catch((error) =>
          this.log(
            traceId,
            LogLevel.Error,
            `Error updating from ${providerName}.`,
            getMetadata(error)
          )
        )
        .finally(() => {
          if (this.shouldClose) {
            this.updateTimeout = null;
            this.log(
              traceId,
              LogLevel.Debug,
              `Stopped checking for updates from ${providerName}.`
            );
            return;
          }
          if (shouldRefreshInitData) {
            this.updateTimeout = setTimeout(
              update,
              this.initDataRefreshIntervalMs
            );
          }
        });
    };

    if (shouldRefreshInitData) {
      this.log(
        initTraceId,
        LogLevel.Info,
        `Started checking for updates from ${providerName}.`
      );
    } else {
      this.log(
        initTraceId,
        LogLevel.Info,
        `Not checking for updates from ${providerName}.`
      );
    }
    if (
      shouldRefreshInitDataOnCreate &&
      // Only need to check for updates immediately when not skipping update
      // on refresh as otherwise it would trigger an unnecessary notification.
      !this.shouldSkipInitDataUpdateOnRefresh
    ) {
      update(initTraceId); // Refresh immediately
      return;
    }
    if (shouldRefreshInitData) {
      // Refresh after interval
      this.updateTimeout = setTimeout(update, this.initDataRefreshIntervalMs);
    }
  }

  // @internal
  isReady(): boolean {
    return this.initDataProvider
      ? !!this.lastInitDataRefreshTime
      : !!this.initData;
  }

  // @internal
  async close(traceId: string): Promise<void> {
    this.log(traceId, LogLevel.Info, "Closing...");
    this.shouldClose = true;

    if (this.updateTimeout) {
      clearTimeout(this.updateTimeout);
      this.updateTimeout = null;
    }
    await this.logger.close(traceId);

    this.log(traceId, LogLevel.Info, "Closed.");
  }

  // @internal
  getStateHash(): string {
    const initDataHash = this.initData?.hash ?? null;
    const ssOverride = stableStringify(this.override);
    const ssVariableValues = stableStringify(this.variableValues);

    return hash(`${ssVariableValues}/${initDataHash}/${ssOverride}`).toString();
  }

  // @internal
  addUpdateListener(listener: UpdateListener): void {
    this.updateListeners.set(listener, true);
  }

  // @internal
  removeUpdateListener(listener: UpdateListener): void {
    this.updateListeners.delete(listener);
  }

  // @internal
  setOverride<TOverride extends ObjectValue>(
    traceId: string,
    override: DeepPartial<TOverride> | null
  ): void {
    this.withUpdateNotification(() => {
      const hasUpdated = this.updateOverride(traceId, override);

      return { updateTrigger: "override", hasUpdated };
    });
  }

  private updateOverride<TOverride extends ObjectValue>(
    traceId: string,
    override: DeepPartial<TOverride> | null
  ): boolean {
    if (stableStringify(override) === stableStringify(this.override)) {
      if (override) {
        this.log(
          traceId,
          LogLevel.Debug,
          "Skipped setting override as it's equal to the one already set."
        );
      }
      return false;
    }

    this.override = override;
    this.log(traceId, LogLevel.Info, "Set override.", { override });
    return true;
  }

  // @internal
  dehydrate<TOverride extends ObjectValue, TVariableValues extends ObjectValue>(
    query?: Query<ObjectValueWithVariables>,
    variableValues?: TVariableValues
  ): DehydratedState<TOverride, TVariableValues> | null {
    // Create a copy of the init data as we are modifying it below.
    const initData = this.initData ? { ...this.initData } : null;
    if (!initData) {
      return null;
    }
    const { lastInitDataRefreshTime, override } = this;

    const dehydrateQuery = query ?? this.query;
    const dehydrateQueryVariableValues: TVariableValues =
      variableValues ?? (this.variableValues as TVariableValues);

    initData.reducedExpression = prefixError(
      () =>
        reduce(
          initData.splits,
          initData.commitConfig,
          dehydrateQuery,
          dehydrateQueryVariableValues,
          initData.reducedExpression,
          /* allowMissingVariables */ false
        ),
      "Reduction Error: "
    );
    const { splits, commitConfig } = getSplitsAndCommitConfigForExpression(
      initData.reducedExpression,
      initData.splits,
      initData.commitConfig
    );
    initData.splits = splits;
    initData.commitConfig = commitConfig;

    return {
      initData,
      lastInitDataRefreshTime,
      override: override as DeepPartial<TOverride> | null,
      variableValues: dehydrateQueryVariableValues,
    };
  }

  // @internal
  hydrate<TOverride extends ObjectValue, TVariableValues extends ObjectValue>(
    traceId: string,
    dehydratedState: DehydratedState<TOverride, TVariableValues>
  ): void {
    return this.withUpdateNotification(() => {
      this.log(traceId, LogLevel.Info, "Hydrating...");
      const { initData, override, variableValues, lastInitDataRefreshTime } =
        dehydratedState;

      const variableValuesUpdated =
        stableStringify(this.variableValues) !==
        stableStringify(variableValues);

      if (variableValuesUpdated) {
        this.variableValues = variableValues;
      }

      const overrideUpdated = this.updateOverride(traceId, override);

      let initDataUpdated = initData && initData.hash !== this.initData?.hash;
      if (initDataUpdated) {
        initDataUpdated = this.updateInitData(
          traceId,
          "hydration",
          initData,
          lastInitDataRefreshTime
        );
      } else {
        this.updateLastInitDataRefreshTime(lastInitDataRefreshTime);
      }
      this.log(traceId, LogLevel.Info, "Hydrated.");

      return {
        updateTrigger: "hydration",
        hasUpdated: variableValuesUpdated || overrideUpdated || initDataUpdated,
      };
    });
  }

  private updateLastInitDataRefreshTime(newTime: number | null): void {
    if (!this.initDataProvider || !newTime) {
      return;
    }
    // Don't set the time to a future value.
    const now = Date.now();
    this.lastInitDataRefreshTime = newTime > now ? now : newTime;
  }

  private withUpdateNotification(
    performUpdate: () => {
      updateTrigger: UpdateTrigger;
      hasUpdated: boolean;
    }
  ): void {
    const wasReady = this.isReady();
    const { updateTrigger, hasUpdated } = performUpdate();
    this.notifyUpdateListenersIfNeeded({
      notify: hasUpdated,
      wasReady,
      updateTrigger,
      hasUpdated,
    });
  }

  private async withUpdateNotificationAsync(
    performUpdate: () => Promise<{
      updateTrigger: UpdateTrigger;
      notify: boolean;
      hasUpdated: boolean;
    }>
  ): Promise<void> {
    const wasReady = this.isReady();
    const { updateTrigger, notify, hasUpdated } = await performUpdate();
    this.notifyUpdateListenersIfNeeded({
      notify,
      wasReady,
      updateTrigger,
      hasUpdated,
    });
  }

  private notifyUpdateListenersIfNeeded({
    notify,
    wasReady,
    updateTrigger,
    hasUpdated,
  }: {
    notify: boolean;
    wasReady: boolean;
    updateTrigger: UpdateTrigger;
    hasUpdated: boolean;
  }): void {
    const stateHash = this.getStateHash();
    const becameReady = !wasReady && this.isReady();

    if (becameReady || notify) {
      this.updateListeners.forEach((_, listener) => {
        listener(stateHash, { becameReady, updateTrigger, hasUpdated });
      });
    }
  }

  // @internal
  reduce(
    fieldQuery: FieldQuery<ObjectValueWithVariables> | null,
    expression: Expression
  ): Expression {
    const { splits, commitConfig } = nullThrows(
      this.initData,
      "No init data so cannot reduce expression."
    );

    return prefixError(
      () =>
        reduce(
          splits,
          commitConfig,
          fieldQuery
            ? {
                variableDefinitions: {},
                fragmentDefinitions: {},
                fieldQuery,
              }
            : null,
          /* variableValues */ {},
          expression,
          /* allowMissingVariables */ false
        ),
      "Reduction error: "
    );
  }

  private log(
    traceId: string,
    level: LogLevel,
    message: string,
    metadata: object = {}
  ): void {
    this.logger.log(
      level,
      this.initData?.commitId.toString() ?? null,
      message,
      { traceId, ...metadata }
    );
  }
}
