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

import 'package:analysis_server/src/status/pages.dart';
import 'package:analyzer/dart/analysis/analysis_context_collection.dart';
import 'package:analyzer/dart/analysis/context_root.dart';
import 'package:analyzer/dart/analysis/results.dart';
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/visitor.dart';
import 'package:analyzer/dart/element/type.dart';
import 'package:analyzer/file_system/physical_file_system.dart';
import 'package:analyzer/src/util/file_paths.dart' as file_paths;
import 'package:analyzer/src/utilities/extensions/diagnostic.dart';
import 'package:args/args.dart';

/// Compute metrics to determine which untyped variable declarations can be used
/// to imply an expected context type, i.e. the RHS of 'var string = ^' could be
/// assumed to be a [String].
Future<void> main(List<String> args) async {
  var parser = createArgParser();
  var result = parser.parse(args);

  if (validArguments(parser, result)) {
    var out = io.stdout;
    var rootPath = result.rest[0];
    out.writeln('Analyzing root: "$rootPath"');

    var computer = ImpliedTypeComputer();
    var stopwatch = Stopwatch();
    stopwatch.start();
    await computer.compute(rootPath, verbose: result['verbose'] as bool);
    stopwatch.stop();

    var duration = Duration(milliseconds: stopwatch.elapsedMilliseconds);
    out.writeln('Metrics computed in $duration');
    computer.writeMetrics(out);
    await out.flush();
  }
  io.exit(0);
}

/// Create a parser that can be used to parse the command-line arguments.
ArgParser createArgParser() {
  var parser = ArgParser();
  parser.addOption('help', abbr: 'h', help: 'Print this help message.');
  parser.addFlag(
    'verbose',
    abbr: 'v',
    help: 'Print additional information about the analysis',
    negatable: false,
  );
  return parser;
}

/// Print usage information for this tool.
void printUsage(ArgParser parser, {String? error}) {
  if (error != null) {
    print(error);
    print('');
  }
  print('usage: dart implicit_type_declarations.dart [options] packagePath');
  print('');
  print(
    'Compute implicit types in field declaration locations without a '
    'specified type.',
  );
  print('');
  print(parser.usage);
}

/// Return `true` if the command-line arguments (represented by the [result] and
/// parsed by the [parser]) are valid.
bool validArguments(ArgParser parser, ArgResults result) {
  if (result.wasParsed('help')) {
    printUsage(parser);
    return false;
  } else if (result.rest.length != 1) {
    printUsage(parser, error: 'No package path specified.');
    return false;
  }
  var rootPath = result.rest[0];
  if (!io.Directory(rootPath).existsSync()) {
    printUsage(parser, error: 'The directory "$rootPath" does not exist.');
    return false;
  }
  return true;
}

/// An object that visits a compilation unit in order to record the data used to
/// compute the metrics.
class ImpliedTypeCollector extends RecursiveAstVisitor<void> {
  /// The implied type data being collected.
  ImpliedTypeData data;

  /// Initialize a newly created collector to add data points to the given
  /// [data].
  new(this.data);

  void handleVariableDeclaration(VariableDeclaration node, DartType? dartType) {
    // If some untyped variable declaration
    if (node.equals != null && dartType == null ||
        (dartType != null &&
            (dartType is DynamicType || dartType is VoidType))) {
      // And if we can determine the type on the RHS of the variable declaration
      var rhsType = node.initializer?.staticType;
      if (rhsType != null && rhsType is! DynamicType) {
        // Record the name with the type.
        data.recordImpliedType(node.name.lexeme, rhsType.getDisplayString());
      }
    }
  }

  @override
  void visitVariableDeclarationList(VariableDeclarationList node) {
    for (var varDecl in node.variables) {
      handleVariableDeclaration(varDecl, node.type?.type);
    }
    return;
  }
}

/// An object used to compute metrics for a single file or directory.
class ImpliedTypeComputer {
  /// The metrics data that was computed.
  final ImpliedTypeData data = ImpliedTypeData();

  /// Initialize a newly created metrics computer that can compute the metrics
  /// in one or more files and directories.
  new();

  /// Compute the metrics for the file(s) in the [rootPath].
  /// If [corpus] is true, treat rootPath as a container of packages, creating
  /// a new context collection for each subdirectory.
  Future<void> compute(String rootPath, {required bool verbose}) async {
    var collection = AnalysisContextCollection(
      includedPaths: [rootPath],
      resourceProvider: PhysicalResourceProvider.INSTANCE,
    );
    var collector = ImpliedTypeCollector(data);
    for (var context in collection.contexts) {
      await _computeInContext(context.contextRoot, collector, verbose: verbose);
    }
  }

  /// Write a report of the metrics that were computed to the [sink].
  void writeMetrics(StringSink sink) {
    data.impliedTypesMap.forEach((String name, Map<String, int> displayStrMap) {
      var sum = 0;
      displayStrMap.forEach((String displayStr, int count) {
        sum += count;
      });
      if (sum >= 5) {
        sink.writeln('$name $sum:');
        displayStrMap.forEach((String displayStr, int count) {
          sink.writeln('  $displayStr $count ${printPercentage(count / sum)}');
        });
      }
    });
  }

  /// Compute the metrics for the files in the context [root], creating a
  /// separate context collection to prevent accumulating memory. The metrics
  /// should be captured in the [collector]. Include additional details in the
  /// output if [verbose] is `true`.
  Future<void> _computeInContext(
    ContextRoot root,
    ImpliedTypeCollector collector, {
    required bool verbose,
  }) async {
    // Create a new collection to avoid consuming large quantities of memory.
    var collection = AnalysisContextCollection(
      includedPaths: root.includedPaths.toList(),
      resourceProvider: PhysicalResourceProvider.INSTANCE,
    );
    var context = collection.contexts[0];
    var pathContext = context.contextRoot.resourceProvider.pathContext;
    for (var filePath in context.contextRoot.analyzedFiles()) {
      if (file_paths.isDart(pathContext, filePath)) {
        try {
          var resolvedUnitResult = await context.currentSession.getResolvedUnit(
            filePath,
          );
          //
          // Check for errors that cause the file to be skipped.
          //
          if (resolvedUnitResult is! ResolvedUnitResult) {
            print('File $filePath skipped because it could not be analyzed.');
            if (verbose) {
              print('');
            }
            continue;
          } else if (resolvedUnitResult.diagnostics.errors.isNotEmpty) {
            if (verbose) {
              print('File $filePath skipped due to errors:');
              for (var diagnostics in resolvedUnitResult.diagnostics.errors) {
                print('  ${diagnostics.toString()}');
              }
              print('');
            } else {
              print('File $filePath skipped due to analysis errors.');
            }
            continue;
          }

          resolvedUnitResult.unit.accept(collector);
        } catch (exception, stacktrace) {
          print('Exception caught analyzing: "$filePath"');
          print(exception);
          print(stacktrace);
        }
      }
    }
  }
}

class ImpliedTypeData {
  Map<String, Map<String, int>> impliedTypesMap = {};

  /// Record the variable name with the type.
  void recordImpliedType(String name, String displayString) {
    var nameMap = impliedTypesMap.putIfAbsent(name, () => {});
    nameMap[displayString] = (nameMap[displayString] ?? 0) + 1;
  }
}
