import ansiStyles from './ansi-styles';
import supportsColor from './supports-color';
import { stringReplaceAll, stringEncaseCRLFWithFirstIndex } from './utilities';
import { ChalkInstance } from './index.d';

const { stdout: stdoutColor, stderr: stderrColor } = supportsColor;

const GENERATOR = Symbol('GENERATOR');
const STYLER = Symbol('STYLER');
const IS_EMPTY = Symbol('IS_EMPTY');

// `supportsColor.level` → `ansiStyles.color[name]` mapping
const levelMapping = ['ansi', 'ansi', 'ansi256', 'ansi16m'];

const styles = Object.create(null);

const applyOptions = (object, options = {}) => {
  if (
    options.level &&
    !(Number.isInteger(options.level) && options.level >= 0 && options.level <= 3)
  ) {
    throw new Error('The `level` option should be an integer from 0 to 3');
  }

  // Detect level if not set manually
  const colorLevel = stdoutColor ? stdoutColor.level : 0;
  object.level = options.level === undefined ? colorLevel : options.level;
};

export class Chalk {
  constructor(options) {
    // eslint-disable-next-line no-constructor-return
    return chalkFactory(options);
  }
}

const chalkFactory = (options) => {
  const chalk = (...strings) => strings.join(' ');
  applyOptions(chalk, options);

  Object.setPrototypeOf(chalk, createChalk.prototype);

  return chalk;
};

function createChalk(options) {
  return chalkFactory(options);
}

Object.setPrototypeOf(createChalk.prototype, Function.prototype);

for (const [styleName, style] of Object.entries(ansiStyles)) {
  styles[styleName] = {
    get() {
      const builder = createBuilder(
        this,
        createStyler(style.open, style.close, this[STYLER]),
        this[IS_EMPTY]
      );
      Object.defineProperty(this, styleName, { value: builder });
      return builder;
    },
  };
}

styles.visible = {
  get() {
    const builder = createBuilder(this, this[STYLER], true);
    Object.defineProperty(this, 'visible', { value: builder });
    return builder;
  },
};

const getModelAnsi = (model, level, type, ...arguments_) => {
  if (model === 'rgb') {
    if (level === 'ansi16m') {
      return ansiStyles[type].ansi16m(...arguments_);
    }

    if (level === 'ansi256') {
      return ansiStyles[type].ansi256(ansiStyles.rgbToAnsi256(...arguments_));
    }

    return ansiStyles[type].ansi(ansiStyles.rgbToAnsi(...arguments_));
  }

  if (model === 'hex') {
    return getModelAnsi('rgb', level, type, ...ansiStyles.hexToRgb(...arguments_));
  }

  return ansiStyles[type][model](...arguments_);
};

const usedModels = ['rgb', 'hex', 'ansi256'];

for (const model of usedModels) {
  styles[model] = {
    get() {
      const { level } = this;
      return function (...arguments_) {
        const styler = createStyler(
          getModelAnsi(model, levelMapping[level], 'color', ...arguments_),
          ansiStyles.color.close,
          this[STYLER]
        );
        return createBuilder(this, styler, this[IS_EMPTY]);
      };
    },
  };

  const bgModel = 'bg' + model[0].toUpperCase() + model.slice(1);
  styles[bgModel] = {
    get() {
      const { level } = this;
      return function (...arguments_) {
        const styler = createStyler(
          getModelAnsi(model, levelMapping[level], 'bgColor', ...arguments_),
          ansiStyles.bgColor.close,
          this[STYLER]
        );
        return createBuilder(this, styler, this[IS_EMPTY]);
      };
    },
  };
}

const proto = Object.defineProperties(() => {}, {
  ...styles,
  level: {
    enumerable: true,
    get() {
      return this[GENERATOR].level;
    },
    set(level) {
      this[GENERATOR].level = level;
    },
  },
});

const createStyler = (open, close, parent) => {
  let openAll;
  let closeAll;
  if (parent === undefined) {
    openAll = open;
    closeAll = close;
  } else {
    openAll = parent.openAll + open;
    closeAll = close + parent.closeAll;
  }

  return {
    open,
    close,
    openAll,
    closeAll,
    parent,
  };
};

const createBuilder = (self, _styler, _isEmpty) => {
  // Single argument is hot path, implicit coercion is faster than anything
  // eslint-disable-next-line no-implicit-coercion
  const builder = (...arguments_) =>
    applyStyle(builder, arguments_.length === 1 ? '' + arguments_[0] : arguments_.join(' '));

  // We alter the prototype because we must return a function, but there is
  // no way to create a function with a different prototype
  Object.setPrototypeOf(builder, proto);

  builder[GENERATOR] = self;
  builder[STYLER] = _styler;
  builder[IS_EMPTY] = _isEmpty;

  return builder;
};

const applyStyle = (self, string) => {
  if (self.level <= 0 || !string) {
    return self[IS_EMPTY] ? '' : string;
  }

  let styler = self[STYLER];

  if (styler === undefined) {
    return string;
  }

  const { openAll, closeAll } = styler;
  if (string.includes('\u001B')) {
    while (styler !== undefined) {
      // Replace any instances already present with a re-opening code
      // otherwise only the part of the string until said closing code
      // will be colored, and the rest will simply be 'plain'.
      string = stringReplaceAll(string, styler.close, styler.open);

      styler = styler.parent;
    }
  }

  // We can move both next actions out of loop, because remaining actions in loop won't have
  // any/visible effect on parts we add here. Close the styling before a linebreak and reopen
  // after next line to fix a bleed issue on macOS: https://github.com/chalk/chalk/pull/92
  const lfIndex = string.indexOf('\n');
  if (lfIndex !== -1) {
    string = stringEncaseCRLFWithFirstIndex(string, closeAll, openAll, lfIndex);
  }

  return openAll + string + closeAll;
};

Object.defineProperties(createChalk.prototype, styles);

const chalk: ChalkInstance = createChalk();
export const chalkStderr = createChalk({ level: stderrColor ? stderrColor.level : 0 });

export { stdoutColor as supportsColor, stderrColor as supportsColorStderr };

export default chalk;