/**
 * Default console-based logger.
 *
 * **Important:** This logger is always added as part of {@link LoggerManager} instance configuration and can't be
 * removed.
 *
 * @internal
 */

import {
  NetworkResponseLogMessage,
  NetworkRequestLogMessage,
  ObjectLogMessage,
  ErrorLogMessage,
  LogLevelString,
  LogMessage,
  LogLevel,
  Logger,
} from '../core/interfaces/logger';
import { queryStringFromObject } from '../core/utils';
import { Payload } from '../core/types/api';

/**
 * Custom {@link Logger} implementation to show a message in the native console.
 */
export class ConsoleLogger implements Logger {
  /**
   * Binary data decoder.
   */
  private static readonly decoder = new TextDecoder();

  /**
   * Process a `debug` level message.
   *
   * @param message - Message which should be handled by custom logger implementation.
   */
  debug(message: LogMessage): void {
    this.log(message);
  }

  /**
   * Process a `error` level message.
   *
   * @param message - Message which should be handled by custom logger implementation.
   */
  error(message: LogMessage): void {
    this.log(message);
  }

  /**
   * Process an `info` level message.
   *
   * @param message - Message which should be handled by custom logger implementation.
   */
  info(message: LogMessage): void {
    this.log(message);
  }

  /**
   * Process a `trace` level message.
   *
   * @param message - Message which should be handled by custom logger implementation.
   */
  trace(message: LogMessage): void {
    this.log(message);
  }

  /**
   * Process an `warn` level message.
   *
   * @param message - Message which should be handled by custom logger implementation.
   */
  warn(message: LogMessage): void {
    this.log(message);
  }

  /**
   * Stringify logger object.
   *
   * @returns Serialized logger object.
   */
  toString(): string {
    return `ConsoleLogger {}`;
  }

  /**
   * Process log message object.
   *
   * @param message - Object with information which can be used to identify level and prepare log entry payload.
   */
  private log(message: LogMessage) {
    const logLevelString = LogLevel[message.level];
    const level = logLevelString.toLowerCase() as LogLevelString;

    console[level === 'trace' ? 'debug' : level](
      `${message.timestamp.toISOString()} PubNub-${message.pubNubId} ${logLevelString.padEnd(5, ' ')}${
        message.location ? ` ${message.location}` : ''
      } ${this.logMessage(message)}`,
    );
  }

  /**
   * Get a pre-formatted log message.
   *
   * @param message - Log message which should be stringified.
   *
   * @returns String formatted for log entry in console.
   */
  private logMessage(message: LogMessage): string {
    if (message.messageType === 'text') return message.message;
    else if (message.messageType === 'object')
      return `${message.details ? `${message.details}\n` : ''}${this.formattedObject(message)}`;
    else if (message.messageType === 'network-request') {
      const showOnlyBasicInfo = !!message.canceled || !!message.failed;
      const headersList =
        message.minimumLevel === LogLevel.Trace && !showOnlyBasicInfo ? this.formattedHeaders(message) : undefined;
      const request = message.message;

      const queryString =
        request.queryParameters && Object.keys(request.queryParameters).length > 0
          ? queryStringFromObject(request.queryParameters)
          : undefined;

      const url = `${request.origin}${request.path}${queryString ? `?${queryString}` : ''}`;
      const formattedBody = !showOnlyBasicInfo ? this.formattedBody(message) : undefined;

      let action = 'Sending';
      if (showOnlyBasicInfo)
        action = `${!!message.canceled ? 'Canceled' : 'Failed'}${message.details ? ` (${message.details})` : ''}`;
      const padding = (formattedBody?.formData ? 'FormData' : 'Method').length;

      return `${action} HTTP request:\n  ${this.paddedString('Method', padding)}: ${
        request.method
      }\n  ${this.paddedString('URL', padding)}: ${url}${
        headersList ? `\n  ${this.paddedString('Headers', padding)}:\n${headersList}` : ''
      }${formattedBody?.formData ? `\n  ${this.paddedString('FormData', padding)}:\n${formattedBody.formData}` : ''}${
        formattedBody?.body ? `\n  ${this.paddedString('Body', padding)}:\n${formattedBody.body}` : ''
      }`;
    } else if (message.messageType === 'network-response') {
      const headersList = message.minimumLevel === LogLevel.Trace ? this.formattedHeaders(message) : undefined;
      const formattedBody = this.formattedBody(message);
      const padding = (formattedBody?.formData ? 'Headers' : 'Status').length;
      const response = message.message;

      return `Received HTTP response:\n  ${this.paddedString('URL', padding)}: ${
        response.url
      }\n  ${this.paddedString('Status', padding)}: ${response.status}${
        headersList ? `\n  ${this.paddedString('Headers', padding)}:\n${headersList}` : ''
      }${formattedBody?.body ? `\n  ${this.paddedString('Body', padding)}:\n${formattedBody.body}` : ''}`;
    } else if (message.messageType === 'error') {
      const formattedStatus = this.formattedErrorStatus(message);
      const error = message.message;

      return `${error.name}: ${error.message}${formattedStatus ? `\n${formattedStatus}` : ''}`;
    }

    return '<unknown log message data>';
  }

  /**
   * Get a pre-formatted object (dictionary / array).
   *
   * @param message - Log message which may contain an object for formatting.
   *
   * @returns String formatted for log entry in console or `undefined` if a log message doesn't have suitable data.
   */
  private formattedObject(message: ObjectLogMessage): string | undefined {
    const stringify = (
      obj: Record<string, unknown> | unknown[] | unknown,
      level: number = 1,
      skipIndentOnce: boolean = false,
    ) => {
      const maxIndentReached = level === 10;
      const targetIndent = ' '.repeat(level * 2);
      const lines: string[] = [];

      const isIgnored = (key: string, obj: Record<string, unknown>) => {
        if (!message.ignoredKeys) return false;
        if (typeof message.ignoredKeys === 'function') return message.ignoredKeys(key, obj);
        return message.ignoredKeys.includes(key);
      };

      if (typeof obj === 'string') lines.push(`${targetIndent}- ${obj}`);
      else if (typeof obj === 'number') lines.push(`${targetIndent}- ${obj}`);
      else if (typeof obj === 'boolean') lines.push(`${targetIndent}- ${obj}`);
      else if (obj === null) lines.push(`${targetIndent}- null`);
      else if (obj === undefined) lines.push(`${targetIndent}- undefined`);
      else if (typeof obj === 'function') lines.push(`${targetIndent}- <function>`);
      else if (typeof obj === 'object') {
        if (!Array.isArray(obj) && typeof obj.toString === 'function' && obj.toString().indexOf('[object') !== 0) {
          lines.push(`${skipIndentOnce ? '' : targetIndent}${obj.toString()}`);
          skipIndentOnce = false;
        } else if (Array.isArray(obj)) {
          for (const element of obj) {
            const indent = skipIndentOnce ? '' : targetIndent;
            if (element === null) lines.push(`${indent}- null`);
            else if (element === undefined) lines.push(`${indent}- undefined`);
            else if (typeof element === 'function') lines.push(`${indent}- <function>`);
            else if (typeof element === 'object') {
              const isArray = Array.isArray(element);
              const entry = maxIndentReached ? '...' : stringify(element, level + 1, !isArray);
              lines.push(`${indent}-${isArray && !maxIndentReached ? '\n' : ' '}${entry}`);
            } else lines.push(`${indent}- ${element}`);

            skipIndentOnce = false;
          }
        } else {
          const object = obj as Record<string, unknown>;
          const keys = Object.keys(object);
          const maxKeyLen = keys.reduce((max, key) => Math.max(max, isIgnored(key, object) ? max : key.length), 0);

          for (const key of keys) {
            if (isIgnored(key, object)) continue;
            const indent = skipIndentOnce ? '' : targetIndent;

            const raw = object[key];
            const paddedKey = key.padEnd(maxKeyLen, ' ');

            if (raw === null) lines.push(`${indent}${paddedKey}: null`);
            else if (raw === undefined) lines.push(`${indent}${paddedKey}: undefined`);
            else if (typeof raw === 'function') lines.push(`${indent}${paddedKey}: <function>`);
            else if (typeof raw === 'object') {
              const isArray = Array.isArray(raw);
              const isEmptyArray = isArray && raw.length === 0;
              const isEmptyObject = !isArray && !(raw instanceof String) && Object.keys(raw).length === 0;
              const hasToString =
                !isArray && typeof raw.toString === 'function' && raw.toString().indexOf('[object') !== 0;
              const entry = maxIndentReached
                ? '...'
                : isEmptyArray
                  ? '[]'
                  : isEmptyObject
                    ? '{}'
                    : stringify(raw, level + 1, hasToString);
              lines.push(
                `${indent}${paddedKey}:${
                  maxIndentReached || hasToString || isEmptyArray || isEmptyObject ? ' ' : '\n'
                }${entry}`,
              );
            } else lines.push(`${indent}${paddedKey}: ${raw}`);

            skipIndentOnce = false;
          }
        }
      }

      return lines.join('\n');
    };

    return stringify(message.message);
  }

  /**
   * Get a pre-formatted headers list.
   *
   * @param message - Log message which may contain an object with headers to be used for formatting.
   *
   * @returns String formatted for log entry in console or `undefined` if a log message not related to the network data.
   */
  private formattedHeaders(message: NetworkRequestLogMessage | NetworkResponseLogMessage) {
    if (!message.message.headers) return undefined;

    const headers = message.message.headers;
    const maxHeaderLength = Object.keys(headers).reduce((max, key) => Math.max(max, key.length), 0);

    return Object.keys(headers)
      .map((key) => `    - ${key.toLowerCase().padEnd(maxHeaderLength, ' ')}: ${headers[key]}`)
      .join('\n');
  }

  /**
   * Get a pre-formatted body.
   *
   * @param message - Log message which may contain an object with `body` (request or response).
   *
   * @returns Object with formatted string of form data and / or body for log entry in console or `undefined` if a log
   * message not related to the network data.
   */
  private formattedBody(
    message: NetworkRequestLogMessage | NetworkResponseLogMessage,
  ): { body?: string; formData?: string } | undefined {
    if (!message.message.headers) return undefined;

    let stringifiedFormData: string | undefined;
    let stringifiedBody: string | undefined;
    const headers = message.message.headers;
    const contentType = headers['content-type'] ?? headers['Content-Type'];

    const formData = 'formData' in message.message ? message.message.formData : undefined;
    const body = message.message.body;

    // The presence of this object means that we are sending `multipart/form-data` (potentially uploading a file).
    if (formData) {
      const maxFieldLength = formData.reduce((max, { key }) => Math.max(max, key.length), 0);
      stringifiedFormData = formData
        .map(({ key, value }) => `    - ${key.padEnd(maxFieldLength, ' ')}: ${value}`)
        .join('\n');
    }

    if (!body) return { formData: stringifiedFormData };

    if (typeof body === 'string') {
      stringifiedBody = `    ${body}`;
    } else if (body instanceof ArrayBuffer || Object.prototype.toString.call(body) === '[object ArrayBuffer]') {
      if (contentType && (contentType.indexOf('javascript') !== -1 || contentType.indexOf('json') !== -1))
        stringifiedBody = `    ${ConsoleLogger.decoder.decode(body as ArrayBuffer)}`;
      else stringifiedBody = `    ArrayBuffer { byteLength: ${(body as ArrayBuffer).byteLength} }`;
    } else {
      stringifiedBody = `    File { name: ${body.name}${
        body.contentLength ? `, contentLength: ${body.contentLength}` : ''
      }${body.mimeType ? `, mimeType: ${body.mimeType}` : ''} }`;
    }

    return { body: stringifiedBody, formData: stringifiedFormData };
  }

  /**
   * Get a pre-formatted status object.
   *
   * @param message - Log message which may contain a {@link Status} object.
   *
   * @returns String formatted for log entry in console or `undefined` if a log message doesn't have {@link Status}
   * object.
   */
  private formattedErrorStatus(message: ErrorLogMessage) {
    if (!message.message.status) return undefined;

    const status = message.message.status;
    const errorData = status.errorData;
    let stringifiedErrorData: string | undefined;

    if (ConsoleLogger.isError(errorData)) {
      stringifiedErrorData = `    ${errorData.name}: ${errorData.message}`;
      if (errorData.stack) {
        stringifiedErrorData += `\n${errorData.stack
          .split('\n')
          .map((line) => `      ${line}`)
          .join('\n')}`;
      }
    } else if (errorData) {
      try {
        stringifiedErrorData = `    ${JSON.stringify(errorData)}`;
      } catch (_) {
        stringifiedErrorData = `    ${errorData}`;
      }
    }

    return `  Category  : ${status.category}\n  Operation : ${status.operation}\n  Status    : ${
      status.statusCode
    }${stringifiedErrorData ? `\n  Error data:\n${stringifiedErrorData}` : ''}`;
  }

  /**
   * Append the required amount of space to provide proper padding.
   *
   * @param str - Source string which should be appended with necessary number of spaces.
   * @param maxLength - Maximum length of the string to which source string should be padded.
   * @returns End-padded string.
   */
  private paddedString(str: string, maxLength: number) {
    return str.padEnd(maxLength - str.length, ' ');
  }

  /**
   * Check whether passed object is {@link Error} instance.
   *
   * @param errorData - Object which should be checked.
   *
   * @returns `true` in case if an object actually {@link Error}.
   */
  private static isError(errorData?: Error | Payload): errorData is Error {
    if (!errorData) return false;
    return errorData instanceof Error || Object.prototype.toString.call(errorData) === '[object Error]';
  }
}
