import pRetry from "p-retry";
import {
  CreateLogsInput,
  LogType,
  LogLevel,
  CountMap,
  Event,
  Exposure,
  LocalLogger,
  ObjectValue,
} from "../types";
import toDimensionTypeEnum from "./toDimensionTypeEnum";
import uniqueId from "./uniqueId";
import getMetadata from "./getMetadata";
import { graphqlTypeNameKey } from "../constants";

/**
 * `RemoteLogger` provides a granular API to send evaluations, events, exposures
 * and generic log messages to a remote server. It queues them internally and
 * sends them to the remote server when the flush method is called manually.
 * When `flushIntervalMs` option is set to a non null value it also sends them
 * periodically in the background.
 */
export default class RemoteLogger {
  private readonly token: string;
  private readonly queue: {
    logs: NonNullable<CreateLogsInput["logs"]>;
    evaluations: Map<
      /* Commit ID */ string,
      Map</* Expression ID */ string, number>
    >;
    events: NonNullable<CreateLogsInput["events"]>;
    exposures: NonNullable<CreateLogsInput["exposures"]>;
  } = {
    logs: [],
    evaluations: new Map(),
    events: [],
    exposures: [],
  };
  private readonly createLogs: (
    traceId: string,
    input: CreateLogsInput
  ) => Promise<void>;
  private readonly localLogger: LocalLogger;
  private shouldClose = false;
  private flushLogsTimeout: NodeJS.Timeout | null = null;

  constructor({
    traceId,
    token,
    createLogs,
    localLogger,
    flushIntervalMs,
  }: {
    traceId: string;
    token: string;
    createLogs: (
      createLogTraceId: string,
      input: CreateLogsInput
    ) => Promise<void>;
    localLogger: LocalLogger;
    flushIntervalMs: number | null;
  }) {
    this.token = token;
    this.createLogs = createLogs;
    this.localLogger = localLogger;
    this.startFlushIntervals(traceId, flushIntervalMs);
  }

  // eslint-disable-next-line max-params
  public log(
    type: LogType,
    level: LogLevel,
    commitId: string | null,
    message: string,
    metadata: object
  ): void {
    const { queue } = this;

    // Avoid memory leak in case we generate lots of logs or fail to flush them.
    if (queue.logs.length > 1000) {
      this.localLogger(
        LogLevel.Debug,
        "Ignoring log, as more than 1000 in the queue already.",
        /* metadata */ {}
      );
      return;
    }

    queue.logs.push({
      commitId,
      type,
      level,
      message,
      metadataJson: JSON.stringify(metadata),
      createdAt: new Date().toJSON(),
    });
  }

  public evaluations(commitId: string, evaluations: CountMap): void {
    const { queue } = this;

    let maybeCommitMap = queue.evaluations.get(commitId);
    if (!maybeCommitMap) {
      maybeCommitMap = new Map();
      queue.evaluations.set(commitId, maybeCommitMap);
    }
    const commitMap = maybeCommitMap;

    Object.entries(evaluations).forEach(([expressionId, count]) => {
      commitMap.set(expressionId, (commitMap.get(expressionId) ?? 0) + count);
    });
  }

  public events(commitId: string, events: Event[]): void {
    const { queue } = this;

    events.forEach(
      ({ objectTypeName: eventObjectTypeName, payload: eventPayload }) =>
        queue.events.push({
          commitId,
          eventObjectTypeName,
          eventPayloadJson: stringifyEventPayload(eventPayload),
          createdAt: new Date().toJSON(),
        })
    );
  }

  public exposures(commitId: string, exposures: Exposure[]): void {
    const { queue } = this;

    exposures.forEach(({ splitId, unitId, assignment, event }) =>
      queue.exposures.push({
        commitId,
        splitId,
        unitId,
        assignment: Object.entries(assignment).map(([dimensionId, entry]) => ({
          dimensionId,
          entryType: toDimensionTypeEnum(entry.type),
          ...(entry.type === "discrete"
            ? { discreteArmId: entry.armId }
            : { continuousValue: entry.value }),
        })),
        eventObjectTypeName: event?.objectTypeName ?? null,
        eventPayloadJson: stringifyEventPayload(event?.payload ?? null),
        createdAt: new Date().toJSON(),
      })
    );
  }

  public async flush(traceId: string): Promise<void> {
    const { queue, token, createLogs, localLogger } = this;

    const anythingToFlush =
      queue.logs.length > 0 ||
      queue.evaluations.size > 0 ||
      queue.events.length > 0 ||
      queue.exposures.length > 0;

    if (!anythingToFlush) {
      return;
    }

    // Only include 1 random log to ensure the payload doesn't get too big
    const randomLog =
      queue.logs.length > 0
        ? queue.logs[Math.floor(queue.logs.length * Math.random())]
        : null;

    const createLogsInput: CreateLogsInput = {
      token,
      idempotencyKey: uniqueId(),
      logs: randomLog ? [randomLog] : [],
      evaluations: [...queue.evaluations.entries()].flatMap(
        ([commitId, commitMap]) =>
          [...commitMap.entries()].map(([expressionId, count]) => ({
            commitId,
            expressionId,
            count,
          }))
      ),
      events: queue.events,
      exposures: queue.exposures,
    };

    queue.logs = [];
    queue.evaluations.clear();
    queue.events = [];
    queue.exposures = [];

    try {
      await pRetry(
        (attemptNumber) => {
          localLogger(
            LogLevel.Debug,
            `Attempt ${attemptNumber} to flush logs to backend...`,
            /* metadata */ { traceId, createLogsInput }
          );
          return createLogs(traceId, createLogsInput);
        },
        {
          onFailedAttempt: (error) => {
            localLogger(
              LogLevel.Debug,
              `Attempt ${error.attemptNumber} to flush logs failed. There are ${error.retriesLeft} retries left.`,
              { traceId, ...getMetadata(error), createLogsInput }
            );
          },
        }
      );

      localLogger(
        LogLevel.Debug,
        "Successfully flushed logs to backend.",
        /* metadata */ { traceId, createLogsInput }
      );
    } catch (error) {
      localLogger(
        LogLevel.Error,
        "All attempts to flush logs failed. These logs will be lost.",
        { traceId, ...getMetadata(error), createLogsInput }
      );
    }
  }

  public async close(traceId: string): Promise<void> {
    this.shouldClose = true;

    if (this.flushLogsTimeout) {
      clearTimeout(this.flushLogsTimeout);
      this.flushLogsTimeout = null;
    }
    await this.flush(traceId);
  }

  private startFlushIntervals(
    initTraceId: string,
    flushIntervalMs: number | null
  ): void {
    if (!flushIntervalMs) {
      this.localLogger(LogLevel.Info, "Not automatically flushing logs.", {
        traceId: initTraceId,
      });
      return;
    }

    // eslint-disable-next-line func-style
    const flushLogQueue = (): void => {
      this.flushLogsTimeout = setTimeout(() => {
        const traceId = uniqueId();

        if (this.shouldClose) {
          this.flushLogsTimeout = null;
          this.localLogger(LogLevel.Debug, "Stopped flushing log queue.", {
            traceId,
          });
          return;
        }

        this.flush(traceId)
          .catch((error) =>
            this.localLogger(LogLevel.Error, "Error flushing logs.", {
              ...getMetadata(error),
              traceId,
            })
          )
          .finally(() => {
            if (this.shouldClose) {
              this.flushLogsTimeout = null;
              this.localLogger(LogLevel.Debug, "Stopped flushing log queue.", {
                traceId,
              });
              return;
            }
            flushLogQueue();
          });
      }, flushIntervalMs);
    };
    flushLogQueue();
  }
}

function stringifyEventPayload(
  eventPayload: ObjectValue | null
): string | null {
  return eventPayload
    ? JSON.stringify(eventPayload, (key, value) =>
        key === graphqlTypeNameKey ? undefined : value
      )
    : null;
}
