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

import '../application_package.dart';
import '../base/common.dart';
import '../base/file_system.dart';
import '../base/io.dart';
import '../base/logger.dart';
import '../build_info.dart';
import '../convert.dart';
import '../daemon.dart';
import '../device.dart';
import '../device_port_forwarder.dart';
import '../project.dart';
import 'file_transfer.dart';

bool _isNullable<T>() => null is T;

T _cast<T>(Object? object) {
  if (!_isNullable<T>() && object == null) {
    throw Exception('Expected $T, received null!');
  } else {
    return object as T;
  }
}

/// A [DeviceDiscovery] that will connect to a flutter daemon and connects to
/// the devices remotely.
///
/// If [deltaFileTransfer] is true, the proxy will use an rsync-like algorithm that
/// only transfers the changed part of the application package for deployment.
class ProxiedDevices extends DeviceDiscovery {
  ProxiedDevices(this.connection, {
    bool deltaFileTransfer = true,
    required Logger logger,
  }) : _deltaFileTransfer = deltaFileTransfer,
       _logger = logger;

  /// [DaemonConnection] used to communicate with the daemon.
  final DaemonConnection connection;

  final Logger _logger;

  final bool _deltaFileTransfer;

  @override
  bool get supportsPlatform => true;

  @override
  bool get canListAnything => true;

  List<Device>? _devices;

  @override
  Future<List<Device>> get devices async =>
      _devices ?? await discoverDevices();

  @override
  Future<List<Device>> discoverDevices({Duration? timeout}) async {
    final List<Map<String, Object?>> discoveredDevices = _cast<List<dynamic>>(await connection.sendRequest('device.discoverDevices')).cast<Map<String, Object?>>();
    final List<ProxiedDevice> devices = <ProxiedDevice>[
      for (final Map<String, Object?> device in discoveredDevices)
        _deviceFromDaemonResult(device),
    ];

    _devices = devices;
    return devices;
  }

  @override
  List<String> get wellKnownIds => const <String>[];

  ProxiedDevice _deviceFromDaemonResult(Map<String, Object?> device) {
    final Map<String, Object?> capabilities = _cast<Map<String, Object?>>(device['capabilities']);
    return ProxiedDevice(
      connection, _cast<String>(device['id']),
      deltaFileTransfer: _deltaFileTransfer,
      category: Category.fromString(_cast<String>(device['category'])),
      platformType: PlatformType.fromString(_cast<String>(device['platformType'])),
      targetPlatform: getTargetPlatformForName(_cast<String>(device['platform'])),
      ephemeral: _cast<bool>(device['ephemeral']),
      name: 'Proxied ${device['name']}',
      isLocalEmulator: _cast<bool>(device['emulator']),
      emulatorId: _cast<String?>(device['emulatorId']),
      sdkNameAndVersion: _cast<String>(device['sdk']),
      supportsHotReload: _cast<bool>(capabilities['hotReload']),
      supportsHotRestart: _cast<bool>(capabilities['hotRestart']),
      supportsFlutterExit: _cast<bool>(capabilities['flutterExit']),
      supportsScreenshot: _cast<bool>(capabilities['screenshot']),
      supportsFastStart: _cast<bool>(capabilities['fastStart']),
      supportsHardwareRendering: _cast<bool>(capabilities['hardwareRendering']),
      logger: _logger,
    );
  }
}

/// A [Device] that acts as a proxy to remotely connected device.
///
/// The communication happens via a flutter daemon.
///
/// If [deltaFileTransfer] is true, the proxy will use an rsync-like algorithm that
/// only transfers the changed part of the application package for deployment.
class ProxiedDevice extends Device {
  ProxiedDevice(this.connection, String id, {
    bool deltaFileTransfer = true,
    required Category? category,
    required PlatformType? platformType,
    required TargetPlatform targetPlatform,
    required bool ephemeral,
    required this.name,
    required bool isLocalEmulator,
    required String? emulatorId,
    required String sdkNameAndVersion,
    required this.supportsHotReload,
    required this.supportsHotRestart,
    required this.supportsFlutterExit,
    required this.supportsScreenshot,
    required this.supportsFastStart,
    required bool supportsHardwareRendering,
    required Logger logger,
  }): _deltaFileTransfer = deltaFileTransfer,
      _isLocalEmulator = isLocalEmulator,
      _emulatorId = emulatorId,
      _sdkNameAndVersion = sdkNameAndVersion,
      _supportsHardwareRendering = supportsHardwareRendering,
      _targetPlatform = targetPlatform,
      _logger = logger,
      super(id,
        category: category,
        platformType: platformType,
        ephemeral: ephemeral);

  /// [DaemonConnection] used to communicate with the daemon.
  final DaemonConnection connection;

  final Logger _logger;

  final bool _deltaFileTransfer;

  @override
  final String name;

  final bool _isLocalEmulator;
  @override
  Future<bool> get isLocalEmulator async => _isLocalEmulator;

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

  @override
  Future<bool> supportsRuntimeMode(BuildMode buildMode) async =>
     _cast<bool>(await connection.sendRequest('device.supportsRuntimeMode', <String, Object>{
      'deviceId': id,
      'buildMode': buildMode.toString(),
    }));

  final bool _supportsHardwareRendering;
  @override
  Future<bool> get supportsHardwareRendering async => _supportsHardwareRendering;

  // ProxiedDevice is intended to be used with prebuilt projects. No building
  // is required, so we returns true for all projects.
  @override
  bool isSupportedForProject(FlutterProject flutterProject) => true;

  @override
  Future<bool> isAppInstalled(
    covariant ApplicationPackage app, {
    String? userIdentifier,
  }) => throw UnimplementedError();

  @override
  Future<bool> isLatestBuildInstalled(covariant ApplicationPackage app) => throw UnimplementedError();

  @override
  Future<bool> installApp(
    covariant ApplicationPackage app, {
    String? userIdentifier,
  }) => throw UnimplementedError();

  @override
  Future<bool> uninstallApp(
    covariant ApplicationPackage app, {
    String? userIdentifier,
  }) => throw UnimplementedError();

  @override
  bool isSupported() => true;

  final TargetPlatform _targetPlatform;
  @override
  Future<TargetPlatform> get targetPlatform async => _targetPlatform;
  TargetPlatform get targetPlatformSync => _targetPlatform;

  final String _sdkNameAndVersion;
  @override
  Future<String> get sdkNameAndVersion async => _sdkNameAndVersion;

  @override
  FutureOr<DeviceLogReader> getLogReader({
    covariant PrebuiltApplicationPackage? app,
    bool includePastLogs = false,
  }) => _ProxiedLogReader(connection, this, app);

  _ProxiedPortForwarder? _proxiedPortForwarder;
  _ProxiedPortForwarder get proxiedPortForwarder => _proxiedPortForwarder ??= _ProxiedPortForwarder(connection, logger: _logger);

  @override
  DevicePortForwarder get portForwarder => throw UnimplementedError();

  @override
  void clearLogs() => throw UnimplementedError();

  @override
  Future<LaunchResult> startApp(
    covariant PrebuiltApplicationPackage 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 Map<String, Object?> result = _cast<Map<String, Object?>>(await connection.sendRequest('device.startApp', <String, Object?>{
      'deviceId': id,
      'applicationPackageId': await applicationPackageId(package),
      'mainPath': mainPath,
      'route': route,
      'debuggingOptions': debuggingOptions.toJson(),
      'platformArgs': platformArgs,
      'prebuiltApplication': prebuiltApplication,
      'ipv6': ipv6,
      'userIdentifier': userIdentifier,
    }));
    final bool started = _cast<bool>(result['started']);
    final String? observatoryUriStr = _cast<String?>(result['observatoryUri']);
    final Uri? observatoryUri = observatoryUriStr == null ? null : Uri.parse(observatoryUriStr);
    if (started) {
      if (observatoryUri != null) {
        final int hostPort = await proxiedPortForwarder.forward(observatoryUri.port);
        return LaunchResult.succeeded(observatoryUri: observatoryUri.replace(port: hostPort));
      } else {
        return LaunchResult.succeeded();
      }
    } else {
      return LaunchResult.failed();
    }
  }

  @override
  final bool supportsHotReload;

  @override
  final bool supportsHotRestart;

  @override
  final bool supportsFlutterExit;

  @override
  final bool supportsScreenshot;

  @override
  final bool supportsFastStart;

  @override
  Future<bool> stopApp(
    covariant PrebuiltApplicationPackage app, {
    String? userIdentifier,
  }) async {
    return _cast<bool>(await connection.sendRequest('device.stopApp', <String, Object?>{
      'deviceId': id,
      'applicationPackageId': await applicationPackageId(app),
      'userIdentifier': userIdentifier,
    }));
  }

  @override
  Future<MemoryInfo> queryMemoryInfo() => throw UnimplementedError();

  @override
  Future<void> takeScreenshot(File outputFile) async {
    final String imageBase64 = _cast<String>(await connection.sendRequest('device.takeScreenshot', <String, Object?>{
      'deviceId': id,
    }));
    await outputFile.writeAsBytes(base64.decode(imageBase64));
  }

  @override
  Future<void> dispose() async {}

  final Map<String, Future<String>> _applicationPackageMap = <String, Future<String>>{};
  Future<String> applicationPackageId(PrebuiltApplicationPackage package) async {
    final File binary = package.applicationPackage as File;
    final String path = binary.absolute.path;
    if (_applicationPackageMap.containsKey(path)) {
      return _applicationPackageMap[path]!;
    }
    final String fileName = binary.basename;
    final Completer<String> idCompleter = Completer<String>();
    _applicationPackageMap[path] = idCompleter.future;

    final Map<String, Object> args = <String, Object>{'path': fileName};

    Map<String, Object?>? rollingHashResultJson;
    if (_deltaFileTransfer) {
     rollingHashResultJson = _cast<Map<String, Object?>?>(await connection.sendRequest('proxy.calculateFileHashes', args));
    }

    if (rollingHashResultJson == null) {
      // Either file not found on the remote end, or deltaFileTransfer is set to false, transfer the file directly.
      if (_deltaFileTransfer) {
        _logger.printTrace('Delta file transfer is enabled but file is not found on the remote end, do a full transfer.');
      }

      await connection.sendRequest('proxy.writeTempFile', args, await binary.readAsBytes());
    } else {
      final BlockHashes rollingHashResult = BlockHashes.fromJson(rollingHashResultJson);
      final List<FileDeltaBlock> delta = await FileTransfer().computeDelta(binary, rollingHashResult);

      // Delta is empty if the file does not need to be updated
      if (delta.isNotEmpty) {
        final List<Map<String, Object>> deltaJson = delta.map((FileDeltaBlock block) => block.toJson()).toList();
        final Uint8List buffer = await FileTransfer().binaryForRebuilding(binary, delta);

        await connection.sendRequest('proxy.updateFile', <String, Object>{
          'path': fileName,
          'delta': deltaJson,
        }, buffer);
      }
    }

    final String id = _cast<String>(await connection.sendRequest('device.uploadApplicationPackage', <String, Object>{
      'targetPlatform': getNameForTargetPlatform(_targetPlatform),
      'applicationBinary': fileName,
    }));
    idCompleter.complete(id);
    return id;
  }
}

/// A [DeviceLogReader] for a proxied device.
class _ProxiedLogReader extends DeviceLogReader {
  _ProxiedLogReader(this.connection, this.device, this.applicationPackage);

  final DaemonConnection connection;
  final ProxiedDevice device;
  final PrebuiltApplicationPackage? applicationPackage;

  @override
  String get name => device.name;

  final StreamController<String> _logLinesStreamController = StreamController<String>();
  Stream<String>? _logLines;

  String? _id;

  @override
  Stream<String> get logLines => _logLines ??= _start();

  Stream<String> _start() {
    final PrebuiltApplicationPackage? package = applicationPackage;
    final Future<String?> applicationPackageId = package != null ? device.applicationPackageId(package) : Future<String?>.value();
    final Future<String> idFuture = applicationPackageId.then((String? applicationPackageId) async =>
       _cast<String>(await connection.sendRequest('device.logReader.start', <String, Object>{
        'deviceId': device.id,
        if (applicationPackageId != null)
          'applicationPackageId': applicationPackageId,
      })));
    idFuture.then((String id) {
      _id = id;
      final Stream<String> stream = connection.listenToEvent('device.logReader.logLines.$_id').map((DaemonEventData event) => event.data! as String);
      _logLinesStreamController.addStream(stream);
    });
    return _logLinesStreamController.stream;
  }

  @override
  void dispose() {
    if (_id != null) {
      connection.sendRequest('device.logReader.stop', <String, Object?>{
        'id': _id,
      });
    }
  }
}

/// A [DevicePortForwarder] for a proxied device.
class _ProxiedPortForwarder extends DevicePortForwarder {
  _ProxiedPortForwarder(this.connection, {
    required Logger logger,
  }) : _logger = logger;

  DaemonConnection connection;

  final Logger _logger;

  @override
  final List<ForwardedPort> forwardedPorts = <ForwardedPort>[];

  final List<Socket> _connectedSockets = <Socket>[];

  final Map<int, ServerSocket> _serverSockets = <int, ServerSocket>{};

  @override
  Future<int> forward(int devicePort, { int? hostPort }) async {
    final ServerSocket serverSocket = await ServerSocket.bind(InternetAddress.loopbackIPv4, hostPort ?? 0);

    _serverSockets[serverSocket.port] = serverSocket;
    forwardedPorts.add(ForwardedPort(serverSocket.port, devicePort));

    serverSocket.listen((Socket socket) async {
      final String id = _cast<String>(await connection.sendRequest('proxy.connect', <String, Object>{
        'port': devicePort,
      }));
      final Stream<List<int>> dataStream = connection.listenToEvent('proxy.data.$id').asyncExpand((DaemonEventData event) => event.binary);
      dataStream.listen(socket.add);
      final Future<DaemonEventData> disconnectFuture = connection.listenToEvent('proxy.disconnected.$id').first;
      unawaited(disconnectFuture.then((_) {
        socket.close();
      }));
      socket.listen((Uint8List data) {
        connection.sendRequest('proxy.write', <String, Object>{
          'id': id,
        }, data);
      });
      _connectedSockets.add(socket);

      unawaited(socket.done.then((dynamic value) {
        connection.sendRequest('proxy.disconnect', <String, Object>{
          'id': id,
        });
        _connectedSockets.remove(socket);
      }).onError((Object? error, StackTrace stackTrace) {
        connection.sendRequest('proxy.disconnect', <String, Object>{
          'id': id,
        });
        _connectedSockets.remove(socket);
      }));
    }, onError: (Object error, StackTrace stackTrace) {
      _logger.printWarning('Server socket error: $error');
      _logger.printTrace('Server socket error: $error, stack trace: $stackTrace');
    });
    return serverSocket.port;
  }

  @override
  Future<void> unforward(ForwardedPort forwardedPort) async {
    if (!forwardedPorts.remove(forwardedPort)) {
      // Not in list. Nothing to remove.
      return;
    }

    forwardedPort.dispose();

    final ServerSocket? serverSocket = _serverSockets.remove(forwardedPort.hostPort);
    await serverSocket?.close();
  }

  @override
  Future<void> dispose() async {
    for (final ForwardedPort forwardedPort in forwardedPorts) {
      forwardedPort.dispose();
    }

    for (final ServerSocket serverSocket in _serverSockets.values) {
      await serverSocket.close();
    }

    for (final Socket socket in _connectedSockets) {
      await socket.close();
    }
  }
}
