import { styleText } from 'util';

const enum LogLevel {
  ERROR = 'error',
  WARN = 'warn',
  INFO = 'info',
  LOG = 'log',
  DEBUG = 'debug',
}

type PrimitiveValue = undefined | null | string | string[] | number | number[] | boolean | boolean[];
type Value = {
  [key: string]: PrimitiveValue | Value | PrimitiveValue[] | Value[];
};

export type Metadata = Value & { message?: never };
type ForbidenMetadataKey = 'message';

/**
 * See https://docs.datadoghq.com/logs/log_collection/?tab=host#custom-log-forwarding
 * - Datadog Agent splits at 256kB (256000 bytes)...
 * - ... but the same docs say that "for optimal performance, it is
 *   recommended that an individual log be no greater than 25kB"
 * -> Truncating at 25kB - a bit of wiggle room for metadata = 20kB.
 */
const MAX_LOG_MESSAGE_SIZE = parseInt(process.env.MAX_LOG_MESSAGE_SIZE ?? '20000', 10);
const LOG_LINE_TRUNCATED_SUFFIX = ' - LOG LINE TRUNCATED';

/**
 * For *LogMeta* sanitization, we let in anything that was passed, except for clearly-problematic keys
 */
const LOGMETA_BLACKLIST = [
  // Security
  'access_token',
  'bot_auth_code',
  'client_secret',
  'jwt',
  'oauth_token',
  'password',
  'refresh_token',
  'shared_secret',
  'token',
  // Privacy
  'billing_email',
  'email',
  'first_name',
  'last_name',
];

/**
 * Logger class that can be configured with metadata add creation and when logging to add additional context to your logs.
 */
export default class Logger {
  private metadata: Metadata;
  constructor(
    metadata: Metadata = {},
    private isDisabled: boolean = false,
  ) {
    this.metadata = structuredClone(metadata);
  }

  /**
   * Logs a message with the 'log' log level.
   * @param message The message to be logged.
   * @param metadata Optional metadata to be associated with the log message.
   */
  public log(message: string, metadata?: Metadata): void {
    this.send(LogLevel.LOG, message, metadata);
  }

  /**
   * Logs an error message with the 'error' log level.
   * @param message The error message to be logged.
   * @param metadata Optional metadata to be associated with the log message.
   */
  public error(message: string, metadata?: Metadata): void {
    this.send(LogLevel.ERROR, message, metadata);
  }

  /**
   * Logs a warning message with the 'warn' log level.
   * @param message The warning message to be logged.
   * @param metadata Optional metadata to be associated with the log message.
   */
  public warn(message: string, metadata?: Metadata): void {
    this.send(LogLevel.WARN, message, metadata);
  }

  /**
   * Logs an informational message with the 'info' log level.
   * @param message The informational message to be logged.
   * @param metadata Optional metadata to be associated with the log message.
   */
  public info(message: string, metadata?: Metadata): void {
    this.send(LogLevel.INFO, message, metadata);
  }

  /**
   * Logs a debug message with the 'debug' log level.
   * @param message The debug message to be logged.
   * @param metadata Optional metadata to be associated with the log message.
   */
  public debug(message: string, metadata?: Metadata): void {
    this.send(LogLevel.DEBUG, message, metadata);
  }

  /**
   * Decorates the logger with additional metadata.
   * @param metadata Additional metadata to be added to the logger.
   */
  public decorate(metadata: Metadata): void {
    this.metadata = { ...this.metadata, ...metadata };
  }

  /**
   * Return a copy of the Logger's metadata.
   * @returns The  {@link Metadata} associated with the logger.
   */
  public getMetadata(): Metadata {
    return structuredClone(this.metadata);
  }

  /**
   * Sets a key-value pair in the metadata. If the key already exists, it will be overwritten.
   *
   * @param key Key of the metadata to be set.
   * May be any string other than 'message', which is reserved for the actual message logged.
   * @param value Value of the metadata to be set.
   */
  public setMetadata<Key extends string>(
    key: Key extends ForbidenMetadataKey ? never : Key,
    value: PrimitiveValue | Value,
  ): void {
    this.metadata[key] = value;
  }

  /**
   * Clears the Logger's metadata.
   */
  public clearMetadata(): void {
    this.metadata = {};
  }

  private send(logLevel: LogLevel, message: string, metadata?: Metadata): void {
    if (this.isDisabled) {
      return;
    }

    // We need to provide the date to Datadog. Otherwise, the date is set to when they receive the log.
    const date = Date.now();

    if (message.length > MAX_LOG_MESSAGE_SIZE) {
      message = `${message.substring(0, MAX_LOG_MESSAGE_SIZE)}${LOG_LINE_TRUNCATED_SUFFIX}`;
    }

    let processedMetadata = Logger.snakifyKeys({ ...this.metadata, ...metadata, logMessageSize: message.length });
    processedMetadata = Logger.pruneSensitiveMetadata(processedMetadata);

    const processedLogs = {
      ...processedMetadata,
      message,
      date,
      status: logLevel,
    };

    if (process.env.NODE_ENV === 'development') {
      const coloredMessage = Logger.colorize(message, processedLogs, logLevel);

      const metadata = {
        date: new Date(processedLogs.date).toISOString(),
        ...(processedMetadata.error && { error: processedMetadata.error }),
      };

      const metadataString =
        Object.keys(metadata).length > 1 ? ` ${JSON.stringify(metadata, null, 2)}` : ` ${JSON.stringify(metadata)}`;

      console[logLevel](`${coloredMessage}${metadataString}`);
    } else {
      console[logLevel](JSON.stringify(processedLogs));
    }
  }

  private static snakifyKeys(value: Value): Value {
    const result: Value = {};

    for (const key in value) {
      let deepValue;

      if (Array.isArray(value[key])) {
        deepValue = value[key].map(item => this.snakifyKeys(item as Value));
      } else if (typeof value[key] === 'object' && value[key] !== null) {
        deepValue = this.snakifyKeys(value[key] as Value);
      } else {
        deepValue = value[key];
      }

      const snakifiedKey = key.replace(/[\w](?<!_)([A-Z])/g, k => `${k[0]}_${k[1]}`).toLowerCase();
      result[snakifiedKey] = deepValue;
    }

    return result;
  }

  private static pruneSensitiveMetadata(metadata: Value, topLevelMeta?: Value): Value {
    const prunedMetadata: Value = {};

    for (const key in metadata) {
      if (LOGMETA_BLACKLIST.includes(key)) {
        prunedMetadata[key] = '[REDACTED]';
        (topLevelMeta ?? prunedMetadata).has_sensitive_attribute = true;
      } else if (Array.isArray(metadata[key])) {
        prunedMetadata[key] = metadata[key].map(value =>
          Logger.pruneSensitiveMetadata(value as Value, topLevelMeta ?? prunedMetadata),
        );
      } else if (typeof metadata[key] === 'object' && metadata[key] !== null) {
        prunedMetadata[key] = Logger.pruneSensitiveMetadata(metadata[key] as Value, topLevelMeta ?? prunedMetadata);
      } else {
        prunedMetadata[key] = metadata[key];
      }
    }

    return prunedMetadata;
  }

  /**
   * Colorizes the log message based on the log level and status codes.
   * @param message The message to colorize.
   * @param metadata The metadata associated with the log.
   * @param logLevel The log level of the message.
   * @returns The colorized output string.
   */
  private static colorize(message: string, metadata: Value, logLevel: LogLevel): string {
    if (!process.stdout.isTTY) {
      return `${logLevel}: ${message}`;
    }

    // Extract status code from logs
    let statusCode: number | undefined;
    if (metadata.http && typeof metadata.http === 'object' && !Array.isArray(metadata.http)) {
      const statusCodeValue = metadata.http.status_code;

      if (typeof statusCodeValue === 'number') {
        statusCode = statusCodeValue;
      } else if (typeof statusCodeValue === 'string') {
        statusCode = parseInt(statusCodeValue, 10);
      }
    }

    // Color based on status code first
    if (statusCode) {
      if (statusCode >= 400) {
        return `${styleText('red', logLevel)}: ${styleText('red', message)}`;
      } else if (statusCode >= 300) {
        return `${styleText('yellow', logLevel)}: ${styleText('yellow', message)}`;
      } else if (statusCode >= 200) {
        return `${styleText('green', logLevel)}: ${styleText('green', message)}`;
      }
    }

    // Fall back to log level if no status code found
    switch (logLevel) {
      case LogLevel.ERROR:
        return `${styleText('red', logLevel)}: ${styleText('red', message)}`;
      case LogLevel.WARN:
        return `${styleText('yellow', logLevel)}: ${styleText('yellow', message)}`;
      case LogLevel.INFO:
      case LogLevel.LOG:
        return `${styleText('green', logLevel)}: ${styleText('green', message)}`;
      case LogLevel.DEBUG:
        return `${styleText('cyan', logLevel)}: ${styleText('cyan', message)}`;
      default:
        return `${logLevel}: ${message}`;
    }
  }
}

export const NULL_LOGGER = new Logger({}, true);
