// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import './LinearMemoryViewer.js';

import * as Common from '../../../core/common/common.js';
import * as i18n from '../../../core/i18n/i18n.js';
import * as UI from '../../../ui/legacy/legacy.js';
import {html, nothing, render} from '../../../ui/lit/lit.js';

import {LinearMemoryHighlightChipList} from './LinearMemoryHighlightChipList.js';
import linearMemoryInspectorStyles from './linearMemoryInspector.css.js';
import {formatAddress, parseAddress} from './LinearMemoryInspectorUtils.js';
import {
  type AddressInputChangedEvent,
  type HistoryNavigationEvent,
  Mode,
  Navigation,
  type PageNavigationEvent,
} from './LinearMemoryNavigator.js';
import {LinearMemoryValueInterpreter} from './LinearMemoryValueInterpreter.js';
import type {ByteSelectedEvent, ResizeEvent} from './LinearMemoryViewer.js';
import type {HighlightInfo} from './LinearMemoryViewerUtils.js';
import {
  Endianness,
  getDefaultValueTypeMapping,
  VALUE_INTEPRETER_MAX_NUM_BYTES,
  type ValueType,
  type ValueTypeMode,
} from './ValueInterpreterDisplayUtils.js';

const UIStrings = {
  /**
   * @description Tooltip text that appears when hovering over an invalid address in the address line in the Linear memory inspector
   * @example {0x00000000} PH1
   * @example {0x00400000} PH2
   */
  addressHasToBeANumberBetweenSAnd: 'Address has to be a number between {PH1} and {PH2}',
} as const;
const str_ =
    i18n.i18n.registerUIStrings('panels/linear_memory_inspector/components/LinearMemoryInspector.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
const {widget} = UI.Widget;

/**
 * If the LinearMemoryInspector only receives a portion
 * of the original Uint8Array to show, it requires information
 * on the 1. memoryOffset (at which index this portion starts),
 * and on the 2. outerMemoryLength (length of the original Uint8Array).
 **/
export interface LinearMemoryInspectorData {
  memory: Uint8Array<ArrayBuffer>;
  address: number;
  memoryOffset: number;
  outerMemoryLength: number;
  valueTypes?: Set<ValueType>;
  valueTypeModes?: Map<ValueType, ValueTypeMode>;
  endianness?: Endianness;
  highlightInfo?: HighlightInfo;
  hideValueInspector?: boolean;
}

export interface Settings {
  valueTypes: Set<ValueType>;
  modes: Map<ValueType, ValueTypeMode>;
  endianness: Endianness;
}

export const enum Events {
  MEMORY_REQUEST = 'MemoryRequest',
  ADDRESS_CHANGED = 'AddressChanged',
  SETTINGS_CHANGED = 'SettingsChanged',
  DELETE_MEMORY_HIGHLIGHT = 'DeleteMemoryHighlight',
}

export interface EventTypes {
  [Events.MEMORY_REQUEST]: {start: number, end: number, address: number};
  [Events.ADDRESS_CHANGED]: number;
  [Events.SETTINGS_CHANGED]: Settings;
  [Events.DELETE_MEMORY_HIGHLIGHT]: HighlightInfo;
}

class AddressHistoryEntry implements Common.SimpleHistoryManager.HistoryEntry {
  #address = 0;
  #callback;

  constructor(address: number, callback: (x: number) => void) {
    if (address < 0) {
      throw new Error('Address should be a greater or equal to zero');
    }
    this.#address = address;
    this.#callback = callback;
  }

  valid(): boolean {
    return true;
  }

  reveal(): void {
    this.#callback(this.#address);
  }
}

export interface ViewInput {
  memory: Uint8Array;
  address: number;
  memoryOffset: number;
  outerMemoryLength: number;
  valueTypes: Set<ValueType>;
  valueTypeModes: Map<ValueType, ValueTypeMode>;
  endianness: Endianness;
  highlightInfo?: HighlightInfo;
  hideValueInspector: boolean;
  currentNavigatorMode: Mode;
  currentNavigatorAddressLine: string;
  canGoBackInHistory: boolean;
  canGoForwardInHistory: boolean;
  onRefreshRequest: () => void;
  onAddressChange: (e: AddressInputChangedEvent) => void;
  onNavigatePage: (e: PageNavigationEvent) => void;
  onNavigateHistory: (e: HistoryNavigationEvent) => boolean;
  onJumpToAddress: (address: number) => void;
  onDeleteMemoryHighlight: (info: HighlightInfo) => void;
  onByteSelected: (e: ByteSelectedEvent) => void;
  onResize: (e: ResizeEvent) => void;
  onValueTypeToggled: (type: ValueType, checked: boolean) => void;
  onValueTypeModeChanged: (type: ValueType, mode: ValueTypeMode) => void;
  onEndiannessChanged: (endianness: Endianness) => void;
  memorySlice: Uint8Array<ArrayBuffer>;
  viewerStart: number;
}

export const DEFAULT_VIEW = (input: ViewInput, _output: Record<string, unknown>, target: HTMLElement): void => {
  const navigatorAddressToShow =
      input.currentNavigatorMode === Mode.SUBMITTED ? formatAddress(input.address) : input.currentNavigatorAddressLine;
  const navigatorAddressIsValid = isValidAddress(navigatorAddressToShow, input.outerMemoryLength);

  const invalidAddressMsg = i18nString(
      UIStrings.addressHasToBeANumberBetweenSAnd, {PH1: formatAddress(0), PH2: formatAddress(input.outerMemoryLength)});

  const errorMsg = navigatorAddressIsValid ? undefined : invalidAddressMsg;

  const highlightedMemoryAreas = input.highlightInfo ? [input.highlightInfo] : [];
  const focusedMemoryHighlight = getSmallestEnclosingMemoryHighlight(highlightedMemoryAreas, input.address);
  // Disabled until https://crbug.com/1079231 is fixed.
  // clang-format off
  render(html`
    <style>${linearMemoryInspectorStyles}</style>
    <div class="view">
      <devtools-linear-memory-inspector-navigator
        .data=${
      {
        address: navigatorAddressToShow,
        valid: navigatorAddressIsValid,
        mode: input.currentNavigatorMode,
        error: errorMsg,
        canGoBackInHistory: input.canGoBackInHistory,
        canGoForwardInHistory: input.canGoForwardInHistory,
      }}
        @refreshrequested=${input.onRefreshRequest}
        @addressinputchanged=${input.onAddressChange}
        @pagenavigation=${input.onNavigatePage}
        @historynavigation=${input.onNavigateHistory}></devtools-linear-memory-inspector-navigator>
      ${widget(LinearMemoryHighlightChipList, {
        highlightInfos: highlightedMemoryAreas,
        focusedMemoryHighlight,
        jumpToAddress: (address: number) => input.onJumpToAddress(address),
        deleteHighlight: input.onDeleteMemoryHighlight,
      })}
      <devtools-linear-memory-inspector-viewer
        .data=${
      {
        memory: input.memorySlice,
        address: input.address,
        memoryOffset: input.viewerStart,
        focus: input.currentNavigatorMode === Mode.SUBMITTED,
        highlightInfo: input.highlightInfo,
        focusedMemoryHighlight,
      }}
        @byteselected=${input.onByteSelected}
        @resize=${input.onResize}>
      </devtools-linear-memory-inspector-viewer>
    </div>
    ${
      input.hideValueInspector ? nothing : html`
    <div class="value-interpreter">
      ${widget(LinearMemoryValueInterpreter, {
            buffer: input.memory
                       .slice(
                           input.address - input.memoryOffset,
                           input.address + VALUE_INTEPRETER_MAX_NUM_BYTES,
                           )
                       .buffer,
            valueTypes: input.valueTypes,
            valueTypeModes: input.valueTypeModes,
            endianness: input.endianness,
            memoryLength: input.outerMemoryLength,
            onValueTypeModeChange: input.onValueTypeModeChanged,
            onJumpToAddressClicked: input.onJumpToAddress,
            onValueTypeToggled: input.onValueTypeToggled,
            onEndiannessChanged: input.onEndiannessChanged,
      })}
    </div>`}
    `,
         target);
  // clang-format on
};

function getPageRangeForAddress(
    address: number, numBytesPerPage: number, outerMemoryLength: number): {start: number, end: number} {
  const pageNumber = Math.floor(address / numBytesPerPage);
  const pageStartAddress = pageNumber * numBytesPerPage;
  const pageEndAddress = Math.min(pageStartAddress + numBytesPerPage, outerMemoryLength);
  return {start: pageStartAddress, end: pageEndAddress};
}

function isValidAddress(address: string, outerMemoryLength: number): boolean {
  const newAddress = parseAddress(address);
  return newAddress !== undefined && newAddress >= 0 && newAddress < outerMemoryLength;
}

// Returns the highlightInfo with the smallest size property that encloses the provided address.
// If there are multiple smallest enclosing highlights, we pick the one appearing the earliest in highlightedMemoryAreas.
// If no such highlightInfo exists, it returns undefined.
//
// Selecting the smallest enclosing memory highlight is a heuristic that aims to pick the
// most specific highlight given a provided address. This way, objects contained in other objects are
// potentially still accessible.
function getSmallestEnclosingMemoryHighlight(highlightedMemoryAreas: HighlightInfo[], address: number): HighlightInfo|
    undefined {
  let smallestEnclosingHighlight;
  for (const highlightedMemory of highlightedMemoryAreas) {
    if (highlightedMemory.startAddress <= address &&
        address < highlightedMemory.startAddress + highlightedMemory.size) {
      if (!smallestEnclosingHighlight) {
        smallestEnclosingHighlight = highlightedMemory;
      } else if (highlightedMemory.size < smallestEnclosingHighlight.size) {
        smallestEnclosingHighlight = highlightedMemory;
      }
    }
  }
  return smallestEnclosingHighlight;
}

export type View = typeof DEFAULT_VIEW;

export class LinearMemoryInspector extends Common.ObjectWrapper.eventMixin<EventTypes, typeof UI.Widget.Widget>(
    UI.Widget.Widget) {
  readonly #history = new Common.SimpleHistoryManager.SimpleHistoryManager(10);

  #memory = new Uint8Array();
  #memoryOffset = 0;
  #outerMemoryLength = 0;

  #address = -1;
  #highlightInfo?: HighlightInfo;

  #currentNavigatorMode = Mode.SUBMITTED;
  #currentNavigatorAddressLine = `${this.#address}`;

  #numBytesPerPage = 4;

  #valueTypeModes = getDefaultValueTypeMapping();
  #valueTypes = new Set(this.#valueTypeModes.keys());
  #endianness = Endianness.LITTLE;

  #hideValueInspector = false;
  #view: View;

  constructor(element?: HTMLElement, view?: View) {
    super(element);
    this.#view = view ?? DEFAULT_VIEW;
  }

  set memory(value: Uint8Array<ArrayBuffer>) {
    this.#memory = value;
    void this.requestUpdate();
  }

  set memoryOffset(value: number) {
    this.#memoryOffset = value;
    void this.requestUpdate();
  }

  set outerMemoryLength(value: number) {
    this.#outerMemoryLength = value;
    void this.requestUpdate();
  }

  set highlightInfo(value: HighlightInfo|undefined) {
    this.#highlightInfo = value;
    void this.requestUpdate();
  }

  set valueTypeModes(value: Map<ValueType, ValueTypeMode>) {
    this.#valueTypeModes = value;
    void this.requestUpdate();
  }

  set valueTypes(value: Set<ValueType>) {
    this.#valueTypes = value;
    void this.requestUpdate();
  }

  set endianness(value: Endianness) {
    this.#endianness = value;
    void this.requestUpdate();
  }

  set hideValueInspector(value: boolean) {
    this.#hideValueInspector = value;
    void this.requestUpdate();
  }

  get hideValueInspector(): boolean {
    return this.#hideValueInspector;
  }

  override performUpdate(): void {
    const {start, end} = getPageRangeForAddress(this.#address, this.#numBytesPerPage, this.#outerMemoryLength);

    if (start < this.#memoryOffset || end > this.#memoryOffset + this.#memory.length) {
      this.dispatchEventToListeners(Events.MEMORY_REQUEST, {start, end, address: this.#address});
      return;
    }

    if (this.#address < this.#memoryOffset || this.#address > this.#memoryOffset + this.#memory.length ||
        this.#address < 0) {
      throw new Error('Address is out of bounds.');
    }

    if (this.#highlightInfo) {
      if (this.#highlightInfo.size < 0) {
        this.#highlightInfo = undefined;
        throw new Error('Object size has to be greater than or equal to zero');
      }
      if (this.#highlightInfo.startAddress < 0 || this.#highlightInfo.startAddress >= this.#outerMemoryLength) {
        this.#highlightInfo = undefined;
        throw new Error('Object start address is out of bounds.');
      }
    }

    const viewInput: ViewInput = {
      memory: this.#memory,
      address: this.#address,
      memoryOffset: this.#memoryOffset,
      outerMemoryLength: this.#outerMemoryLength,
      valueTypes: this.#valueTypes,
      valueTypeModes: this.#valueTypeModes,
      endianness: this.#endianness,
      highlightInfo: this.#highlightInfo,
      hideValueInspector: this.#hideValueInspector,
      currentNavigatorMode: this.#currentNavigatorMode,
      currentNavigatorAddressLine: this.#currentNavigatorAddressLine,
      canGoBackInHistory: this.#history.canRollback(),
      canGoForwardInHistory: this.#history.canRollover(),
      onRefreshRequest: this.#onRefreshRequest.bind(this),
      onAddressChange: this.#onAddressChange.bind(this),
      onNavigatePage: this.#navigatePage.bind(this),
      onNavigateHistory: this.#navigateHistory.bind(this),
      onJumpToAddress: this.#onJumpToAddress.bind(this),
      onDeleteMemoryHighlight: this.#onDeleteMemoryHighlight.bind(this),
      onByteSelected: this.#onByteSelected.bind(this),
      onResize: this.#resize.bind(this),
      onValueTypeToggled: this.#onValueTypeToggled.bind(this),
      onValueTypeModeChanged: this.#onValueTypeModeChanged.bind(this),
      onEndiannessChanged: this.#onEndiannessChanged.bind(this),
      memorySlice: this.#memory.slice(start - this.#memoryOffset, end - this.#memoryOffset),
      viewerStart: start,
    };
    this.#view(viewInput, {}, this.contentElement);
  }

  #onJumpToAddress(address: number): void {
    this.#currentNavigatorMode = Mode.SUBMITTED;
    const addressInRange = Math.max(0, Math.min(address, this.#outerMemoryLength - 1));
    this.#jumpToAddress(addressInRange);
  }

  #onDeleteMemoryHighlight(highlight: HighlightInfo): void {
    this.dispatchEventToListeners(Events.DELETE_MEMORY_HIGHLIGHT, highlight);
  }

  #onRefreshRequest(): void {
    const {start, end} = getPageRangeForAddress(this.#address, this.#numBytesPerPage, this.#outerMemoryLength);
    this.dispatchEventToListeners(Events.MEMORY_REQUEST, {start, end, address: this.#address});
  }

  #onByteSelected(e: ByteSelectedEvent): void {
    this.#currentNavigatorMode = Mode.SUBMITTED;
    const addressInRange = Math.max(0, Math.min(e.data, this.#outerMemoryLength - 1));
    this.#jumpToAddress(addressInRange);
  }

  #createSettings(): Settings {
    return {valueTypes: this.#valueTypes, modes: this.#valueTypeModes, endianness: this.#endianness};
  }

  #onEndiannessChanged(endianness: Endianness): void {
    this.#endianness = endianness;
    this.dispatchEventToListeners(Events.SETTINGS_CHANGED, this.#createSettings());
    void this.requestUpdate();
  }

  #onAddressChange(e: AddressInputChangedEvent): void {
    const {address, mode} = e.data;
    const isValid = isValidAddress(address, this.#outerMemoryLength);
    const newAddress = parseAddress(address);
    this.#currentNavigatorAddressLine = address;

    if (newAddress !== undefined && isValid) {
      this.#currentNavigatorMode = mode;
      this.#jumpToAddress(newAddress);
      return;
    }

    if (mode === Mode.SUBMITTED && !isValid) {
      this.#currentNavigatorMode = Mode.INVALID_SUBMIT;
    } else {
      this.#currentNavigatorMode = Mode.EDIT;
    }

    void this.requestUpdate();
  }

  #onValueTypeToggled(type: ValueType, checked: boolean): void {
    const valueTypes = new Set(this.#valueTypes);
    if (checked) {
      valueTypes.add(type);
    } else {
      valueTypes.delete(type);
    }
    this.#valueTypes = valueTypes;
    this.dispatchEventToListeners(Events.SETTINGS_CHANGED, this.#createSettings());
    void this.requestUpdate();
  }

  #onValueTypeModeChanged(type: ValueType, mode: ValueTypeMode): void {
    const valueTypeModes = new Map(this.#valueTypeModes);
    valueTypeModes.set(type, mode);
    this.#valueTypeModes = valueTypeModes;
    this.dispatchEventToListeners(Events.SETTINGS_CHANGED, this.#createSettings());
    void this.requestUpdate();
  }

  #navigateHistory(e: HistoryNavigationEvent): boolean {
    return e.data === Navigation.FORWARD ? this.#history.rollover() : this.#history.rollback();
  }

  #navigatePage(e: PageNavigationEvent): void {
    const newAddress =
        e.data === Navigation.FORWARD ? this.#address + this.#numBytesPerPage : this.#address - this.#numBytesPerPage;
    const addressInRange = Math.max(0, Math.min(newAddress, this.#outerMemoryLength - 1));
    this.#jumpToAddress(addressInRange);
  }

  #jumpToAddress(address: number): void {
    if (address < 0 || address >= this.#outerMemoryLength) {
      console.warn(`Specified address is out of bounds: ${address}`);
      return;
    }
    this.address = address;
    void this.requestUpdate();
  }

  #resize(event: ResizeEvent): void {
    this.#numBytesPerPage = event.data;
    void this.requestUpdate();
  }

  set address(address: number) {
    // If we are already showing the address that is requested, no need to act upon it.
    if (this.#address === address) {
      return;
    }
    const historyEntry = new AddressHistoryEntry(address, () => this.#jumpToAddress(address));
    this.#history.push(historyEntry);
    this.#address = address;
    this.dispatchEventToListeners(Events.ADDRESS_CHANGED, this.#address);
    void this.requestUpdate();
  }
}
