// Copyright 2021 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/* eslint-disable @devtools/no-imperative-dom-api */
/* eslint-disable @devtools/no-lit-render-outside-of-view */

/*
 * Copyright (C) 2008 Apple Inc. All Rights Reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 * 1. Redistributions of source code must retain the above copyright
 *    notice, this list of conditions and the following disclaimer.
 * 2. Redistributions in binary form must reproduce the above copyright
 *    notice, this list of conditions and the following disclaimer in the
 *    documentation and/or other materials provided with the distribution.
 *
 * THIS SOFTWARE IS PROVIDED BY APPLE INC. ``AS IS'' AND ANY
 * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
 * PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL APPLE INC. OR
 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
 * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

import * as Common from '../../core/common/common.js';
import * as Host from '../../core/host/host.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as Platform from '../../core/platform/platform.js';
import type * as SDK from '../../core/sdk/sdk.js';
import * as Bindings from '../../models/bindings/bindings.js';
import * as Persistence from '../../models/persistence/persistence.js';
import * as StackTrace from '../../models/stack_trace/stack_trace.js';
import * as Workspace from '../../models/workspace/workspace.js';
import {Icon} from '../../ui/kit/kit.js';
import * as UI from '../../ui/legacy/legacy.js';
import {Directives, html, render} from '../../ui/lit/lit.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';

import callStackSidebarPaneStyles from './callStackSidebarPane.css.js';
import {QuickSourceView, SourcesPanel} from './SourcesPanel.js';

const UIStrings = {
  /**
   * @description Text in Call Stack Sidebar Pane of the Sources panel
   */
  callStack: 'Call Stack',
  /**
   * @description Not paused message element text content in Call Stack Sidebar Pane of the Sources panel
   */
  notPaused: 'Not paused',
  /**
   * @description Text exposed to screen reader when navigating through a ignore-listed call frame in the sources panel
   */
  onIgnoreList: 'on ignore list',
  /**
   * @description Show all link text content in Call Stack Sidebar Pane of the Sources panel
   */
  showIgnorelistedFrames: 'Show ignore-listed frames',
  /**
   * @description Text to show more content
   */
  showMore: 'Show more',
  /**
   * @description A context menu item in the Call Stack Sidebar Pane of the Sources panel
   */
  copyStackTrace: 'Copy stack trace',
  /**
   * @description Text in Call Stack Sidebar Pane of the Sources panel when some call frames have warnings
   */
  callFrameWarnings: 'Some call frames have warnings',
  /**
   * @description Error message that is displayed in UI when a file needed for debugging information for a call frame is missing
   * @example {src/myapp.debug.wasm.dwp} PH1
   */
  debugFileNotFound: 'Failed to load debug file "{PH1}".',
  /**
   * @description A context menu item in the Call Stack Sidebar Pane. "Restart" is a verb and
   * "frame" is a noun. "Frame" refers to an individual item in the call stack, i.e. a call frame.
   * The user opens this context menu by selecting a specific call frame in the call stack sidebar pane.
   */
  restartFrame: 'Restart frame',
  /**
   * @description Error message that is displayed in UI debugging information cannot be found for a call frame
   * @example {main} PH1
   */
  failedToLoadDebugSymbolsForFunction: 'No debug information for function "{PH1}"',
  /**
   * @description Error message that is displayed in UI when a file needed for debugging information for a call frame is missing
   * @example {mainp.debug.wasm.dwp} PH1
   */
  debugSymbolsIncomplete: 'The debug information for function {PH1} is incomplete',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/sources/CallStackSidebarPane.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);

const {createRef, ref} = Directives;

let callstackSidebarPaneInstance: CallStackSidebarPane;

export class CallStackSidebarPane extends UI.View.SimpleView implements UI.ContextFlavorListener.ContextFlavorListener,
                                                                        UI.ListControl.ListDelegate<Item> {
  private readonly ignoreListMessageElement: Element;
  private readonly ignoreListCheckboxElement: HTMLInputElement;
  private readonly notPausedMessageElement: HTMLElement;
  private readonly callFrameWarningsElement: HTMLElement;
  private readonly items: UI.ListModel.ListModel<Item>;
  private list: UI.ListControl.ListControl<Item>;
  private readonly showMoreMessageElement: Element;
  private showIgnoreListed = false;
  private maxAsyncStackChainDepth = defaultMaxAsyncStackChainDepth;
  private readonly updateItemThrottler = new Common.Throttler.Throttler(100);
  private readonly scheduledForUpdateItems = new Set<Item>();
  private muteActivateItem?: boolean;

  #stackTrace: StackTrace.StackTrace.DebuggableStackTrace|null = null;

  private constructor() {
    super({
      jslog: `${VisualLogging.section('sources.callstack')}`,
      title: i18nString(UIStrings.callStack),
      viewId: 'sources.callstack',
      useShadowDom: true,
    });

    const [ignoreListMessageRef, ignoreListCheckboxRef, notPausedRef, warningRef, showMoreRef] = [
      createRef<HTMLElement>(),
      createRef<HTMLInputElement>(),
      createRef<HTMLElement>(),
      createRef<HTMLElement>(),
      createRef<HTMLElement>(),
    ];
    const ignoreListCheckboxChanged = (): void => {
      this.showIgnoreListed = Boolean(ignoreListCheckboxRef.value?.checked);
      for (const item of this.items) {
        this.refreshItem(item);
      }
    };

    this.items = new UI.ListModel.ListModel();
    this.list = new UI.ListControl.ListControl(this.items, this, UI.ListControl.ListMode.NonViewport);
    this.list.element.addEventListener('contextmenu', this.onContextMenu.bind(this), false);
    self.onInvokeElement(this.list.element, event => {
      const item = this.list.itemForNode((event.target as Node | null));
      if (item) {
        this.activateItem(item);
        event.consume(true);
      }
    });

    const onShowMoreClicked = (): void => {
      this.maxAsyncStackChainDepth += defaultMaxAsyncStackChainDepth;
      this.requestUpdate();
    };

    // clang-format off
    render(html`
      <style>${callStackSidebarPaneStyles}</style>
      <div class='ignore-listed-message' ${ref(ignoreListMessageRef)}>
        <label class='ignore-listed-message-label'>
          <input type='checkbox' tabindex=0 class='ignore-listed-checkbox'
              @change=${ignoreListCheckboxChanged} ${ref(ignoreListCheckboxRef)} />
          ${i18nString(UIStrings.showIgnorelistedFrames)}
        </label>
      </div>
      <div class='gray-info-message' tabindex=-1 ${ref(notPausedRef)}>
        ${i18nString(UIStrings.notPaused)}
      </div>
      <div class='call-frame-warnings-message' tabindex=-1 ${ref(warningRef)}>
        <devtools-icon .name=${'warning-filled'} class='call-frame-warning-icon small'></devtools-icon>
        ${i18nString(UIStrings.callFrameWarnings)}
      </div>
      ${this.list.element}
      <div class='show-more-message hidden' ${ref(showMoreRef)}>
        <button class='link' @click=${onShowMoreClicked}>${i18nString(UIStrings.showMore)}</button>
      </div>
    `, this.contentElement);
    // clang-format on

    this.ignoreListMessageElement = ignoreListMessageRef.value as HTMLElement;
    this.ignoreListCheckboxElement = ignoreListCheckboxRef.value as HTMLInputElement;
    this.notPausedMessageElement = notPausedRef.value as HTMLElement;
    this.callFrameWarningsElement = warningRef.value as HTMLElement;
    this.showMoreMessageElement = showMoreRef.value as HTMLElement;

    this.requestUpdate();
  }

  static instance(opts: {
    forceNew: boolean|null,
  } = {forceNew: null}): CallStackSidebarPane {
    const {forceNew} = opts;
    if (!callstackSidebarPaneInstance || forceNew) {
      callstackSidebarPaneInstance = new CallStackSidebarPane();
    }

    return callstackSidebarPaneInstance;
  }

  async flavorChanged(details: SDK.DebuggerModel.DebuggerPausedDetails|null): Promise<void> {
    this.showIgnoreListed = false;
    this.ignoreListCheckboxElement.checked = false;
    this.maxAsyncStackChainDepth = defaultMaxAsyncStackChainDepth;

    if (this.#stackTrace) {
      this.#stackTrace.removeEventListener(StackTrace.StackTrace.Events.UPDATED, this.requestUpdate, this);
      this.#stackTrace = null;
      this.requestUpdate();  // In case creating the stack trace takes a while, we render an empty view first.
    }

    if (details) {
      this.#stackTrace = await Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance()
                             .createStackTraceFromDebuggerPaused(details, details.debuggerModel.target());
      this.#stackTrace.addEventListener(StackTrace.StackTrace.Events.UPDATED, this.requestUpdate, this);
    }

    this.requestUpdate();
  }

  override performUpdate(): void {
    this.callFrameWarningsElement.classList.add('hidden');

    if (!this.#stackTrace) {
      this.notPausedMessageElement.classList.remove('hidden');
      this.ignoreListMessageElement.classList.add('hidden');
      this.showMoreMessageElement.classList.add('hidden');
      this.items.replaceAll([]);
      UI.Context.Context.instance().setFlavor(StackTrace.StackTrace.DebuggableFrameFlavor, null);
      return;
    }

    this.notPausedMessageElement.classList.add('hidden');

    const items: Item[] = [];
    const uniqueWarnings = new Set<string>();
    for (const frame of this.#stackTrace.syncFragment.frames) {
      items.push(Item.createForDebuggableFrame(frame));
      if (frame.missingDebugInfo) {
        uniqueWarnings.add(convertMissingDebugInfo(frame.missingDebugInfo, frame.sdkFrame.functionName).details);
      }
    }

    if (uniqueWarnings.size) {
      this.callFrameWarningsElement.classList.remove('hidden');
      UI.Tooltip.Tooltip.install(this.callFrameWarningsElement, Array.from(uniqueWarnings).join('\n'));
    }

    let {maxAsyncStackChainDepth} = this;
    let hasMore = false;
    for (const asyncFragment of this.#stackTrace.asyncFragments) {
      items.push(Item.createForAsyncHeader(this.#stackTrace, asyncFragment));
      for (const frame of asyncFragment.frames) {
        items.push(Item.createForFrame(frame));
      }

      if (--maxAsyncStackChainDepth <= 0) {
        hasMore = asyncFragment !== this.#stackTrace.asyncFragments.at(-1);
        break;
      }
    }

    this.showMoreMessageElement.classList.toggle('hidden', !hasMore);
    this.items.replaceAll(items);
    for (const item of this.items) {
      this.refreshItem(item);
    }
    if (this.maxAsyncStackChainDepth === defaultMaxAsyncStackChainDepth) {
      this.list.selectNextItem(true /* canWrap */, false /* center */);
      const selectedItem = this.list.selectedItem();
      if (selectedItem &&
          (UI.Context.Context.instance().flavor(QuickSourceView) ||
           UI.Context.Context.instance().flavor(SourcesPanel))) {
        this.activateItem(selectedItem);
      }
    }
    this.updatedForTest();
  }

  private updatedForTest(): void {
  }

  private refreshItem(item: Item): void {
    this.scheduledForUpdateItems.add(item);
    void this.updateItemThrottler.schedule(async () => {
      const items = Array.from(this.scheduledForUpdateItems);
      this.scheduledForUpdateItems.clear();

      this.muteActivateItem = true;
      if (!this.showIgnoreListed && this.items.every(item => item.isIgnoreListed)) {
        this.showIgnoreListed = true;
        for (let i = 0; i < this.items.length; ++i) {
          this.list.refreshItemByIndex(i);
        }
        this.ignoreListMessageElement.classList.toggle('hidden', true);
      } else {
        this.showIgnoreListed = this.ignoreListCheckboxElement.checked;
        const itemsSet = new Set<Item>(items);
        let hasIgnoreListed = false;
        for (let i = 0; i < this.items.length; ++i) {
          const item = this.items.at(i);
          if (itemsSet.has(item)) {
            this.list.refreshItemByIndex(i);
          }
          hasIgnoreListed = hasIgnoreListed || item.isIgnoreListed;
        }
        this.ignoreListMessageElement.classList.toggle('hidden', !hasIgnoreListed);
      }
      delete this.muteActivateItem;
    });
  }

  createElementForItem(item: Item): Element {
    const element = document.createElement('div');
    element.classList.add('call-frame-item');
    const title = element.createChild('div', 'call-frame-item-title');
    const titleElement = title.createChild('div', 'call-frame-title-text');
    titleElement.textContent = item.title;
    if (item.isAsyncHeader) {
      element.classList.add('async-header');
    } else {
      UI.Tooltip.Tooltip.install(titleElement, item.title);
      const linkElement = element.createChild('div', 'call-frame-location');
      linkElement.textContent = Platform.StringUtilities.trimMiddle(item.linkText, 30);
      UI.Tooltip.Tooltip.install(linkElement, item.linkText);
      element.classList.toggle('ignore-listed-call-frame', item.isIgnoreListed);
      if (item.isIgnoreListed) {
        UI.ARIAUtils.setDescription(element, i18nString(UIStrings.onIgnoreList));
      }
      if (!item.frame) {
        UI.ARIAUtils.setDisabled(element, true);
      }
    }
    const isSelected =
        item.frame === UI.Context.Context.instance().flavor(StackTrace.StackTrace.DebuggableFrameFlavor)?.frame;

    element.classList.toggle('selected', isSelected);
    UI.ARIAUtils.setSelected(element, isSelected);
    element.classList.toggle('hidden', !this.showIgnoreListed && item.isIgnoreListed);
    const icon = new Icon();
    icon.name = 'large-arrow-right-filled';
    icon.classList.add('selected-call-frame-icon', 'small');
    element.appendChild(icon);
    element.tabIndex = item === this.list.selectedItem() ? 0 : -1;

    if (item.frame?.missingDebugInfo) {
      const icon = new Icon();
      icon.name = 'warning-filled';
      icon.classList.add('call-frame-warning-icon', 'small');
      const {resources, details} =
          convertMissingDebugInfo(item.frame.missingDebugInfo, item.frame.sdkFrame.functionName);
      const messages = resources.map(
          r => i18nString(UIStrings.debugFileNotFound, {PH1: Common.ParsedURL.ParsedURL.extractName(r.resourceUrl)}));
      UI.Tooltip.Tooltip.install(icon, [details, ...messages].join('\n'));
      element.appendChild(icon);
    }
    return element;
  }

  heightForItem(_item: Item): number {
    console.assert(false);  // Should not be called.
    return 0;
  }

  isItemSelectable(_item: Item): boolean {
    return true;
  }

  selectedItemChanged(_from: Item|null, _to: Item|null, fromElement: HTMLElement|null, toElement: HTMLElement|null):
      void {
    if (fromElement) {
      fromElement.tabIndex = -1;
    }
    if (toElement) {
      this.setDefaultFocusedElement(toElement);
      toElement.tabIndex = 0;
      if (this.hasFocus()) {
        toElement.focus();
      }
    }
  }

  updateSelectedItemARIA(_fromElement: Element|null, _toElement: Element|null): boolean {
    return true;
  }

  private onContextMenu(event: Event): void {
    const item = this.list.itemForNode((event.target as Node | null));
    if (!item) {
      return;
    }
    const contextMenu = new UI.ContextMenu.ContextMenu(event);
    const debuggerCallFrame = item.frame?.sdkFrame;
    if (debuggerCallFrame) {
      contextMenu.defaultSection().appendItem(i18nString(UIStrings.restartFrame), () => {
        Host.userMetrics.actionTaken(Host.UserMetrics.Action.StackFrameRestarted);
        void debuggerCallFrame.restart();
      }, {disabled: !debuggerCallFrame.canBeRestarted, jslogContext: 'restart-frame'});
    }
    contextMenu.defaultSection().appendItem(
        i18nString(UIStrings.copyStackTrace), this.copyStackTrace.bind(this), {jslogContext: 'copy-stack-trace'});
    if (item.uiLocation) {
      this.appendIgnoreListURLContextMenuItems(contextMenu, item.uiLocation.uiSourceCode);
    }
    void contextMenu.show();
  }

  private activateItem(item: Item): void {
    const uiLocation = item.uiLocation;
    if (this.muteActivateItem || !uiLocation) {
      return;
    }
    this.list.selectItem(item);
    const debuggerCallFrame = item.frame;
    const oldItem = this.activeCallFrameItem();
    if (debuggerCallFrame) {
      debuggerCallFrame.sdkFrame.debuggerModel.setSelectedCallFrame(debuggerCallFrame.sdkFrame);
      UI.Context.Context.instance().setFlavor(
          StackTrace.StackTrace.DebuggableFrameFlavor,
          StackTrace.StackTrace.DebuggableFrameFlavor.for(debuggerCallFrame));
    } else {
      void Common.Revealer.reveal(uiLocation);
    }
    if (oldItem !== item) {
      if (oldItem) {
        this.refreshItem(oldItem);
      }
      this.refreshItem(item);
    }
  }

  activeCallFrameItem(): Item|null {
    const frameFlavor = UI.Context.Context.instance().flavor(StackTrace.StackTrace.DebuggableFrameFlavor);
    if (frameFlavor) {
      return this.items.find(callFrameItem => callFrameItem.frame === frameFlavor.frame) || null;
    }
    return null;
  }

  appendIgnoreListURLContextMenuItems(
      contextMenu: UI.ContextMenu.ContextMenu, uiSourceCode: Workspace.UISourceCode.UISourceCode): void {
    const binding = Persistence.Persistence.PersistenceImpl.instance().binding(uiSourceCode);
    if (binding) {
      uiSourceCode = binding.network;
    }

    const menuSection = contextMenu.section('ignoreList');
    if (menuSection.items.length > 0) {
      // Already added menu items.
      return;
    }

    for (const {text, callback, jslogContext} of Workspace.IgnoreListManager.IgnoreListManager.instance()
             .getIgnoreListURLContextMenuItems(uiSourceCode)) {
      menuSection.appendItem(text, callback, {jslogContext});
    }
  }

  selectNextCallFrameOnStack(): void {
    const oldItem = this.activeCallFrameItem();
    const startIndex = oldItem ? this.items.indexOf(oldItem) + 1 : 0;
    for (let i = startIndex; i < this.items.length; i++) {
      const newItem = this.items.at(i);
      if (newItem.frame) {
        this.activateItem(newItem);
        break;
      }
    }
  }

  selectPreviousCallFrameOnStack(): void {
    const oldItem = this.activeCallFrameItem();
    const startIndex = oldItem ? this.items.indexOf(oldItem) - 1 : this.items.length - 1;
    for (let i = startIndex; i >= 0; i--) {
      const newItem = this.items.at(i);
      if (newItem.frame) {
        this.activateItem(newItem);
        break;
      }
    }
  }

  private copyStackTrace(): void {
    const text = [];
    for (const item of this.items) {
      let itemText = item.title;
      if (item.uiLocation) {
        itemText += ' (' + item.uiLocation.linkText(true /* skipTrim */) + ')';
      }
      text.push(itemText);
    }
    Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(text.join('\n'));
  }
}

export const elementSymbol = Symbol('element');
export const defaultMaxAsyncStackChainDepth = 32;

export class ActionDelegate implements UI.ActionRegistration.ActionDelegate {
  handleAction(_context: UI.Context.Context, actionId: string): boolean {
    switch (actionId) {
      case 'debugger.next-call-frame':
        CallStackSidebarPane.instance().selectNextCallFrameOnStack();
        return true;
      case 'debugger.previous-call-frame':
        CallStackSidebarPane.instance().selectPreviousCallFrameOnStack();
        return true;
    }
    return false;
  }
}

export class Item {
  isIgnoreListed = false;
  title = '';
  linkText = '';
  uiLocation: Workspace.UISourceCode.UILocation|null = null;
  isAsyncHeader = false;

  /** Only set for synchronous frames */
  frame?: StackTrace.StackTrace.DebuggableFrame;

  static createForDebuggableFrame(frame: StackTrace.StackTrace.DebuggableFrame): Item {
    const item = Item.createForFrame(frame);
    item.frame = frame;
    return item;
  }

  static createForFrame(frame: StackTrace.StackTrace.Frame): Item {
    const item = new Item(UI.UIUtils.beautifyFunctionName(frame.name ?? ''));
    const uiSourceCode = frame.uiSourceCode;
    if (uiSourceCode) {
      item.isIgnoreListed = uiSourceCode.isIgnoreListed() ?? false;
      item.uiLocation = uiSourceCode.uiLocation(frame.line, frame.column);
      item.linkText = item.uiLocation.linkText();
    }
    return item;
  }

  static createForAsyncHeader(
      stackTrace: StackTrace.StackTrace.StackTrace, fragment: StackTrace.StackTrace.AsyncFragment): Item {
    const description = UI.UIUtils.asyncFragmentLabel(stackTrace, fragment);
    const item = new Item(description);
    item.isAsyncHeader = true;
    return item;
  }

  private constructor(title: string) {
    this.title = title;
  }
}

export function convertMissingDebugInfo(
    missingDebugInfo: StackTrace.StackTrace.MissingDebugInfo, functionName: string|undefined):
    {details: Platform.UIString.LocalizedString, resources: SDK.DebuggerModel.MissingDebugFiles[]} {
  switch (missingDebugInfo.type) {
    case StackTrace.StackTrace.MissingDebugInfoType.PARTIAL_INFO:
      return {
        details: i18nString(UIStrings.debugSymbolsIncomplete, {PH1: functionName ?? ''}),
        resources: missingDebugInfo.missingDebugFiles
      };
    case StackTrace.StackTrace.MissingDebugInfoType.NO_INFO:
      return {
        details: i18nString(UIStrings.failedToLoadDebugSymbolsForFunction, {PH1: functionName ?? ''}),
        resources: []
      };
  }
}
