// Copyright (C) 2023 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// This module provides hotkey detection using type-safe human-readable strings.
//
// The basic premise is this: Let's say you have a KeyboardEvent |event|, and
// you wanted to check whether it contains the hotkey 'Ctrl+O', you can execute
// the following function:
//
//   checkHotkey('Shift+O', event);
//
// ...which will evaluate to true if 'Shift+O' is discovered in the event.
//
// This will only trigger when O is pressed while the Shift key is held, not O
// on it's own, and not if other modifiers such as Alt or Ctrl were also held.
//
// Modifiers include 'Shift', 'Ctrl', 'Alt', and 'Mod':
// - 'Shift' and 'Ctrl' are fairly self explanatory.
// - 'Alt' is 'option' on Macs.
// - 'Mod' is a special modifier which means 'Ctrl' on PC and 'Cmd' on Mac.
// Modifiers may be combined in various ways - check the |Modifier| type.
//
// By default hotkeys will not register when the event target is inside an
// editable element, such as <textarea> and some <input>s.
// Prefixing a hotkey with a bang '!' relaxes is requirement, meaning the hotkey
// will register inside editable fields.

// E.g. '!Mod+Shift+P' will register when pressed when a text box has focus but
// 'Mod+Shift+P' (no bang) will not.
// Warning: Be careful using this with single key hotkeys, e.g. '!P' is usually
// never what you want!
//
// Some single-key hotkeys like '?' and '!' normally cannot be activated in
// without also pressing shift key, so the shift requirement is relaxed for
// these keys.

import {elementIsEditable} from './dom_utils';

type Alphabet = 'A'|'B'|'C'|'D'|'E'|'F'|'G'|'H'|'I'|'J'|'K'|'L'|'M'|'N'|'O'|'P'|
    'Q'|'R'|'S'|'T'|'U'|'V'|'W'|'X'|'Y'|'Z';
type Number = '0'|'1'|'2'|'3'|'4'|'5'|'6'|'7'|'8'|'9';
type Special = 'Enter'|'Escape'|'/'|'?'|'!'|'Space'|'ArrowUp'|'ArrowDown'|
    'ArrowLeft'|'ArrowRight';
export type Key = Alphabet|Number|Special;
export type Modifier = ''|'Mod+'|'Shift+'|'Ctrl+'|'Alt+'|'Mod+Shift+'|'Mod+Alt'|
    'Mod+Shift+Alt'|'Ctrl+Shift+'|'Ctrl+Alt'|'Ctrl+Shift+Alt';
type AllowInEditable = '!'|'';
export type Hotkey = `${AllowInEditable}${Modifier}${Key}`;

// Represents a deconstructed hotkey.
export interface HotkeyParts {
  // The name of the primary key of this hotkey.
  key: Key;

  // All the modifiers as one chunk. E.g. 'Mod+Shift+'.
  modifier: Modifier;

  // Whether this hotkey should register when the event target is inside an
  // editable field.
  allowInEditable: boolean;
}

// Deconstruct a hotkey from its string representation into its constituent
// parts.
export function parseHotkey(hotkey: Hotkey): HotkeyParts|null {
  const regex = /^(!?)((?:Mod\+|Shift\+|Alt\+|Ctrl\+)*)(.*)$/;
  const result = hotkey.match(regex);

  if (!result) {
    return null;
  }

  return {
    allowInEditable: result[1] === '!',
    modifier: result[2] as Modifier,
    key: result[3] as Key,
  };
}

// Like |KeyboardEvent| but all fields apart from |key| are optional.
export type KeyboardEventLike =
    Pick<KeyboardEvent, 'key'>&Partial<KeyboardEvent>;

// Check whether |hotkey| is present in the keyboard event |event|.
export function checkHotkey(
    hotkey: Hotkey, event: KeyboardEventLike, spoofPlatform?: Platform):
    boolean {
  const result = parseHotkey(hotkey);
  if (!result) {
    return false;
  }

  const {key, allowInEditable} = result;
  const {target = null} = event;

  const inEditable = elementIsEditable(target);
  if (inEditable && !allowInEditable) {
    return false;
  }
  return compareKeys(event, key) && checkMods(event, result, spoofPlatform);
}

// Return true if |key| matches the event's key.
function compareKeys(e: KeyboardEventLike, key: Key): boolean {
  return e.key.toLowerCase() === key.toLowerCase();
}

// Return true if modifiers specified in |mods| match those in the event.
function checkMods(
    event: KeyboardEventLike, hotkey: HotkeyParts, spoofPlatform?: Platform):
    boolean {
  const platform = spoofPlatform ?? getPlatform();

  const {key, modifier} = hotkey;

  const {
    ctrlKey = false,
    altKey = false,
    shiftKey = false,
    metaKey = false,
  } = event;

  const wantShift = modifier.includes('Shift');
  const wantAlt = modifier.includes('Alt');
  const wantCtrl = platform === 'Mac' ?
      modifier.includes('Ctrl') :
      (modifier.includes('Ctrl') || modifier.includes('Mod'));
  const wantMeta = platform === 'Mac' && modifier.includes('Mod');

  // For certain keys we relax the shift requirement, as they usually cannot be
  // pressed without the shift key on English keyboards.
  const shiftOk = key.match(/[\?\!]/) || shiftKey === wantShift;

  return metaKey === wantMeta && Boolean(shiftOk) && altKey === wantAlt &&
      ctrlKey === wantCtrl;
}

export type Platform = 'Mac'|'PC';

// Get the current platform (PC or Mac).
export function getPlatform(spoof?: Platform): Platform {
  if (spoof) {
    return spoof;
  } else {
    return window.navigator.platform.indexOf('Mac') !== -1 ? 'Mac' : 'PC';
  }
}
