import {util} from '@appium/support';
import {sleep, waitForCondition} from 'asyncbox';
import type {ADB} from 'appium-adb';
import type {Position, StringRecord} from '@appium/types';
import type {AndroidDriver, AndroidDriverCaps} from '../../driver';
import type {UnlockType, FastUnlockOptions} from '../types';

export const PIN_UNLOCK = 'pin';
export const PIN_UNLOCK_KEY_EVENT = 'pinWithKeyEvent';
export const PASSWORD_UNLOCK = 'password';
export const PATTERN_UNLOCK = 'pattern';
export const FINGERPRINT_UNLOCK = 'fingerprint';
const UNLOCK_TYPES = [
  PIN_UNLOCK,
  PIN_UNLOCK_KEY_EVENT,
  PASSWORD_UNLOCK,
  PATTERN_UNLOCK,
  FINGERPRINT_UNLOCK,
] as const;
export const KEYCODE_NUMPAD_ENTER = 66;
export const UNLOCK_WAIT_TIME = 100;
export const INPUT_KEYS_WAIT_TIME = 100;
const NUMBER_ZERO_KEYCODE = 7;
const TOUCH_DELAY_MS = 1000;

/**
 * Converts an unlock type to a credential type string.
 *
 * @param unlockType - The unlock type
 * @returns The credential type string
 * @throws {Error} If the unlock type is not known
 */
export function toCredentialType(unlockType: UnlockType): string {
  const result = {
    [PIN_UNLOCK]: 'pin',
    [PIN_UNLOCK_KEY_EVENT]: 'pin',
    [PASSWORD_UNLOCK]: 'password',
    [PATTERN_UNLOCK]: 'pattern',
  }[unlockType];
  if (result) {
    return result;
  }
  throw new Error(`Unlock type '${unlockType}' is not known`);
}

/**
 * Validates unlock capabilities and returns them if valid.
 *
 * @param caps - The capabilities to validate
 * @returns The validated capabilities
 * @throws {Error} If the capabilities are invalid
 */
export function validateUnlockCapabilities<T extends AndroidDriverCaps>(caps: T): T {
  const {unlockKey, unlockType} = caps ?? {};
  if (!isNonEmptyString(unlockType)) {
    throw new Error('A non-empty unlock key value must be provided');
  }

  if ([PIN_UNLOCK, PIN_UNLOCK_KEY_EVENT, FINGERPRINT_UNLOCK].includes(unlockType)) {
    if (!/^[0-9]+$/.test(String(unlockKey)?.trim())) {
      throw new Error(`Unlock key value '${unlockKey}' must only consist of digits`);
    }
  } else if (unlockType === PATTERN_UNLOCK) {
    if (!/^[1-9]{2,9}$/.test(String(unlockKey)?.trim())) {
      throw new Error(
        `Unlock key value '${unlockKey}' must only include from two to nine digits in range 1..9`,
      );
    }
    if (/([1-9]).*?\1/.test(String(unlockKey)?.trim())) {
      throw new Error(
        `Unlock key value '${unlockKey}' must define a valid pattern where repeats are not allowed`,
      );
    }
  } else if (unlockType === PASSWORD_UNLOCK) {
    // Dont trim password key, you can use blank spaces in your android password
    // ¯\_(ツ)_/¯
    if (!/.{4,}/g.test(String(unlockKey))) {
      throw new Error(
        `The minimum allowed length of unlock key value '${unlockKey}' is 4 characters`,
      );
    }
  } else {
    throw new Error(
      `Invalid unlock type '${unlockType}'. ` +
        `Only the following unlock types are supported: ${UNLOCK_TYPES}`,
    );
  }
  return caps;
}

/**
 * Performs a fast unlock using ADB commands.
 *
 * @param opts - Fast unlock options with credential and credential type
 */
export async function fastUnlock(this: AndroidDriver, opts: FastUnlockOptions): Promise<void> {
  const {credential, credentialType} = opts;
  this.log.info(`Unlocking the device via ADB using ${credentialType} credential '${credential}'`);
  const wasLockEnabled = await this.adb.isLockEnabled();
  if (wasLockEnabled) {
    await this.adb.clearLockCredential(credential);
    // not sure why, but the device's screen still remains locked
    // if a preliminary wake up cycle has not been performed
    await this.adb.cycleWakeUp();
  } else {
    this.log.info('No active lock has been detected. Proceeding to the keyguard dismissal');
  }
  try {
    await this.adb.dismissKeyguard();
  } finally {
    if (wasLockEnabled) {
      await this.adb.setLockCredential(credentialType, credential);
    }
  }
}

/**
 * Encodes a password by replacing spaces with %s.
 *
 * @param key - The password key
 * @returns The encoded password
 */
export function encodePassword(key: string): string {
  return `${key}`.replace(/\s/gi, '%s');
}

/**
 * Converts a string key to an array of characters.
 *
 * @param key - The key string
 * @returns An array of characters
 */
export function stringKeyToArr(key: string): string[] {
  return `${key}`.trim().replace(/\s+/g, '').split(/\s*/);
}

/**
 * Unlocks the device using fingerprint.
 *
 * @param capabilities - Driver capabilities containing unlockKey
 */
export async function fingerprintUnlock(
  this: AndroidDriver,
  capabilities: AndroidDriverCaps,
): Promise<void> {
  await this.adb.fingerprint(String(capabilities.unlockKey));
  await sleep(UNLOCK_WAIT_TIME);
}

/**
 * Unlocks the device using PIN by clicking on-screen buttons.
 *
 * @param capabilities - Driver capabilities containing unlockKey
 */
export async function pinUnlock(
  this: AndroidDriver,
  capabilities: AndroidDriverCaps,
): Promise<void> {
  this.log.info(`Trying to unlock device using pin ${capabilities.unlockKey}`);
  await this.adb.dismissKeyguard();
  const keys = stringKeyToArr(String(capabilities.unlockKey));
  const els = await this.findElOrEls('id', 'com.android.systemui:id/digit_text', true);
  if (util.isEmpty(els)) {
    // fallback to pin with key event
    return await pinUnlockWithKeyEvent.bind(this)(capabilities);
  }
  const pins: Record<string, any> = {};
  for (const el of els) {
    const text = await this.getAttribute('text', util.unwrapElement(el));
    if (text) {
      pins[text] = el;
    }
  }
  for (const pin of keys) {
    const el = pins[pin];
    await this.click(util.unwrapElement(el));
  }
  await waitForUnlock(this.adb);
}

/**
 * Unlocks the device using PIN by sending key events.
 *
 * @param capabilities - Driver capabilities containing unlockKey
 */
export async function pinUnlockWithKeyEvent(
  this: AndroidDriver,
  capabilities: AndroidDriverCaps,
): Promise<void> {
  this.log.info(`Trying to unlock device using pin with keycode ${capabilities.unlockKey}`);
  await this.adb.dismissKeyguard();
  const keys = stringKeyToArr(String(capabilities.unlockKey));

  // Some device does not have system key ids like 'com.android.keyguard:id/key'
  // Then, sending keyevents are more reliable to unlock the screen.
  for (const pin of keys) {
    // 'pin' is number (0-9) in string.
    // Number '0' is keycode '7'. number '9' is keycode '16'.
    await this.adb.shell(['input', 'keyevent', String(parseInt(pin, 10) + NUMBER_ZERO_KEYCODE)]);
  }
  await waitForUnlock(this.adb);
}

/**
 * Unlocks the device using password.
 *
 * @param capabilities - Driver capabilities containing unlockKey
 */
export async function passwordUnlock(
  this: AndroidDriver,
  capabilities: AndroidDriverCaps,
): Promise<void> {
  const {unlockKey} = capabilities;
  this.log.info(`Trying to unlock device using password ${unlockKey}`);
  await this.adb.dismissKeyguard();
  // Replace blank spaces with %s
  const key = encodePassword(String(unlockKey));
  // Why adb ? It was less flaky
  await this.adb.shell(['input', 'text', key]);
  // Why sleeps ? Avoid some flakyness waiting for the input to receive the keys
  await sleep(INPUT_KEYS_WAIT_TIME);
  await this.adb.shell(['input', 'keyevent', String(KEYCODE_NUMPAD_ENTER)]);
  // Waits a bit for the device to be unlocked
  await waitForUnlock(this.adb);
}

/**
 * Calculates the position of a pattern key based on the initial position and piece size.
 *
 * @param key - The pattern key number (1-9)
 * @param initPos - The initial position of the pattern view
 * @param piece - The size of each pattern piece
 * @returns The calculated position for the key
 */
export function getPatternKeyPosition(key: number, initPos: Position, piece: number): Position {
  /*
  How the math works:
  We have 9 buttons divided in 3 columns and 3 rows inside the lockPatternView,
  every button has a position on the screen corresponding to the lockPatternView since
  it is the parent view right at the middle of each column or row.
  */
  const cols = 3;
  const pins = 9;
  const xPos = (key: number, x: number, piece: number) =>
    Math.round(x + (key % cols || cols) * piece - piece / 2);
  const yPos = (key: number, y: number, piece: number) =>
    Math.round(y + (Math.ceil((key % pins || pins) / cols) * piece - piece / 2));
  return {
    x: xPos(key, initPos.x, piece),
    y: yPos(key, initPos.y, piece),
  };
}

/**
 * Generates pointer actions for pattern unlock gesture.
 *
 * @param keys - Array of pattern keys (string or number)
 * @param initPos - The initial position of the pattern view
 * @param piece - The size of each pattern piece
 * @returns An array of W3C action objects for pattern unlock
 */
export function getPatternActions(
  keys: string[] | number[],
  initPos: Position,
  piece: number,
): StringRecord[] {
  // https://www.w3.org/TR/webdriver2/#actions
  const pointerActions: StringRecord[] = [];
  const intKeys = keys.map((key) => (typeof key === 'string' ? parseInt(key, 10) : key));
  let lastPos: Position | undefined;
  for (const key of intKeys) {
    const keyPos = getPatternKeyPosition(key, initPos, piece);
    if (!lastPos) {
      pointerActions.push(
        {type: 'pointerMove', duration: TOUCH_DELAY_MS, x: keyPos.x, y: keyPos.y},
        {type: 'pointerDown', button: 0},
      );
      lastPos = keyPos;
      continue;
    }
    const moveTo = {x: 0, y: 0};
    const diffX = keyPos.x - lastPos.x;
    if (diffX > 0) {
      moveTo.x = piece;
      if (Math.abs(diffX) > piece) {
        moveTo.x += piece;
      }
    } else if (diffX < 0) {
      moveTo.x = -1 * piece;
      if (Math.abs(diffX) > piece) {
        moveTo.x -= piece;
      }
    }
    const diffY = keyPos.y - lastPos.y;
    if (diffY > 0) {
      moveTo.y = piece;
      if (Math.abs(diffY) > piece) {
        moveTo.y += piece;
      }
    } else if (diffY < 0) {
      moveTo.y = -1 * piece;
      if (Math.abs(diffY) > piece) {
        moveTo.y -= piece;
      }
    }
    pointerActions.push({
      type: 'pointerMove',
      duration: TOUCH_DELAY_MS,
      x: moveTo.x + lastPos.x,
      y: moveTo.y + lastPos.y,
    });
    lastPos = keyPos;
  }
  pointerActions.push({type: 'pointerUp', button: 0});
  return [
    {
      type: 'pointer',
      id: 'patternUnlock',
      parameters: {
        pointerType: 'touch',
      },
      actions: pointerActions,
    },
  ];
}

/**
 * Verifies that the device has been unlocked.
 *
 * @param timeoutMs - Optional timeout in milliseconds (default: 2000)
 * @throws {Error} If the device fails to unlock within the timeout
 */
export async function verifyUnlock(
  this: AndroidDriver,
  timeoutMs: number | null = null,
): Promise<void> {
  try {
    await waitForCondition(async () => !(await this.adb.isScreenLocked()), {
      waitMs: timeoutMs ?? 2000,
      intervalMs: 500,
    });
  } catch {
    throw new Error('The device has failed to be unlocked');
  }
  this.log.info('The device has been successfully unlocked');
}

/**
 * Unlocks the device using pattern gesture.
 *
 * @param capabilities - Driver capabilities containing unlockKey
 */
export async function patternUnlock(
  this: AndroidDriver,
  capabilities: AndroidDriverCaps,
): Promise<void> {
  const {unlockKey} = capabilities;
  this.log.info(`Trying to unlock device using pattern ${unlockKey}`);
  await this.adb.dismissKeyguard();
  const keys = stringKeyToArr(String(unlockKey));
  /* We set the device pattern buttons as number of a regular phone
    *  | • • • |     | 1 2 3 |
    *  | • • • | --> | 4 5 6 |
    *  | • • • |     | 7 8 9 |

  The pattern view buttons are not seeing by the uiautomator since they are
  included inside a FrameLayout, so we are going to try clicking on the buttons
  using the parent view bounds and math.
  */
  const apiLevel = await this.adb.getApiLevel();
  const el = await this.findElOrEls(
    'id',
    `com.android.${apiLevel >= 21 ? 'systemui' : 'keyguard'}:id/lockPatternView`,
    false,
  );
  const initPos = await this.getLocation(util.unwrapElement(el));
  const size = await this.getSize(util.unwrapElement(el));
  // Get actions to perform
  const actions = getPatternActions(keys, initPos, size.width / 3);
  // Perform gesture
  await this.performActions(actions);
  // Waits a bit for the device to be unlocked
  await sleep(UNLOCK_WAIT_TIME);
}

// #region Private Functions

/**
 * Type guard to check if a value is a non-empty string.
 */
function isNonEmptyString(value: any): value is string {
  return typeof value === 'string' && value !== '';
}

/**
 * Waits for the display to be unlocked.
 * Some devices automatically accept typed 'pin' and 'password' code
 * without pressing the Enter key. But some devices need it.
 * This method waits a few seconds first for such automatic acceptance case.
 * If the device is still locked, then this method will try to send
 * the enter key code.
 *
 * @param adb - The instance of ADB
 */
async function waitForUnlock(adb: ADB): Promise<void> {
  await sleep(UNLOCK_WAIT_TIME);
  if (!(await adb.isScreenLocked())) {
    return;
  }

  await adb.keyevent(KEYCODE_NUMPAD_ENTER);
  await sleep(UNLOCK_WAIT_TIME);
}

// #endregion
