import type {
  DefaultCreateSessionResult,
  DriverData,
  ExternalDriver,
  InitialOpts,
  RouteMatcher,
  StringRecord,
  SingularSessionData,
  SessionCapabilities,
} from '@appium/types';
import type {EspressoConstraints} from './constraints.js';
import {errors, isErrorType, DeviceSettings, BaseDriver} from 'appium/driver.js';
import * as serverCmds from './commands/server/index.js';
import type {EspressoRunner} from './commands/server/index.js';
import * as appManagementCmds from './commands/app-management.js';
import * as contextCmds from './commands/context.js';
import * as elementCmds from './commands/element.js';
import * as miscCmds from './commands/misc.js';
import * as servicesCmds from './commands/services.js';
import * as screenshotCmds from './commands/screenshot.js';
import * as idlingResourcesCmds from './commands/idling-resources.js';
import * as actionsCmds from './commands/actions.js';
import * as clipboardCmds from './commands/clipboard.js';
import * as appInstallCmds from './commands/app-install.js';
import {DEFAULT_ADB_PORT} from 'appium-adb';
import {AndroidDriver} from 'appium-android-driver';
import {ESPRESSO_CONSTRAINTS} from './constraints.js';
import {findAPortNotInUse} from 'portscanner';
import {retryInterval} from 'asyncbox';
import {isEmptyValue} from './utils/index.js';
import {newMethodMap} from './method-map.js';
import type {EspressoDriverCaps, EspressoDriverOpts, W3CEspressoDriverCaps} from './types.js';
import {executeMethodMap} from './execute-method-map.js';

// NO_PROXY contains the paths that we never want to proxy to espresso server.
// TODO:  Add the list of paths that we never want to proxy to espresso server.
// TODO: Need to segregate the paths better way using regular expressions wherever applicable.
// (Not segregating right away because more paths to be added in the NO_PROXY list)
const NO_PROXY: RouteMatcher[] = [
  ['GET', new RegExp('^/session/(?!.*/)')],
  ['GET', new RegExp('^/session/[^/]+/appium/capabilities')],
  ['GET', new RegExp('^/session/[^/]+/appium/commands')],
  ['GET', new RegExp('^/session/[^/]+/appium/device/current_activity')],
  ['GET', new RegExp('^/session/[^/]+/appium/device/current_package')],
  ['GET', new RegExp('^/session/[^/]+/appium/device/display_density')],
  ['GET', new RegExp('^/session/[^/]+/appium/device/is_keyboard_shown')],
  ['GET', new RegExp('^/session/[^/]+/appium/device/system_bars')],
  ['GET', new RegExp('^/session/[^/]+/appium/device/system_time')],
  ['GET', new RegExp('^/session/[^/]+/appium/extensions')],
  ['GET', new RegExp('^/session/[^/]+/appium/settings')],
  ['GET', new RegExp('^/session/[^/]+/context')],
  ['GET', new RegExp('^/session/[^/]+/contexts')],
  ['GET', new RegExp('^/session/[^/]+/ime/[^/]+')],
  ['GET', new RegExp('^/session/[^/]+/network_connection')],
  ['GET', new RegExp('^/session/[^/]+/timeouts')],
  ['GET', new RegExp('^/session/[^/]+/url')],
  ['POST', new RegExp('^/session/[^/]+/appium/app/background')],
  ['POST', new RegExp('^/session/[^/]+/appium/app/close')],
  ['POST', new RegExp('^/session/[^/]+/appium/app/launch')],
  ['POST', new RegExp('^/session/[^/]+/appium/app/reset')],
  ['POST', new RegExp('^/session/[^/]+/appium/app/strings')],
  ['POST', new RegExp('^/session/[^/]+/appium/compare_images')],
  ['POST', new RegExp('^/session/[^/]+/appium/device/activate_app')],
  ['POST', new RegExp('^/session/[^/]+/appium/device/app_installed')],
  ['POST', new RegExp('^/session/[^/]+/appium/device/app_state')],
  ['POST', new RegExp('^/session/[^/]+/appium/device/finger_print')],
  ['POST', new RegExp('^/session/[^/]+/appium/device/get_clipboard')],
  ['POST', new RegExp('^/session/[^/]+/appium/device/install_app')],
  ['POST', new RegExp('^/session/[^/]+/appium/device/is_locked')],
  ['POST', new RegExp('^/session/[^/]+/appium/device/lock')],
  ['POST', new RegExp('^/session/[^/]+/appium/device/pull_file')],
  ['POST', new RegExp('^/session/[^/]+/appium/device/pull_folder')],
  ['POST', new RegExp('^/session/[^/]+/appium/device/push_file')],
  ['POST', new RegExp('^/session/[^/]+/appium/device/remove_app')],
  ['POST', new RegExp('^/session/[^/]+/appium/device/start_activity')],
  ['POST', new RegExp('^/session/[^/]+/appium/device/terminate_app')],
  ['POST', new RegExp('^/session/[^/]+/appium/device/unlock')],
  ['POST', new RegExp('^/session/[^/]+/appium/getPerformanceData')],
  ['POST', new RegExp('^/session/[^/]+/appium/performanceData/types')],
  ['POST', new RegExp('^/session/[^/]+/appium/settings')],
  ['POST', new RegExp('^/session/[^/]+/appium/execute_driver')],
  ['POST', new RegExp('^/session/[^/]+/appium/start_recording_screen')],
  ['POST', new RegExp('^/session/[^/]+/appium/stop_recording_screen')],
  ['POST', new RegExp('^/session/[^/]+/context')],
  ['POST', new RegExp('^/session/[^/]+/execute')],
  ['POST', new RegExp('^/session/[^/]+/execute/async')],
  ['POST', new RegExp('^/session/[^/]+/execute/sync')],
  ['POST', new RegExp('^/session/[^/]+/execute_async')],
  ['POST', new RegExp('^/session/[^/]+/ime/[^/]+')],
  ['POST', new RegExp('^/session/[^/]+/location')],
  ['POST', new RegExp('^/session/[^/]+/network_connection')],
  ['POST', new RegExp('^/session/[^/]+/timeouts')],
  ['POST', new RegExp('^/session/[^/]+/url')],

  // MJSONWP commands
  ['GET', new RegExp('^/session/[^/]+/log/types')],
  ['POST', new RegExp('^/session/[^/]+/log')],

  // W3C commands
  // For Selenium v4 (W3C does not have this route)
  ['GET', new RegExp('^/session/[^/]+/se/log/types')],
  // For Selenium v4 (W3C does not have this route)
  ['POST', new RegExp('^/session/[^/]+/se/log')],
];

// This is a set of methods and paths that we never want to proxy to Chromedriver.
const CHROME_NO_PROXY: RouteMatcher[] = [
  ['GET', new RegExp('^/session/[^/]+/appium')],
  ['GET', new RegExp('^/session/[^/]+/context')],
  ['GET', new RegExp('^/session/[^/]+/element/[^/]+/rect')],
  ['GET', new RegExp('^/session/[^/]+/orientation')],
  ['POST', new RegExp('^/session/[^/]+/appium')],
  ['POST', new RegExp('^/session/[^/]+/context')],
  ['POST', new RegExp('^/session/[^/]+/orientation')],
  ['POST', new RegExp('^/session/[^/]+/touch/multi/perform')],
  ['POST', new RegExp('^/session/[^/]+/touch/perform')],

  // this is needed to make the mobile: commands working in web context
  ['POST', new RegExp('^/session/[^/]+/execute$')],
  ['POST', new RegExp('^/session/[^/]+/execute/sync')],

  // MJSONWP commands
  ['GET', new RegExp('^/session/[^/]+/log/types')],
  ['POST', new RegExp('^/session/[^/]+/log')],

  // W3C commands
  // For Selenium v4 (W3C does not have this route)
  ['GET', new RegExp('^/session/[^/]+/se/log/types')],
  // For Selenium v4 (W3C does not have this route)
  ['POST', new RegExp('^/session/[^/]+/se/log')],
];

export class EspressoDriver
  extends AndroidDriver
  implements ExternalDriver<EspressoConstraints, string, StringRecord>
{
  static newMethodMap = newMethodMap;
  static executeMethodMap = executeMethodMap as unknown as typeof AndroidDriver.executeMethodMap;

  _originalIme: string | null;

  espresso!: EspressoRunner;

  wasAnimationEnabled?: boolean;

  override caps: EspressoDriverCaps;

  override opts: EspressoDriverOpts;

  override desiredCapConstraints: EspressoConstraints;

  performActions = actionsCmds.performActions as AndroidDriver['performActions'];

  startActivity = appManagementCmds.startActivity;
  mobileStartActivity =
    appManagementCmds.mobileStartActivity as unknown as AndroidDriver['mobileStartActivity'];

  mobileWebAtoms = contextCmds.mobileWebAtoms;
  suspendChromedriverProxy =
    contextCmds.suspendChromedriverProxy as AndroidDriver['suspendChromedriverProxy'];

  mobilePerformEditorAction =
    elementCmds.mobilePerformEditorAction as AndroidDriver['mobilePerformEditorAction'];
  mobileSwipe = elementCmds.mobileSwipe;
  mobileOpenDrawer = elementCmds.mobileOpenDrawer;
  mobileCloseDrawer = elementCmds.mobileCloseDrawer;
  mobileSetDate = elementCmds.mobileSetDate;
  mobileSetTime = elementCmds.mobileSetTime;
  mobileNavigateTo = elementCmds.mobileNavigateTo;
  mobileScrollToPage = elementCmds.mobileScrollToPage;
  mobileFlashElement = elementCmds.mobileFlashElement;
  mobileClickAction = elementCmds.mobileClickAction;
  mobileDismissAutofill = elementCmds.mobileDismissAutofill;

  mobilePressKey = miscCmds.mobilePressKey;
  mobileGetDeviceInfo = miscCmds.mobileGetDeviceInfo;
  mobileIsToastVisible = miscCmds.mobileIsToastVisible;
  getDisplayDensity = miscCmds.getDisplayDensity as AndroidDriver['getDisplayDensity'];
  mobileBackdoor = miscCmds.mobileBackdoor;
  mobileUiautomator = miscCmds.mobileUiautomator;
  mobileUiautomatorPageSource = miscCmds.mobileUiautomatorPageSource;
  updateSettings = miscCmds.updateSettings;
  getSettings = miscCmds.getSettings;

  getClipboard = clipboardCmds.getClipboard;
  mobileGetClipboard = clipboardCmds.getClipboard;
  mobileSetClipboard = clipboardCmds.mobileSetClipboard;

  mobileStartService =
    servicesCmds.mobileStartService as unknown as AndroidDriver['mobileStartService'];
  mobileStopService =
    servicesCmds.mobileStopService as unknown as AndroidDriver['mobileStopService'];

  getScreenshot = screenshotCmds.getScreenshot;
  mobileScreenshots = screenshotCmds.mobileScreenshots;

  mobileRegisterIdlingResources = idlingResourcesCmds.mobileRegisterIdlingResources;
  mobileUnregisterIdlingResources = idlingResourcesCmds.mobileUnregisterIdlingResources;
  mobileListIdlingResources = idlingResourcesCmds.mobileListIdlingResources;
  mobileWaitForUIThread = idlingResourcesCmds.mobileWaitForUIThread;

  startEspressoSession = serverCmds.startSession;
  initEspressoServer = serverCmds.initServer;

  unzipApp = appInstallCmds.unzipApp;
  onPostConfigureApp = appInstallCmds.onPostConfigureApp;
  initAUT = appInstallCmds.initAUT;

  constructor(opts: InitialOpts = {} as InitialOpts, shouldValidateCaps = true) {
    // `shell` overwrites adb.shell, so remove
    if ('shell' in opts) {
      delete (opts as {shell?: unknown}).shell;
    }

    super(opts, shouldValidateCaps);
    this.locatorStrategies = ['id', 'class name', 'accessibility id'];
    this.desiredCapConstraints = ESPRESSO_CONSTRAINTS;
    this.jwpProxyAvoid = NO_PROXY;
    this._originalIme = null;

    this.caps = {} as EspressoDriverCaps;
    this.opts = opts as EspressoDriverOpts;
    this.settings = new DeviceSettings({}, this.onSettingsUpdate.bind(this));

    this.chromedriver = undefined;
  }

  get driverData() {
    return {};
  }

  get appOnDevice(): boolean {
    return appInstallCmds.isAppOnDevice(this);
  }

  override async getSession(): Promise<SingularSessionData<EspressoConstraints>> {
    return await BaseDriver.prototype.getSession.call(this);
  }

  // needed to make the typechecker happy
  async getAppiumSessionCapabilities(): Promise<SessionCapabilities<EspressoConstraints>> {
    return (await super.getAppiumSessionCapabilities()) as SessionCapabilities<EspressoConstraints>;
  }

  override async createSession(
    w3cCaps1: W3CEspressoDriverCaps,
    w3cCaps2?: W3CEspressoDriverCaps,
    w3cCaps3?: W3CEspressoDriverCaps,
    driverData?: DriverData[],
  ): Promise<any> {
    try {
      const [sessionId, caps] = (await BaseDriver.prototype.createSession.call(
        this,
        w3cCaps1,
        w3cCaps2,
        w3cCaps3,
        driverData,
      )) as DefaultCreateSessionResult<EspressoConstraints>;

      const serverDetails = {
        platform: 'LINUX',
        webStorageEnabled: false,
        takesScreenshot: true,
        javascriptEnabled: true,
        databaseEnabled: false,
        networkConnectionEnabled: true,
        locationContextEnabled: false,
        warnings: {},
        desired: Object.assign({}, this.caps),
      };

      this.caps = Object.assign(serverDetails, this.caps);

      this.curContext = this.defaultContextName();

      const defaultOpts = {
        fullReset: false,
        autoLaunch: true,
        adbPort: DEFAULT_ADB_PORT,
        androidInstallTimeout: 90000,
      };
      this.opts = Object.assign({}, defaultOpts, this.opts);

      if (this.isChromeSession) {
        if (this.opts.app) {
          this.log.warn(`'browserName' capability will be ignored`);
          this.log.warn(
            `Chrome browser cannot be run in Espresso sessions because Espresso automation doesn't ` +
              `have permission to access Chrome`,
          );
        } else {
          throw this.log.errorWithException(
            `Chrome browser sessions cannot be run in Espresso because Espresso ` +
              `automation doesn't have permission to access Chrome`,
          );
        }
      }

      this.opts.systemPort =
        this.opts.systemPort ||
        (await findAPortNotInUse(serverCmds.SYSTEM_PORT_RANGE[0], serverCmds.SYSTEM_PORT_RANGE[1]));
      this.opts.adbPort = this.opts.adbPort || DEFAULT_ADB_PORT;
      // get device udid for this session
      const {udid, emPort} = await this.getDeviceInfoFromCaps();
      this.opts.udid = udid;
      (this.opts as EspressoDriverOpts & {emPort: typeof emPort}).emPort = emPort;
      // now that we know our java version and device info, we can create our
      // ADB instance
      this.adb = await this.createADB();

      if (this.opts.app) {
        // find and copy, or download and unzip an app url or path
        this.opts.app = await this.helpers.configureApp(this.opts.app, {
          onPostProcess: this.onPostConfigureApp.bind(this),
          supportedExtensions: appInstallCmds.SUPPORTED_EXTENSIONS,
        });
      } else if (this.appOnDevice) {
        // the app isn't an actual app file but rather something we want to
        // assume is on the device and just launch via the appPackage
        this.log.info(
          `App file was not listed, instead we're going to run ` +
            `${this.opts.appPackage} directly on the device`,
        );
        if (!(await this.adb.isAppInstalled(this.opts.appPackage as string))) {
          throw this.log.errorWithException(
            `Could not find the package '${this.opts.appPackage}' installed on the device`,
          );
        }
      }

      await this.startEspressoSession();
      return [sessionId, caps];
    } catch (e: unknown) {
      await this.deleteSession();
      const cause = e instanceof Error ? e : new Error(String(e));
      cause.message +=
        `${cause.message.endsWith('.') ? '' : '.'} Check ` +
        'https://github.com/appium/appium-espresso-driver#troubleshooting ' +
        'regarding advanced session startup troubleshooting.';
      if (isErrorType(e, errors.SessionNotCreatedError)) {
        throw e;
      }
      throw new errors.SessionNotCreatedError(cause.message, cause as Error);
    }
  }

  override async deleteSession() {
    this.log.debug('Deleting espresso session');

    const screenRecordingStopTasks = [
      async () => {
        if (!isEmptyValue(this._screenRecordingProperties)) {
          await this.stopRecordingScreen();
        }
      },
      async () => {
        if (await this.mobileIsMediaProjectionRecordingRunning()) {
          await this.mobileStopMediaProjectionRecording();
        }
      },
      async () => {
        if (!isEmptyValue(this._screenStreamingProps)) {
          await this.mobileStopScreenStreaming();
        }
      },
    ];

    await serverCmds.teardown(this);

    if (this.adb) {
      await Promise.all(
        screenRecordingStopTasks.map((task) =>
          (async () => {
            try {
              await task();
            } catch {}
          })(),
        ),
      );
      if (this.wasAnimationEnabled) {
        try {
          await this.settingsApp.setAnimationState(true);
        } catch (err: unknown) {
          const message = err instanceof Error ? err.message : String(err);
          this.log.warn(`Unable to reset animation: ${message}`);
        }
      }
      if (this._originalIme) {
        try {
          await this.adb.setIME(this._originalIme);
        } catch (e: unknown) {
          const message = e instanceof Error ? e.message : String(e);
          this.log.warn(`Cannot restore the original IME: ${message}`);
        }
      }
      if (!this.isChromeSession && this.opts.appPackage && !this.opts.dontStopAppOnReset) {
        await this.adb.forceStop(this.opts.appPackage);
      }
      if (this.opts.fullReset && !this.opts.skipUninstall && !this.appOnDevice) {
        this.log.debug(`FULL_RESET set to 'true', Uninstalling '${this.opts.appPackage}'`);
        await this.adb.uninstallApk(this.opts.appPackage as string);
      }
      if ((await this.adb.getApiLevel()) >= 28) {
        // Android P
        this.log.info('Restoring hidden api policy to the device default configuration');
        await this.adb.setDefaultHiddenApiPolicy(!!this.opts.ignoreHiddenApiPolicyError);
      }
    }
    await super.deleteSession();
    await serverCmds.removePortForward(this);
  }

  /**
   * Turn on or off animation scale.
   * '--no-window-animation' instrument argument for Espresso disables window animations,
   * but it does not bring the animation scale back to the pre-instrument process start state in Espresso
   * unlike Appium UIA2 driver case. We want to disable/enable the animation scale only in an appium espresso session as possible.
   * @param isEnabled
   */
  async setWindowAnimationState(isEnabled: boolean): Promise<void> {
    const isAnimationOn = await this.adb.isAnimationOn();
    const shouldDisableAnimation = !isEnabled && isAnimationOn;
    const shouldEnableAnimation = isEnabled && !isAnimationOn;

    if (shouldDisableAnimation) {
      this.log.debug(
        'Disabling window animation as "disableWindowAnimation" capability is set to true/fallback to default value "true"',
      );
      await this.settingsApp.setAnimationState(false);
      this.wasAnimationEnabled = true;
    } else if (shouldEnableAnimation) {
      this.log.debug(
        'Enabling window animation as "disableWindowAnimation" capability is set to false',
      );
      await this.settingsApp.setAnimationState(true);
      this.wasAnimationEnabled = false;
    } else {
      this.log.debug(`Window animation is already ${isEnabled ? 'enabled' : 'disabled'}`);
    }
  }

  async initWebview(): Promise<void> {
    const viewName = this.defaultWebviewName();
    const timeout = this.opts.autoWebviewTimeout || 2000;
    this.log.info(`Setting webview to context '${viewName}' with timeout ${timeout}ms`);
    await retryInterval(timeout / 500, 500, this.setContext.bind(this), viewName);
  }

  async addDeviceInfoToCaps(): Promise<void> {
    const {apiVersion, platformVersion, manufacturer, model, realDisplaySize, displayDensity} =
      await this.mobileGetDeviceInfo();
    this.caps.deviceApiLevel = parseInt(apiVersion, 10);
    this.caps.platformVersion = platformVersion;
    this.caps.deviceScreenSize = realDisplaySize;
    this.caps.deviceScreenDensity = displayDensity;
    this.caps.deviceModel = model;
    this.caps.deviceManufacturer = manufacturer;
  }

  async onSettingsUpdate() {
    // intentionally do nothing here, since commands.updateSettings proxies
    // settings to the espresso server already
  }

  override proxyActive(sessionId?: string | null) {
    void sessionId;
    // we always have an active proxy to the espresso server
    return true;
  }

  override canProxy(sessionId?: string | null) {
    void sessionId;
    // we can always proxy to the espresso server
    return true;
  }

  override getProxyAvoidList(sessionId?: string | null): RouteMatcher[] {
    void sessionId;
    // we are maintaining two sets of NO_PROXY lists, one for chromedriver(CHROME_NO_PROXY)
    // and one for Espresso(NO_PROXY), based on current context will return related NO_PROXY list
    this.jwpProxyAvoid = this.chromedriver == null ? NO_PROXY : CHROME_NO_PROXY;
    if (this.opts.nativeWebScreenshot) {
      this.jwpProxyAvoid = [
        ...this.jwpProxyAvoid,
        ['GET', new RegExp('^/session/[^/]+/screenshot')],
      ];
    }

    return this.jwpProxyAvoid;
  }
}

export default EspressoDriver;
