import {JWProxy, errors} from 'appium/driver.js';
import {sleep, waitForCondition} from 'asyncbox';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import {fs, node, util, timing} from 'appium/support.js';
import {getPackageInfoSync, getPackageInfo, isPlainObject} from '../../utils/index.js';
import {ServerBuilder, buildServerSigningConfig, type ServerSigningConfig} from './builder.js';
import {
  ESPRESSO_SERVER_LAUNCH_TIMEOUT_MS,
  TARGET_PACKAGE_CONTAINER,
  TEST_APK_PKG,
} from './constants.js';
import axios from 'axios';
import * as semver from 'semver';
import type {
  AppiumLogger,
  StringRecord,
  HTTPMethod,
  HTTPBody,
  ProxyResponse,
  ProxyOptions,
} from '@appium/types';
import type {ADB} from 'appium-adb';
import type {SubProcess} from 'teen_process';

export interface EspressoRunnerOptions {
  adb: ADB;
  tmpDir: string;
  host: string;
  systemPort: number;
  devicePort: number;
  appPackage: string;
  forceEspressoRebuild: boolean;
  reqBasePath?: string;
  showGradleLog?: boolean;
  espressoBuildConfig?: string;
  serverLaunchTimeout?: number;
  androidInstallTimeout?: number;
  disableSuppressAccessibilityService?: boolean;
  useKeystore?: boolean;
  keystorePath?: string;
  keystorePassword?: string;
  keyAlias?: string;
  keyPassword?: string;
}

interface InstrumentationState {
  crashed: boolean;
  exited: boolean;
}

interface ServerStatus {
  build: {
    version: string;
    packageName?: string;
  };
}

interface SessionInfo {
  id: string;
}

interface SessionsResponse {
  value: SessionInfo[];
}

class EspressoProxy extends JWProxy {
  instrumentationState!: InstrumentationState;

  override async proxyCommand(
    url: string,
    method: HTTPMethod,
    body: HTTPBody = null,
  ): Promise<[ProxyResponse, HTTPBody]> {
    const {crashed, exited} = this.instrumentationState;
    if (exited) {
      throw new errors.InvalidContextError(
        `'${method} ${url}' cannot be proxied to Espresso server because ` +
          `the instrumentation process has ${crashed ? 'crashed' : 'been unexpectedly terminated'}. ` +
          `Check the Appium server log and the logcat output for more details`,
      );
    }
    return await super.proxyCommand(url, method, body);
  }
}

export class EspressoRunner {
  public readonly host: string;
  public readonly systemPort: number;
  public readonly appPackage: string;
  public readonly adb: ADB;
  public readonly tmpDir: string;
  public readonly forceEspressoRebuild: boolean;
  public readonly devicePort: number;
  public readonly jwproxy: EspressoProxy;
  public readonly proxyReqRes: typeof EspressoProxy.prototype.proxyReqRes;
  public readonly proxyCommand: typeof EspressoProxy.prototype.command;
  public readonly modServerPath: string;
  public readonly showGradleLog?: boolean;
  public readonly espressoBuildConfig?: string;
  public readonly serverLaunchTimeout: number;
  public readonly androidInstallTimeout?: number;
  public readonly disableSuppressAccessibilityService?: boolean;
  public readonly signingConfig: ServerSigningConfig | null;
  public instProcess: SubProcess | null = null;
  private readonly log: AppiumLogger;

  constructor(log: AppiumLogger, opts: EspressoRunnerOptions) {
    this.adb = requireOption(opts, 'adb');
    this.tmpDir = requireOption(opts, 'tmpDir');
    this.host = requireOption(opts, 'host');
    this.systemPort = requireOption(opts, 'systemPort');
    this.devicePort = requireOption(opts, 'devicePort');
    this.appPackage = requireOption(opts, 'appPackage');
    this.forceEspressoRebuild = requireOption(opts, 'forceEspressoRebuild');
    this.log = log;
    const proxyOpts: ProxyOptions = {
      log,
      server: this.host,
      port: this.systemPort,
      keepAlive: true,
    };
    if (opts.reqBasePath) {
      proxyOpts.reqBasePath = opts.reqBasePath;
    }
    this.jwproxy = new EspressoProxy(proxyOpts);
    this.proxyReqRes = this.jwproxy.proxyReqRes.bind(this.jwproxy);
    this.proxyCommand = this.jwproxy.command.bind(this.jwproxy);
    this.jwproxy.instrumentationState = {
      exited: false,
      crashed: false,
    };

    const {manifestPayload} = getPackageInfoSync();
    const modServerName = fs.sanitizeName(
      `${TEST_APK_PKG}_${manifestPayload.version}_${this.appPackage}_${this.adb.curDeviceId}.apk`,
      {replacement: '-'},
    );
    this.modServerPath = path.resolve(this.tmpDir, modServerName);
    this.showGradleLog = opts.showGradleLog;
    this.espressoBuildConfig = opts.espressoBuildConfig;

    this.serverLaunchTimeout = opts.serverLaunchTimeout || ESPRESSO_SERVER_LAUNCH_TIMEOUT_MS;
    this.androidInstallTimeout = opts.androidInstallTimeout;

    this.disableSuppressAccessibilityService = opts.disableSuppressAccessibilityService;

    // Espresso Server app needs to be signed with same keyStore as appPackage
    if (
      opts.useKeystore &&
      opts.keystorePath &&
      opts.keystorePassword &&
      opts.keyAlias &&
      opts.keyPassword
    ) {
      this.signingConfig = buildServerSigningConfig({
        keystoreFile: opts.keystorePath,
        keystorePassword: opts.keystorePassword,
        keyAlias: opts.keyAlias,
        keyPassword: opts.keyPassword,
      });
    } else {
      this.signingConfig = null;
    }
  }

  async isAppPackageChanged(): Promise<boolean> {
    if (!(await this.adb.fileExists(TARGET_PACKAGE_CONTAINER))) {
      this.log.debug('The previous target application package is unknown');
      return true;
    }
    const previousAppPackage = (await this.adb.shell(['cat', TARGET_PACKAGE_CONTAINER])).trim();
    this.log.debug(
      `The previous target application package was '${previousAppPackage}'. ` +
        `The current package is '${this.appPackage}'`,
    );
    return previousAppPackage !== this.appPackage;
  }

  /**
   * Installs Espresso server apk on to the device or emulator.
   * Each adb command uses default timeout by them.
   */
  async installServer(): Promise<void> {
    const appState = await this.adb.getApplicationInstallState(this.modServerPath, TEST_APK_PKG);

    const shouldUninstallApp = [
      this.adb.APP_INSTALL_STATE.OLDER_VERSION_INSTALLED,
      this.adb.APP_INSTALL_STATE.NEWER_VERSION_INSTALLED,
    ].includes(appState);
    const shouldInstallApp =
      shouldUninstallApp || [this.adb.APP_INSTALL_STATE.NOT_INSTALLED].includes(appState);

    if (shouldUninstallApp) {
      this.log.info(
        `Uninstalling Espresso Test Server apk from the target device (pkg: '${TEST_APK_PKG}')`,
      );
      try {
        await this.adb.uninstallApk(TEST_APK_PKG);
      } catch (err: any) {
        this.log.warn(`Error uninstalling '${TEST_APK_PKG}': ${err.message}`);
      }
    }

    if (shouldInstallApp) {
      this.log.info(
        `Installing Espresso Test Server apk from the target device (path: '${this.modServerPath}')`,
      );
      try {
        await this.adb.install(this.modServerPath, {
          replace: false,
          timeout: this.androidInstallTimeout,
        });
        this.log.info(
          `Installed Espresso Test Server apk '${this.modServerPath}' (pkg: '${TEST_APK_PKG}')`,
        );
      } catch (err: any) {
        throw this.log.errorWithException(
          `Cannot install '${this.modServerPath}' because of '${err.message}'`,
        );
      }
    }
  }

  async installTestApk(): Promise<void> {
    let rebuild = this.forceEspressoRebuild;
    if (rebuild) {
      this.log.debug(`'forceEspressoRebuild' capability is enabled`);
    } else if (await this.isAppPackageChanged()) {
      this.log.info(`Forcing Espresso server rebuild because of changed application package`);
      rebuild = true;
    }

    if (rebuild && (await fs.exists(this.modServerPath))) {
      this.log.debug(`Deleting the obsolete Espresso server package '${this.modServerPath}'`);
      await fs.unlink(this.modServerPath);
    }
    if (!(await fs.exists(this.modServerPath))) {
      await this.buildNewModServer();
    }
    const wasSigned = await this.adb.checkApkCert(this.modServerPath);
    if (!wasSigned) {
      await this.adb.sign(this.modServerPath);
    }
    if ((rebuild || !wasSigned) && (await this.adb.uninstallApk(TEST_APK_PKG))) {
      this.log.info('Uninstalled the obsolete Espresso server package from the device under test');
    }

    await this.installServer();
  }

  async buildNewModServer(): Promise<void> {
    let buildConfiguration: Record<string, any> = {};
    if (this.espressoBuildConfig) {
      let buildConfigurationStr: string;
      if (await fs.exists(this.espressoBuildConfig)) {
        this.log.info(`Loading the build configuration from '${this.espressoBuildConfig}'`);
        buildConfigurationStr = await fs.readFile(this.espressoBuildConfig, 'utf8');
      } else {
        this.log.info(`Loading the build configuration from 'espressoBuildConfig' capability`);
        buildConfigurationStr = this.espressoBuildConfig;
      }
      try {
        buildConfiguration = JSON.parse(buildConfigurationStr);
      } catch (e: any) {
        this.log.error('Cannot parse the build configuration JSON', e);
        throw e;
      }
    }
    const dirName = fs.sanitizeName(`espresso-server-${this.adb.curDeviceId}`, {
      replacement: '-',
    });
    const serverPath = path.resolve(this.tmpDir, dirName);
    this.log.info(`Building espresso server in '${serverPath}'`);
    this.log.debug(`The build folder root could be customized by changing the 'tmpDir' capability`);
    await fs.rimraf(serverPath);
    await fs.mkdirp(serverPath);
    this.log.debug(
      `Copying espresso server template from ('${getTestServerRoot()}' to '${serverPath}')`,
    );
    await copyGradleProjectRecursively(getTestServerRoot(), serverPath);
    this.log.debug('Bulding espresso server');
    await new ServerBuilder(this.log, {
      serverPath,
      buildConfiguration,
      showGradleLog: this.showGradleLog,
      testAppPackage: this.appPackage,
      signingConfig: this.signingConfig,
    }).build();
    const apkPath = path.resolve(
      serverPath,
      'app',
      'build',
      'outputs',
      'apk',
      'androidTest',
      'debug',
      'app-debug-androidTest.apk',
    );
    this.log.debug(`Copying built apk from '${apkPath}' to '${this.modServerPath}'`);
    await fs.copyFile(apkPath, this.modServerPath);
  }

  async startSession(caps: StringRecord): Promise<void> {
    await this.cleanupSessionLeftovers();

    const cmd: string[] = [
      'shell',
      'am',
      'instrument',
      '-w',
      '-e',
      'debug',
      String(process.env.ESPRESSO_JAVA_DEBUG === 'true'),
      '-e',
      'disableAnalytics',
      'true', // To avoid unexpected error by google analytics
    ];

    if (typeof this.disableSuppressAccessibilityService === 'boolean') {
      cmd.push(
        '-e',
        'DISABLE_SUPPRESS_ACCESSIBILITY_SERVICES',
        String(this.disableSuppressAccessibilityService),
      );
    }

    cmd.push(`${TEST_APK_PKG}/androidx.test.runner.AndroidJUnitRunner`);

    const {manifestPayload} = await getPackageInfo();
    this.log.info(
      `Starting Espresso Server v${manifestPayload.version} with cmd: adb ${cmd.join(' ')}`,
    );

    let hasSocketError = false;
    // start the instrumentation process
    this.jwproxy.instrumentationState = {
      exited: false,
      crashed: false,
    };
    this.instProcess = this.adb.createSubProcess(cmd);
    this.instProcess.on('exit', (code: number | null, signal: string | null) => {
      this.log.info(`Instrumentation process exited with code ${code} from signal ${signal}`);
      this.jwproxy.instrumentationState.exited = true;
    });
    this.instProcess.on('output', (stdout: string, stderr: string) => {
      const line = stdout || stderr;
      if (!line.trim()) {
        // Do not print empty lines into the system log
        return;
      }

      this.log.debug(`[Instrumentation] ${line.trim()}`);
      // A 'SocketException' indicates that we couldn't connect to the Espresso server,
      // because the INTERNET permission is not set
      if (line.toLowerCase().includes('java.net.socketexception')) {
        hasSocketError = true;
      } else if (line.includes('Process crashed')) {
        this.jwproxy.instrumentationState.crashed = true;
      }
    });

    const timer = new timing.Timer().start();
    await this.instProcess.start(0);
    this.log.info(`Waiting up to ${this.serverLaunchTimeout}ms for Espresso server to be online`);
    try {
      await waitForCondition(
        async () => {
          if (hasSocketError) {
            throw this.log.errorWithException(
              `Espresso server has failed to start due to an unexpected exception. ` +
                `Make sure the 'INTERNET' permission is requested in the Android manifest of your ` +
                `application under test (<uses-permission android:name="android.permission.INTERNET" />)`,
            );
          } else if (this.jwproxy.instrumentationState.exited) {
            throw this.log.errorWithException(
              `Espresso server process has been unexpectedly terminated. ` +
                `Check the Appium server log and the logcat output for more details`,
            );
          }
          let serverStatus: ServerStatus;
          try {
            serverStatus = (await this.jwproxy.command('/status', 'GET')) as ServerStatus;
          } catch {
            return false;
          }
          return await this._verifyServerStatus(manifestPayload.version, serverStatus);
        },
        {
          waitMs: this.serverLaunchTimeout,
          intervalMs: 500,
        },
      );
    } catch (e: any) {
      if (/Condition unmet/.test(e.message)) {
        throw this.log.errorWithException(
          `Timed out waiting for Espresso server to be ` +
            `online within ${this.serverLaunchTimeout}ms. The timeout value could be ` +
            `customized using 'espressoServerLaunchTimeout' capability`,
        );
      }
      throw e;
    }
    this.log.info(
      `Espresso server is online. ` +
        `The initialization process took ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`,
    );
    this.log.info('Starting the session');

    await this.jwproxy.command('/session', 'POST', {
      capabilities: {
        firstMatch: [caps],
        alwaysMatch: {},
      },
    });
    await this.recordTargetAppPackage();
  }

  async deleteSession(): Promise<void> {
    this.log.debug('Deleting Espresso server session');
    // rely on jwproxy's intelligence to know what we're talking about and
    // delete the current session
    try {
      await this.jwproxy.command('/', 'DELETE');
    } catch (err: any) {
      this.log.warn(
        `Did not get confirmation Espresso deleteSession worked; ` + `Error was: ${err}`,
      );
    }

    if (this.instProcess?.isRunning) {
      await this.instProcess.stop();
    }
  }

  private async cleanupSessionLeftovers(): Promise<void> {
    this.log.debug('Performing cleanup of automation leftovers');

    try {
      const {value} = (
        await axios({
          url: `http://${this.host}:${this.systemPort}/sessions`,
          timeout: 500,
        })
      ).data as SessionsResponse;
      const activeSessionIds = value.map((sess) => sess.id);
      if (activeSessionIds.length) {
        this.log.debug(
          `The following obsolete sessions are still running: ${JSON.stringify(activeSessionIds)}`,
        );
        this.log.debug('Cleaning up the obsolete sessions');
        await Promise.all(
          activeSessionIds.map((id) =>
            axios({
              url: `http://${this.host}:${this.systemPort}/session/${id}`,
              method: 'DELETE',
            }),
          ),
        );
        // Let all sessions to be properly terminated before continuing
        await sleep(1000);
      } else {
        this.log.debug('No obsolete sessions have been detected');
      }
    } catch (e: any) {
      this.log.debug(`No obsolete sessions have been detected (${e.message})`);
    }
  }

  private async recordTargetAppPackage(): Promise<void> {
    await this.adb.shell([`echo "${this.appPackage}" > "${TARGET_PACKAGE_CONTAINER}"`]);
    this.log.info(
      `Recorded the target application package '${this.appPackage}' to ${TARGET_PACKAGE_CONTAINER}`,
    );
  }

  private async _verifyServerStatus(
    driverVersion: string,
    serverStatus: ServerStatus,
  ): Promise<boolean> {
    if (!isPlainObject(serverStatus) || !isPlainObject(serverStatus.build)) {
      throw this.log.errorWithException(
        `The Espresso server version integrated with the application under test is not compatible ` +
          `with the current driver version '${driverVersion}'.`,
      );
    }
    const {
      build: {version: serverVersion, packageName: serverPackageName},
    } = serverStatus;
    const appLabel = serverPackageName
      ? `'${serverPackageName}' application`
      : 'application under test';
    const parsedServerVersion = semver.coerce(serverVersion);
    const parsedDriverVersion = semver.coerce(driverVersion);
    if (parsedServerVersion && parsedDriverVersion) {
      if (parsedServerVersion.major !== parsedDriverVersion.major) {
        throw this.log.errorWithException(
          `The Espresso server version '${serverVersion}' integrated with the ${appLabel} is not compatible ` +
            `with the current driver version '${driverVersion}'.`,
        );
      } else if (parsedServerVersion.minor < parsedDriverVersion.minor) {
        this.log.warn(
          `The Espresso server version integrated with the ${appLabel} might not be compatible ` +
            `with the current driver version (${serverVersion} < ${driverVersion})'.`,
        );
      }
    } else {
      const warnMessage = parsedServerVersion
        ? `The Espresso driver version '${driverVersion}' ` +
          `cannot be parsed. It might be incompatible with the current server '${serverVersion}' ` +
          `integrated with the ${appLabel}.`
        : `The Espresso server version '${serverVersion}' integrated with the ${appLabel} ` +
          `cannot be parsed. It might be incompatible with the current driver ` +
          `version '${driverVersion}'.`;
      this.log.warn(warnMessage);
    }
    if (this.appPackage && serverPackageName && this.appPackage !== serverPackageName) {
      throw this.log.errorWithException(
        `The Espresso server that is listening on the device under tests is built for a different ` +
          `application package (${serverPackageName} !== ${this.appPackage}).`,
      );
    }
    return true;
  }
}

function requireOption<K extends keyof EspressoRunnerOptions>(
  opts: EspressoRunnerOptions,
  key: K,
): NonNullable<EspressoRunnerOptions[K]> {
  if (!util.hasValue(opts[key])) {
    throw new Error(`Option '${key}' is required!`);
  }
  return opts[key] as NonNullable<EspressoRunnerOptions[K]>;
}

const MODULE_NAME = 'appium-espresso-driver';
const FILENAME = fileURLToPath(import.meta.url);

let testServerRoot: string | undefined;

/**
 * Recursively copy all files except build directories contents
 * @param sourceBaseDir directory to copy files from
 * @param targetBaseDir directory to copy files to
 */
export async function copyGradleProjectRecursively(
  sourceBaseDir: string,
  targetBaseDir: string,
): Promise<void> {
  // @ts-ignore it is ok to have the async callback
  await fs.walkDir(sourceBaseDir, true, async (itemPath: string, isDirectory: boolean) => {
    const relativePath = path.relative(sourceBaseDir, itemPath);
    const targetPath = path.resolve(targetBaseDir, relativePath);

    const isInGradleBuildDir = `${path.sep}${itemPath}`.includes(`${path.sep}build${path.sep}`);
    if (isInGradleBuildDir) {
      return false;
    }

    if (isDirectory) {
      await fs.mkdirp(targetPath);
    } else {
      await fs.copyFile(itemPath, targetPath);
    }
    return false;
  });
}

/** Root path of the bundled Espresso server Gradle project. */
function getTestServerRoot(): string {
  if (testServerRoot === undefined) {
    const moduleRoot = node.getModuleRootSync(MODULE_NAME, FILENAME);
    if (!moduleRoot) {
      throw new Error(`Cannot find the root folder of the ${MODULE_NAME} Node.js module`);
    }
    testServerRoot = path.join(moduleRoot, 'espresso-server');
  }
  return testServerRoot;
}
