import {
  Expression,
  sdkVersion,
  LocalLogger,
  CreateLogsInput,
  LogLevel,
  LogType,
  TracedFetch,
} from "../shared";
import { LogsHandler, Logs, RemoteLoggingMode } from "../shared/types";
import RemoteLogger from "../shared/helpers/RemoteLogger";
import LRUCache from "../shared/helpers/LRUCache";
import getNodeCacheKey from "./getNodeCacheKey";
import newTracedFetch from "../shared/helpers/newTracedFetch";

const fetchMaxKeepAliveRequestSizeBytes = 64_000;

/**
 * Logger provides a high level API for Node and generic SDK logs. It emits
 * these logs using the `localLogger` and it also forwards them to the
 * `remoteLogger` based on the provided `remoteLoggingMode`.
 */
export default class Logger {
  public readonly id: string;
  private readonly remoteLoggingMode: RemoteLoggingMode;
  private readonly remoteLogCache: LRUCache<boolean> = new LRUCache(10_000);
  private readonly remoteLogger: RemoteLogger | null;
  private readonly localLogger: LocalLogger;
  private readonly logsHandler: LogsHandler;

  constructor({
    id,
    traceId,
    token,
    remoteLoggingMode,
    remoteFlushIntervalMs,
    remoteLoggingEndpointUrl,
    localLogger,
    logsHandler,
  }: {
    id: string;
    traceId: string;
    token: string;
    remoteLoggingMode: RemoteLoggingMode;
    remoteFlushIntervalMs: number | null;
    remoteLoggingEndpointUrl: string;
    logsHandler: LogsHandler;
    localLogger: LocalLogger;
  }) {
    this.id = id;
    this.remoteLoggingMode = remoteLoggingMode;

    this.logsHandler = logsHandler;
    this.localLogger = localLogger;

    this.remoteLogger =
      remoteLoggingMode === "off"
        ? null
        : new RemoteLogger({
            traceId,
            token,
            createLogs: getCreateLogsFunction(
              newTracedFetch({
                timeoutMs: 20_000,
                localLogger: this.localLogger,
              }),
              remoteLoggingEndpointUrl
            ),
            localLogger: this.localLogger,
            flushIntervalMs: remoteFlushIntervalMs,
          });
    if (remoteLoggingMode === "off") {
      this.localLogger(LogLevel.Info, "Remote logging is disabled.", {
        traceId,
      });
    }
  }

  public nodeLog({
    commitId,
    initDataHash,
    nodeTypeName,
    nodePath,
    nodeExpression,
    reductionLogs,
  }: {
    commitId: string | null;
    initDataHash: string | null;
    nodeTypeName: string;
    nodePath: string;
    nodeExpression: Expression | null;
    reductionLogs: Logs;
  }): void {
    const baseLogMetadata = {
      sdkVersion,
      nodeTypeName,
      nodePath,
      nodeExpression,
      reductionLogs,
    };
    this.logsHandler({
      messageList: reductionLogs.messageList ?? [],
      eventList: reductionLogs.eventList ?? [],
      exposureList: reductionLogs.exposureList ?? [],
      evaluationList: reductionLogs.evaluationList ?? [],
    });

    reductionLogs.messageList?.forEach(({ level, message, metadata }) => {
      const logMessage = `${nodeTypeName}Node at ${nodePath}: ${message}`;
      const logMetadata = { ...baseLogMetadata, ...metadata };

      if (
        (level === LogLevel.Warn || level === LogLevel.Error) &&
        this.shouldRemoteNodeLog(initDataHash, nodePath, message)
      ) {
        this.remoteLogger?.log(
          LogType.SdkNode,
          level,
          commitId,
          logMessage,
          logMetadata
        );
      }
    });

    if (
      reductionLogs &&
      (reductionLogs.evaluations ||
        reductionLogs.eventList ||
        reductionLogs.exposureList)
    ) {
      if (!commitId) {
        const errorMessage = `${nodeTypeName}Node at ${nodePath}: Missing commitId so cannot remote log evaluations, events and exposures.`;
        this.localLogger(LogLevel.Error, errorMessage, baseLogMetadata);
        if (this.shouldRemoteNodeLog(initDataHash, nodePath, errorMessage)) {
          this.remoteLogger?.log(
            LogType.SdkNode,
            LogLevel.Error,
            commitId,
            errorMessage,
            baseLogMetadata
          );
        }
        return;
      }
      if (
        reductionLogs.evaluations &&
        this.shouldRemoteNodeLog(initDataHash, nodePath, "evaluations")
      ) {
        this.remoteLogger?.evaluations(commitId, reductionLogs.evaluations);
      }

      if (reductionLogs.eventList) {
        // We always log events to the backend
        this.remoteLogger?.events(commitId, reductionLogs.eventList);
      }

      if (
        reductionLogs.exposureList &&
        this.shouldRemoteNodeLog(initDataHash, nodePath, "exposures")
      ) {
        this.remoteLogger?.exposures(commitId, reductionLogs.exposureList);
      }
    }
  }

  private shouldRemoteNodeLog(
    initDataHash: string | null,
    nodePath: string,
    cacheKeySuffix: string
  ): boolean {
    switch (this.remoteLoggingMode) {
      case "session": {
        const cacheKey = getNodeCacheKey(
          initDataHash ?? "",
          nodePath,
          cacheKeySuffix
        );

        if (this.remoteLogCache.get(cacheKey)) {
          this.localLogger(
            LogLevel.Debug,
            `Remote log cache hit.`,
            /* metadata */ { initDataHash, nodePath, cacheKeySuffix }
          );
          return false;
        }

        this.remoteLogCache.set(cacheKey, true);
        return true;
      }

      case "normal": {
        return true;
      }

      case "off": {
        return false;
      }

      default: {
        const neverLoggingMode: never = this.remoteLoggingMode;
        throw new Error(`Unexpected logging mode: ${neverLoggingMode}`);
      }
    }
  }

  public log(
    level: LogLevel,
    commitId: string | null,
    message: string,
    metadata: object
  ): void {
    this.localLogger(level, message, metadata);
    if (
      this.remoteLoggingMode !== "off" &&
      (level === LogLevel.Warn || level === LogLevel.Error)
    ) {
      this.remoteLogger?.log(LogType.SdkMessage, level, commitId, message, {
        ...metadata,
        sdkVersion,
      });
    }
  }

  public flush(traceId: string): Promise<void> {
    return this.remoteLogger
      ? this.remoteLogger.flush(traceId)
      : Promise.resolve();
  }

  public close(traceId: string): Promise<void> {
    return this.remoteLogger
      ? this.remoteLogger.close(traceId)
      : Promise.resolve();
  }
}

function getCreateLogsFunction(tracedFetch: TracedFetch, logsUrl: string) {
  return async (traceId: string, input: CreateLogsInput) => {
    const bodyJson = JSON.stringify(input);
    const bodyBlob = new Blob([bodyJson]);

    const response = await tracedFetch(traceId, logsUrl, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Cache-Control": "no-store",
      },
      body: bodyJson,
      // Only use keepalive if the request is smaller than the maximum
      // allowed size with keepalive enabled.
      keepalive: bodyBlob.size < fetchMaxKeepAliveRequestSizeBytes,
    });
    if (!response.ok) {
      throw new Error(
        `Failed to create logs status: "${response.status}" response: "${await response.text()}"`
      );
    }

    const data: { success: boolean } = await response.json();
    if (!data.success) {
      throw new Error(
        `Failed to create logs status: "${response.status}" response: "${JSON.stringify(data)}"`
      );
    }
  };
}
