// Copyright 2013 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 'dart:async';
import 'dart:io' as io;

import 'package:args/command_runner.dart';
import 'package:path/path.dart' as path;

import 'package:watcher/src/watch_event.dart';

import 'environment.dart';
import 'exceptions.dart';
import 'felt_config.dart';
import 'generate_builder_json.dart';
import 'pipeline.dart';
import 'steps/compile_bundle_step.dart';
import 'steps/copy_artifacts_step.dart';
import 'steps/run_suite_step.dart';
import 'suite_filter.dart';
import 'utils.dart';

/// Runs tests.
class TestCommand extends Command<bool> with ArgUtils<bool> {
  TestCommand() {
    argParser
      ..addFlag(
        'start-paused',
        help: 'Pauses the browser before running a test, giving you an '
            'opportunity to add breakpoints or inspect loaded code before '
            'running the code.',
      )
      ..addFlag(
        'verbose',
        abbr: 'v',
        help: 'Enable verbose output.'
      )
      ..addFlag(
        'watch',
        abbr: 'w',
        help: 'Run in watch mode so the tests re-run whenever a change is '
            'made.',
      )
      ..addFlag(
        'list',
        help:
            'Lists the bundles that would be compiled and the suites that '
            'will be run as part of this invocation, without actually '
            'compiling or running them.'
      )
      ..addFlag(
        'generate-builder-json',
        help:
            'Generates JSON for the engine_v2 builders to build and copy all'
            'artifacts, compile all test bundles, and run all test suites on'
            'all platforms.'
      )
      ..addFlag(
        'compile',
        help:
            'Compile test bundles. If this is specified on its own, we will '
            'only compile and not run the suites.'
      )
      ..addFlag(
        'run',
        help:
            'Run test suites. If this is specified on its own, we will only '
            'run the suites and not compile the bundles.'
      )
      ..addFlag(
        'copy-artifacts',
        help:
            'Copy artifacts needed for test suites. If this is specified on '
            'its own, we will only copy the artifacts and not compile or run'
            'the tests bundles or suites.'
      )
      ..addFlag(
        'profile',
        help:
            'Use artifacts from the profile build instead of release.'
      )
      ..addFlag(
        'debug',
        help: 'Use artifacts from the debug build instead of release.'
      )
      ..addFlag(
        'dwarf',
        help: 'Debug wasm modules using embedded DWARF data.'
      )
      ..addFlag(
        'require-skia-gold',
        help:
            'Whether we require Skia Gold to be available or not. When this '
            'flag is true, the tests will fail if Skia Gold is not available.',
      )
      ..addFlag(
        'update-screenshot-goldens',
        help:
            'When running screenshot tests writes them to the file system into '
            '.dart_tool/goldens. Use this option to bulk-update all screenshots, '
            'for example, when a new browser version affects pixels.',
      )
      ..addMultiOption(
        'browser',
        help: 'Filter test suites by browser.',
      )
      ..addMultiOption(
        'compiler',
        help: 'Filter test suites by compiler.',
      )
      ..addMultiOption(
        'renderer',
        help: 'Filter test suites by renderer.',
      )
      ..addMultiOption(
        'canvaskit-variant',
        help: 'Filter test suites by CanvasKit variant.',
      )
      ..addMultiOption(
        'suite',
        help: 'Filter test suites by suite name.',
      )
      ..addMultiOption(
        'bundle',
        help: 'Filter test suites by bundle name.',
      )
      ..addFlag(
        'fail-early',
        help: 'If set, causes the test runner to exit upon the first test '
              'failure. If not set, the test runner will continue running '
              'test despite failures and will report them after all tests '
              'finish.',
      )
      ..addOption(
        'canvaskit-path',
        help: 'Optional. The path to a local build of CanvasKit to use in '
              'tests. If omitted, the test runner uses the default CanvasKit '
              'build.',
      )
      ..addFlag(
        'wasm',
        help: 'Whether the test we are running are compiled to webassembly.'
      );
  }

  @override
  final String name = 'test';

  @override
  final String description = 'Run tests.';

  bool get isWatchMode => boolArg('watch');

  bool get isList => boolArg('list');

  bool get failEarly => boolArg('fail-early');

  /// Whether to start the browser in debug mode.
  ///
  /// In this mode the browser pauses before running the test to allow
  /// you set breakpoints or inspect the code.
  bool get startPaused => boolArg('start-paused');

  bool get isVerbose => boolArg('verbose');

  /// The target test files to run.
  List<FilePath> get targetFiles => argResults!.rest.map((String t) => FilePath.fromCwd(t)).toList();

  /// When running screenshot tests, require Skia Gold to be available and
  /// reachable.
  bool get requireSkiaGold => boolArg('require-skia-gold');

  /// When running screenshot tests writes them to the file system into
  /// ".dart_tool/goldens".
  bool get doUpdateScreenshotGoldens => boolArg('update-screenshot-goldens');

  /// Path to a CanvasKit build. Overrides the default CanvasKit.
  String? get overridePathToCanvasKit => argResults!['canvaskit-path'] as String?;

  final FeltConfig config = FeltConfig.fromFile(
    path.join(environment.webUiTestDir.path, 'felt_config.yaml')
  );

  BrowserSuiteFilter? makeBrowserFilter() {
    final List<String>? browserArgs = argResults!['browser'] as List<String>?;
    if (browserArgs == null || browserArgs.isEmpty) {
      return null;
    }
    final Set<BrowserName> browserNames = Set<BrowserName>.from(browserArgs.map((String arg) => BrowserName.values.byName(arg)));
    return BrowserSuiteFilter(allowList: browserNames);
  }

  CompilerFilter? makeCompilerFilter() {
    final List<String>? compilerArgs = argResults!['compiler'] as List<String>?;
    if (compilerArgs == null || compilerArgs.isEmpty) {
      return null;
    }
    final Set<Compiler> compilers = Set<Compiler>.from(compilerArgs.map((String arg) => Compiler.values.byName(arg)));
    return CompilerFilter(allowList: compilers);
  }

  RendererFilter? makeRendererFilter() {
    final List<String>? rendererArgs = argResults!['renderer'] as List<String>?;
    if (rendererArgs == null || rendererArgs.isEmpty) {
      return null;
    }
    final Set<Renderer> renderers = Set<Renderer>.from(rendererArgs.map((String arg) => Renderer.values.byName(arg)));
    return RendererFilter(allowList: renderers);
  }

  CanvasKitVariantFilter? makeCanvasKitVariantFilter() {
    final List<String>? variantArgs = argResults!['canvaskit-variant'] as List<String>?;
    if (variantArgs == null || variantArgs.isEmpty) {
      return null;
    }
    final Set<CanvasKitVariant> variants = Set<CanvasKitVariant>.from(variantArgs.map((String arg) => CanvasKitVariant.values.byName(arg)));
    return CanvasKitVariantFilter(allowList: variants);
  }

  SuiteNameFilter? makeSuiteNameFilter() {
    final List<String>? suiteNameArgs = argResults!['suite'] as List<String>?;
    if (suiteNameArgs == null || suiteNameArgs.isEmpty) {
      return null;
    }

    final Iterable<String> allSuiteNames = config.testSuites.map((TestSuite suite) => suite.name);
    for (final String suiteName in suiteNameArgs) {
      if (!allSuiteNames.contains(suiteName)) {
        throw ToolExit('No suite found named $suiteName');
      }
    }
    return SuiteNameFilter(allowList: Set<String>.from(suiteNameArgs));
  }

  BundleNameFilter? makeBundleNameFilter() {
    final List<String>? bundleNameArgs = argResults!['bundle'] as List<String>?;
    if (bundleNameArgs == null || bundleNameArgs.isEmpty) {
      return null;
    }

    final Iterable<String> allBundleNames = config.testSuites.map(
      (TestSuite suite) => suite.testBundle.name
    );
    for (final String bundleName in bundleNameArgs) {
      if (!allBundleNames.contains(bundleName)) {
        throw ToolExit('No bundle found named $bundleName');
      }
    }
    return BundleNameFilter(allowList: Set<String>.from(bundleNameArgs));
  }

  FileFilter? makeFileFilter() {
    final List<FilePath> tests = targetFiles;
    if (tests.isEmpty) {
      return null;
    }
    final Set<String> bundleNames = <String>{};
    for (final FilePath testPath in tests) {
      if (!io.File(testPath.absolute).existsSync()) {
        throw ToolExit('Test path not found: $testPath');
      }
      bool bundleFound = false;
      for (final TestBundle bundle in config.testBundles) {
        final String testSetPath = getTestSetDirectory(bundle.testSet).path;
        if (path.isWithin(testSetPath, testPath.absolute)) {
          bundleFound = true;
          bundleNames.add(bundle.name);
        }
      }
      if (!bundleFound) {
        throw ToolExit('Test path not in any known test bundle: $testPath');
      }
    }
    return FileFilter(allowList: bundleNames);
  }

  List<SuiteFilter> get suiteFilters {
    final BrowserSuiteFilter? browserFilter = makeBrowserFilter();
    final CompilerFilter? compilerFilter = makeCompilerFilter();
    final RendererFilter? rendererFilter = makeRendererFilter();
    final CanvasKitVariantFilter? canvaskitVariantFilter = makeCanvasKitVariantFilter();
    final SuiteNameFilter? suiteNameFilter = makeSuiteNameFilter();
    final BundleNameFilter? bundleNameFilter = makeBundleNameFilter();
    final FileFilter? fileFilter = makeFileFilter();
    return <SuiteFilter>[
      PlatformBrowserFilter(),
      if (browserFilter != null) browserFilter,
      if (compilerFilter != null) compilerFilter,
      if (rendererFilter != null) rendererFilter,
      if (canvaskitVariantFilter != null) canvaskitVariantFilter,
      if (suiteNameFilter != null) suiteNameFilter,
      if (bundleNameFilter != null) bundleNameFilter,
      if (fileFilter != null) fileFilter,
    ];
  }

  List<TestSuite> _filterTestSuites() {
    if (isVerbose) {
      print('Filtering suites...');
    }
    final List<SuiteFilter> filters = suiteFilters;
    final List<TestSuite> filteredSuites = config.testSuites.where((TestSuite suite) {
      for (final SuiteFilter filter in filters) {
        final SuiteFilterResult result = filter.filterSuite(suite);
        if (!result.isAccepted) {
          if (isVerbose) {
            print('  ${suite.name.ansiCyan} rejected for reason: ${result.rejectReason}');
          }
          return false;
        }
      }
      return true;
    }).toList();
    return filteredSuites;
  }

  List<TestBundle> _filterBundlesForSuites(List<TestSuite> suites) {
    final Set<TestBundle> seenBundles =
      Set<TestBundle>.from(suites.map((TestSuite suite) => suite.testBundle));
    return config.testBundles.where((TestBundle bundle) => seenBundles.contains(bundle)).toList();
  }

  ArtifactDependencies _artifactsForSuites(List<TestSuite> suites) {
    return suites.fold(ArtifactDependencies.none(),
      (ArtifactDependencies deps, TestSuite suite) => deps | suite.artifactDependencies);
  }

  @override
  Future<bool> run() async {
    final List<TestSuite> filteredSuites = _filterTestSuites();
    final List<TestBundle> bundles = _filterBundlesForSuites(filteredSuites);
    final ArtifactDependencies artifacts = _artifactsForSuites(filteredSuites);
    if (boolArg('generate-builder-json')) {
      final String configString = generateBuilderJson(config);
      final io.File configFile = io.File(path.join(
        environment.flutterDirectory.path,
        'ci',
        'builders',
        'linux_web_engine.json',
      ));
      configFile.writeAsStringSync(configString);
      return true;
    }
    if (isList || isVerbose) {
      print('Suites:');
      for (final TestSuite suite in filteredSuites) {
        print('  ${suite.name.ansiCyan}');
      }
      print('Bundles:');
      for (final TestBundle bundle in bundles) {
        print('  ${bundle.name.ansiMagenta}');
      }
      print('Artifacts:');
      if (artifacts.canvasKit) {
        print('  canvaskit'.ansiYellow);
      }
      if (artifacts.canvasKitChromium) {
        print('  canvaskit_chromium'.ansiYellow);
      }
      if (artifacts.skwasm) {
        print('  skwasm'.ansiYellow);
      }
    }
    if (isList) {
      return true;
    }

    bool shouldRun = boolArg('run');
    bool shouldCompile = boolArg('compile');
    bool shouldCopyArtifacts = boolArg('copy-artifacts');
    if (!shouldRun && !shouldCompile && !shouldCopyArtifacts) {
      // If none of these is specified, we should assume we need to do all of them.
      shouldRun = true;
      shouldCompile = true;
      shouldCopyArtifacts = true;
    }

    final Set<FilePath>? testFiles = targetFiles.isEmpty ? null : Set<FilePath>.from(targetFiles);
    final Pipeline testPipeline = Pipeline(steps: <PipelineStep>[
      if (isWatchMode) ClearTerminalScreenStep(),
      if (shouldCopyArtifacts) CopyArtifactsStep(artifacts, runtimeMode: runtimeMode),
      if (shouldCompile)
        for (final TestBundle bundle in bundles)
          CompileBundleStep(
            bundle: bundle,
            isVerbose: isVerbose,
            testFiles: testFiles,
          ),
      if (shouldRun)
        for (final TestSuite suite in filteredSuites)
          RunSuiteStep(
            suite,
            startPaused: startPaused,
            isVerbose: isVerbose,
            doUpdateScreenshotGoldens: doUpdateScreenshotGoldens,
            requireSkiaGold: requireSkiaGold,
            overridePathToCanvasKit: overridePathToCanvasKit,
            testFiles: testFiles,
            useDwarf: boolArg('dwarf'),
          ),
    ]);

    try {
      await testPipeline.run();
      if (isWatchMode) {
        print('');
        print('Initial test succeeded!');
      }
    } catch(error, stackTrace) {
      if (isWatchMode) {
        // The error is printed but not rethrown in watch mode because
        // failures are expected. The idea is that the developer corrects the
        // error, saves the file, and the pipeline reruns.
        print('');
        print('Initial test failed!\n');
        print(error);
        print(stackTrace);
      } else {
        rethrow;
      }
    }

    if (isWatchMode) {
      final FilePath dir = FilePath.fromWebUi('');
      print('');
      print(
          'Watching ${dir.relativeToCwd}/lib and ${dir.relativeToCwd}/test to re-run tests');
      print('');
      await PipelineWatcher(
          dir: dir.absolute,
          pipeline: testPipeline,
          ignore: (WatchEvent event) {
            // Ignore font files that are copied whenever tests run.
            if (event.path.endsWith('.ttf')) {
              return true;
            }

            // React to changes in lib/ and test/ folders.
            final String relativePath =
                path.relative(event.path, from: dir.absolute);
            if (path.isWithin('lib', relativePath) ||
                path.isWithin('test', relativePath)) {
              return false;
            }

            // Ignore anything else.
            return true;
          }).start();
    }
    return true;
  }
}

/// Clears the terminal screen and places the cursor at the top left corner.
///
/// This works on Linux and Mac. On Windows, it's a no-op.
class ClearTerminalScreenStep implements PipelineStep {
  @override
  String get description => 'clearing terminal screen';

  @override
  bool get isSafeToInterrupt => false;

  @override
  Future<void> interrupt() async {}

  @override
  Future<void> run() async {
    if (!io.Platform.isWindows) {
      // See: https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_sequences
      print('\x1B[2J\x1B[1;2H');
    }
  }
}
