/* eslint-disable no-underscore-dangle */
import {
  evaluate,
  nullThrows,
  prefixError,
  getExpressionEvaluationCountLogs,
  mergeLogs,
  Expression,
  Fragment,
  ObjectValue,
  Query,
  Selection,
  Step,
  Value,
  breakingSchemaChangesError,
  UpdateListener,
  asError,
  DehydratedState,
  DeepPartial,
  LogLevel,
  uniqueId,
  ObjectValueWithVariables,
  FieldQuery,
  Logs,
  formatHashData,
  InitData,
} from "../shared";
import getMetadata from "../shared/helpers/getMetadata";
import merge from "./merge";
import throwIfObjectValueIsInvalid from "../shared/helpers/throwIfObjectValueIsInvalid";
import Context from "./Context";
import Logger from "./Logger";
import getLocalLogArguments from "../shared/helpers/getLocalLogArguments";
import getNodeCacheKey from "./getNodeCacheKey";
import getNodePath, { getJsonNodePathAndArgs } from "./getNodePath";

export type NodeProps = {
  readonly logger: Logger | null;
  readonly context: Context | null;
  readonly parent: Node | null;
  readonly step: Step | null;
  readonly initDataHash: string | null;
  readonly expression: Expression | null;
};

export default class Node {
  // @internal
  public props: NodeProps;
  // This should be overridden by subclasses as the constructor name may be
  // minified and mangled by bundlers
  public typeName: string = this.constructor.name;

  constructor(props: NodeProps) {
    this.props = props;
  }

  protected updateIfNeeded(): void {
    const { context, parent, step } = this.props;

    if (!context || !context.initData) {
      return;
    }

    // If we're on the latest commit hash, we don't need to update
    if (this.props.initDataHash === context.initData.hash) {
      return;
    }

    // If we don't have a parent, we're the source node
    if (!parent) {
      this.props = {
        ...this.props,
        initDataHash: context.initData.hash,
        expression: context.initData.reducedExpression,
      };
      return;
    }

    // Update from the parent
    if (!step) {
      throw new Error("Node update error: Missing step.");
    }

    switch (step.type) {
      case "GetFieldStep": {
        this.props = parent.getFieldNodeProps(step.fieldName, {
          fieldArguments: step.fieldArguments,
        });
        break;
      }
      case "GetItemStep": {
        const newProps: NodeProps | undefined = parent.getItemNodeProps({
          fallbackLength: step.fallbackLength,
        })[step.index];

        if (!newProps) {
          throw new Error("Node update error: No item props.");
        }

        this.props = newProps;
        break;
      }
      default: {
        const neverStep: never = step;
        throw new Error(`Unexpected step: ${JSON.stringify(neverStep)}`);
      }
    }
  }

  getFieldNode(
    fieldName: string,
    { fieldArguments = {} }: { fieldArguments?: ObjectValue } = {}
  ): Node {
    return new Node(this.getFieldNodeProps(fieldName, { fieldArguments }));
  }

  /**
   * @deprecated This method will be removed in the next major SDK version.
   */
  protected getField(
    fieldName: string,
    fieldArguments: ObjectValue
  ): NodeProps {
    return this.getFieldNodeProps(fieldName, { fieldArguments });
  }

  protected getFieldNodeProps(
    fieldName: string,
    { fieldArguments = {} }: { fieldArguments?: ObjectValue } = {}
  ): NodeProps {
    const step: Step = { type: "GetFieldStep", fieldName, fieldArguments };
    const { context } = this.props;
    const initDataHash = context?.initData?.hash;

    if (!initDataHash || !context.getFieldCache) {
      // No caching if the sdk hasn't been initialized or there is no cache.
      return this.createProps(
        step,
        this.getReducedFieldExpression(fieldName, fieldArguments)
      );
    }

    const cacheKey = getNodeCacheKey(
      initDataHash,
      getNodePath(/* parent */ this, step),
      /* suffix */ ""
    );
    const cachedReducedFieldExpression = context.getFieldCache.get(cacheKey);
    if (cachedReducedFieldExpression) {
      return this.createProps(step, cachedReducedFieldExpression);
    }

    const reducedFieldExpression = this.getReducedFieldExpression(
      fieldName,
      fieldArguments
    );
    if (reducedFieldExpression) {
      context.getFieldCache.set(cacheKey, reducedFieldExpression);
    }
    return this.createProps(step, reducedFieldExpression);
  }

  // @internal
  private getReducedFieldExpression(
    fieldName: string,
    fieldArguments: ObjectValue
  ): Expression | null {
    try {
      prefixError(
        () => throwIfObjectValueIsInvalid(fieldArguments),
        "Invalid field arguments: "
      );

      this.updateIfNeeded();

      const { expression } = this.props;

      if (!expression) {
        this.log(
          LogLevel.Debug,
          `Using fallback for field "${fieldName}" as expression is null. This is expected before initialization.`
        );
        return null;
      }

      if (expression.type !== "ObjectExpression") {
        throw new Error(
          `Cannot get field "${fieldName}" as expression type is "${expression.type}".`
        );
      }

      const context = nullThrows(
        this.props.context,
        `Cannot get field "${fieldName}" as context is null.`
      );

      const selection: Selection<ObjectValue> = {
        [fieldName]: { fieldArguments, fieldQuery: null },
      };
      const { objectTypeName } = expression;
      const fragment: Fragment<ObjectValue> = {
        type: "InlineFragment",
        objectTypeName,
        selection,
      };
      const fieldQuery: FieldQuery<ObjectValue> = {
        [objectTypeName]: fragment,
      };

      const reducedObjectExpression = context.reduce(fieldQuery, expression);
      if (reducedObjectExpression.type !== "ObjectExpression") {
        throw new Error(
          `Cannot get field "${fieldName}" as reduced expression type is "${expression.type}".`
        );
      }

      const reducedFieldExpression = nullThrows(
        reducedObjectExpression.fields[fieldName],
        `Object expression does not contain field "${fieldName}".`
      );
      reducedFieldExpression.logs = mergeLogs(
        reducedObjectExpression.logs,
        getExpressionEvaluationCountLogs(reducedObjectExpression),
        reducedFieldExpression.logs
      );

      return reducedFieldExpression;
    } catch (error) {
      this.log(
        LogLevel.Error,
        `Error getting field "${fieldName}" with arguments ${JSON.stringify(fieldArguments)}: ${asError(error).message}`,
        getMetadata(error)
      );
      return null;
    }
  }

  getItemNodes({
    fallbackLength = 0,
  }: { fallbackLength?: number } = {}): Node[] {
    return this.getItemNodeProps({ fallbackLength }).map(
      (props) => new Node(props)
    );
  }

  /**
   * @deprecated This method will be removed in the next major SDK version.
   */
  _getItems(fallbackLength: number): NodeProps[] {
    return this.getItemNodeProps({ fallbackLength });
  }

  getItemNodeProps({
    fallbackLength = 0,
  }: { fallbackLength?: number } = {}): NodeProps[] {
    const { context, parent, step } = this.props;
    const initDataHash = context?.initData?.hash;

    if (!initDataHash || !context.getItemsCache) {
      // No caching if the sdk hasn't been initialized or there is no cache.
      return this.createPropsArray(this._getItemExpressions(), fallbackLength);
    }

    const cacheKey = getNodeCacheKey(
      initDataHash,
      getNodePath(parent, step),
      /* suffix */ ""
    );
    const cachedItemExpressions = context.getItemsCache.get(cacheKey);
    if (cachedItemExpressions) {
      return this.createPropsArray(cachedItemExpressions, fallbackLength);
    }

    const itemExpressions = this._getItemExpressions();
    if (itemExpressions) {
      context.getItemsCache.set(cacheKey, itemExpressions);
    }
    return this.createPropsArray(itemExpressions, fallbackLength);
  }

  // @internal
  private _getItemExpressions(): Expression[] | null {
    try {
      this.updateIfNeeded();

      const { expression } = this.props;

      if (!expression) {
        this.log(
          LogLevel.Debug,
          "Using fallback for array items as expression is null. This is expected before initialization."
        );
        return null;
      }

      if (expression.type !== "ListExpression") {
        throw new Error(
          `Cannot get items as expression type is "${expression.type}". ${breakingSchemaChangesError}`
        );
      }

      const listLogs = mergeLogs(
        expression.logs,
        getExpressionEvaluationCountLogs(expression)
      );

      const result: Expression[] = expression.items.map((item, index) => {
        const itemExpression = nullThrows(
          item,
          `List expression has null item at index ${index}.`
        );
        itemExpression.logs = mergeLogs(listLogs, itemExpression.logs);

        return itemExpression;
      });

      return result;
    } catch (error) {
      this.log(
        LogLevel.Error,
        `Error getting items: ${asError(error).message}`,
        getMetadata(error)
      );
      return null;
    }
  }

  getFieldValue(
    fieldName: string,
    {
      fallback,
      query = null,
      fieldArguments = {},
    }: {
      fallback: Value;
      query?: FieldQuery<ObjectValue> | null;
      fieldArguments?: ObjectValue;
    }
  ): Value {
    return this.getFieldNode(fieldName, { fieldArguments }).getValue({
      fallback,
      query,
    });
  }

  /**
   * @deprecated This method will be removed in the next major SDK version.
   */
  protected evaluate(
    query: FieldQuery<ObjectValue> | null,
    fallback: Value
  ): Value {
    return this.getValue({ query, fallback });
  }

  getValue({
    query = null,
    fallback,
  }: {
    query?: FieldQuery<ObjectValue> | null;
    fallback: Value;
  }): Value {
    const valueAndLogs = this.getValueAndLogsWithCache(query);
    const {
      value: valueWithoutOverride,
      logs: reductionLogs,
      path,
      args,
      shouldLogEvaluation,
    } = valueAndLogs;

    const isFallback = valueWithoutOverride === null;
    const value = merge(
      isFallback ? fallback : valueWithoutOverride,
      this.getNodeOverride()
    );

    this.logReductionLogs({
      ...reductionLogs,
      evaluationList: shouldLogEvaluation
        ? [
            ...(reductionLogs.evaluationList ?? []),
            { path, value, args, isFallback },
          ]
        : reductionLogs.evaluationList,
    });

    return value;
  }

  private getNodeOverride(): DeepPartial<Value> {
    const { context, parent, step } = this.props;

    if (!context) {
      return undefined;
    }

    if (context.override === null || context.override === undefined) {
      // Short-circuit if no override in context
      return undefined;
    }

    if (!parent) {
      // We're the Query node
      return context.override ?? undefined;
    }

    if (!step) {
      return undefined;
    }

    const parentOverride = parent.getNodeOverride();

    if (parentOverride === null || parentOverride === undefined) {
      return undefined;
    }

    if (step.type === "GetFieldStep") {
      return (parentOverride as ObjectValue)[step.fieldName] ?? undefined;
    }

    return (parentOverride as Value[])[step.index] ?? undefined;
  }

  // @internal
  protected getValueAndLogsWithCache(query: FieldQuery<ObjectValue> | null): {
    value: Value | null;
    logs: Logs;
    path: string;
    args: { [path: string]: ObjectValue };
    shouldLogEvaluation: boolean;
  } {
    const { context, parent, step } = this.props;
    const initDataHash = context?.initData?.hash;

    if (!initDataHash || !context.evaluateCache) {
      // No caching if the sdk hasn't been initialized or there is no cache.
      return this.getValueAndLogs(query);
    }

    const cacheKey = getNodeCacheKey(
      initDataHash,
      getNodePath(parent, step),
      /* suffix */ JSON.stringify(query)
    );
    const cachedValueAndLogs = context.evaluateCache.get(cacheKey);
    if (cachedValueAndLogs) {
      return cachedValueAndLogs;
    }

    const valueAndLogs = this.getValueAndLogs(query);
    if (valueAndLogs.value !== null) {
      context.evaluateCache.set(cacheKey, valueAndLogs as any);
    }
    return valueAndLogs;
  }

  // @internal
  private getValueAndLogs(query: FieldQuery<ObjectValue> | null): {
    value: Value | null;
    logs: Logs;
    path: string;
    args: { [path: string]: ObjectValue };
    shouldLogEvaluation: boolean;
  } {
    const { path, args } = getJsonNodePathAndArgs(
      this.props.parent,
      this.props.step
    );

    try {
      this.updateIfNeeded();
      const { expression } = this.props;

      if (!expression) {
        return {
          path,
          args,
          value: null,
          logs: {
            messageList: [
              {
                level: LogLevel.Debug,
                message: `Using fallback while evaluating as expression is null. This is expected before initialization.`,
                metadata: {},
              },
            ],
          },
          shouldLogEvaluation: true,
        };
      }

      const context = nullThrows(
        this.props.context,
        "Cannot evaluate as context is null."
      );

      const reducedExpression = context.reduce(query, expression);

      const { value, logs, shouldLogEvaluation } = prefixError(
        () => evaluate(reducedExpression),
        "Evaluation error: "
      );

      return { value, logs, path, args, shouldLogEvaluation };
    } catch (error) {
      return {
        path,
        args,
        value: null,
        logs: {
          messageList: [
            {
              level: LogLevel.Error,
              message: `Error getting value and logs: ${asError(error).message}`,
              metadata: getMetadata(error),
            },
          ],
        },
        shouldLogEvaluation: true,
      };
    }
  }

  // @internal
  private createProps(step: Step, expression: Expression | null): NodeProps {
    const { context, logger, initDataHash } = this.props;
    return { step, parent: this, context, expression, logger, initDataHash };
  }

  // @internal
  private createPropsArray(
    itemExpressions: Expression[] | null,
    fallbackLength: number
  ): NodeProps[] {
    return (itemExpressions || Array(fallbackLength).fill(null)).map(
      (expression, index) =>
        this.createProps(
          { type: "GetItemStep", index, fallbackLength },
          expression
        )
    );
  }

  _logUnexpectedTypeError(): void {
    if (!this.props.expression) {
      this.log(
        LogLevel.Debug,
        `Unexpected expression type as expression is null but this is expected before initialization.`
      );
      return;
    }

    this.log(LogLevel.Error, "Unexpected expression type.");
  }

  protected logUnexpectedValueError(value: Value): void {
    this.log(
      LogLevel.Error,
      `Evaluated to unexpected value: ${JSON.stringify(value)}`
    );
  }

  private log(level: LogLevel, message: string, metadata: object = {}): void {
    this.logReductionLogs({
      messageList: [{ level, message, metadata }],
    });
  }

  private logReductionLogs(reductionLogs: Logs): void {
    const { typeName } = this;
    const { parent, step, logger, expression, initDataHash } = this.props;
    const commitId = this.props.context?.initData?.commitId.toString() ?? null;

    const nodePath = getNodePath(parent, step);

    if (!logger) {
      // eslint-disable-next-line no-console
      console.error(
        ...getLocalLogArguments(
          `No logger for ${typeName}Node at ${nodePath} to log reduction logs`,
          { reductionLogs }
        )
      );
      return;
    }

    logger.nodeLog({
      commitId,
      initDataHash,
      nodeTypeName: typeName,
      nodePath,
      nodeExpression: expression,
      reductionLogs,
    });
  }

  getStateHash(): string | null {
    const { context } = this.props;
    if (!context) {
      this.log(LogLevel.Error, "No context so cannot get state hash.");
      return null;
    }
    return context.getStateHash();
  }

  getInitResponse(): InitData | null {
    const { context } = this.props;
    if (!context || !context.initData) {
      this.log(LogLevel.Error, "No context so cannot get init data.");
      return null;
    }
    return context.initData;
  }

  getHashResponse(): string | null {
    const { context } = this.props;
    if (!context || !context.initData) {
      this.log(LogLevel.Error, "No context so cannot get hash data.");
      return null;
    }
    return formatHashData(context.initData);
  }

  addUpdateListener(listener: UpdateListener): void {
    const { context } = this.props;
    if (!context) {
      this.log(LogLevel.Error, "No context so cannot add update listener.");
      return;
    }
    context.addUpdateListener(listener);
  }

  removeUpdateListener(listener: UpdateListener): void {
    const { context } = this.props;
    if (!context) {
      this.log(LogLevel.Error, "No context so cannot remove update listener.");
      return;
    }
    context.removeUpdateListener(listener);
  }

  /**
   * Initialize from the init data provider if needed
   */
  initIfNeeded(traceId = uniqueId(), retries = 0): Promise<void> {
    const { context } = this.props;
    if (!context) {
      this.log(
        LogLevel.Error,
        "No context so cannot initialize from the data provider."
      );
      return Promise.resolve();
    }
    return context.initIfNeeded(traceId, retries);
  }

  /**
   * Returns the timestamp of the last time the SDK was initialized from
   * the init data provider
   */
  getLastInitDataRefreshTime(): number | null {
    const { context } = this.props;
    if (!context) {
      this.log(
        LogLevel.Error,
        "No context so cannot get the last data provider init time."
      );
      return null;
    }
    return context.lastInitDataRefreshTime;
  }

  /**
   * @returns @deprecated use `getLastInitDataRefreshTime` instead
   */
  getLastDataProviderInitTime(): number | null {
    return this.getLastInitDataRefreshTime();
  }

  /**
   * Indicates whether the SDK is ready to evaluate flags and log events.
   */
  isReady(): boolean {
    const { context } = this.props;
    if (!context) {
      return false;
    }
    return context.isReady();
  }

  flushLogs(traceId = uniqueId()): Promise<void> {
    const { context } = this.props;
    if (!context) {
      this.log(LogLevel.Error, "No context so cannot flush logs.");
      return Promise.resolve();
    }
    return context.logger.flush(traceId);
  }

  setOverride<T extends ObjectValue>(
    override: DeepPartial<T> | null,
    traceId = uniqueId()
  ): void {
    const { context } = this.props;
    if (!context) {
      this.log(LogLevel.Error, "No context so cannot set override.");
      return;
    }
    context.setOverride<T>(traceId, override);
  }

  dehydrate<TOverride extends ObjectValue, TVariableValues extends ObjectValue>(
    query?: Query<ObjectValueWithVariables>,
    variableValues?: TVariableValues
  ): DehydratedState<TOverride, TVariableValues> | null {
    const { context } = this.props;
    if (!context) {
      this.log(LogLevel.Error, "No context so cannot dehydrate.");
      return null;
    }
    return context.dehydrate(query, variableValues);
  }

  hydrate<TOverride extends ObjectValue, TVariableValues extends ObjectValue>(
    dehydratedState: DehydratedState<TOverride, TVariableValues>,
    traceId = uniqueId()
  ): void {
    const { context } = this.props;
    if (!context) {
      this.log(LogLevel.Error, "No context so cannot hydrate.");
      return;
    }
    context.hydrate(traceId, dehydratedState);
  }

  /**
   * Close flushes any remaining logs and stops all background processes
   * ensuring clean shutdown.
   */
  close(traceId = uniqueId()): Promise<void> {
    const { context } = this.props;
    if (!context) {
      this.log(LogLevel.Error, "No context so cannot close.");
      return Promise.resolve();
    }
    return context.close(traceId);
  }

  getFlagValues<
    FlagValues extends ObjectValue,
    FlagPath extends keyof FlagValues & string,
  >({
    flagFallbacks,
    flagPaths,
  }: {
    flagFallbacks: FlagValues;
    flagPaths: FlagPath[];
  }): Pick<FlagValues, FlagPath> {
    return flagPaths.reduce<Pick<FlagValues, FlagPath>>(
      (current, flag) => {
        current[flag] = this.getFlagValue(
          flag,
          flagFallbacks[flag]
        ) as FlagValues[FlagPath];

        return current;
      },
      {} as Pick<FlagValues, FlagPath>
    );
  }

  private getFlagValue(flagPath: string, fallback: Value): Value {
    return flagPath
      .split(".")
      .reduce<Node>((node, step) => node.getFieldNode(step, {}), this)
      .getValue({ fallback });
  }

  getEncodedFlagValues<
    FlagValues extends ObjectValue,
    Flag extends keyof FlagValues & string,
  >({
    flagFallbacks,
    flagPaths,
  }: {
    flagFallbacks: FlagValues;
    flagPaths: Flag[];
  }): string {
    return btoa(
      JSON.stringify(this.getFlagValues({ flagFallbacks, flagPaths }))
    );
  }
}
