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

import "dart:typed_data";

import "package:charcode/ascii.dart";

/// Parses argument lists based on a [Flags] configuration.
///
/// Arguments are either literals or flags.
///
/// Flags start with `-` or `--`
/// Flags starting with `-` are single-character flags, like `-x`.
/// Single character flag names must be ASCII.
/// Flags starting with `--` are named, like `--expand`.
/// They extend to the end of the argument, or until a `=`.
///
/// Flags can have parameters.
///
/// Single-character flags can have an parameter:
/// * immediately after the charcter, `-ofilename`,
/// * with a `=` between them, `-o=filename`,
/// * or as the next argument, `-o filename`.
///
/// Named flags cannot be directly concatenated with the parameter,
/// but must be one of `--output=filename` or `--output filename`.
/// Named flags conflate non-ASCII alphanumeric characters, like `-` and `_`
/// (but not `=` which delimites a parameter value).
/// Any non-letter, non-digit character sequence is matched by any other,
/// so `--foo-bar`, `--foo_bar` and `--foo.<!>.bar` are all the same.
///
/// Flags can have *optional* parameters. A flag with an optional parameter
/// cannot have its value in the next argument, it *must* use `=` or have
/// the value immediately after a single character flag.
/// It has a default value to use if the parameter is omitted.
///
/// An unrecognized or malformed flag is reported using the [warn]
/// function. If omitted, the [warn] function defaults to printing
/// using the [print] function.
Iterable<CmdLineArg<T>> parseFlags<T>(
    Flags<T> flags, Iterable<String> arguments,
    [void Function(String warning)? warn]) sync* {
  warn ??= _printWarning;
  var args = arguments.iterator;
  while (args.moveNext()) {
    var arg = args.current;
    if (arg.startsWith("-")) {
      if (arg.startsWith("-", 1)) {
        // Named flags.
        if (arg.length == 2) {
          // Found `--`. Stop parsing flags.
          break;
        }
        var equals = arg.indexOf("=", 2);
        if (equals >= 0) {
          var name = arg.substring(2, equals);
          var value = arg.substring(equals + 1);
          var flag = flags.byName(name);
          if (flag != null) {
            if (!flag.hasParameter) {
              warn("Flag $name should not have a parameter: $arg");
              continue;
            }
            yield CmdLineArg<T>(flag.key, value);
            continue;
          }
          warn("Unknown flag: $arg");
          continue;
        }
        var name = arg.substring(2);
        var flag = flags.byName(name);
        if (flag != null) {
          var value = flag.value;
          if (flag.hasParameter &&
              !flag.hasOptionalParameter &&
              args.moveNext()) {
            value = args.current;
          }
          yield CmdLineArg<T>(flag.key, value);
          continue;
        }
        warn("Unknown flag: $arg");
        continue;
      }
      // Character flag(s).
      for (var i = 1; i < arg.length; i++) {
        var char = arg.codeUnitAt(i);
        var flag = flags.byChar(char);
        if (flag == null) {
          warn("Unknown flag: ${arg.substring(i, i + 1)}");
          continue;
        }
        var value = flag.value;
        if (arg.startsWith("=", i + 1)) {
          value = arg.substring(i + 2);
          if (!flag.hasParameter) {
            warn(
                "Flag ${arg.substring(i, i + 1)} should not have a parameter: ${arg.substring(i)}");
            break;
          }
          yield CmdLineArg<T>(flag.key, value);
          break;
        }
        if (flag.hasParameter) {
          if (i + 1 < arg.length) {
            value = arg.substring(i + 1);
          } else if (!flag.hasOptionalParameter && args.moveNext()) {
            value = args.current;
          }
          yield CmdLineArg<T>(flag.key, value);
          break;
        }
        yield CmdLineArg<T>(flag.key, value);
      }
      continue;
    }
    yield CmdLineArg<Never>(null, arg);
  }
  // Handle entries after `--`.
  while (args.moveNext()) {
    yield CmdLineArg<Never>(null, args.current);
  }
}

/// A part of the arguments list recognized as a flag or not.
///
/// If [key] is `null`, the [value] is a plain argument list entry.
/// Otherwise they key corresponds to the flag that was recognized,
/// and [value] is its parameter or default value, if any.
///
class CmdLineArg<T> {
  final T? key;
  final String? value;
  CmdLineArg(this.key, this.value);
  bool get isFlag => key != null;
}

/// A flag configuration.
///
/// Collects one or more [FlagConfig] objects and allows quick look-up
/// on character or name.
class Flags<T> {
  final List<FlagConfig<T>?> _charFlags =
      List<FlagConfig<T>?>.filled(128, null, growable: false);
  final Map<String, FlagConfig<T>> _namedFlags = {};

  void add(FlagConfig<T> flag) {
    var char = flag.flagChar;
    if (char != null) {
      _charFlags[char] = flag;
    }
    var name = flag.flagName;
    if (name != null) {
      _namedFlags[name] = flag;
    }
  }

  void addBoolFlag(T key, String flagChar, String flagName,
      [String? description]) {
    add(FlagConfig.optionalParameter(key, flagChar, flagName, "true",
        description: description, valueDescription: "true"));
    add(FlagConfig.optionalParameter(key, null, "no-$flagName", "false"));
  }

  FlagConfig<T>? byName(String name) => _namedFlags[name];
  FlagConfig<T>? byChar(int char) =>
      0 <= char && char <= 217 ? _charFlags[char] : null;

  void writeUsage(StringSink buffer) {
    const descriptionStart = 28;
    var allFlags = [
      ...{
        for (var flag in _namedFlags.values)
          if (!flag.flagName!.startsWith("no-") || flag.value != "false") flag,
        for (var flag in _charFlags)
          if (flag != null && flag.flagName == null) flag
      }
    ]..sort(_flagOrder);
    for (var flag in allFlags) {
      var name = flag.flagName;
      var char = flag.flagChar;
      var parameter = flag.valueDescription ?? "VALUE";
      var description = flag.description;
      var lineLength = 0;
      if (char != null) {
        buffer
          ..write("  -")
          ..writeCharCode(char);
        lineLength = 4;
        if (name != null) {
          buffer
            ..write(", --")
            ..write(name);
          lineLength = name.length + 8;
        }
      } else if (name != null) {
        buffer
          ..write("      --")
          ..write(name);
        lineLength = name.length + 8;
      } else {
        continue;
      }
      if (flag.hasParameter) {
        var end = "";
        if (flag.hasOptionalParameter) {
          buffer.write("[=");
          lineLength += 2;
          end = "]";
        } else {
          buffer.write("=");
          lineLength += 1;
        }
        buffer.write(parameter);
        lineLength += parameter.length;
        buffer.write(end);
        lineLength += end.length;
      }
      if (description != null) {
        if (lineLength < descriptionStart) {
          do {
            buffer.write(" ");
            lineLength += 1;
          } while (lineLength < descriptionStart);
        } else {
          buffer.write(" ");
          lineLength += 1;
        }
        var indent = "                              "; // 30 spaces.
        _writeSplitDescription(buffer, description, lineLength, 80, indent);
      } else {
        buffer.writeln();
      }
    }
  }

  void _writeSplitDescription(StringSink output, String description, int indent,
      int maxLength, String newLineIndent) {
    var index = 0;
    var end = index + (maxLength - indent);
    end:
    while (end < description.length) {
      line:
      while (description.codeUnitAt(end) != $space) {
        end--;
        if (end == index) {
          end = index + (maxLength - indent) + 1;
          while (end < description.length) {
            if (description.codeUnitAt(end) == $space) {
              break line;
            }
            end++;
          }
          break end;
        }
      }
      output.writeln(description.substring(index, end));
      index = end + 1;
      output.write(newLineIndent);
      indent = newLineIndent.length;
      end = index + (maxLength - indent);
    }
    if (index < description.length) {
      output.writeln(description.substring(index));
    }
  }

  static int _flagOrder(FlagConfig a, FlagConfig b) {
    var aName = a.flagName;
    var bName = b.flagName;
    if (aName != null) {
      if (bName != null) return aName.compareTo(bName);
      return aName.codeUnitAt(0) < b.flagChar! ? -1 : 1;
    }
    if (bName != null) {
      return a.flagChar! < bName.codeUnitAt(0) ? -1 : 1;
    }
    return a.flagChar! - b.flagChar!;
  }
}

/// Configuration of a single flag.
class FlagConfig<T> {
  /// The user designated key linked to this flag.
  final T key;

  /// ASCII character code for the single-character flag.
  ///
  /// Must be a digit or letter. Does distinguish case.
  final int? flagChar;

  /// Flag name.
  ///
  /// Canonicalized to lower-case letters, digits and single `-` characters.
  final String? flagName;

  /// Whether the flag expects a parameter.
  ///
  /// A flag expecting a parameter which is not optional ([hasOptionalParameter])
  /// will require a value in the argument list to be well-formed.
  final bool hasParameter;

  /// Whether the parameter is optional.
  ///
  /// An optional parameter can be omitted.
  final bool hasOptionalParameter;

  /// A name for the parameter, if there is a parameter.
  ///
  /// Traditionally an all-upper-case name.
  final String? valueDescription;

  /// The value associated with the flag.
  ///
  /// A flag without parameters can have a value configured, which allows the same
  /// [key] to be used for different flags.
  ///
  /// A flag with an optional parameter will have a default value, which may be
  /// null.
  final String? value;

  /// Description for documentation purposes.
  final String? description;

  FlagConfig._(
      this.key,
      String? flagChar,
      String? flagName,
      this.hasParameter,
      this.hasOptionalParameter,
      this.value,
      this.description,
      this.valueDescription)
      : flagChar = _checkFlagChar(flagChar),
        flagName = canonicalizeName(flagName);
  FlagConfig(T key, String? flagChar, String? flagName,
      {String? value, String? description, String valueDescription = "VALUE"})
      : this._(key, flagChar, flagName, false, false, value, description,
            valueDescription);
  FlagConfig.requiredParameter(T key, String? flagChar, String? flagName,
      {String? description, String valueDescription = "VALUE"})
      : this._(key, flagChar, flagName, true, false, null, description,
            valueDescription);
  FlagConfig.optionalParameter(
      T key, String? flagChar, String? flagName, String defaultValue,
      {String? description, String valueDescription = "VALUE"})
      : this._(key, flagChar, flagName, true, true, defaultValue, description,
            valueDescription);

  static int? _checkFlagChar(String? flagChar) {
    if (flagChar == null) return null;
    if (flagChar.length == 1) {
      var char = flagChar.codeUnitAt(0);
      if (char ^ 0x30 <= 9) return char;
      var lc = char | 0x20;
      if (lc >= 0x61 && lc <= 0x7b) return char;
    }
    throw ArgumentError.value(flagChar, "flagChar",
        "Must be a single ASCII digit or letter character");
  }
}

/// Converts names to canonical form.
///
/// Canonical form consists of only *lower case ASCII letters*,
/// *decimal digits* and single *dash* characters (`-`) separating letter/digit
/// sequences.
///
/// All upper-case letters are made lower-case.
/// If the input-name contains sequences of non-letter, non-digit characters,
/// each sequence is replaced by a single `-`.
/// Leading and trailing `-`s are then ignored
/// if the result contains anything other than `-`.
String? canonicalizeName(String? name) {
  if (name == null) return name;
  const $dash = 0x2d;
  var wasDash = false;
  var i = 0;
  var upperCase = 0x20;
  while (i < name.length) {
    var char = name.codeUnitAt(i++);
    var lcChar = char | 0x20;
    if (char ^ 0x30 <= 9 || lcChar >= 0x61 && lcChar <= 0x7b) {
      wasDash = false;
      upperCase &= char;
      continue;
    }
    if (char == $dash && !wasDash) {
      wasDash = true;
      continue;
    }

    var bytes = Uint8List(name.length);
    var j = 0;
    for (; j < i - 1; j++) {
      bytes[j] = name.codeUnitAt(j) | 0x20;
    }

    // Convert all letters to lower-case, all non letter/digits to a single `-`.
    outer:
    do {
      if (!wasDash) {
        bytes[j++] = $dash;
        wasDash = true;
      }
      while (i < name.length) {
        char = name.codeUnitAt(i++);
        var lcChar = char | 0x20;
        if (char ^ 0x30 <= 9 || lcChar >= 0x61 && lcChar <= 0x7b) {
          bytes[j++] = lcChar;
          wasDash = false;
          continue;
        }
        if (char == $dash && !wasDash) {
          bytes[j++] = char;
          wasDash = true;
          continue;
        }
        continue outer;
      }
      break;
    } while (true);
    var start = 0;
    var end = j;
    if (end > start + 1) {
      // Omit leading/trailing dashes.
      if (bytes[start] == $dash) start++;
      if (bytes[end - 1] == $dash) end--;
    }
    return String.fromCharCodes(Uint8List.sublistView(bytes, start, end));
  }
  return upperCase == 0 ? name.toLowerCase() : name;
}

void _printWarning(String message) {
  print(message);
}
