// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'dart:async';
import 'dart:io' as io;

const _ansiEscapeLiteral = '\x1B';
const _ansiEscapeForScript = r'\033';

/// Whether formatted ANSI output is enabled for [wrapWith] and [AnsiCode.wrap].
///
/// By default, returns `true` if both `stdout.supportsAnsiEscapes` and
/// `stderr.supportsAnsiEscapes` from `dart:io` are `true`.
///
/// The default can be overridden by setting the [Zone] variable [AnsiCode] to
/// either `true` or `false`.
///
/// [overrideAnsiOutput] is provided to make this easy.
bool get ansiOutputEnabled =>
    Zone.current[AnsiCode] as bool? ??
    (io.stdout.supportsAnsiEscapes && io.stderr.supportsAnsiEscapes);

/// Returns `true` no formatting is required for [input].
bool _isNoop(bool skip, String? input, bool? forScript) =>
    skip ||
    input == null ||
    input.isEmpty ||
    !((forScript ?? false) || ansiOutputEnabled);

/// Allows overriding [ansiOutputEnabled] to [enableAnsiOutput] for the code run
/// within [body].
T overrideAnsiOutput<T>(bool enableAnsiOutput, T Function() body) =>
    runZoned(body, zoneValues: <Object, Object>{AnsiCode: enableAnsiOutput});

/// The type of code represented by [AnsiCode].
class AnsiCodeType {
  final String _name;

  /// A foreground color.
  static const AnsiCodeType foreground = AnsiCodeType._('foreground');

  /// A style.
  static const AnsiCodeType style = AnsiCodeType._('style');

  /// A background color.
  static const AnsiCodeType background = AnsiCodeType._('background');

  /// A reset value.
  static const AnsiCodeType reset = AnsiCodeType._('reset');

  const AnsiCodeType._(this._name);

  @override
  String toString() => 'AnsiType.$_name';
}

/// Standard ANSI escape code for customizing terminal text output.
///
/// [Source](https://en.wikipedia.org/wiki/ANSI_escape_code#Colors)
class AnsiCode {
  /// The numeric value associated with this code.
  final int code;

  /// The [AnsiCode] that resets this value, if one exists.
  ///
  /// Otherwise, `null`.
  final AnsiCode? reset;

  /// A description of this code.
  final String name;

  /// The type of code that is represented.
  final AnsiCodeType type;

  const AnsiCode._(this.name, this.type, this.code, this.reset);

  /// Represents the value escaped for use in terminal output.
  String get escape => '$_ansiEscapeLiteral[${code}m';

  /// Represents the value as an unescaped literal suitable for scripts.
  String get escapeForScript => '$_ansiEscapeForScript[${code}m';

  String _escapeValue({bool forScript = false}) =>
      forScript ? escapeForScript : escape;

  /// Wraps [value] with the [escape] value for this code, followed by
  /// [resetAll].
  ///
  /// If [forScript] is `true`, the return value is an unescaped literal. The
  /// value of [ansiOutputEnabled] is also ignored.
  ///
  /// Returns `value` unchanged if
  ///   * [value] is `null` or empty
  ///   * both [ansiOutputEnabled] and [forScript] are `false`.
  ///   * [type] is [AnsiCodeType.reset]
  String? wrap(String? value, {bool forScript = false}) =>
      _isNoop(type == AnsiCodeType.reset, value, forScript)
          ? value
          : '${_escapeValue(forScript: forScript)}$value'
              '${reset!._escapeValue(forScript: forScript)}';

  @override
  String toString() => '$name ${type._name} ($code)';
}

/// Returns a [String] formatted with [codes].
///
/// If [forScript] is `true`, the return value is an unescaped literal. The
/// value of [ansiOutputEnabled] is also ignored.
///
/// Returns `value` unchanged if
///   * [value] is `null` or empty.
///   * both [ansiOutputEnabled] and [forScript] are `false`.
///   * [codes] is empty.
///
/// Throws an [ArgumentError] if
///   * [codes] contains more than one value of type [AnsiCodeType.foreground].
///   * [codes] contains more than one value of type [AnsiCodeType.background].
///   * [codes] contains any value of type [AnsiCodeType.reset].
String? wrapWith(String? value, Iterable<AnsiCode> codes,
    {bool forScript = false}) {
  // Eliminate duplicates
  final myCodes = codes.toSet();

  if (_isNoop(myCodes.isEmpty, value, forScript)) {
    return value;
  }

  var foreground = 0, background = 0;
  for (var code in myCodes) {
    switch (code.type) {
      case AnsiCodeType.foreground:
        foreground++;
        if (foreground > 1) {
          throw ArgumentError.value(codes, 'codes',
              'Cannot contain more than one foreground color code.');
        }
      case AnsiCodeType.background:
        background++;
        if (background > 1) {
          throw ArgumentError.value(codes, 'codes',
              'Cannot contain more than one foreground color code.');
        }
      case AnsiCodeType.reset:
        throw ArgumentError.value(
            codes, 'codes', 'Cannot contain reset codes.');
      case AnsiCodeType.style:
        // Ignore.
        break;
    }
  }

  final sortedCodes = myCodes.map((ac) => ac.code).toList()..sort();
  final escapeValue = forScript ? _ansiEscapeForScript : _ansiEscapeLiteral;

  return "$escapeValue[${sortedCodes.join(';')}m$value"
      '${resetAll._escapeValue(forScript: forScript)}';
}

//
// Style values
//

const styleBold = AnsiCode._('bold', AnsiCodeType.style, 1, resetBold);
const styleDim = AnsiCode._('dim', AnsiCodeType.style, 2, resetDim);
const styleItalic = AnsiCode._('italic', AnsiCodeType.style, 3, resetItalic);
const styleUnderlined =
    AnsiCode._('underlined', AnsiCodeType.style, 4, resetUnderlined);
const styleBlink = AnsiCode._('blink', AnsiCodeType.style, 5, resetBlink);
const styleReverse = AnsiCode._('reverse', AnsiCodeType.style, 7, resetReverse);

/// Not widely supported.
const styleHidden = AnsiCode._('hidden', AnsiCodeType.style, 8, resetHidden);

/// Not widely supported.
const styleCrossedOut =
    AnsiCode._('crossed out', AnsiCodeType.style, 9, resetCrossedOut);

//
// Reset values
//

const resetAll = AnsiCode._('all', AnsiCodeType.reset, 0, null);

// NOTE: bold is weird. The reset code seems to be 22 sometimes – not 21
// See https://gitlab.com/gnachman/iterm2/issues/3208
const resetBold = AnsiCode._('bold', AnsiCodeType.reset, 22, null);
const resetDim = AnsiCode._('dim', AnsiCodeType.reset, 22, null);
const resetItalic = AnsiCode._('italic', AnsiCodeType.reset, 23, null);
const resetUnderlined = AnsiCode._('underlined', AnsiCodeType.reset, 24, null);
const resetBlink = AnsiCode._('blink', AnsiCodeType.reset, 25, null);
const resetReverse = AnsiCode._('reverse', AnsiCodeType.reset, 27, null);
const resetHidden = AnsiCode._('hidden', AnsiCodeType.reset, 28, null);
const resetCrossedOut = AnsiCode._('crossed out', AnsiCodeType.reset, 29, null);

//
// Foreground values
//

const black = AnsiCode._('black', AnsiCodeType.foreground, 30, resetAll);
const red = AnsiCode._('red', AnsiCodeType.foreground, 31, resetAll);
const green = AnsiCode._('green', AnsiCodeType.foreground, 32, resetAll);
const yellow = AnsiCode._('yellow', AnsiCodeType.foreground, 33, resetAll);
const blue = AnsiCode._('blue', AnsiCodeType.foreground, 34, resetAll);
const magenta = AnsiCode._('magenta', AnsiCodeType.foreground, 35, resetAll);
const cyan = AnsiCode._('cyan', AnsiCodeType.foreground, 36, resetAll);
const lightGray =
    AnsiCode._('light gray', AnsiCodeType.foreground, 37, resetAll);
const defaultForeground =
    AnsiCode._('default', AnsiCodeType.foreground, 39, resetAll);
const darkGray = AnsiCode._('dark gray', AnsiCodeType.foreground, 90, resetAll);
const lightRed = AnsiCode._('light red', AnsiCodeType.foreground, 91, resetAll);
const lightGreen =
    AnsiCode._('light green', AnsiCodeType.foreground, 92, resetAll);
const lightYellow =
    AnsiCode._('light yellow', AnsiCodeType.foreground, 93, resetAll);
const lightBlue =
    AnsiCode._('light blue', AnsiCodeType.foreground, 94, resetAll);
const lightMagenta =
    AnsiCode._('light magenta', AnsiCodeType.foreground, 95, resetAll);
const lightCyan =
    AnsiCode._('light cyan', AnsiCodeType.foreground, 96, resetAll);
const white = AnsiCode._('white', AnsiCodeType.foreground, 97, resetAll);

//
// Background values
//

const backgroundBlack =
    AnsiCode._('black', AnsiCodeType.background, 40, resetAll);
const backgroundRed = AnsiCode._('red', AnsiCodeType.background, 41, resetAll);
const backgroundGreen =
    AnsiCode._('green', AnsiCodeType.background, 42, resetAll);
const backgroundYellow =
    AnsiCode._('yellow', AnsiCodeType.background, 43, resetAll);
const backgroundBlue =
    AnsiCode._('blue', AnsiCodeType.background, 44, resetAll);
const backgroundMagenta =
    AnsiCode._('magenta', AnsiCodeType.background, 45, resetAll);
const backgroundCyan =
    AnsiCode._('cyan', AnsiCodeType.background, 46, resetAll);
const backgroundLightGray =
    AnsiCode._('light gray', AnsiCodeType.background, 47, resetAll);
const backgroundDefault =
    AnsiCode._('default', AnsiCodeType.background, 49, resetAll);
const backgroundDarkGray =
    AnsiCode._('dark gray', AnsiCodeType.background, 100, resetAll);
const backgroundLightRed =
    AnsiCode._('light red', AnsiCodeType.background, 101, resetAll);
const backgroundLightGreen =
    AnsiCode._('light green', AnsiCodeType.background, 102, resetAll);
const backgroundLightYellow =
    AnsiCode._('light yellow', AnsiCodeType.background, 103, resetAll);
const backgroundLightBlue =
    AnsiCode._('light blue', AnsiCodeType.background, 104, resetAll);
const backgroundLightMagenta =
    AnsiCode._('light magenta', AnsiCodeType.background, 105, resetAll);
const backgroundLightCyan =
    AnsiCode._('light cyan', AnsiCodeType.background, 106, resetAll);
const backgroundWhite =
    AnsiCode._('white', AnsiCodeType.background, 107, resetAll);

/// All of the [AnsiCode] values that represent [AnsiCodeType.style].
const List<AnsiCode> styles = [
  styleBold,
  styleDim,
  styleItalic,
  styleUnderlined,
  styleBlink,
  styleReverse,
  styleHidden,
  styleCrossedOut
];

/// All of the [AnsiCode] values that represent [AnsiCodeType.foreground].
const List<AnsiCode> foregroundColors = [
  black,
  red,
  green,
  yellow,
  blue,
  magenta,
  cyan,
  lightGray,
  defaultForeground,
  darkGray,
  lightRed,
  lightGreen,
  lightYellow,
  lightBlue,
  lightMagenta,
  lightCyan,
  white
];

/// All of the [AnsiCode] values that represent [AnsiCodeType.background].
const List<AnsiCode> backgroundColors = [
  backgroundBlack,
  backgroundRed,
  backgroundGreen,
  backgroundYellow,
  backgroundBlue,
  backgroundMagenta,
  backgroundCyan,
  backgroundLightGray,
  backgroundDefault,
  backgroundDarkGray,
  backgroundLightRed,
  backgroundLightGreen,
  backgroundLightYellow,
  backgroundLightBlue,
  backgroundLightMagenta,
  backgroundLightCyan,
  backgroundWhite
];
