import ADBKit from '@devicefarmer/adbkit';

import { isErrorWithCode, UsageError, WebExtError } from '../errors.js';
import { createLogger } from '../util/logger.js';
import packageIdentifiers, {
  defaultApkComponents,
} from '../firefox/package-identifiers.js';

export const DEVICE_DIR_BASE = '/data/local/tmp/';
export const ARTIFACTS_DIR_PREFIX = 'web-ext-artifacts-';

const defaultADB = ADBKit.default;

const log = createLogger(import.meta.url);

// Helper function used to raise an UsageError when the adb binary has not been found.
async function wrapADBCall(asyncFn) {
  try {
    return await asyncFn();
  } catch (error) {
    if (
      isErrorWithCode('ENOENT', error) &&
      error.message.includes('spawn adb')
    ) {
      throw new UsageError(
        'No adb executable has been found. ' +
          'You can Use --adb-bin, --adb-host/--adb-port ' +
          'to configure it manually if needed.',
      );
    }

    throw error;
  }
}

export default class ADBUtils {
  params;
  adb;
  adbClient;

  // Map<deviceId -> artifactsDir>
  artifactsDirMap;
  // Toggled when the user wants to abort the RDP Unix Socket discovery loop
  // while it is still executing.
  userAbortDiscovery;

  constructor(params) {
    this.params = params;

    const { adb, adbBin, adbHost, adbPort } = params;

    this.adb = adb || defaultADB;

    this.adbClient = this.adb.createClient({
      bin: adbBin,
      host: adbHost,
      port: adbPort,
    });

    this.artifactsDirMap = new Map();

    this.userAbortDiscovery = false;
  }

  async runShellCommand(deviceId, cmd) {
    const { adb, adbClient } = this;

    log.debug(`Run adb shell command on ${deviceId}: ${JSON.stringify(cmd)}`);

    return wrapADBCall(async () => {
      return await adbClient
        .getDevice(deviceId)
        .shell(cmd)
        .then(adb.util.readAll);
    }).then((res) => res.toString());
  }

  async discoverDevices() {
    const { adbClient } = this;

    let devices = [];

    log.debug('Listing android devices');
    devices = await wrapADBCall(async () => adbClient.listDevices());

    return devices.map((dev) => dev.id);
  }

  async getCurrentUser(deviceId) {
    log.debug(`Retrieving  current user on ${deviceId}`);

    const currentUser = await this.runShellCommand(deviceId, [
      'am',
      'get-current-user',
    ]);

    const userId = parseInt(currentUser.trim());
    if (isNaN(userId)) {
      throw new WebExtError(`Unable to retrieve current user on ${deviceId}`);
    }
    return userId;
  }

  async discoverInstalledFirefoxAPKs(deviceId, firefoxApk) {
    const userId = await this.getCurrentUser(deviceId);

    log.debug(`Listing installed Firefox APKs on ${deviceId}`);
    const pmList = await this.runShellCommand(deviceId, [
      'pm',
      'list',
      'packages',
      '--user',
      `${userId}`,
    ]);

    return pmList
      .split('\n')
      .map((line) => line.replace('package:', '').trim())
      .filter((line) => {
        // Look for an exact match if firefoxApk is defined.
        if (firefoxApk) {
          return line === firefoxApk;
        }
        // Match any package name that starts with the package name of a Firefox for Android browser.
        for (const browser of packageIdentifiers) {
          if (line.startsWith(browser)) {
            return true;
          }
        }

        return false;
      });
  }

  async amForceStopAPK(deviceId, apk) {
    await this.runShellCommand(deviceId, ['am', 'force-stop', apk]);
  }

  async getOrCreateArtifactsDir(deviceId) {
    let artifactsDir = this.artifactsDirMap.get(deviceId);

    if (artifactsDir) {
      return artifactsDir;
    }

    artifactsDir = `${DEVICE_DIR_BASE}${ARTIFACTS_DIR_PREFIX}${Date.now()}`;

    const testDirOut = (
      await this.runShellCommand(deviceId, `test -d ${artifactsDir} ; echo $?`)
    ).trim();

    if (testDirOut !== '1') {
      throw new WebExtError(
        `Cannot create artifacts directory ${artifactsDir} ` +
          `because it exists on ${deviceId}.`,
      );
    }

    await this.runShellCommand(deviceId, ['mkdir', '-p', artifactsDir]);

    this.artifactsDirMap.set(deviceId, artifactsDir);

    return artifactsDir;
  }

  async detectOrRemoveOldArtifacts(deviceId, removeArtifactDirs = false) {
    const { adbClient } = this;

    log.debug('Checking adb device for existing web-ext artifacts dirs');

    return wrapADBCall(async () => {
      const files = await adbClient
        .getDevice(deviceId)
        .readdir(DEVICE_DIR_BASE);
      let found = false;

      for (const file of files) {
        if (
          !file.isDirectory() ||
          !file.name.startsWith(ARTIFACTS_DIR_PREFIX)
        ) {
          continue;
        }

        // Return earlier if we only need to warn the user that some
        // existing artifacts dirs have been found on the adb device.
        if (!removeArtifactDirs) {
          return true;
        }

        found = true;

        const artifactsDir = `${DEVICE_DIR_BASE}${file.name}`;

        log.debug(
          `Removing artifacts directory ${artifactsDir} from device ${deviceId}`,
        );

        await this.runShellCommand(deviceId, ['rm', '-rf', artifactsDir]);
      }

      return found;
    });
  }

  async clearArtifactsDir(deviceId) {
    const artifactsDir = this.artifactsDirMap.get(deviceId);

    if (!artifactsDir) {
      // nothing to do here.
      return;
    }

    this.artifactsDirMap.delete(deviceId);

    log.debug(
      `Removing ${artifactsDir} artifacts directory on ${deviceId} device`,
    );

    await this.runShellCommand(deviceId, ['rm', '-rf', artifactsDir]);
  }

  async pushFile(deviceId, localPath, devicePath) {
    const { adbClient } = this;

    log.debug(`Pushing ${localPath} to ${devicePath} on ${deviceId}`);

    await wrapADBCall(async () => {
      await adbClient
        .getDevice(deviceId)
        .push(localPath, devicePath)
        .then(function (transfer) {
          return new Promise((resolve) => {
            transfer.on('end', resolve);
          });
        });
    });
  }

  async startFirefoxAPK(deviceId, apk, apkComponent, deviceProfileDir) {
    const { adbClient } = this;

    log.debug(`Starting ${apk} on ${deviceId}`);

    // Fenix does ignore the -profile parameter, on the contrary Fennec
    // would run using the given path as the profile to be used during
    // this execution.
    const extras = [
      {
        key: 'args',
        value: `-profile ${deviceProfileDir}`,
      },
    ];

    if (!apkComponent) {
      apkComponent = '.App';
      if (defaultApkComponents[apk]) {
        apkComponent = defaultApkComponents[apk];
      }
    } else if (!apkComponent.includes('.')) {
      apkComponent = `.${apkComponent}`;
    }

    // If `apk` is a browser package or the `apk` has a browser package prefix:
    // prepend the package identifier before `apkComponent`.
    if (apkComponent.startsWith('.')) {
      for (const browser of packageIdentifiers) {
        if (apk === browser || apk.startsWith(`${browser}.`)) {
          apkComponent = browser + apkComponent;
          break;
        }
      }
    }

    // If `apkComponent` starts with a '.', then adb will expand the following
    // to: `${apk}/${apk}.${apkComponent}`
    let component = `${apk}`;
    if (apkComponent) {
      component += `/${apkComponent}`;
    }

    await wrapADBCall(async () => {
      try {
        // TODO: once Fenix (release) uses Android 13, we can get rid of this
        // call and only use the second call in the `catch` block.
        await adbClient.getDevice(deviceId).startActivity({
          wait: true,
          action: 'android.activity.MAIN',
          component,
          extras,
        });
      } catch {
        // Android 13+ requires a different action/category but we still need
        // to support older Fenix builds.
        await adbClient.getDevice(deviceId).startActivity({
          wait: true,
          action: 'android.intent.action.MAIN',
          category: 'android.intent.category.LAUNCHER',
          component,
          extras,
        });
      }
    });
  }

  setUserAbortDiscovery(value) {
    this.userAbortDiscovery = value;
  }

  async discoverRDPUnixSocket(
    deviceId,
    apk,
    { maxDiscoveryTime, retryInterval } = {},
  ) {
    let rdpUnixSockets = [];

    const discoveryStartedAt = Date.now();
    const msg =
      `Waiting for ${apk} Remote Debugging Server...` +
      '\nMake sure to enable "Remote Debugging via USB" ' +
      'from Settings -> Developer Tools if it is not yet enabled.';

    while (rdpUnixSockets.length === 0) {
      log.info(msg);
      if (this.userAbortDiscovery) {
        throw new UsageError(
          'Exiting Firefox Remote Debugging socket discovery on user request',
        );
      }

      if (Date.now() - discoveryStartedAt > maxDiscoveryTime) {
        throw new WebExtError(
          'Timeout while waiting for the Android Firefox Debugger Socket',
        );
      }

      rdpUnixSockets = (
        await this.runShellCommand(deviceId, ['cat', '/proc/net/unix'])
      )
        .split('\n')
        .filter((line) => {
          // The RDP unix socket is expected to be a path in the form:
          //   /data/data/org.mozilla.fennec_rpl/firefox-debugger-socket
          return line.trim().endsWith(`${apk}/firefox-debugger-socket`);
        });

      if (rdpUnixSockets.length === 0) {
        await new Promise((resolve) => setTimeout(resolve, retryInterval));
      }
    }

    // Convert into an array of unix socket filenames.
    rdpUnixSockets = rdpUnixSockets.map((line) => {
      return line.trim().split(/\s/).pop();
    });

    if (rdpUnixSockets.length > 1) {
      throw new WebExtError(
        'Unexpected multiple RDP sockets: ' +
          `${JSON.stringify(rdpUnixSockets)}`,
      );
    }

    return rdpUnixSockets[0];
  }

  async setupForward(deviceId, remote, local) {
    const { adbClient } = this;

    // TODO(rpl): we should use adb.listForwards and reuse the existing one if any (especially
    // because adbkit doesn't seem to support `adb forward --remote` yet).
    log.debug(`Configuring ADB forward for ${deviceId}: ${remote} -> ${local}`);

    await wrapADBCall(async () => {
      await adbClient.getDevice(deviceId).forward(local, remote);
    });
  }
}

export async function listADBDevices(adbBin) {
  const adbUtils = new ADBUtils({ adbBin });
  return adbUtils.discoverDevices();
}

export async function listADBFirefoxAPKs(deviceId, adbBin) {
  const adbUtils = new ADBUtils({ adbBin });
  return adbUtils.discoverInstalledFirefoxAPKs(deviceId);
}
