// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

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/element.dart';
import 'package:analyzer/dart/element/type.dart';
import 'package:path/path.dart' as path;

import '../utils.dart';
import 'analyze.dart';

// The comment pattern representing the "flutter_ignore" inline directive that
// indicates the line should be exempt from the stopwatch check.
final Pattern _ignoreStopwatch = RegExp(r'// flutter_ignore: .*stopwatch .*\(see analyze\.dart\)');

/// Use of Stopwatches can introduce test flakes as the logical time of a
/// stopwatch can fall out of sync with the mocked time of FakeAsync in testing.
/// The Clock object provides a safe stopwatch instead, which is paired with
/// FakeAsync as part of the test binding.
final AnalyzeRule noStopwatches = _NoStopwatches();

class _NoStopwatches implements AnalyzeRule {
  final Map<ResolvedUnitResult, List<AstNode>> _errors = <ResolvedUnitResult, List<AstNode>>{};

  @override
  void applyTo(ResolvedUnitResult unit) {
    final _StopwatchVisitor visitor = _StopwatchVisitor(unit);
    unit.unit.visitChildren(visitor);
    final List<AstNode> violationsInUnit = visitor.stopwatchAccessNodes;
    if (violationsInUnit.isNotEmpty) {
      _errors.putIfAbsent(unit, () => <AstNode>[]).addAll(violationsInUnit);
    }
  }

  @override
  void reportViolations(String workingDirectory) {
    if (_errors.isEmpty) {
      return;
    }

    String locationInFile(ResolvedUnitResult unit, AstNode node) {
      return '${path.relative(path.relative(unit.path, from: workingDirectory))}:${unit.lineInfo.getLocation(node.offset).lineNumber}';
    }

    foundError(<String>[
      for (final MapEntry<ResolvedUnitResult, List<AstNode>> entry in _errors.entries)
        for (final AstNode node in entry.value)
          '${locationInFile(entry.key, node)}: ${node.parent}',
      '\n${bold}Stopwatches introduce flakes by falling out of sync with the FakeAsync used in testing.$reset',
      'A Stopwatch that stays in sync with FakeAsync is available through the Gesture or Test bindings, through samplingClock.',
    ]);
  }

  @override
  String toString() => 'No "Stopwatch"';
}

// This visitor finds invocation sites of Stopwatch (and subclasses) constructors
// and references to "external" functions that return a Stopwatch (and subclasses),
// including constructors, and put them in the stopwatchAccessNodes list.
class _StopwatchVisitor extends RecursiveAstVisitor<void> {
  _StopwatchVisitor(this.compilationUnit);

  final ResolvedUnitResult compilationUnit;

  final List<AstNode> stopwatchAccessNodes = <AstNode>[];

  final Map<ClassElement, bool> _isStopwatchClassElementCache = <ClassElement, bool>{};

  bool _checkIfImplementsStopwatchRecursively(ClassElement classElement) {
    if (classElement.library.isDartCore) {
      return classElement.name == 'Stopwatch';
    }
    return classElement.allSupertypes.any((InterfaceType interface) {
      final InterfaceElement interfaceElement = interface.element;
      return interfaceElement is ClassElement && _implementsStopwatch(interfaceElement);
    });
  }

  // The cached version, call this method instead of _checkIfImplementsStopwatchRecursively.
  bool _implementsStopwatch(ClassElement classElement) {
    return classElement.library.isDartCore
        ? classElement.name == 'Stopwatch'
        : _isStopwatchClassElementCache.putIfAbsent(
            classElement,
            () => _checkIfImplementsStopwatchRecursively(classElement),
          );
  }

  bool _isInternal(LibraryElement libraryElement) {
    return path.isWithin(
      compilationUnit.session.analysisContext.contextRoot.root.path,
      libraryElement.source.fullName,
    );
  }

  bool _hasTrailingFlutterIgnore(AstNode node) {
    return compilationUnit.content
        .substring(
          node.offset + node.length,
          compilationUnit.lineInfo.getOffsetOfLineAfter(node.offset + node.length),
        )
        .contains(_ignoreStopwatch);
  }

  // We don't care about directives or comments, skip them.
  @override
  void visitImportDirective(ImportDirective node) {}

  @override
  void visitExportDirective(ExportDirective node) {}

  @override
  void visitComment(Comment node) {}

  @override
  void visitConstructorName(ConstructorName node) {
    final Element? element = node.staticElement;
    if (element is! ConstructorElement) {
      assert(false, '$element of $node is not a ConstructorElement.');
      return;
    }
    final bool isAllowed = switch (element.returnType) {
      InterfaceType(element: final ClassElement classElement) => !_implementsStopwatch(
        classElement,
      ),
      InterfaceType(element: InterfaceElement()) => true,
    };
    if (isAllowed || _hasTrailingFlutterIgnore(node)) {
      return;
    }
    stopwatchAccessNodes.add(node);
  }

  @override
  void visitSimpleIdentifier(SimpleIdentifier node) {
    final bool isAllowed = switch (node.staticElement) {
      ExecutableElement(
        returnType: DartType(element: final ClassElement classElement),
        library: final LibraryElement libraryElement,
      ) =>
        _isInternal(libraryElement) || !_implementsStopwatch(classElement),
      Element() || null => true,
    };
    if (isAllowed || _hasTrailingFlutterIgnore(node)) {
      return;
    }
    stopwatchAccessNodes.add(node);
  }
}
