import { styleText } from 'util';

const LOG_LEVELS = ['debug', 'info', 'warn', 'error', 'disabled'] as const;

export type LogLevelName = (typeof LOG_LEVELS)[number];

const LOG_LEVEL_SEVERITY = Object.fromEntries(LOG_LEVELS.map((name, i) => [name, i])) as Record<LogLevelName, number>;

const DEFAULT_LOG_LEVEL: LogLevelName = 'info';

function readEnvLogLevel(): LogLevelName {
  const value = process.env.INTEGRATION_LOG_LEVEL;
  if (value && (LOG_LEVELS as readonly string[]).includes(value)) {
    return value as LogLevelName;
  }
  return DEFAULT_LOG_LEVEL;
}

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;
  private level: LogLevelName;

  constructor(metadata: Metadata = {}) {
    this.metadata = structuredClone(metadata);
    this.level = readEnvLogLevel();
  }

  /**
   * Sets the minimum log level. Messages below this level are suppressed.
   * @param level The minimum log level name.
   */
  public setLevel(level: LogLevelName): void {
    this.level = level;
  }

  /**
   * 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.log('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.log('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.log('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.log('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 = {};
  }

  public log(logLevel: Exclude<LogLevelName, 'disabled'>, message: string, metadata?: Metadata): void {
    if (LOG_LEVEL_SEVERITY[logLevel] < LOG_LEVEL_SEVERITY[this.level]) {
      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])) {
        if (value[key].every(v => typeof v === 'object')) {
          deepValue = value[key].map(item => this.snakifyKeys(item as Value));
        } else {
          deepValue = value[key];
        }
      } 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])) {
        if (metadata[key].every(value => typeof value === 'object')) {
          prunedMetadata[key] = metadata[key].map(value =>
            Logger.pruneSensitiveMetadata(value as Value, topLevelMeta ?? prunedMetadata),
          );
        } else {
          prunedMetadata[key] = metadata[key];
        }
      } 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: LogLevelName): 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 'error':
        return `${styleText('red', logLevel)}: ${styleText('red', message)}`;
      case 'warn':
        return `${styleText('yellow', logLevel)}: ${styleText('yellow', message)}`;
      case 'info':
        return `${styleText('green', logLevel)}: ${styleText('green', message)}`;
      case 'debug':
        return `${styleText('cyan', logLevel)}: ${styleText('cyan', message)}`;
      default:
        return `${logLevel}: ${message}`;
    }
  }
}

export const NULL_LOGGER = new Logger();
NULL_LOGGER.setLevel('disabled');
