// 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 'package:meta/meta.dart';
import 'package:process/process.dart';

import '../application_package.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 '../base/version.dart';
import '../build_info.dart';
import '../device.dart';
import '../device_port_forwarder.dart';
import '../features.dart';
import '../project.dart';
import 'chrome.dart';

class WebApplicationPackage extends ApplicationPackage {
  WebApplicationPackage(this.flutterProject) : super(id: flutterProject.manifest.appName);

  final FlutterProject flutterProject;

  @override
  String get name => flutterProject.manifest.appName;

  /// The location of the web source assets.
  Directory get webSourcePath => flutterProject.directory.childDirectory('web');
}

/// A web device that supports a chromium browser.
abstract class ChromiumDevice extends Device {
  ChromiumDevice({
    required String name,
    required this.chromeLauncher,
    required FileSystem fileSystem,
    required Logger logger,
  }) : _fileSystem = fileSystem,
       _logger = logger,
       super(
         name,
         category: Category.web,
         platformType: PlatformType.web,
         ephemeral: false,
       );

  final ChromiumLauncher chromeLauncher;

  final FileSystem _fileSystem;
  final Logger _logger;

  /// The active chrome instance.
  Chromium? _chrome;

  // This device does not actually support hot reload, but the current implementation of the resident runner
  // requires both supportsHotReload and supportsHotRestart to be true in order to allow hot restart.
  @override
  bool get supportsHotReload => true;

  @override
  bool get supportsHotRestart => true;

  @override
  bool get supportsStartPaused => true;

  @override
  bool get supportsFlutterExit => false;

  @override
  bool get supportsScreenshot => false;

  @override
  bool supportsRuntimeMode(BuildMode buildMode) => buildMode != BuildMode.jitRelease;

  @override
  void clearLogs() { }

  DeviceLogReader? _logReader;

  @override
  DeviceLogReader getLogReader({
    ApplicationPackage? app,
    bool includePastLogs = false,
  }) {
    return _logReader ??= NoOpDeviceLogReader(app?.name);
  }

  @override
  Future<bool> installApp(
    ApplicationPackage app, {
    String? userIdentifier,
  }) async => true;

  @override
  Future<bool> isAppInstalled(
    ApplicationPackage app, {
    String? userIdentifier,
  }) async => true;

  @override
  Future<bool> isLatestBuildInstalled(ApplicationPackage app) async => true;

  @override
  Future<bool> get isLocalEmulator async => false;

  @override
  Future<String?> get emulatorId async => null;

  @override
  bool isSupported() =>  chromeLauncher.canFindExecutable();

  @override
  DevicePortForwarder? get portForwarder => const NoOpDevicePortForwarder();

  @override
  Future<LaunchResult> startApp(
    covariant WebApplicationPackage package, {
    String? mainPath,
    String? route,
    required DebuggingOptions debuggingOptions,
    Map<String, Object?> platformArgs = const <String, Object?>{},
    bool prebuiltApplication = false,
    bool ipv6 = false,
    String? userIdentifier,
  }) async {
    // See [ResidentWebRunner.run] in flutter_tools/lib/src/resident_web_runner.dart
    // for the web initialization and server logic.
    String url;
    if (debuggingOptions.webLaunchUrl != null) {
      final RegExp pattern = RegExp(r'^((http)?:\/\/)[^\s]+');
      if (pattern.hasMatch(debuggingOptions.webLaunchUrl!)) {
        url = debuggingOptions.webLaunchUrl!;
      } else {
        throwToolExit('"${debuggingOptions.webLaunchUrl}" is not a vaild HTTP URL.');
      }
    } else {
      url = platformArgs['uri']! as String;
    }
    final bool launchChrome = platformArgs['no-launch-chrome'] != true;
    if (launchChrome) {
      _chrome = await chromeLauncher.launch(
        url,
        cacheDir: _fileSystem.currentDirectory
            .childDirectory('.dart_tool')
            .childDirectory('chrome-device'),
        headless: debuggingOptions.webRunHeadless,
        debugPort: debuggingOptions.webBrowserDebugPort,
      );
    }
    _logger.sendEvent('app.webLaunchUrl', <String, Object>{'url': url, 'launched': launchChrome});
    return LaunchResult.succeeded(observatoryUri: url != null ? Uri.parse(url): null);
  }

  @override
  Future<bool> stopApp(
    ApplicationPackage app, {
    String? userIdentifier,
  }) async {
    await _chrome?.close();
    return true;
  }

  @override
  Future<TargetPlatform> get targetPlatform async => TargetPlatform.web_javascript;

  @override
  Future<bool> uninstallApp(
    ApplicationPackage app, {
    String? userIdentifier,
  }) async => true;

  @override
  bool isSupportedForProject(FlutterProject flutterProject) {
    return flutterProject.web.existsSync();
  }

  @override
  Future<void> dispose() async {
    _logReader?.dispose();
    await portForwarder?.dispose();
  }
}

/// The Google Chrome browser based on Chromium.
class GoogleChromeDevice extends ChromiumDevice {
  GoogleChromeDevice({
    required Platform platform,
    required ProcessManager processManager,
    required ChromiumLauncher chromiumLauncher,
    required Logger logger,
    required FileSystem fileSystem,
  }) : _platform = platform,
       _processManager = processManager,
       super(
          name: 'chrome',
          chromeLauncher: chromiumLauncher,
          logger: logger,
          fileSystem: fileSystem,
       );

  final Platform _platform;
  final ProcessManager _processManager;

  @override
  String get name => 'Chrome';

  @override
  late final Future<String> sdkNameAndVersion = _computeSdkNameAndVersion();

  Future<String> _computeSdkNameAndVersion() async {
    if (!isSupported()) {
      return 'unknown';
    }
    // See https://bugs.chromium.org/p/chromium/issues/detail?id=158372
    String version = 'unknown';
    if (_platform.isWindows) {
      if (_processManager.canRun('reg')) {
        final ProcessResult result = await _processManager.run(<String>[
          r'reg', 'query', r'HKEY_CURRENT_USER\Software\Google\Chrome\BLBeacon', '/v', 'version',
        ]);
        if (result.exitCode == 0) {
          final List<String> parts = (result.stdout as String).split(RegExp(r'\s+'));
          if (parts.length > 2) {
            version = 'Google Chrome ${parts[parts.length - 2]}';
          }
        }
      }
    } else {
      final String chrome = chromeLauncher.findExecutable();
      final ProcessResult result = await _processManager.run(<String>[
        chrome,
        '--version',
      ]);
      if (result.exitCode == 0) {
        version = result.stdout as String;
      }
    }
    return version.trim();
  }
}

/// The Microsoft Edge browser based on Chromium.
class MicrosoftEdgeDevice extends ChromiumDevice {
  MicrosoftEdgeDevice({
    required ChromiumLauncher chromiumLauncher,
    required Logger logger,
    required FileSystem fileSystem,
    required ProcessManager processManager,
  }) : _processManager = processManager,
       super(
         name: 'edge',
         chromeLauncher: chromiumLauncher,
         logger: logger,
         fileSystem: fileSystem,
       );

  final ProcessManager _processManager;

  // The first version of Edge with chromium support.
  static const int _kFirstChromiumEdgeMajorVersion = 79;

  @override
  String get name => 'Edge';

  Future<bool> _meetsVersionConstraint() async {
    final String rawVersion = (await sdkNameAndVersion).replaceFirst('Microsoft Edge ', '');
    final Version? version = Version.parse(rawVersion);
    if (version == null) {
      return false;
    }
    return version.major >= _kFirstChromiumEdgeMajorVersion;
  }

  @override
  late final Future<String> sdkNameAndVersion = _getSdkNameAndVersion();

  Future<String> _getSdkNameAndVersion() async {
    if (_processManager.canRun('reg')) {
      final ProcessResult result = await _processManager.run(<String>[
        r'reg', 'query', r'HKEY_CURRENT_USER\Software\Microsoft\Edge\BLBeacon', '/v', 'version',
      ]);
      if (result.exitCode == 0) {
        final List<String> parts = (result.stdout as String).split(RegExp(r'\s+'));
        if (parts.length > 2) {
          return 'Microsoft Edge ${parts[parts.length - 2]}';
        }
      }
    }
    // Return a non-null string so that the tool can validate the version
    // does not meet the constraint above in _meetsVersionConstraint.
    return '';
  }
}

class WebDevices extends PollingDeviceDiscovery {
  WebDevices({
    required FileSystem fileSystem,
    required Logger logger,
    required Platform platform,
    required ProcessManager processManager,
    required FeatureFlags featureFlags,
  }) : _featureFlags = featureFlags,
       _webServerDevice = WebServerDevice(
         logger: logger,
       ),
       super('Chrome') {
    final OperatingSystemUtils operatingSystemUtils = OperatingSystemUtils(
      fileSystem: fileSystem,
      platform: platform,
      logger: logger,
      processManager: processManager,
    );
    _chromeDevice = GoogleChromeDevice(
      fileSystem: fileSystem,
      logger: logger,
      platform: platform,
      processManager: processManager,
      chromiumLauncher: ChromiumLauncher(
        browserFinder: findChromeExecutable,
        fileSystem: fileSystem,
        platform: platform,
        processManager: processManager,
        operatingSystemUtils: operatingSystemUtils,
        logger: logger,
      ),
    );
    if (platform.isWindows) {
      _edgeDevice = MicrosoftEdgeDevice(
        chromiumLauncher: ChromiumLauncher(
          browserFinder: findEdgeExecutable,
          fileSystem: fileSystem,
          platform: platform,
          processManager: processManager,
          operatingSystemUtils: operatingSystemUtils,
          logger: logger,
        ),
        processManager: processManager,
        logger: logger,
        fileSystem: fileSystem,
      );
    }
  }

  late final GoogleChromeDevice _chromeDevice;
  final WebServerDevice _webServerDevice;
  MicrosoftEdgeDevice? _edgeDevice;
  final FeatureFlags _featureFlags;

  @override
  bool get canListAnything => featureFlags.isWebEnabled;

  @override
  Future<List<Device>> pollingGetDevices({ Duration? timeout }) async {
    if (!_featureFlags.isWebEnabled) {
      return <Device>[];
    }
    final MicrosoftEdgeDevice? edgeDevice = _edgeDevice;
    return <Device>[
      if (WebServerDevice.showWebServerDevice)
        _webServerDevice,
      if (_chromeDevice.isSupported())
        _chromeDevice,
      if (edgeDevice != null && await edgeDevice._meetsVersionConstraint())
        edgeDevice,
    ];
  }

  @override
  bool get supportsPlatform =>  _featureFlags.isWebEnabled;

  @override
  List<String> get wellKnownIds => const <String>['chrome', 'web-server', 'edge'];
}

@visibleForTesting
String parseVersionForWindows(String input) {
  return input.split(RegExp(r'\w')).last;
}


/// A special device type to allow serving for arbitrary browsers.
class WebServerDevice extends Device {
  WebServerDevice({
    required Logger logger,
  }) : _logger = logger,
       super(
         'web-server',
          platformType: PlatformType.web,
          category: Category.web,
          ephemeral: false,
       );

  static const String kWebServerDeviceId = 'web-server';
  static bool showWebServerDevice = false;

  final Logger _logger;

  @override
  void clearLogs() { }

  @override
  Future<String?> get emulatorId async => null;

  DeviceLogReader? _logReader;

  @override
  DeviceLogReader getLogReader({
    ApplicationPackage? app,
    bool includePastLogs = false,
  }) {
    return _logReader ??= NoOpDeviceLogReader(app?.name);
  }

  @override
  Future<bool> installApp(
    ApplicationPackage app, {
    String? userIdentifier,
  }) async => true;

  @override
  Future<bool> isAppInstalled(
    ApplicationPackage app, {
    String? userIdentifier,
  }) async => true;

  @override
  Future<bool> isLatestBuildInstalled(ApplicationPackage app) async => true;

  @override
  bool get supportsFlutterExit => false;

  @override
  bool supportsRuntimeMode(BuildMode buildMode) => buildMode != BuildMode.jitRelease;

  @override
  Future<bool> get isLocalEmulator async => false;

  @override
  bool isSupported() => true;

  @override
  bool isSupportedForProject(FlutterProject flutterProject) {
    return flutterProject.web.existsSync();
  }

  @override
  String get name => 'Web Server';

  @override
  DevicePortForwarder? get portForwarder => const NoOpDevicePortForwarder();

  @override
  Future<String> get sdkNameAndVersion async => 'Flutter Tools';

  @override
  Future<LaunchResult> startApp(ApplicationPackage package, {
    String? mainPath,
    String? route,
    required DebuggingOptions debuggingOptions,
    Map<String, Object?> platformArgs = const <String, Object?>{},
    bool prebuiltApplication = false,
    bool ipv6 = false,
    String? userIdentifier,
  }) async {
    final String? url = platformArgs['uri'] as String?;
    if (debuggingOptions.startPaused) {
      _logger.printStatus('Waiting for connection from Dart debug extension at $url', emphasis: true);
    } else {
      _logger.printStatus('$mainPath is being served at $url', emphasis: true);
    }
    _logger.printStatus(
      'The web-server device requires the Dart Debug Chrome extension for debugging. '
      'Consider using the Chrome or Edge devices for an improved development workflow.'
    );
    _logger.sendEvent('app.webLaunchUrl', <String, Object?>{'url': url, 'launched': false});
    return LaunchResult.succeeded(observatoryUri: url != null ? Uri.parse(url): null);
  }

  @override
  Future<bool> stopApp(
    ApplicationPackage app, {
    String? userIdentifier,
  }) async {
    return true;
  }

  @override
  Future<TargetPlatform> get targetPlatform async => TargetPlatform.web_javascript;

  @override
  Future<bool> uninstallApp(
    ApplicationPackage app, {
    String? userIdentifier,
  }) async {
    return true;
  }

  @override
  Future<void> dispose() async {
    _logReader?.dispose();
    await portForwarder?.dispose();
  }
}
