// Copyright (c) 2013, 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:collection';

import 'arg_parser.dart';
import 'arg_parser_exception.dart';
import 'arg_results.dart';
import 'option.dart';

/// The actual argument parsing class.
///
/// Unlike [ArgParser] which is really more an "arg grammar", this is the class
/// that does the parsing and holds the mutable state required during a parse.
class Parser {
  /// If parser is parsing a command's options, this will be the name of the
  /// command. For top-level results, this returns `null`.
  final String? _commandName;

  /// The parser for the supercommand of this command parser, or `null` if this
  /// is the top-level parser.
  final Parser? _parent;

  /// The grammar being parsed.
  final ArgParser _grammar;

  /// The arguments being parsed.
  final Queue<String> _args;

  /// The remaining non-option, non-command arguments.
  final List<String> _rest;

  /// The accumulated parsed options.
  final Map<String, dynamic> _results = <String, dynamic>{};

  Parser(this._commandName, this._grammar, this._args,
      [this._parent, List<String>? rest])
      : _rest = [...?rest];

  /// The current argument being parsed.
  String get _current => _args.first;

  /// Parses the arguments. This can only be called once.
  ArgResults parse() {
    var arguments = _args.toList();
    if (_grammar.allowsAnything) {
      return newArgResults(
          _grammar, const {}, _commandName, null, arguments, arguments);
    }

    ArgResults? commandResults;

    // Parse the args.
    while (_args.isNotEmpty) {
      if (_current == '--') {
        // Reached the argument terminator, so stop here.
        _args.removeFirst();
        break;
      }

      // Try to parse the current argument as a command. This happens before
      // options so that commands can have option-like names.
      var command = _grammar.commands[_current];
      if (command != null) {
        _validate(_rest.isEmpty, 'Cannot specify arguments before a command.',
            _current);
        var commandName = _args.removeFirst();
        var commandParser = Parser(commandName, command, _args, this, _rest);

        try {
          commandResults = commandParser.parse();
        } on ArgParserException catch (error) {
          throw ArgParserException(
              error.message,
              [commandName, ...error.commands],
              error.argumentName,
              error.source,
              error.offset);
        }

        // All remaining arguments were passed to command so clear them here.
        _rest.clear();
        break;
      }

      // Try to parse the current argument as an option. Note that the order
      // here matters.
      if (_parseSoloOption()) continue;
      if (_parseAbbreviation(this)) continue;
      if (_parseLongOption()) continue;

      // This argument is neither option nor command, so stop parsing unless
      // the [allowTrailingOptions] option is set.
      if (!_grammar.allowTrailingOptions) break;
      _rest.add(_args.removeFirst());
    }

    // Check if mandatory and invoke existing callbacks.
    _grammar.options.forEach((name, option) {
      var parsedOption = _results[name];

      var callback = option.callback;
      if (callback == null) return;

      // Check if an option is mandatory and was passed; if not, throw an
      // exception.
      if (option.mandatory && parsedOption == null) {
        throw ArgParserException('Option $name is mandatory.', null, name);
      }

      // ignore: avoid_dynamic_calls
      callback(option.valueOrDefault(parsedOption));
    });

    // Add in the leftover arguments we didn't parse to the innermost command.
    _rest.addAll(_args);
    _args.clear();
    return newArgResults(
        _grammar, _results, _commandName, commandResults, _rest, arguments);
  }

  /// Pulls the value for [option] from the second argument in [_args].
  ///
  /// Validates that there is a valid value there.
  void _readNextArgAsValue(Option option, String arg) {
    // Take the option argument from the next command line arg.
    _validate(_args.isNotEmpty, 'Missing argument for "$arg".', arg);

    _setOption(_results, option, _current, arg);
    _args.removeFirst();
  }

  /// Tries to parse the current argument as a "solo" option, which is a single
  /// hyphen followed by a single letter.
  ///
  /// We treat this differently than collapsed abbreviations (like "-abc") to
  /// handle the possible value that may follow it.
  bool _parseSoloOption() {
    // Hand coded regexp: r'^-([a-zA-Z0-9])$'
    // Length must be two, hyphen followed by any letter/digit.
    if (_current.length != 2) return false;
    if (!_current.startsWith('-')) return false;
    var opt = _current[1];
    if (!_isLetterOrDigit(opt.codeUnitAt(0))) return false;
    return _handleSoloOption(opt);
  }

  bool _handleSoloOption(String opt) {
    var option = _grammar.findByAbbreviation(opt);
    if (option == null) {
      // Walk up to the parent command if possible.
      _validate(_parent != null, 'Could not find an option or flag "-$opt".',
          '-$opt');
      return _parent!._handleSoloOption(opt);
    }

    _args.removeFirst();

    if (option.isFlag) {
      _setFlag(_results, option, true);
    } else {
      _readNextArgAsValue(option, '-$opt');
    }

    return true;
  }

  /// Tries to parse the current argument as a series of collapsed abbreviations
  /// (like "-abc") or a single abbreviation with the value directly attached
  /// to it (like "-mrelease").
  bool _parseAbbreviation(Parser innermostCommand) {
    // Hand coded regexp: r'^-([a-zA-Z0-9]+)(.*)$'
    // Hyphen then at least one letter/digit then zero or more
    // anything-but-newlines.
    if (_current.length < 2) return false;
    if (!_current.startsWith('-')) return false;

    // Find where we go from letters/digits to rest.
    var index = 1;
    while (index < _current.length &&
        _isLetterOrDigit(_current.codeUnitAt(index))) {
      ++index;
    }
    // Must be at least one letter/digit.
    if (index == 1) return false;

    // If the first character is the abbreviation for a non-flag option, then
    // the rest is the value.
    var lettersAndDigits = _current.substring(1, index);
    var rest = _current.substring(index);
    if (rest.contains('\n') || rest.contains('\r')) return false;
    return _handleAbbreviation(lettersAndDigits, rest, innermostCommand);
  }

  bool _handleAbbreviation(
      String lettersAndDigits, String rest, Parser innermostCommand) {
    var c = lettersAndDigits.substring(0, 1);
    var first = _grammar.findByAbbreviation(c);
    if (first == null) {
      // Walk up to the parent command if possible.
      _validate(_parent != null,
          'Could not find an option with short name "-$c".', '-$c');
      return _parent!
          ._handleAbbreviation(lettersAndDigits, rest, innermostCommand);
    } else if (!first.isFlag) {
      // The first character is a non-flag option, so the rest must be the
      // value.
      var value = '${lettersAndDigits.substring(1)}$rest';
      _setOption(_results, first, value, '-$c');
    } else {
      // If we got some non-flag characters, then it must be a value, but
      // if we got here, it's a flag, which is wrong.
      _validate(
          rest == '',
          'Option "-$c" is a flag and cannot handle value '
              '"${lettersAndDigits.substring(1)}$rest".',
          '-$c');

      // Not an option, so all characters should be flags.
      // We use "innermostCommand" here so that if a parent command parses the
      // *first* letter, subcommands can still be found to parse the other
      // letters.
      for (var i = 0; i < lettersAndDigits.length; i++) {
        var c = lettersAndDigits.substring(i, i + 1);
        innermostCommand._parseShortFlag(c);
      }
    }

    _args.removeFirst();
    return true;
  }

  void _parseShortFlag(String c) {
    var option = _grammar.findByAbbreviation(c);
    if (option == null) {
      // Walk up to the parent command if possible.
      _validate(_parent != null,
          'Could not find an option with short name "-$c".', '-$c');
      _parent!._parseShortFlag(c);
      return;
    }

    // In a list of short options, only the first can be a non-flag. If
    // we get here we've checked that already.
    _validate(option.isFlag,
        'Option "-$c" must be a flag to be in a collapsed "-".', '-$c');

    _setFlag(_results, option, true);
  }

  /// Tries to parse the current argument as a long-form named option, which
  /// may include a value like "--mode=release" or "--mode release".
  bool _parseLongOption() {
    // Hand coded regexp: r'^--([a-zA-Z\-_0-9]+)(=(.*))?$'
    // Two hyphens then at least one letter/digit/hyphen, optionally an equal
    // sign followed by zero or more anything-but-newlines.

    if (!_current.startsWith('--')) return false;

    var index = _current.indexOf('=');
    var name =
        index == -1 ? _current.substring(2) : _current.substring(2, index);
    for (var i = 0; i != name.length; ++i) {
      if (!_isLetterDigitHyphenOrUnderscore(name.codeUnitAt(i))) return false;
    }
    var value = index == -1 ? null : _current.substring(index + 1);
    if (value != null && (value.contains('\n') || value.contains('\r'))) {
      return false;
    }
    return _handleLongOption(name, value);
  }

  bool _handleLongOption(String name, String? value) {
    var option = _grammar.findByNameOrAlias(name);
    if (option != null) {
      _args.removeFirst();
      if (option.isFlag) {
        _validate(value == null,
            'Flag option "--$name" should not be given a value.', '--$name');

        _setFlag(_results, option, true);
      } else if (value != null) {
        // We have a value like --foo=bar.
        _setOption(_results, option, value, '--$name');
      } else {
        // Option like --foo, so look for the value as the next arg.
        _readNextArgAsValue(option, '--$name');
      }
    } else if (name.startsWith('no-')) {
      // See if it's a negated flag.
      var positiveName = name.substring('no-'.length);
      option = _grammar.findByNameOrAlias(positiveName);
      if (option == null) {
        // Walk up to the parent command if possible.
        _validate(_parent != null, 'Could not find an option named "--$name".',
            '--$name');
        return _parent!._handleLongOption(name, value);
      }

      _args.removeFirst();
      _validate(
          option.isFlag, 'Cannot negate non-flag option "--$name".', '--$name');
      _validate(
          option.negatable!, 'Cannot negate option "--$name".', '--$name');

      _setFlag(_results, option, false);
    } else {
      // Walk up to the parent command if possible.
      _validate(_parent != null, 'Could not find an option named "--$name".',
          '--$name');
      return _parent!._handleLongOption(name, value);
    }

    return true;
  }

  /// Called during parsing to validate the arguments.
  ///
  /// Throws an [ArgParserException] if [condition] is `false`.
  void _validate(bool condition, String message,
      [String? args, List<String>? source, int? offset]) {
    if (!condition) {
      throw ArgParserException(message, null, args, source, offset);
    }
  }

  /// Validates and stores [value] as the value for [option], which must not be
  /// a flag.
  void _setOption(Map results, Option option, String value, String arg) {
    assert(!option.isFlag);

    if (!option.isMultiple) {
      _validateAllowed(option, value, arg);
      results[option.name] = value;
      return;
    }

    var list = results.putIfAbsent(option.name, () => <String>[]) as List;

    if (option.splitCommas) {
      for (var element in value.split(',')) {
        _validateAllowed(option, element, arg);
        list.add(element);
      }
    } else {
      _validateAllowed(option, value, arg);
      list.add(value);
    }
  }

  /// Validates and stores [value] as the value for [option], which must be a
  /// flag.
  void _setFlag(Map results, Option option, bool value) {
    assert(option.isFlag);
    results[option.name] = value;
  }

  /// Validates that [value] is allowed as a value of [option].
  void _validateAllowed(Option option, String value, String arg) {
    if (option.allowed == null) return;

    _validate(option.allowed!.contains(value),
        '"$value" is not an allowed value for option "$arg".', arg);
  }
}

bool _isLetterOrDigit(int codeUnit) =>
    // Uppercase letters.
    (codeUnit >= 65 && codeUnit <= 90) ||
    // Lowercase letters.
    (codeUnit >= 97 && codeUnit <= 122) ||
    // Digits.
    (codeUnit >= 48 && codeUnit <= 57);

bool _isLetterDigitHyphenOrUnderscore(int codeUnit) =>
    _isLetterOrDigit(codeUnit) ||
    // Hyphen.
    codeUnit == 45 ||
    // Underscore.
    codeUnit == 95;
