// Copyright (c) 2014, 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:math' as math;

import 'package:analyzer/dart/analysis/features.dart';
import 'package:analyzer/dart/analysis/utilities.dart';
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/token.dart';
import 'package:analyzer/error/error.dart';
// ignore: implementation_imports
import 'package:analyzer/src/dart/scanner/scanner.dart';
// ignore: implementation_imports
import 'package:analyzer/src/string_source.dart';
import 'package:pub_semver/pub_semver.dart';

import 'exceptions.dart';
import 'front_end/ast_node_visitor.dart';
import 'short/source_visitor.dart';
import 'source_code.dart';
import 'string_compare.dart' as string_compare;

/// Regular expression that matches a format width comment like:
///
///     // dart format width=123
final RegExp _widthCommentPattern = RegExp(r'^// dart format width=(\d+)$');

/// A Dart source code formatter.
///
/// This is a lightweight class that mostly bundles formatting options so that
/// you don't have to pass a long argument list to [format()] and
/// [formatStatement()]. You can efficiently create a new instance of this for
/// every format invocation.
final class DartFormatter {
  /// The latest Dart language version that can be parsed and formatted by this
  /// version of the formatter.
  static final latestLanguageVersion = Version(3, 8, 0);

  /// The latest Dart language version that will be formatted using the older
  /// "short" style.
  ///
  /// Any Dart code at a language version later than this will be formatted
  /// using the new "tall" style.
  static final latestShortStyleLanguageVersion = Version(3, 6, 0);

  /// The page width that the formatter tries to fit code inside if no other
  /// width is specified.
  static const defaultPageWidth = 80;

  /// The Dart language version that formatted code should be parsed as.
  ///
  /// Note that a `// @dart=` comment inside the code overrides this.
  final Version languageVersion;

  /// The string that newlines should use.
  ///
  /// If not explicitly provided, this is inferred from the source text. If the
  /// first newline is `\r\n` (Windows), it will use that. Otherwise, it uses
  /// Unix-style line endings (`\n`).
  String? lineEnding;

  /// The number of characters allowed in a single line.
  final int pageWidth;

  /// The number of characters of indentation to prefix the output lines with.
  final int indent;

  /// How trailing commas in various constructs should affect formatting.
  ///
  /// The default is [TrailingCommas.automate] where the formatter is free to
  /// add and remove them if it decides a constructor should be split or
  /// collapsed.
  final TrailingCommas trailingCommas;

  /// Flags to enable experimental language features.
  ///
  /// See dart.dev/go/experiments for details.
  final List<String> experimentFlags;

  /// Creates a new formatter for Dart code at [languageVersion].
  ///
  /// If [lineEnding] is given, that will be used for any newlines in the
  /// output. Otherwise, the line separator will be inferred from the line
  /// endings in the source file.
  ///
  /// If [indent] is given, that many levels of indentation will be prefixed
  /// before each resulting line in the output.
  DartFormatter({
    required this.languageVersion,
    this.lineEnding,
    int? pageWidth,
    int? indent,
    TrailingCommas? trailingCommas,
    List<String>? experimentFlags,
  }) : pageWidth = pageWidth ?? defaultPageWidth,
       indent = indent ?? 0,
       trailingCommas = trailingCommas ?? TrailingCommas.automate,
       experimentFlags = [...?experimentFlags];

  /// Formats the given [source] string containing an entire Dart compilation
  /// unit.
  ///
  /// If [uri] is given, it is a [String] or [Uri] used to identify the file
  /// being formatted in error messages.
  String format(String source, {Object? uri}) {
    var uriString = switch (uri) {
      null => null,
      Uri() => uri.toString(),
      String() => uri,
      _ => throw ArgumentError('uri must be `null`, a Uri, or a String.'),
    };

    return formatSource(
      SourceCode(source, uri: uriString, isCompilationUnit: true),
    ).text;
  }

  /// Formats the given [source] string containing a single Dart statement.
  String formatStatement(String source) {
    return formatSource(SourceCode(source, isCompilationUnit: false)).text;
  }

  /// Formats the given [source].
  ///
  /// Returns a new [SourceCode] containing the formatted code and the resulting
  /// selection, if any.
  SourceCode formatSource(SourceCode source) {
    var inputOffset = 0;
    var text = source.text;
    var unitSourceCode = source;

    // If we're parsing a single statement, wrap the source in a fake function.
    if (!source.isCompilationUnit) {
      var prefix = 'void foo() { ';
      inputOffset = prefix.length;
      text = '$prefix$text\n }';
      unitSourceCode = SourceCode(
        text,
        uri: source.uri,
        isCompilationUnit: false,
        selectionStart:
            source.selectionStart != null
                ? source.selectionStart! + inputOffset
                : null,
        selectionLength: source.selectionLength,
      );
    }

    var featureSet = FeatureSet.fromEnableFlags2(
      sdkLanguageVersion: languageVersion,
      flags: experimentFlags,
    );

    // Parse it.
    var parseResult = parseString(
      content: text,
      featureSet: featureSet,
      path: source.uri,
      throwIfDiagnostics: false,
    );

    // Infer the line ending if not given one. Do it here since now we know
    // where the lines start.
    if (lineEnding == null) {
      // If the first newline is "\r\n", use that. Otherwise, use "\n".
      var lineStarts = parseResult.lineInfo.lineStarts;
      if (lineStarts.length > 1 &&
          lineStarts[1] >= 2 &&
          text[lineStarts[1] - 2] == '\r') {
        lineEnding = '\r\n';
      } else {
        lineEnding = '\n';
      }
    }

    // Throw if there are syntactic errors.
    var syntacticErrors =
        parseResult.errors.where((error) {
          return error.errorCode.type == ErrorType.SYNTACTIC_ERROR;
        }).toList();
    if (syntacticErrors.isNotEmpty) {
      throw FormatterException(syntacticErrors);
    }

    AstNode node;
    if (source.isCompilationUnit) {
      node = parseResult.unit;
    } else {
      var function = parseResult.unit.declarations[0] as FunctionDeclaration;
      var body = function.functionExpression.body as BlockFunctionBody;
      node = body.block.statements[0];

      // Make sure we consumed all of the source.
      var token = node.endToken.next!;
      if (token.type != TokenType.CLOSE_CURLY_BRACKET) {
        var stringSource = StringSource(text, source.uri);
        var error = AnalysisError.tmp(
          source: stringSource,
          offset: token.offset - inputOffset,
          length: math.max(token.length, 1),
          errorCode: ParserErrorCode.UNEXPECTED_TOKEN,
          arguments: [token.lexeme],
        );
        throw FormatterException([error]);
      }
    }

    // Format it.
    var lineInfo = parseResult.lineInfo;

    // If the code has an `@dart=` comment, use that to determine the style.
    var sourceLanguageVersion = languageVersion;
    if (parseResult.unit.languageVersionToken case var token?) {
      sourceLanguageVersion = Version(token.major, token.minor, 0);
    }

    // Use language version to determine what formatting style to apply.
    SourceCode output;
    if (sourceLanguageVersion > latestShortStyleLanguageVersion) {
      // Look for a page width comment before the code.
      int? pageWidthFromComment;
      for (
        Token? comment = node.beginToken.precedingComments;
        comment != null;
        comment = comment.next
      ) {
        if (_widthCommentPattern.firstMatch(comment.lexeme) case var match?) {
          // If integer parsing fails for some reason, the returned `null`
          // means we correctly ignore the comment.
          pageWidthFromComment = int.tryParse(match[1]!);
          break;
        }
      }

      var visitor = AstNodeVisitor(this, lineInfo, unitSourceCode);
      output = visitor.run(unitSourceCode, node, pageWidthFromComment);
    } else {
      // Use the old style.
      var visitor = SourceVisitor(this, lineInfo, unitSourceCode);
      output = visitor.run(node);
    }

    // Sanity check that only whitespace was changed if that's all we expect.
    if (!string_compare.equalIgnoringWhitespace(source.text, output.text)) {
      throw UnexpectedOutputException(source.text, output.text);
    }

    return output;
  }
}

/// Configuration for how trailing commas should be handled by the formatter.
///
/// Note that this only applies when using the new formatter to format code at
/// language version 3.7 or later. On older versions, it always behaves as if
/// it were [TrailingCommas.preserve].
enum TrailingCommas {
  /// The formatter will add a trailing comma if a construct is split and
  /// remove the trailing comma and collapse the construct if it decides to do
  /// so.
  automate,

  /// The formatter will add a trailing comma if a construct splits. If the
  /// construct has a trailing comma, it will always be forced to split and the
  /// trailing comma is preserved.
  preserve,
}
