// Copyright 2018 The Flutter Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.

// ignore_for_file: avoid_print

import 'dart:async';
import 'dart:io';

import 'package:path/path.dart' as path;
import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart'
    hide ChromeTab;
import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart'
    as wip show ChromeTab;

import 'io_utils.dart';

// Change this if you want to be able to see Chrome opening while tests run
// to aid debugging.
const useChromeHeadless = true;

// Chrome headless currently hangs on Windows (both Travis and locally),
// so we won't use it there.
final headlessModeIsSupported = !Platform.isWindows;

class Chrome {
  Chrome._(this.executable);

  static Chrome? from(String executable) {
    return FileSystemEntity.isFileSync(executable)
        ? Chrome._(executable)
        : null;
  }

  static Chrome? locate() {
    if (Platform.isMacOS) {
      const defaultPath = '/Applications/Google Chrome.app';
      const bundlePath = 'Contents/MacOS/Google Chrome';

      if (FileSystemEntity.isDirectorySync(defaultPath)) {
        return Chrome.from(path.join(defaultPath, bundlePath));
      }
    } else if (Platform.isLinux) {
      const defaultPath = '/usr/bin/google-chrome';

      if (FileSystemEntity.isFileSync(defaultPath)) {
        return Chrome.from(defaultPath);
      }
    } else if (Platform.isWindows) {
      final progFiles = Platform.environment['PROGRAMFILES(X86)']!;
      final chromeInstall = '$progFiles\\Google\\Chrome';
      final defaultPath = '$chromeInstall\\Application\\chrome.exe';

      if (FileSystemEntity.isFileSync(defaultPath)) {
        return Chrome.from(defaultPath);
      }
    }

    final pathFromEnv = Platform.environment['CHROME_PATH'];
    if (pathFromEnv != null && FileSystemEntity.isFileSync(pathFromEnv)) {
      return Chrome.from(pathFromEnv);
    }

    // TODO(devoncarew): check default install locations for linux
    // TODO(devoncarew): try `which` on mac, linux

    return null;
  }

  /// Return the path to a per-user Chrome data dir.
  ///
  /// This method will create the directory if it does not exist.
  static String getCreateChromeDataDir() {
    final prefsDir = getDartPrefsDirectory();
    final chromeDataDir = Directory(path.join(prefsDir.path, 'chrome'));
    if (!chromeDataDir.existsSync()) {
      chromeDataDir.createSync(recursive: true);
    }
    return chromeDataDir.path;
  }

  final String executable;

  Future<ChromeProcess> start({String? url, int debugPort = 9222}) {
    final args = <String>[
      '--no-default-browser-check',
      '--no-first-run',
      '--user-data-dir=${getCreateChromeDataDir()}',
      '--remote-debugging-port=$debugPort',
    ];
    if (useChromeHeadless && headlessModeIsSupported) {
      args.addAll(<String>[
        '--headless',
        '--disable-gpu',
      ]);
    }
    if (url != null) {
      args.add(url);
    }
    return Process.start(executable, args).then((Process process) {
      return ChromeProcess(process, debugPort);
    });
  }
}

class ChromeProcess {
  ChromeProcess(this.process, this.debugPort);

  final Process process;
  final int debugPort;
  bool _processAlive = true;

  Future<ChromeTab?> connectToTab(
    String url, {
    Duration timeout = const Duration(seconds: 20),
  }) async {
    return await _connectToTab(
      connection: ChromeConnection(Uri.parse(url).host, debugPort),
      tabFound: (tab) => tab.url == url,
      timeout: timeout,
    );
  }

  Future<ChromeTab?> connectToTabId(
    String host,
    String id, {
    Duration timeout = const Duration(seconds: 20),
  }) async {
    return await _connectToTab(
      connection: ChromeConnection(host, debugPort),
      tabFound: (tab) => tab.id == id,
      timeout: timeout,
    );
  }

  Future<ChromeTab?> getFirstTab({
    Duration timeout = const Duration(seconds: 20),
  }) async {
    return await _connectToTab(
      connection: ChromeConnection('localhost', debugPort),
      tabFound: (tab) => !tab.isBackgroundPage && !tab.isChromeExtension,
      timeout: timeout,
    );
  }

  Future<ChromeTab?> _connectToTab({
    required ChromeConnection connection,
    required bool Function(wip.ChromeTab) tabFound,
    required Duration timeout,
  }) async {
    final wipTab = await connection.getTab(
      (wip.ChromeTab tab) {
        return tabFound(tab);
      },
      retryFor: timeout,
    );

    unawaited(
      process.exitCode.then((_) {
        _processAlive = false;
      }),
    );

    return wipTab == null ? null : ChromeTab(wipTab);
  }

  bool get isAlive => _processAlive;

  /// Returns `true` if the signal is successfully delivered to the process.
  /// Otherwise the signal could not be sent, usually meaning that the process
  /// is already dead.
  bool kill() => process.kill();

  Future<int> get onExit => process.exitCode;
}

class ChromeTab {
  ChromeTab(this.wipTab);

  final wip.ChromeTab wipTab;
  WipConnection? _wip;

  final _entryAddedController = StreamController<LogEntry>.broadcast();
  final _consoleAPICalledController =
      StreamController<ConsoleAPIEvent>.broadcast();
  final _exceptionThrownController =
      StreamController<ExceptionThrownEvent>.broadcast();

  num? _lostConnectionTime;

  Future<WipConnection?> connect({bool verbose = false}) async {
    _wip = await wipTab.connect();
    final wipConnection = _wip!;

    await wipConnection.log.enable();
    wipConnection.log.onEntryAdded.listen((LogEntry entry) {
      if (_lostConnectionTime == null ||
          entry.timestamp > _lostConnectionTime!) {
        _entryAddedController.add(entry);
      }
    });

    await wipConnection.runtime.enable();
    wipConnection.runtime.onConsoleAPICalled.listen((ConsoleAPIEvent event) {
      if (_lostConnectionTime == null ||
          event.timestamp > _lostConnectionTime!) {
        _consoleAPICalledController.add(event);
      }
    });

    unawaited(
      _exceptionThrownController
          .addStream(wipConnection.runtime.onExceptionThrown),
    );

    unawaited(wipConnection.page.enable());

    wipConnection.onClose.listen((_) {
      _wip = null;
      _disconnectStream.add(null);
      _lostConnectionTime = DateTime.now().millisecondsSinceEpoch;
    });

    if (verbose) {
      onLogEntryAdded.listen((entry) {
        print('chrome • log:${entry.source} • ${entry.text} ${entry.url}');
      });

      onConsoleAPICalled.listen((entry) {
        print(
          'chrome • console:${entry.type} • '
          '${entry.args.map((a) => a.value).join(', ')}',
        );
      });

      onExceptionThrown.listen((ex) {
        throw 'JavaScript exception occurred: ${ex.method}\n\n'
            '${ex.params}\n\n'
            '${ex.exceptionDetails.text}\n\n'
            '${ex.exceptionDetails.exception}\n\n'
            '${ex.exceptionDetails.stackTrace}';
      });
    }

    return _wip;
  }

  Future<String> createNewTarget() {
    return _wip!.target.createTarget('about:blank');
  }

  bool get isConnected => _wip != null;

  Stream<void> get onDisconnect => _disconnectStream.stream;
  final _disconnectStream = StreamController<void>.broadcast();

  Stream<LogEntry> get onLogEntryAdded => _entryAddedController.stream;

  Stream<ConsoleAPIEvent> get onConsoleAPICalled =>
      _consoleAPICalledController.stream;

  Stream<ExceptionThrownEvent> get onExceptionThrown =>
      _exceptionThrownController.stream;

  Future<WipResponse> reload() => _wip!.page.reload();

  Future<WipResponse> navigate(String url) => _wip!.page.navigate(url);

  WipConnection? get wipConnection => _wip;
}
