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

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

import 'cipd.dart';
import 'common.dart';
import 'package_lock.dart';
import 'utils.dart';

final ArgParser _argParser = ArgParser(allowTrailingOptions: false)
  ..addFlag(
    'dry-run',
    help:
        'Whether or not to push changes to CIPD. When --dry-run is set, the '
        'script will download everything and attempt to prepare the bundle '
        'but will stop before publishing. When not set, the bundle will be '
        'published.',
    negatable: false,
  )
  ..addFlag('verbose', abbr: 'v', help: 'Enable verbose output.', negatable: false);

late final bool dryRun;
late final bool verbose;

final Client _client = Client();

/// Rolls browser CIPD packages to the version specified in `package_lock.yaml`.
///
/// Currently only rolls Chrome.
///
/// Chrome rolls are consumed by the "chrome_and_driver" and "chrome" LUCI recipes, here:
/// * https://cs.opensource.google/flutter/recipes/+/main:recipe_modules/flutter_deps/api.py;l=146
/// * https://cs.opensource.google/flutter/recipes/+/master:recipe_modules/web_util/api.py;l=22
///
/// Chromedriver is consumed by the same "chrome_and_driver" LUCI recipe, but also "chrome_driver":
/// * https://cs.opensource.google/flutter/recipes/+/master:recipe_modules/web_util/api.py;l=48
///
/// There's a small difference in the layout of the zip file coming from CIPD for the
/// Mac platform. In `Linux` and `Windows`, the chrome(.exe) executable is expected
/// to be placed directly in the root of the zip file.
///
/// However in `Mac`, the `Chromium.app` is expected to be placed inside of a
/// `chrome-mac` directory in the resulting zip file.
///
/// This script respects that historical quirk when building the CIPD packages.
/// In order for all the packages to be the same, the recipes listed above should
/// be made slightly smarter, so they can find the CHROME_EXECUTABLE in the right
/// place.
///
/// All platforms expect the "chromedriver" executable to be placed in the root
/// of the CIPD zip.
Future<void> main(List<String> args) async {
  try {
    processArgs(_argParser.parse(args));
    await _PackageRoller().roll();
    io.exitCode = 0;
  } on FormatException catch (e) {
    print('''
Error! ${e.message}

Available options:

${_argParser.usage}
''');
    io.exitCode = 1;
  } finally {
    _client.close();
  }
}

// Initialize globals from the parsed command-line arguments.
void processArgs(ArgResults args) {
  dryRun = args['dry-run'] as bool;
  verbose = args['verbose'] as bool;
}

class _Platform {
  _Platform(this.os, this.arch, this.binding);

  final String os;
  final String arch;
  final PlatformBinding binding;

  String get name => '$os-$arch';
}

class _PackageRoller {
  _PackageRoller();

  final io.Directory _rollDir = io.Directory.systemTemp.createTempSync('browser-roll-');

  final List<_Platform> _platforms = <_Platform>[
    _Platform('linux', 'amd64', LinuxPlatformBinding()),
    _Platform('mac', 'amd64', Macx64PlatformBinding()),
    _Platform('mac', 'arm64', MacArmPlatformBinding()),
    _Platform('windows', 'amd64', WindowsPlatformBinding()),
  ];

  final PackageLock _lock = PackageLock();

  // Prints output when --verbose is set.
  void vprint(String out) {
    if (verbose) {
      print(out);
    }
  }

  // Roll Chromium and ChromeDriver for each of the Platforms.
  Future<void> roll() async {
    for (final _Platform platform in _platforms) {
      await _rollChromium(platform);
      await _rollChromeDriver(platform);
      // For now, we only test Firefox on Linux.
      if (platform.os == 'linux') {
        await _rollFirefox(platform);
      }
      await _rollEsbuild(platform);
    }
    if (dryRun) {
      print('\nDry Run Done!\nNon-published roll artifacts kept here: ${_rollDir.path}\n');
    } else {
      // Clean-up
      vprint('\nDeleting temporary directory: ${_rollDir.path}');
      await _rollDir.delete(recursive: true);
      print('\nDone.\n');
    }
  }

  // Download a file from the internet, and put it in a temporary location.
  Future<io.File> _downloadTemporaryFile(String url) async {
    // Use the hash of the Url to temporarily store a file under tmp
    final io.File downloadedFile = io.File(
      path.join(io.Directory.systemTemp.path, 'download_${url.hashCode.toRadixString(16)}'),
    );
    vprint('  Downloading [$url] into [${downloadedFile.path}]');
    final StreamedResponse download = await _client.send(Request('GET', Uri.parse(url)));
    await download.stream.pipe(downloadedFile.openWrite());
    return downloadedFile;
  }

  // Unzips a `file` into a `destination` Directory (must exist).
  Future<void> _unzipAndDeleteFile(io.File zipFile, io.Directory destination) async {
    vprint('  Unzipping [${zipFile.path}] into [$destination]');
    await runProcess('unzip', <String>[
      if (!verbose) ...<String>['-q'],
      zipFile.path,
      '-d',
      destination.path,
    ]);
    vprint('  Deleting [${zipFile.path}]');
    await zipFile.delete();
  }

  // Uncompresses a `file` into a `destination` Directory (must exist).
  Future<void> _uncompressAndDeleteFile(io.File tarFile, io.Directory destination) async {
    vprint('  Uncompressing [${tarFile.path}] into [$destination]');
    final io.ProcessResult unzipResult = await io.Process.run('tar', <String>[
      '-x',
      '-f',
      tarFile.path,
      '-C',
      destination.path,
    ]);

    if (unzipResult.exitCode != 0) {
      throw StateError(
        'Failed to unzip the downloaded archive ${tarFile.path}.\n'
        'The unzip process exited with code ${unzipResult.exitCode}.',
      );
    }
    vprint('  Deleting [${tarFile.path}]');
    await tarFile.delete();
  }

  // Locate the first subdirectory that contains more than one file under `root`.
  // (or one ".app" bundle for mac)
  //
  // When uncompressing files, unzip might create some extra directories, but it
  // seems that our scripts want our CIPD packages to contain everything in the root.
  Future<io.Directory?> _locateContentRoot(io.Directory root) async {
    final List<io.FileSystemEntity> children = root.listSync(followLinks: false);
    assert(children.isNotEmpty);
    if (root.path.toLowerCase().endsWith('.app')) {
      // We've gone inside the .app bundle of the mac version!
      return root.parent;
    }
    if (children.length == 1) {
      if (children.first is io.Directory) {
        return _locateContentRoot(children.first as io.Directory);
      } else {
        return root;
      }
    }
    return root;
  }

  // Downloads Chromium from the internet, packs it in the directory structure
  // that the LUCI script wants. The result of this will be then uploaded to CIPD.
  Future<void> _rollChromium(_Platform platform) async {
    final String version = _lock.chromeLock.version;
    final String url = platform.binding.getChromeDownloadUrl(version);
    final String cipdPackageName = 'flutter_internal/browsers/chrome/${platform.name}';
    final io.Directory platformDir = io.Directory(path.join(_rollDir.path, platform.name));
    print('\nRolling Chromium for ${platform.name} (version:$version)');
    // Bail out if CIPD already has version:$majorVersion for this package!
    if (!dryRun &&
        await cipdKnowsPackageVersion(
          package: cipdPackageName,
          versionTag: version,
          isVerbose: verbose,
        )) {
      print('  Skipping $cipdPackageName version:$version. Already uploaded to CIPD!');
      vprint('  Update  package_lock.yaml  and use a different version value.');
      return;
    }

    await platformDir.create(recursive: true);
    vprint('  Created target directory [${platformDir.path}]');

    final io.File chromeDownload = await _downloadTemporaryFile(url);

    await _unzipAndDeleteFile(chromeDownload, platformDir);

    final io.Directory? actualContentRoot = await _locateContentRoot(platformDir);
    assert(actualContentRoot != null);
    final String relativePlatformDirPath = path.relative(
      actualContentRoot!.path,
      from: _rollDir.path,
    );

    vprint('  Uploading Chromium (${platform.name}) to CIPD...');
    await uploadDirectoryToCipd(
      directory: _rollDir,
      packageName: cipdPackageName,
      configFileName: 'cipd.chromium.${platform.name}.yaml',
      description: 'Chromium $version used for testing',
      version: version,
      root: relativePlatformDirPath,
      isDryRun: dryRun,
      isVerbose: verbose,
    );
  }

  // Downloads Chromedriver from the internet, packs it in the directory structure
  // that the LUCI script wants. The result of this will be then uploaded to CIPD.
  Future<void> _rollChromeDriver(_Platform platform) async {
    final String version = _lock.chromeLock.version;
    final String url = platform.binding.getChromeDriverDownloadUrl(version);
    final String cipdPackageName = 'flutter_internal/browser-drivers/chrome/${platform.name}';
    final io.Directory platformDir = io.Directory(
      path.join(_rollDir.path, '${platform.name}_driver'),
    );
    print('\nRolling Chromedriver for ${platform.os}-${platform.arch} (version:$version)');
    // Bail out if CIPD already has version:$majorVersion for this package!
    if (!dryRun &&
        await cipdKnowsPackageVersion(
          package: cipdPackageName,
          versionTag: version,
          isVerbose: verbose,
        )) {
      print('  Skipping $cipdPackageName version:$version. Already uploaded to CIPD!');
      vprint('  Update  package_lock.yaml  and use a different version value.');
      return;
    }

    await platformDir.create(recursive: true);
    vprint('  Created target directory [${platformDir.path}]');

    final io.File chromedriverDownload = await _downloadTemporaryFile(url);

    await _unzipAndDeleteFile(chromedriverDownload, platformDir);

    // Ensure the chromedriver executable is placed in the root of the bundle.
    final io.Directory? actualContentRoot = await _locateContentRoot(platformDir);
    assert(actualContentRoot != null);
    final String relativePlatformDirPath = path.relative(
      actualContentRoot!.path,
      from: _rollDir.path,
    );

    vprint('  Uploading Chromedriver (${platform.name}) to CIPD...');
    await uploadDirectoryToCipd(
      directory: _rollDir,
      packageName: cipdPackageName,
      configFileName: 'cipd.chromedriver.${platform.name}.yaml',
      description: 'Chromedriver for Chromium $version used for testing',
      version: version,
      root: relativePlatformDirPath,
      isDryRun: dryRun,
      isVerbose: verbose,
    );
  }

  // Downloads Firefox from the internet, packs it in the directory structure
  // that the LUCI script wants. The result of this will be then uploaded to CIPD.
  Future<void> _rollFirefox(_Platform platform) async {
    final String version = _lock.firefoxLock.version;
    final String url = platform.binding.getFirefoxDownloadUrl(version);
    final String cipdPackageName = 'flutter_internal/browsers/firefox/${platform.name}';
    final io.Directory platformDir = io.Directory(path.join(_rollDir.path, platform.name));
    print('\nRolling Firefox for ${platform.name} (version:$version)');
    // Bail out if CIPD already has version:$majorVersion for this package!
    if (!dryRun &&
        await cipdKnowsPackageVersion(
          package: cipdPackageName,
          versionTag: version,
          isVerbose: verbose,
        )) {
      print('  Skipping $cipdPackageName version:$version. Already uploaded to CIPD!');
      vprint('  Update  package_lock.yaml  and use a different version value.');
      return;
    }

    await platformDir.create(recursive: true);
    vprint('  Created target directory [${platformDir.path}]');

    final io.File firefoxDownload = await _downloadTemporaryFile(url);

    await _uncompressAndDeleteFile(firefoxDownload, platformDir);

    final io.Directory? actualContentRoot = await _locateContentRoot(platformDir);
    assert(actualContentRoot != null);
    final String relativePlatformDirPath = path.relative(
      actualContentRoot!.path,
      from: _rollDir.path,
    );

    vprint('  Uploading Firefox (${platform.name}) to CIPD...');
    await uploadDirectoryToCipd(
      directory: _rollDir,
      packageName: cipdPackageName,
      configFileName: 'cipd.firefox.${platform.name}.yaml',
      description: 'Firefox $version used for testing',
      version: version,
      root: relativePlatformDirPath,
      isDryRun: dryRun,
      isVerbose: verbose,
    );
  }

  Future<void> _rollEsbuild(_Platform platform) async {
    final String version = _lock.esbuildLock.version;
    final String url = platform.binding.getEsbuildDownloadUrl(version);
    final String cipdPackageName = 'flutter/tools/esbuild/${platform.name}';
    final io.Directory platformDir = io.Directory(path.join(_rollDir.path, platform.name));
    print('\nRolling esbuild for ${platform.name} (version:$version)');
    // Bail out if CIPD already has version:$majorVersion for this package!
    if (!dryRun &&
        await cipdKnowsPackageVersion(
          package: cipdPackageName,
          versionTag: version,
          isVerbose: verbose,
        )) {
      print('  Skipping $cipdPackageName version:$version. Already uploaded to CIPD!');
      vprint('  Update  package_lock.yaml  and use a different version value.');
      return;
    }

    await platformDir.create(recursive: true);
    vprint('  Created target directory [${platformDir.path}]');

    final io.File esbuildDownload = await _downloadTemporaryFile(url);

    await _uncompressAndDeleteFile(esbuildDownload, platformDir);
    final String packageDir = path.join(platformDir.path, 'package');

    // Write out the license file from the github repo.
    // Copied from https://github.com/evanw/esbuild/blob/main/LICENSE.md
    final io.File licenseFile = io.File(path.join(packageDir, 'LICENSE.md'));
    licenseFile
      ..createSync()
      ..writeAsStringSync('''
MIT License

Copyright (c) 2020 Evan Wallace

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
''');

    vprint('  Uploading esbuild (${platform.name}) to CIPD...');
    await uploadDirectoryToCipd(
      directory: _rollDir,
      packageName: cipdPackageName,
      configFileName: 'cipd.esbuild.${platform.name}.yaml',
      description: 'esbuild used by the flutter engine for bundling JavaScript',
      version: version,
      root: packageDir,
      isDryRun: dryRun,
      isVerbose: verbose,
    );
  }
}
