import Debug from 'debug';
import fs from 'fs';
import path from 'path';

import { ClientManager } from './ClientManager';
import { IPLookupResult, OnInstallProgressCallback } from './client/InstallationProxyClient';
import { LockdowndClient } from './client/LockdowndClient';
import { UsbmuxdClient } from './client/UsbmuxdClient';
import { AFC_STATUS, AFCError } from './protocol/AFCProtocol';
import { Log } from '../../../log';
import { XcodeDeveloperDiskImagePrerequisite } from '../../../start/doctor/apple/XcodeDeveloperDiskImagePrerequisite';
import { delayAsync } from '../../../utils/delay';
import { CommandError } from '../../../utils/errors';
import { installExitHooks } from '../../../utils/exit';

const debug = Debug('expo:apple-device');

// NOTE(EvanBacon): I have a feeling this shape will change with new iOS versions (tested against iOS 15).
export interface ConnectedDevice {
  /** @example `00008101-001964A22629003A` */
  udid: string;
  /** @example `Evan's phone` */
  name: string;
  /** @example `iPhone13,4` */
  model: string;
  /** @example `device` */
  deviceType: 'device' | 'catalyst';
  /** @example `USB` */
  connectionType: 'USB' | 'Network';
  /** @example `15.4.1` */
  osVersion: string;
}

/** @returns a list of connected Apple devices. */
export async function getConnectedDevicesAsync(): Promise<ConnectedDevice[]> {
  const client = new UsbmuxdClient(UsbmuxdClient.connectUsbmuxdSocket());
  const devices = await client.getDevices();
  client.socket.end();

  return Promise.all(
    devices.map(async (device): Promise<ConnectedDevice> => {
      const socket = await new UsbmuxdClient(UsbmuxdClient.connectUsbmuxdSocket()).connect(
        device,
        62078
      );
      const deviceValues = await new LockdowndClient(socket).getAllValues();
      socket.end();
      // TODO(EvanBacon): Add support for osType (ipad, watchos, etc)
      return {
        // TODO(EvanBacon): Better name
        name: deviceValues.DeviceName ?? deviceValues.ProductType ?? 'unknown iOS device',
        model: deviceValues.ProductType,
        osVersion: deviceValues.ProductVersion,
        deviceType: 'device',
        connectionType: device.Properties.ConnectionType,
        udid: device.Properties.SerialNumber,
      };
    })
  );
}

/** Install and run an Apple app binary on a connected Apple device. */
export async function runOnDevice({
  udid,
  appPath,
  bundleId,
  waitForApp,
  deltaPath,
  onProgress,
}: {
  /** Apple device UDID */
  udid: string;
  /** File path to the app binary (ipa) */
  appPath: string;
  /** Bundle identifier for the app at `appPath` */
  bundleId: string;
  /** Wait for the app to launch before returning */
  waitForApp: boolean;
  /** File path to the app deltas folder to use for faster subsequent installs */
  deltaPath: string;
  /** Callback to be called with progress updates */
  onProgress: OnInstallProgressCallback;
}) {
  const clientManager = await ClientManager.create(udid);

  try {
    await mountDeveloperDiskImage(clientManager);

    const packageName = path.basename(appPath);
    const destPackagePath = path.join('PublicStaging', packageName);

    await uploadApp(clientManager, { appBinaryPath: appPath, destinationPath: destPackagePath });

    const installer = await clientManager.getInstallationProxyClient();
    await installer.installApp(
      destPackagePath,
      bundleId,
      {
        // https://github.com/ios-control/ios-deploy/blob/0f2ffb1e564aa67a2dfca7cdf13de47ce489d835/src/ios-deploy/ios-deploy.m#L2491-L2508
        ApplicationsType: 'Any',

        CFBundleIdentifier: bundleId,
        CloseOnInvalidate: '1',
        InvalidateOnDetach: '1',
        IsUserInitiated: '1',
        // Disable checking for wifi devices, this is nominally faster.
        PreferWifi: '0',
        // Only info I could find on these:
        // https://github.com/wwxxyx/Quectel_BG96/blob/310876f90fc1093a59e45e381160eddcc31697d0/Apple_Homekit/homekit_certification_tools/ATS%206/ATS%206/ATS.app/Contents/Frameworks/CaptureKit.framework/Versions/A/Resources/MobileDevice/MobileInstallation.h#L112-L121
        PackageType: 'Developer',
        ShadowParentKey: deltaPath,
        // SkipUninstall: '1'
      },
      onProgress
    );

    const {
      // TODO(EvanBacon): This can be undefined when querying App Clips.
      [bundleId]: appInfo,
    } = await installer.lookupApp([bundleId]);

    if (appInfo) {
      // launch fails with EBusy or ENotFound if you try to launch immediately after install
      await delayAsync(200);
      const debugServerClient = await launchApp(clientManager, { appInfo, detach: !waitForApp });
      if (waitForApp) {
        installExitHooks(async () => {
          // causes continue() to return
          debugServerClient.halt();
          // give continue() time to return response
          await delayAsync(64);
        });

        debug(`Waiting for app to close...\n`);
        const result = await debugServerClient.continue();
        // TODO: I have no idea what this packet means yet (successful close?)
        // if not a close (ie, most likely due to halt from onBeforeExit), then kill the app
        if (result !== 'W00') {
          await debugServerClient.kill();
        }
      }
    } else {
      Log.warn(`App "${bundleId}" installed but couldn't be launched. Open on device manually.`);
    }
  } finally {
    clientManager.end();
  }
}

/** Mount the developer disk image for Xcode. */
async function mountDeveloperDiskImage(clientManager: ClientManager) {
  const imageMounter = await clientManager.getMobileImageMounterClient();
  // Check if already mounted. If not, mount.
  if (!(await imageMounter.lookupImage()).ImageSignature) {
    // verify DeveloperDiskImage exists (TODO: how does this work on Windows/Linux?)
    // TODO: if windows/linux, download?
    const version = await (await clientManager.getLockdowndClient()).getValue('ProductVersion');
    const developerDiskImagePath = await XcodeDeveloperDiskImagePrerequisite.instance.assertAsync({
      version,
    });
    const developerDiskImageSig = fs.readFileSync(`${developerDiskImagePath}.signature`);
    await imageMounter.uploadImage(developerDiskImagePath, developerDiskImageSig);
    await imageMounter.mountImage(developerDiskImagePath, developerDiskImageSig);
  }
}

async function uploadApp(
  clientManager: ClientManager,
  { appBinaryPath, destinationPath }: { appBinaryPath: string; destinationPath: string }
) {
  const afcClient = await clientManager.getAFCClient();
  try {
    await afcClient.getFileInfo('PublicStaging');
  } catch (err: any) {
    if (err instanceof AFCError && err.status === AFC_STATUS.OBJECT_NOT_FOUND) {
      await afcClient.makeDirectory('PublicStaging');
    } else {
      throw err;
    }
  }
  await afcClient.uploadDirectory(appBinaryPath, destinationPath);
}

async function launchApp(
  clientManager: ClientManager,
  { appInfo, detach }: { appInfo: IPLookupResult[string]; detach?: boolean }
) {
  let tries = 0;
  while (tries < 3) {
    const debugServerClient = await clientManager.getDebugserverClient();
    await debugServerClient.setMaxPacketSize(1024);
    await debugServerClient.setWorkingDir(appInfo.Container);
    await debugServerClient.launchApp(appInfo.Path, appInfo.CFBundleExecutable);

    const result = await debugServerClient.checkLaunchSuccess();
    if (result === 'OK') {
      if (detach) {
        // https://github.com/libimobiledevice/libimobiledevice/blob/25059d4c7d75e03aab516af2929d7c6e6d4c17de/tools/idevicedebug.c#L455-L464
        const res = await debugServerClient.sendCommand('D', []);
        debug('Disconnect from debug server request:', res);
        if (res !== 'OK') {
          console.warn(
            'Something went wrong while attempting to disconnect from iOS debug server, you may need to reopen the app manually.'
          );
        }
      }

      return debugServerClient;
    } else if (result === 'EBusy' || result === 'ENotFound') {
      debug('Device busy or app not found, trying to launch again in .5s...');
      tries++;
      debugServerClient.socket.end();
      await delayAsync(500);
    } else {
      throw new CommandError(`There was an error launching app: ${result}`);
    }
  }
  throw new CommandError('Unable to launch app, number of tries exceeded');
}
