/**
 * Copyright (c) 2021 Expo, Inc.
 * Copyright (c) 2018 Drifty Co.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 */
import { Socket } from 'net';
import { Duplex } from 'stream';
import * as tls from 'tls';

import { AFCClient } from './client/AFCClient';
import { DebugserverClient } from './client/DebugserverClient';
import { InstallationProxyClient } from './client/InstallationProxyClient';
import { LockdowndClient } from './client/LockdowndClient';
import { MobileImageMounterClient } from './client/MobileImageMounterClient';
import { ServiceClient } from './client/ServiceClient';
import { UsbmuxdClient, UsbmuxdDevice, UsbmuxdPairRecord } from './client/UsbmuxdClient';
import { CommandError } from '../../../utils/errors';

export class ClientManager {
  private connections: Socket[];
  constructor(
    public pairRecord: UsbmuxdPairRecord,
    public device: UsbmuxdDevice,
    private lockdowndClient: LockdowndClient
  ) {
    this.connections = [lockdowndClient.socket];
  }

  static async create(udid?: string) {
    const usbmuxClient = new UsbmuxdClient(UsbmuxdClient.connectUsbmuxdSocket());
    const device = await usbmuxClient.getDevice(udid);
    const pairRecord = await usbmuxClient.readPairRecord(device.Properties.SerialNumber);
    const lockdownSocket = await usbmuxClient.connect(device, 62078);
    const lockdownClient = new LockdowndClient(lockdownSocket);
    await lockdownClient.doHandshake(pairRecord);
    return new ClientManager(pairRecord, device, lockdownClient);
  }

  async getUsbmuxdClient() {
    const usbmuxClient = new UsbmuxdClient(UsbmuxdClient.connectUsbmuxdSocket());
    this.connections.push(usbmuxClient.socket);
    return usbmuxClient;
  }

  async getLockdowndClient() {
    const usbmuxClient = new UsbmuxdClient(UsbmuxdClient.connectUsbmuxdSocket());
    const lockdownSocket = await usbmuxClient.connect(this.device, 62078);
    const lockdownClient = new LockdowndClient(lockdownSocket);
    this.connections.push(lockdownClient.socket);
    return lockdownClient;
  }

  async getLockdowndClientWithHandshake() {
    const lockdownClient = await this.getLockdowndClient();
    await lockdownClient.doHandshake(this.pairRecord);
    return lockdownClient;
  }

  async getAFCClient() {
    return this.getServiceClient('com.apple.afc', AFCClient);
  }

  async getInstallationProxyClient() {
    return this.getServiceClient('com.apple.mobile.installation_proxy', InstallationProxyClient);
  }

  async getMobileImageMounterClient() {
    return this.getServiceClient('com.apple.mobile.mobile_image_mounter', MobileImageMounterClient);
  }

  async getDebugserverClient() {
    try {
      // iOS 14 added support for a secure debug service so try to connect to that first
      return await this.getServiceClient(
        'com.apple.debugserver.DVTSecureSocketProxy',
        DebugserverClient
      );
    } catch {
      // otherwise, fall back to the previous implementation
      return this.getServiceClient('com.apple.debugserver', DebugserverClient, true);
    }
  }

  private async getServiceClient<T extends ServiceClient<any>>(
    name: string,
    ServiceType: new (...args: any[]) => T,
    disableSSL = false
  ) {
    const { port: servicePort, enableServiceSSL } = await this.lockdowndClient.startService(name);
    const usbmuxClient = new UsbmuxdClient(UsbmuxdClient.connectUsbmuxdSocket());
    let usbmuxdSocket = await usbmuxClient.connect(this.device, servicePort);

    if (enableServiceSSL) {
      const tlsOptions: tls.ConnectionOptions = {
        rejectUnauthorized: false,
        secureContext: tls.createSecureContext({
          // Avoid using `secureProtocol` fixing the socket to a single TLS version.
          // Newer Node versions might not support older TLS versions.
          // By using the default `minVersion` and `maxVersion` options,
          // The socket will automatically use the appropriate TLS version.
          // See: https://nodejs.org/api/tls.html#tlscreatesecurecontextoptions
          cert: this.pairRecord.RootCertificate,
          key: this.pairRecord.RootPrivateKey,
        }),
      };

      // Some services seem to not support TLS/SSL after the initial handshake
      // More info: https://github.com/libimobiledevice/libimobiledevice/issues/793
      if (disableSSL) {
        // According to https://nodejs.org/api/tls.html#tls_tls_connect_options_callback we can
        // pass any Duplex in to tls.connect instead of a Socket. So we'll use our proxy to keep
        // the TLS wrapper and underlying usbmuxd socket separate.
        const proxy: any = new UsbmuxdProxy(usbmuxdSocket);
        tlsOptions.socket = proxy;

        await new Promise<void>((resolve, reject) => {
          const timeoutId = setTimeout(() => {
            reject(
              new CommandError('APPLE_DEVICE', 'The TLS handshake failed to complete after 5s.')
            );
          }, 5000);
          tls.connect(tlsOptions, function (this: tls.TLSSocket) {
            clearTimeout(timeoutId);
            // After the handshake, we don't need TLS or the proxy anymore,
            // since we'll just pass in the naked usbmuxd socket to the service client
            this.destroy();
            resolve();
          });
        });
      } else {
        tlsOptions.socket = usbmuxdSocket;
        usbmuxdSocket = tls.connect(tlsOptions);
      }
    }
    const client = new ServiceType(usbmuxdSocket);
    this.connections.push(client.socket);
    return client;
  }

  end() {
    for (const socket of this.connections) {
      // may already be closed
      try {
        socket.end();
      } catch {}
    }
  }
}

class UsbmuxdProxy extends Duplex {
  constructor(private usbmuxdSock: Socket) {
    super();

    this.usbmuxdSock.on('data', (data) => {
      this.push(data);
    });
  }

  _write(chunk: any, encoding: string, callback: (err?: Error) => void) {
    this.usbmuxdSock.write(chunk);
    callback();
  }

  _read(size: number) {
    // Stub so we don't error, since we push everything we get from usbmuxd as it comes in.
    // TODO: better way to do this?
  }

  _destroy() {
    this.usbmuxdSock.removeAllListeners();
  }
}
