// 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 'dart:async';

import 'package:meta/meta.dart';
import 'package:process/process.dart';
import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart';

import '../base/common.dart';
import '../base/file_system.dart';
import '../base/io.dart';
import '../base/logger.dart';
import '../base/os.dart';
import '../base/platform.dart';
import '../convert.dart';
import '../globals.dart' as globals;

/// An environment variable used to override the location of Google Chrome.
const String kChromeEnvironment = 'CHROME_EXECUTABLE';

/// An environment variable used to override the location of Microsoft Edge.
const String kEdgeEnvironment = 'EDGE_ENVIRONMENT';

/// The expected executable name on linux.
const String kLinuxExecutable = 'google-chrome';

/// The expected executable name on macOS.
const String kMacOSExecutable =
    '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';

/// The expected Chrome executable name on Windows.
const String kWindowsExecutable = r'Google\Chrome\Application\chrome.exe';

/// The expected Edge executable name on Windows.
const String kWindowsEdgeExecutable = r'Microsoft\Edge\Application\msedge.exe';

typedef BrowserFinder = String Function(Platform, FileSystem);

/// Find the chrome executable on the current platform.
///
/// Does not verify whether the executable exists.
String findChromeExecutable(Platform platform, FileSystem fileSystem) {
  if (platform.environment.containsKey(kChromeEnvironment)) {
    return platform.environment[kChromeEnvironment];
  }
  if (platform.isLinux) {
    return kLinuxExecutable;
  }
  if (platform.isMacOS) {
    return kMacOSExecutable;
  }
  if (platform.isWindows) {
    /// The possible locations where the chrome executable can be located on windows.
    final List<String> kWindowsPrefixes = <String>[
      platform.environment['LOCALAPPDATA'],
      platform.environment['PROGRAMFILES'],
      platform.environment['PROGRAMFILES(X86)'],
    ];
    final String windowsPrefix = kWindowsPrefixes.firstWhere((String prefix) {
      if (prefix == null) {
        return false;
      }
      final String path = fileSystem.path.join(prefix, kWindowsExecutable);
      return fileSystem.file(path).existsSync();
    }, orElse: () => '.');
    return fileSystem.path.join(windowsPrefix, kWindowsExecutable);
  }
  throwToolExit('Platform ${platform.operatingSystem} is not supported.');
  return null;
}

/// Find the Microsoft Edge executable on the current platform.
///
/// Does not verify whether the executable exists.
String findEdgeExecutable(Platform platform, FileSystem fileSystem) {
  if (platform.environment.containsKey(kEdgeEnvironment)) {
    return platform.environment[kEdgeEnvironment];
  }
  if (platform.isWindows) {
    /// The possible locations where the Edge executable can be located on windows.
    final List<String> kWindowsPrefixes = <String>[
      platform.environment['LOCALAPPDATA'],
      platform.environment['PROGRAMFILES'],
      platform.environment['PROGRAMFILES(X86)'],
    ];
    final String windowsPrefix = kWindowsPrefixes.firstWhere((String prefix) {
      if (prefix == null) {
        return false;
      }
      final String path = fileSystem.path.join(prefix, kWindowsEdgeExecutable);
      return fileSystem.file(path).existsSync();
    }, orElse: () => '.');
    return fileSystem.path.join(windowsPrefix, kWindowsEdgeExecutable);
  }
  // Not yet supported for macOS and Linux.
  return '';
}

/// A launcher for Chromium browsers with devtools configured.
class ChromiumLauncher {
  ChromiumLauncher({
    @required FileSystem fileSystem,
    @required Platform platform,
    @required ProcessManager processManager,
    @required OperatingSystemUtils operatingSystemUtils,
    @required Logger logger,
    @required BrowserFinder browserFinder,
  }) : _fileSystem = fileSystem,
       _platform = platform,
       _processManager = processManager,
       _operatingSystemUtils = operatingSystemUtils,
       _logger = logger,
       _browserFinder = browserFinder,
       _fileSystemUtils = FileSystemUtils(
         fileSystem: fileSystem,
         platform: platform,
       );

  final FileSystem _fileSystem;
  final Platform _platform;
  final ProcessManager _processManager;
  final OperatingSystemUtils _operatingSystemUtils;
  Logger _logger;
  final BrowserFinder _browserFinder;
  final FileSystemUtils _fileSystemUtils;

  bool get hasChromeInstance => _currentCompleter.isCompleted;

  Completer<Chromium> _currentCompleter = Completer<Chromium>();

  @visibleForTesting
  void testLaunchChromium(Chromium chromium) {
    _currentCompleter.complete(chromium);
  }

  /// Whether we can locate the chrome executable.
  bool canFindExecutable() {
    final String chrome = _browserFinder(_platform, _fileSystem);
    try {
      return _processManager.canRun(chrome);
    } on ArgumentError {
      return false;
    }
  }

  /// The executable this launcher will use.
  String findExecutable() =>  _browserFinder(_platform, _fileSystem);

  /// Launch a Chromium browser to a particular `host` page.
  ///
  /// `headless` defaults to false, and controls whether we open a headless or
  /// a `headfull` browser.
  ///
  /// `debugPort` is Chrome's debugging protocol port. If null, a random free
  /// port is picked automatically.
  ///
  /// `skipCheck` does not attempt to make a devtools connection before returning.
  Future<Chromium> launch(String url, {
    bool headless = false,
    int debugPort,
    bool skipCheck = false,
    Directory cacheDir,
  }) async {
    _logger ??= globals.logger;
    if (_currentCompleter.isCompleted) {
      throwToolExit('Only one instance of chrome can be started.');
    }

    final String chromeExecutable = _browserFinder(_platform, _fileSystem);
    final Directory userDataDir = _fileSystem.systemTempDirectory
      .createTempSync('flutter_tools_chrome_device.');

    if (cacheDir != null) {
      // Seed data dir with previous state.
      _restoreUserSessionInformation(cacheDir, userDataDir);
    }

    final int port = debugPort ?? await _operatingSystemUtils.findFreePort();
    final List<String> args = <String>[
      chromeExecutable,
      // Using a tmp directory ensures that a new instance of chrome launches
      // allowing for the remote debug port to be enabled.
      '--user-data-dir=${userDataDir.path}',
      '--remote-debugging-port=$port',
      // When the DevTools has focus we don't want to slow down the application.
      '--disable-background-timer-throttling',
      // Since we are using a temp profile, disable features that slow the
      // Chrome launch.
      '--disable-extensions',
      '--disable-popup-blocking',
      '--bwsi',
      '--no-first-run',
      '--no-default-browser-check',
      '--disable-default-apps',
      '--disable-translate',
      if (headless)
        ...<String>[
          '--headless',
          '--disable-gpu',
          '--no-sandbox',
          '--window-size=2400,1800',
        ],
      url,
    ];

    final Process process = await _processManager.start(args);

    // When the process exits, copy the user settings back to the provided data-dir.
    if (cacheDir != null) {
      unawaited(process.exitCode.whenComplete(() {
        _cacheUserSessionInformation(userDataDir, cacheDir);
      }));
    }

    process.stdout
      .transform(utf8.decoder)
      .transform(const LineSplitter())
      .listen((String line) {
        _logger.printTrace('[CHROME]: $line');
      });

    // Wait until the DevTools are listening before trying to connect.
    await process.stderr
      .transform(utf8.decoder)
      .transform(const LineSplitter())
      .map((String line) {
        _logger.printTrace('[CHROME]:$line');
        return line;
      })
      .firstWhere((String line) => line.startsWith('DevTools listening'), orElse: () {
        return 'Failed to spawn stderr';
      });
    final Uri remoteDebuggerUri = await _getRemoteDebuggerUrl(Uri.parse('http://localhost:$port'));
    return _connect(Chromium._(
      port,
      ChromeConnection('localhost', port),
      url: url,
      process: process,
      remoteDebuggerUri: remoteDebuggerUri,
      chromiumLauncher: this,
    ), skipCheck);
  }

  // This is a JSON file which contains configuration from the browser session,
  // such as window position. It is located under the Chrome data-dir folder.
  String get _preferencesPath => _fileSystem.path.join('Default', 'preferences');

  // The directory that Chrome uses to store local storage information for web apps.
  String get _localStoragePath => _fileSystem.path.join('Default', 'Local Storage');

  /// Copy Chrome user information from a Chrome session into a per-project
  /// cache.
  ///
  /// Note: more detailed docs of the Chrome user preferences store exists here:
  /// https://www.chromium.org/developers/design-documents/preferences.
  void _cacheUserSessionInformation(Directory userDataDir, Directory cacheDir) {
    final File targetPreferencesFile = _fileSystem.file(_fileSystem.path.join(cacheDir?.path ?? '', _preferencesPath));
    final File sourcePreferencesFile = _fileSystem.file(_fileSystem.path.join(userDataDir.path, _preferencesPath));
    final Directory targetLocalStorageDir = _fileSystem.directory(_fileSystem.path.join(cacheDir?.path ?? '', _localStoragePath));
    final Directory sourceLocalStorageDir = _fileSystem.directory(_fileSystem.path.join(userDataDir.path, _localStoragePath));

    if (sourcePreferencesFile.existsSync()) {
      targetPreferencesFile.parent.createSync(recursive: true);
      // If the file contains a crash string, remove it to hide the popup on next run.
      final String contents = sourcePreferencesFile.readAsStringSync();
      targetPreferencesFile.writeAsStringSync(contents
          .replaceFirst('"exit_type":"Crashed"', '"exit_type":"Normal"'));
    }

    if (sourceLocalStorageDir.existsSync()) {
      targetLocalStorageDir.createSync(recursive: true);
      _fileSystemUtils.copyDirectorySync(sourceLocalStorageDir, targetLocalStorageDir);
    }
  }

  /// Restore Chrome user information from a per-project cache into Chrome's
  /// user data directory.
  void _restoreUserSessionInformation(Directory cacheDir, Directory userDataDir) {
    final File sourcePreferencesFile = _fileSystem.file(_fileSystem.path.join(cacheDir.path ?? '', _preferencesPath));
    final File targetPreferencesFile = _fileSystem.file(_fileSystem.path.join(userDataDir.path, _preferencesPath));
    final Directory sourceLocalStorageDir = _fileSystem.directory(_fileSystem.path.join(cacheDir.path ?? '', _localStoragePath));
    final Directory targetLocalStorageDir = _fileSystem.directory(_fileSystem.path.join(userDataDir.path, _localStoragePath));

    if (sourcePreferencesFile.existsSync()) {
      targetPreferencesFile.parent.createSync(recursive: true);
      sourcePreferencesFile.copySync(targetPreferencesFile.path);
    }

    if (sourceLocalStorageDir.existsSync()) {
      targetLocalStorageDir.createSync(recursive: true);
      _fileSystemUtils.copyDirectorySync(sourceLocalStorageDir, targetLocalStorageDir);
    }
  }

  Future<Chromium> _connect(Chromium chrome, bool skipCheck) async {
    // The connection is lazy. Try a simple call to make sure the provided
    // connection is valid.
    if (!skipCheck) {
      try {
        await chrome.chromeConnection.getTabs();
      } on Exception catch (e) {
        await chrome.close();
        throwToolExit(
            'Unable to connect to Chrome debug port: ${chrome.debugPort}\n $e');
      }
    }
    _currentCompleter.complete(chrome);
    return chrome;
  }

  Future<Chromium> get connectedInstance => _currentCompleter.future;

  /// Returns the full URL of the Chrome remote debugger for the main page.
  ///
  /// This takes the [base] remote debugger URL (which points to a browser-wide
  /// page) and uses its JSON API to find the resolved URL for debugging the host
  /// page.
  Future<Uri> _getRemoteDebuggerUrl(Uri base) async {
    try {
      final HttpClient client = HttpClient();
      final HttpClientRequest request = await client.getUrl(base.resolve('/json/list'));
      final HttpClientResponse response = await request.close();
      final List<dynamic> jsonObject = await json.fuse(utf8).decoder.bind(response).single as List<dynamic>;
      if (jsonObject == null || jsonObject.isEmpty) {
        return base;
      }
      return base.resolve(jsonObject.first['devtoolsFrontendUrl'] as String);
    } on Exception {
      // If we fail to talk to the remote debugger protocol, give up and return
      // the raw URL rather than crashing.
      return base;
    }
  }
}

/// A class for managing an instance of a Chromium browser.
class Chromium {
  Chromium._(
    this.debugPort,
    this.chromeConnection, {
    this.url,
    Process process,
    this.remoteDebuggerUri,
    @required ChromiumLauncher chromiumLauncher,
  })  : _process = process,
        _chromiumLauncher = chromiumLauncher;

  final String url;
  final int debugPort;
  final Process _process;
  final ChromeConnection chromeConnection;
  final Uri remoteDebuggerUri;
  final ChromiumLauncher _chromiumLauncher;

  Future<int> get onExit => _process.exitCode;

  Future<void> close() async {
    if (_chromiumLauncher.hasChromeInstance) {
      _chromiumLauncher._currentCompleter = Completer<Chromium>();
    }
    chromeConnection.close();
    _process?.kill();
    await _process?.exitCode;
  }
}