// Copyright (c) 2022, 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' show stdout;
import 'dart:math' as math;

import 'package:analyzer/dart/analysis/analysis_context.dart';
import 'package:analyzer/dart/analysis/context_root.dart';
import 'package:analyzer/dart/analysis/results.dart';
import 'package:analyzer/file_system/overlay_file_system.dart';
import 'package:analyzer/file_system/physical_file_system.dart';
import 'package:analyzer/src/dart/analysis/analysis_context_collection.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';
import 'package:cli_util/cli_logging.dart';

import 'visitors.dart';

final logger = Logger.standard(ansi: Ansi(Ansi.terminalSupportsAnsi));

/// This is the main metrics computer class for code completions. After the
/// object is constructed, [computeCompletionMetrics] is executed to do analysis
/// and print a summary of the metrics gathered from the completion tests.
abstract class CompletionMetricsComputer {
  final String rootPath;

  final CompletionMetricsOptions options;

  late ResolvedUnitResult resolvedUnitResult;

  final OverlayResourceProvider provider = OverlayResourceProvider(
    PhysicalResourceProvider.INSTANCE,
  );

  int overlayModificationStamp = 0;

  new(this.rootPath, this.options);

  /// Applies an overlay in [filePath] at [expectedCompletion].
  Future<void> applyOverlay(
    AnalysisContext context,
    String filePath,
    ExpectedCompletion expectedCompletion,
  );

  /// 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].
  Future<void> computeInContext(ContextRoot root) async {
    // Create a new collection to avoid consuming large quantities of memory.
    var collection = AnalysisContextCollectionImpl(
      includedPaths: root.includedPaths.toList(),
      resourceProvider: provider,
      withFineDependencies: true,
    );

    var context = collection.contexts[0];

    setupForResolution(context);

    logger.write('Computing completions at root: ${root.root.path}\n');

    var results = await resolveAnalyzedFiles(context: context);

    logger.write('Analyzing completion suggestions...\n');
    var progress = ProgressBar(logger, results.length);
    for (var result in results) {
      resolvedUnitResult = result;
      var filePath = result.path;
      // Use the ExpectedCompletionsVisitor to compute the set of expected
      // completions for this CompilationUnit.
      var visitor = ExpectedCompletionsVisitor(
        result,
        caretOffset: options.prefixLength,
      );
      resolvedUnitResult.unit.accept(visitor);

      for (var expectedCompletion in visitor.expectedCompletions) {
        await applyOverlay(context, filePath, expectedCompletion);

        await computeSuggestionsAndMetrics(expectedCompletion, context);

        await removeOverlay(context, filePath);
      }
      progress.tick();
    }
    progress.complete();
  }

  Future<void> computeMetrics() async {
    var collection = AnalysisContextCollectionImpl(
      includedPaths: [rootPath],
      resourceProvider: PhysicalResourceProvider.INSTANCE,
      withFineDependencies: true,
    );
    for (var context in collection.contexts) {
      await computeInContext(context.contextRoot);
    }
  }

  /// Computes suggestions for [expectedCompletion] and computes metrics from
  /// the resulting suggestions.
  Future<void> computeSuggestionsAndMetrics(
    ExpectedCompletion expectedCompletion,
    AnalysisContext context,
  );

  /// Removes the overlay which has been applied to [filePath].
  Future<void> removeOverlay(AnalysisContext context, String filePath);

  /// Resolves all analyzed files within [context].
  Future<List<ResolvedUnitResult>> resolveAnalyzedFiles({
    required AnalysisContext context,
  }) async {
    var analyzedFileCount = context.contextRoot.analyzedFiles().length;
    logger.write('Resolving $analyzedFileCount files...\n');

    var progress = ProgressBar(logger, analyzedFileCount);
    var results = <ResolvedUnitResult>[];
    var pathContext = context.contextRoot.resourceProvider.pathContext;
    for (var filePath in context.contextRoot.analyzedFiles()) {
      if (file_paths.isGenerated(filePath) ||
          filePath.endsWith('animated_icons.dart') ||
          filePath.endsWith('animated_icons_data.dart')) {
        continue;
      }
      if (file_paths.isDart(pathContext, filePath)) {
        try {
          var result = await context.currentSession.getResolvedUnit(
            filePath,
          ) as ResolvedUnitResult;

          var analysisError = result.diagnostics.errors.firstOrNull;
          if (analysisError != null) {
            progress.clear();
            print('File $filePath skipped due to errors such as:');
            print('  ${analysisError.toString()}');
            print('');
            continue;
          } else {
            results.add(result);
          }
        } catch (exception, stackTrace) {
          progress.clear();
          print('Exception caught analyzing: $filePath');
          print(exception.toString());
          print(stackTrace);
        }
      }
      progress.tick();
    }
    progress.complete();
    return results;
  }

  /// Performs setup tasks with [context] before resolution.
  void setupForResolution(AnalysisContext context);

  /// Gets overlay content for [content], applying a change at
  /// [expectedCompletion] with [prefixLength], according to [overlay], one of
  /// the [CompletionMetricsOptions].
  static String getOverlayContent(
    String content,
    ExpectedCompletion expectedCompletion,
    OverlayMode overlay,
    int prefixLength,
  ) {
    assert(content.isNotEmpty);
    var offset = expectedCompletion.offset;
    var length = expectedCompletion.syntacticEntity.length;
    assert(offset >= 0);
    assert(length > 0);
    var tokenEndOffset = offset + length;
    if (length >= prefixLength) {
      // Rather than removing the whole token, remove the characters after
      // the given prefix length.
      offset += prefixLength;
    }
    if (overlay == OverlayMode.removeToken) {
      return content.substring(0, offset) + content.substring(tokenEndOffset);
    } else if (overlay == OverlayMode.removeRestOfFile) {
      return content.substring(0, offset);
    } else {
      throw ArgumentError.value(overlay, 'overlay');
    }
  }
}

/// The options specified on the command-line.
class CompletionMetricsOptions {
  /// An option to control whether and how overlays should be produced.
  static const String overlayOption = 'overlay';

  /// An option controlling how long of a prefix should be used.
  ///
  /// This affects the offset of the completion request, and how much content is
  /// removed in each of the overlay modes.
  static const String prefixLengthOption = 'prefix-length';

  /// A flag that causes information to be printed about the completion requests
  /// that were the slowest to return suggestions.
  static const String printSlowestResultsFlag = 'print-slowest-results';

  /// The overlay mode that should be used.
  final OverlayMode overlay;

  final int prefixLength;

  /// A flag indicating whether information should be printed about the
  /// completion requests that were the slowest to return suggestions.
  final bool printSlowestResults;

  new(ArgResults results)
    : overlay = OverlayMode.parseFlag(results.option(overlayOption)!),
      prefixLength = int.parse(results.option(prefixLengthOption)!),
      printSlowestResults = results.flag(printSlowestResultsFlag);
}

enum OverlayMode {
  /// A mode indicating that no overlays should be produced.
  none('none'),

  /// A mode indicating that everything from the completion offset to the end of
  /// the file should be removed.
  removeRestOfFile('remove-rest-of-file'),

  /// A mode indicating that the token whose offset is the same as the
  /// completion offset should be removed.
  removeToken('remove-token');

  final String flag;

  new(this.flag);

  static OverlayMode parseFlag(String flag) {
    for (var mode in values) {
      if (flag == mode.flag) {
        return mode;
      }
    }
    throw ArgumentError.value(flag, 'overlay');
  }
}

/// A facility for drawing a progress bar in the terminal.
///
/// The bar is instantiated with the total number of "ticks" to be completed,
/// and progress is made by calling [tick]. The bar is drawn across one entire
/// line, like so:
///
///     [----------                                                   ]
///
/// The hyphens represent completed progress, and the whitespace represents
/// remaining progress.
///
/// If there is no terminal, the progress bar will not be drawn.
class ProgressBar {
  /// Whether the progress bar should be drawn.
  late bool _shouldDrawProgress;

  /// The width of the terminal, in terms of characters.
  late int _width;

  final Logger _logger;

  /// The inner width of the terminal, in terms of characters.
  ///
  /// This represents the number of characters available for drawing progress.
  late int _innerWidth;

  final int _totalTickCount;

  int _tickCount = 0;

  new(this._logger, this._totalTickCount) {
    if (!stdout.hasTerminal) {
      _shouldDrawProgress = false;
    } else {
      _shouldDrawProgress = true;
      _width = stdout.terminalColumns;
      // Inclusion of the percent indicator assumes a terminal width of at least
      // 12 (2 brackets + 1 space + 2 parenthesis characters + 3 digits +
      // 1 period + 2 digits + 1 '%' character).
      _innerWidth = stdout.terminalColumns - 12;
      _logger.write('[${' ' * _innerWidth}]');
    }
  }

  /// Clears the progress bar from the terminal, allowing other logging to be
  /// printed.
  void clear() {
    if (!_shouldDrawProgress) {
      return;
    }
    _logger.write('\r${' ' * _width}\r');
  }

  /// Draws the progress bar as complete, and print two newlines.
  void complete() {
    if (!_shouldDrawProgress) {
      return;
    }
    _logger.write('\r[${'-' * _innerWidth}]\n\n');
  }

  /// Progresses the bar by one tick.
  void tick() {
    if (!_shouldDrawProgress) {
      return;
    }
    _tickCount++;
    var fractionComplete = math.max(
      0,
      _tickCount * _innerWidth ~/ _totalTickCount - 1,
    );
    // The inner space consists of hyphens, one spinner character, spaces, and a
    // percentage (8 characters).
    var hyphens = '-' * fractionComplete;
    var trailingSpace = ' ' * (_innerWidth - fractionComplete - 1);
    var spinner = AnsiProgress.kAnimationItems[_tickCount % 4];
    var pctComplete = (_tickCount * 100 / _totalTickCount).toStringAsFixed(2);
    _logger.write('\r[$hyphens$spinner$trailingSpace] ($pctComplete%)');
  }
}
