// probe.gl, MIT license

/* eslint-disable no-console,prefer-rest-params */
import {VERSION, isBrowser} from '@probe.gl/env';
import {LocalStorage} from './utils/local-storage';
import {formatTime, leftPad} from './utils/formatters';
import {addColor} from './utils/color';
import {autobind} from './utils/autobind';
import assert from './utils/assert';
import {getHiResTimestamp} from './utils/hi-res-timestamp';

/** "Global" log configuration settings */
type LogConfiguration = {
  enabled?: boolean;
  level?: number;
  [key: string]: unknown;
};

/** Options when logging a message */
type LogOptions = {
  method?: Function;
  time?: boolean;
  total?: number;
  delta?: number;
  tag?: string;
  message?: string;
  once?: boolean;
  nothrottle?: boolean;
  args?: any;
};

type LogFunction = () => void;

type Table = Record<string, any>;

// Instrumentation in other packages may override console methods, so preserve them here
const originalConsole = {
  debug: isBrowser() ? console.debug || console.log : console.log,
  log: console.log,
  info: console.info,
  warn: console.warn,
  error: console.error
};

const DEFAULT_LOG_CONFIGURATION: Required<LogConfiguration> = {
  enabled: true,
  level: 0
};

function noop() {} // eslint-disable-line @typescript-eslint/no-empty-function

const cache = {};
const ONCE = {once: true};

/** A console wrapper */

export class Log {
  static VERSION = VERSION;

  id: string;
  VERSION: string = VERSION;
  _startTs: number = getHiResTimestamp();
  _deltaTs: number = getHiResTimestamp();
  _storage: LocalStorage<LogConfiguration>;
  userData = {};

  // TODO - fix support from throttling groups
  LOG_THROTTLE_TIMEOUT: number = 0; // Time before throttled messages are logged again

  constructor({id} = {id: ''}) {
    this.id = id;
    this.userData = {};
    this._storage = new LocalStorage<LogConfiguration>(
      `__probe-${this.id}__`,
      DEFAULT_LOG_CONFIGURATION
    );

    this.timeStamp(`${this.id} started`);

    autobind(this);
    Object.seal(this);
  }

  set level(newLevel: number) {
    this.setLevel(newLevel);
  }

  get level(): number {
    return this.getLevel();
  }

  isEnabled(): boolean {
    return this._storage.config.enabled;
  }

  getLevel(): number {
    return this._storage.config.level;
  }

  /** @return milliseconds, with fractions */
  getTotal(): number {
    return Number((getHiResTimestamp() - this._startTs).toPrecision(10));
  }

  /** @return milliseconds, with fractions */
  getDelta(): number {
    return Number((getHiResTimestamp() - this._deltaTs).toPrecision(10));
  }

  /** @deprecated use logLevel */
  set priority(newPriority: number) {
    this.level = newPriority;
  }

  /** @deprecated use logLevel */
  get priority(): number {
    return this.level;
  }

  /** @deprecated use logLevel */
  getPriority(): number {
    return this.level;
  }

  // Configure

  enable(enabled: boolean = true): this {
    this._storage.setConfiguration({enabled});
    return this;
  }

  setLevel(level: number): this {
    this._storage.setConfiguration({level});
    return this;
  }

  /** return the current status of the setting */
  get(setting: string): any {
    return this._storage.config[setting];
  }

  // update the status of the setting
  set(setting: string, value: any): void {
    this._storage.setConfiguration({[setting]: value});
  }

  /** Logs the current settings as a table */
  settings(): void {
    if (console.table) {
      console.table(this._storage.config);
    } else {
      console.log(this._storage.config);
    }
  }

  // Unconditional logging

  assert(condition: unknown, message?: string): asserts condition {
    if (!condition) {
      throw new Error(message || 'Assertion failed');
    }
  }

  /** Warn, but only once, no console flooding */
  warn(message: string, ...args: unknown[]): LogFunction;
  warn(message: string): LogFunction {
    return this._getLogFunction(0, message, originalConsole.warn, arguments, ONCE);
  }

  /** Print an error */
  error(message: string, ...args: unknown[]): LogFunction;
  error(message: string): LogFunction {
    return this._getLogFunction(0, message, originalConsole.error, arguments);
  }

  /** Print a deprecation warning */
  deprecated(oldUsage: string, newUsage: string): LogFunction {
    return this.warn(`\`${oldUsage}\` is deprecated and will be removed \
in a later version. Use \`${newUsage}\` instead`);
  }

  /** Print a removal warning */
  removed(oldUsage: string, newUsage: string): LogFunction {
    return this.error(`\`${oldUsage}\` has been removed. Use \`${newUsage}\` instead`);
  }

  // Conditional logging

  /** Log to a group */
  probe(logLevel, message?, ...args: unknown[]): LogFunction;
  probe(logLevel, message?): LogFunction {
    return this._getLogFunction(logLevel, message, originalConsole.log, arguments, {
      time: true,
      once: true
    });
  }

  /** Log a debug message */
  log(logLevel, message?, ...args: unknown[]): LogFunction;
  log(logLevel, message?): LogFunction {
    return this._getLogFunction(logLevel, message, originalConsole.debug, arguments);
  }

  /** Log a normal message */
  info(logLevel, message?, ...args: unknown[]): LogFunction;
  info(logLevel, message?): LogFunction {
    return this._getLogFunction(logLevel, message, console.info, arguments);
  }

  /** Log a normal message, but only once, no console flooding */
  once(logLevel, message?, ...args: unknown[]): LogFunction;
  once(logLevel, message?) {
    return this._getLogFunction(
      logLevel,
      message,
      originalConsole.debug || originalConsole.info,
      arguments,
      ONCE
    );
  }

  /** Logs an object as a table */
  table(logLevel, table?, columns?): LogFunction {
    if (table) {
      return this._getLogFunction(
        logLevel,
        table,
        console.table || noop,
        (columns && [columns]) as unknown as IArguments,
        {
          tag: getTableHeader(table)
        }
      );
    }
    return noop;
  }

  time(logLevel, message) {
    return this._getLogFunction(logLevel, message, console.time ? console.time : console.info);
  }

  timeEnd(logLevel, message) {
    return this._getLogFunction(
      logLevel,
      message,
      console.timeEnd ? console.timeEnd : console.info
    );
  }

  timeStamp(logLevel, message?) {
    return this._getLogFunction(logLevel, message, console.timeStamp || noop);
  }

  group(logLevel, message, opts = {collapsed: false}) {
    const options = normalizeArguments({logLevel, message, opts});
    const {collapsed} = opts;
    // @ts-expect-error
    options.method = (collapsed ? console.groupCollapsed : console.group) || console.info;

    return this._getLogFunction(options);
  }

  groupCollapsed(logLevel, message, opts = {}) {
    return this.group(logLevel, message, Object.assign({}, opts, {collapsed: true}));
  }

  groupEnd(logLevel) {
    return this._getLogFunction(logLevel, '', console.groupEnd || noop);
  }

  // EXPERIMENTAL

  withGroup(logLevel: number, message: string, func: Function): void {
    this.group(logLevel, message)();

    try {
      func();
    } finally {
      this.groupEnd(logLevel)();
    }
  }

  trace(): void {
    if (console.trace) {
      console.trace();
    }
  }

  // PRIVATE METHODS

  /** Deduces log level from a variety of arguments */
  _shouldLog(logLevel: unknown): boolean {
    return this.isEnabled() && this.getLevel() >= normalizeLogLevel(logLevel);
  }

  _getLogFunction(
    logLevel: unknown,
    message?: unknown,
    method?: Function,
    args?: IArguments,
    opts?: LogOptions
  ): LogFunction {
    if (this._shouldLog(logLevel)) {
      // normalized opts + timings
      opts = normalizeArguments({logLevel, message, args, opts});
      method = method || opts.method;
      assert(method);

      opts.total = this.getTotal();
      opts.delta = this.getDelta();
      // reset delta timer
      this._deltaTs = getHiResTimestamp();

      const tag = opts.tag || opts.message;

      if (opts.once && tag) {
        if (!cache[tag]) {
          cache[tag] = getHiResTimestamp();
        } else {
          return noop;
        }
      }

      // TODO - Make throttling work with groups
      // if (opts.nothrottle || !throttle(tag, this.LOG_THROTTLE_TIMEOUT)) {
      //   return noop;
      // }

      message = decorateMessage(this.id, opts.message, opts);

      // Bind console function so that it can be called after being returned
      return method.bind(console, message, ...opts.args);
    }
    return noop;
  }
}

/**
 * Get logLevel from first argument:
 * - log(logLevel, message, args) => logLevel
 * - log(message, args) => 0
 * - log({logLevel, ...}, message, args) => logLevel
 * - log({logLevel, message, args}) => logLevel
 */
function normalizeLogLevel(logLevel: unknown): number {
  if (!logLevel) {
    return 0;
  }
  let resolvedLevel;

  switch (typeof logLevel) {
    case 'number':
      resolvedLevel = logLevel;
      break;

    case 'object':
      // Backward compatibility
      // TODO - deprecate `priority`
      // @ts-expect-error
      resolvedLevel = logLevel.logLevel || logLevel.priority || 0;
      break;

    default:
      return 0;
  }
  // 'log level must be a number'
  assert(Number.isFinite(resolvedLevel) && resolvedLevel >= 0);

  return resolvedLevel;
}

/**
 * "Normalizes" the various argument patterns into an object with known types
 * - log(logLevel, message, args) => {logLevel, message, args}
 * - log(message, args) => {logLevel: 0, message, args}
 * - log({logLevel, ...}, message, args) => {logLevel, message, args}
 * - log({logLevel, message, args}) => {logLevel, message, args}
 */
export function normalizeArguments(opts: {
  logLevel;
  message;
  collapsed?: boolean;
  args?: IArguments | undefined;
  opts?;
}): {
  logLevel: number;
  message: string;
  args: any[];
} {
  const {logLevel, message} = opts;
  opts.logLevel = normalizeLogLevel(logLevel);

  // We use `arguments` instead of rest parameters (...args) because IE
  // does not support the syntax. Rest parameters is transpiled to code with
  // perf impact. Doing it here instead avoids constructing args when logging is
  // disabled.
  // TODO - remove when/if IE support is dropped
  const args: any[] = opts.args ? Array.from(opts.args) : [];
  // args should only contain arguments that appear after `message`
  // eslint-disable-next-line no-empty
  while (args.length && args.shift() !== message) {}

  switch (typeof logLevel) {
    case 'string':
    case 'function':
      if (message !== undefined) {
        args.unshift(message);
      }
      opts.message = logLevel;
      break;

    case 'object':
      Object.assign(opts, logLevel);
      break;

    default:
  }

  // Resolve functions into strings by calling them
  if (typeof opts.message === 'function') {
    opts.message = opts.message();
  }
  const messageType = typeof opts.message;
  // 'log message must be a string' or object
  assert(messageType === 'string' || messageType === 'object');

  // original opts + normalized opts + opts arg + fixed up message
  return Object.assign(opts, {args}, opts.opts);
}

function decorateMessage(id, message, opts) {
  if (typeof message === 'string') {
    const time = opts.time ? leftPad(formatTime(opts.total)) : '';
    message = opts.time ? `${id}: ${time}  ${message}` : `${id}: ${message}`;
    message = addColor(message, opts.color, opts.background);
  }
  return message;
}

function getTableHeader(table: Table): string {
  for (const key in table) {
    for (const title in table[key]) {
      return title || 'untitled';
    }
  }
  return 'empty';
}
