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

import 'package:path/path.dart' as pathlib;
import 'package:pool/pool.dart';

import '../environment.dart';
import '../exceptions.dart';
import '../felt_config.dart';
import '../pipeline.dart';
import '../utils.dart' show AnsiColors, FilePath, ProcessManager, cleanup, getBundleBuildDirectory, startProcess;

/// Compiles a web test bundle into web_ui/build/test_bundles/<bundle-name>.
class CompileBundleStep implements PipelineStep {
  CompileBundleStep({
    required this.bundle,
    required this.isVerbose,
    this.testFiles,
  });

  final TestBundle bundle;
  final bool isVerbose;
  final Set<FilePath>? testFiles;

  // Maximum number of concurrent compile processes to use.
  static final int _compileConcurrency = int.parse(io.Platform.environment['FELT_COMPILE_CONCURRENCY'] ?? '8');
  final Pool compilePool = Pool(_compileConcurrency);

  @override
  String get description => 'compile_bundle';

  @override
  bool get isSafeToInterrupt => true;

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

  io.Directory get testSetDirectory => io.Directory(
    pathlib.join(environment.webUiTestDir.path, bundle.testSet.directory)
  );

  io.Directory get outputBundleDirectory => getBundleBuildDirectory(bundle);

  List<FilePath> _findTestFiles() {
    final io.Directory testDirectory = testSetDirectory;
    if (!testDirectory.existsSync()) {
      throw ToolExit('Test directory "${testDirectory.path}" for bundle ${bundle.name.ansiMagenta} does not exist.');
    }
    return testDirectory
      .listSync(recursive: true)
      .whereType<io.File>()
      .where((io.File f) => f.path.endsWith('_test.dart'))
      .map<FilePath>((io.File f) => FilePath.fromWebUi(
          pathlib.relative(f.path, from: environment.webUiRootDir.path)))
      .toList();
  }

  TestCompiler _createCompiler() {
    switch (bundle.compileConfig.compiler) {
      case Compiler.dart2js:
        return Dart2JSCompiler(
          testSetDirectory,
          outputBundleDirectory,
          renderer: bundle.compileConfig.renderer,
          isVerbose: isVerbose,
        );
      case Compiler.dart2wasm:
        return Dart2WasmCompiler(
          testSetDirectory,
          outputBundleDirectory,
          renderer: bundle.compileConfig.renderer,
          isVerbose: isVerbose,
        );
    }
  }

  @override
  Future<void> run() async {
    print('Compiling test bundle ${bundle.name.ansiMagenta}...');
    final List<FilePath> allTests = _findTestFiles();
    final TestCompiler compiler = _createCompiler();
    final Stopwatch stopwatch = Stopwatch()..start();
    final String testSetDirectoryPath = testSetDirectory.path;

    // Clear out old bundle compilations, if they exist
    if (outputBundleDirectory.existsSync()) {
      outputBundleDirectory.deleteSync(recursive: true );
    }

    final List<Future<MapEntry<String, CompileResult>>> pendingResults = <Future<MapEntry<String, CompileResult>>>[];
    for (final FilePath testFile in allTests) {
      final String relativePath = pathlib.relative(
        testFile.absolute,
        from: testSetDirectoryPath);
      final Future<MapEntry<String, CompileResult>> result = compilePool.withResource(() async {
        if (testFiles != null && !testFiles!.contains(testFile)) {
          return MapEntry<String, CompileResult>(relativePath, CompileResult.filtered);
        }
        final bool success = await compiler.compileTest(testFile);
        const int maxTestNameLength = 80;
        final String truncatedPath = relativePath.length > maxTestNameLength
          ? relativePath.replaceRange(maxTestNameLength - 3, relativePath.length, '...')
          : relativePath;
        final String expandedPath = truncatedPath.padRight(maxTestNameLength);
        io.stdout.write('\r  ${success ? expandedPath.ansiGreen : expandedPath.ansiRed}');
        return success
          ? MapEntry<String, CompileResult>(relativePath, CompileResult.success)
          : MapEntry<String, CompileResult>(relativePath, CompileResult.compilationFailure);
      });
      pendingResults.add(result);
    }
    final Map<String, CompileResult> results = Map<String, CompileResult>.fromEntries(await Future.wait(pendingResults));
    stopwatch.stop();

    final String resultsJson = const JsonEncoder.withIndent('  ').convert(<String, dynamic>{
      'name': bundle.name,
      'directory': bundle.testSet.directory,
      'compiler': bundle.compileConfig.compiler.name,
      'renderer': bundle.compileConfig.renderer.name,
      'compileTimeInMs': stopwatch.elapsedMilliseconds,
      'results': results.map((String k, CompileResult v) => MapEntry<String, String>(k, v.name)),
    });
    final io.File outputResultsFile = io.File(pathlib.join(
      outputBundleDirectory.path,
      'results.json',
    ));
    outputResultsFile.writeAsStringSync(resultsJson);
    final List<String> failedFiles = <String>[];
    results.forEach((String fileName, CompileResult result) {
      if (result == CompileResult.compilationFailure) {
        failedFiles.add(fileName);
      }
    });
    if (failedFiles.isEmpty) {
      print('\rCompleted compilation of ${bundle.name.ansiMagenta} in ${stopwatch.elapsedMilliseconds}ms.'.padRight(82));
    } else {
      print('\rThe bundle ${bundle.name.ansiMagenta} compiled with some failures in ${stopwatch.elapsedMilliseconds}ms.');
      print('Compilation failures:');
      for (final String fileName in failedFiles) {
        print('  $fileName');
      }
      throw ToolExit('Failed to compile ${bundle.name.ansiMagenta}.');
    }
  }
}

enum CompileResult {
  success,
  compilationFailure,
  filtered,
}

abstract class TestCompiler {
  TestCompiler(
    this.inputTestSetDirectory,
    this.outputTestBundleDirectory,
    {
      required this.renderer,
      required this.isVerbose,
    }
  );

  final io.Directory inputTestSetDirectory;
  final io.Directory outputTestBundleDirectory;
  final Renderer renderer;
  final bool isVerbose;

  Future<bool> compileTest(FilePath input);
}

class Dart2JSCompiler extends TestCompiler {
  Dart2JSCompiler(
    super.inputTestSetDirectory,
    super.outputTestBundleDirectory,
    {
      required super.renderer,
      required super.isVerbose,
    }
  );

  @override
  Future<bool> compileTest(FilePath input) async {
    final String relativePath = pathlib.relative(
      input.absolute,
      from: inputTestSetDirectory.path
    );

    final String targetFileName = pathlib.join(
      outputTestBundleDirectory.path,
      '$relativePath.browser_test.dart.js',
    );

    final io.Directory outputDirectory = io.File(targetFileName).parent;
    if (!outputDirectory.existsSync()) {
      outputDirectory.createSync(recursive: true);
    }

    final List<String> arguments = <String>[
      'compile',
      'js',
      '--no-minify',
      '--disable-inlining',
      '--enable-asserts',

      // We do not want to auto-select a renderer in tests. As of today, tests
      // are designed to run in one specific mode. So instead, we specify the
      // renderer explicitly.
      '-DFLUTTER_WEB_AUTO_DETECT=false',
      '-DFLUTTER_WEB_USE_SKIA=${renderer == Renderer.canvaskit}',
      '-DFLUTTER_WEB_USE_SKWASM=${renderer == Renderer.skwasm}',

      '-O2',
      '-o',
      targetFileName, // target path.
      relativePath, // current path.
    ];

    final ProcessManager process = await startProcess(
      environment.dartExecutable,
      arguments,
      workingDirectory: inputTestSetDirectory.path,
      failureIsSuccess: true,
      evalOutput: !isVerbose,
    );
    final int exitCode = await process.wait();
    if (exitCode != 0) {
      io.stderr.writeln('ERROR: Failed to compile test $input. '
          'Dart2js exited with exit code $exitCode');
      return false;
    } else {
      return true;
    }
  }
}

class Dart2WasmCompiler extends TestCompiler {
  Dart2WasmCompiler(
    super.inputTestSetDirectory,
    super.outputTestBundleDirectory,
    {
      required super.renderer,
      required super.isVerbose,
    }
  );

  @override
  Future<bool> compileTest(FilePath input) async {
    final String relativePath = pathlib.relative(
      input.absolute,
      from: inputTestSetDirectory.path
    );

    final String targetFileName = pathlib.join(
      outputTestBundleDirectory.path,
      '$relativePath.browser_test.dart.wasm',
    );

    final io.Directory outputDirectory = io.File(targetFileName).parent;
    if (!outputDirectory.existsSync()) {
      outputDirectory.createSync(recursive: true);
    }

    final List<String> arguments = <String>[
      environment.dart2wasmSnapshotPath,

      '--dart-sdk=${environment.dartSdkDir.path}',
      '--enable-asserts',

      // We do not want to auto-select a renderer in tests. As of today, tests
      // are designed to run in one specific mode. So instead, we specify the
      // renderer explicitly.
      '-DFLUTTER_WEB_AUTO_DETECT=false',
      '-DFLUTTER_WEB_USE_SKIA=${renderer == Renderer.canvaskit}',
      '-DFLUTTER_WEB_USE_SKWASM=${renderer == Renderer.skwasm}',

      if (renderer == Renderer.skwasm) ...<String>[
        '--import-shared-memory',
        '--shared-memory-max-pages=32768',
      ],

      relativePath, // current path.
      targetFileName, // target path.
    ];

    final ProcessManager process = await startProcess(
      environment.dartAotRuntimePath,
      arguments,
      workingDirectory: inputTestSetDirectory.path,
      failureIsSuccess: true,
      evalOutput: !isVerbose,
    );
    final int exitCode = await process.wait();

    if (exitCode != 0) {
      io.stderr.writeln('ERROR: Failed to compile test $input. '
          'dart2wasm exited with exit code $exitCode');
      return false;
    } else {
      return true;
    }
  }
}
