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

import '../../ui/legacy/legacy.js';
import '../../ui/legacy/components/data_grid/data_grid.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 * as ProtocolClient from '../../core/protocol_client/protocol_client.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as Bindings from '../../models/bindings/bindings.js';
import * as TextUtils from '../../models/text_utils/text_utils.js';
import * as Buttons from '../../ui/components/buttons/buttons.js';
import * as SourceFrame from '../../ui/legacy/components/source_frame/source_frame.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 {Events as JSONEditorEvents, JSONEditor, type Parameter} from './JSONEditor.js';
import protocolMonitorStyles from './protocolMonitor.css.js';

const {styleMap} = Directives;
const {widget, widgetRef} = UI.Widget;
const UIStrings = {
  /**
   * @description Text for one or a group of functions
   */
  method: 'Method',
  /**
   * @description Text in Protocol Monitor. Title for a table column which shows in which direction
   * the particular protocol message was travelling. Values in this column will either be 'sent' or
   * 'received'.
   */
  type: 'Type',
  /**
   * @description Text in Protocol Monitor of the Protocol Monitor tab. Noun relating to a network request.
   */
  request: 'Request',
  /**
   * @description Title of a cell content in protocol monitor. A Network response refers to the act of acknowledging a
   * network request. Should not be confused with answer.
   */
  response: 'Response',
  /**
   * @description Text for timestamps of items
   */
  timestamp: 'Timestamp',
  /**
   * @description Title of a cell content in protocol monitor. It describes the time between sending a request and receiving a response.
   */
  elapsedTime: 'Elapsed time',
  /**
   * @description Text in Protocol Monitor of the Protocol Monitor tab
   */
  target: 'Target',
  /**
   * @description Text to record a series of actions for analysis
   */
  record: 'Record',
  /**
   * @description Text to clear everything
   */
  clearAll: 'Clear all',
  /**
   * @description Text to filter result items
   */
  filter: 'Filter',
  /**
   * @description Text for the documentation of something
   */
  documentation: 'Documentation',
  /**
   * @description Text to open the CDP editor with the selected command
   */
  editAndResend: 'Edit and resend',
  /**
   * @description Cell text content in Protocol Monitor of the Protocol Monitor tab
   * @example {30} PH1
   */
  sMs: '{PH1} ms',
  /**
   * @description Text in Protocol Monitor of the Protocol Monitor tab
   */
  noMessageSelected: 'No message selected',
  /**
   * @description Text in Protocol Monitor of the Protocol Monitor tab if no message is selected
   */
  selectAMessageToView: 'Select a message to see its details',
  /**
   * @description Text in Protocol Monitor for the save button
   */
  save: 'Save',
  /**
   * @description Text in Protocol Monitor to describe the sessions column
   */
  session: 'Session',
  /**
   * @description A placeholder for an input in Protocol Monitor. The input accepts commands that are sent to the backend on Enter. CDP stands for Chrome DevTools Protocol.
   */
  sendRawCDPCommand: 'Send a raw `CDP` command',
  /**
   * @description A tooltip text for the input in the Protocol Monitor panel. The tooltip describes what format is expected.
   */
  sendRawCDPCommandExplanation:
      'Format: `\'Domain.commandName\'` for a command without parameters, or `\'{"command":"Domain.commandName", "parameters": {...}}\'` as a JSON object for a command with parameters. `\'cmd\'`/`\'method\'` and `\'args\'`/`\'params\'`/`\'arguments\'` are also supported as alternative keys for the `JSON` object.',

  /**
   * @description A label for a select input that allows selecting a CDP target to send the commands to.
   */
  selectTarget: 'Select a target',
  /**
   * @description Tooltip for the the console sidebar toggle in the Console panel. Command to
   * open/show the sidebar.
   */
  showCDPCommandEditor: 'Show CDP command editor',
  /**
   * @description Tooltip for the the console sidebar toggle in the Console panel. Command to
   * open/show the sidebar.
   */
  hideCDPCommandEditor: 'Hide  CDP command editor',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/protocol_monitor/ProtocolMonitor.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);

export const buildProtocolMetadata = (domains: Iterable<ProtocolDomain>):
    Map<string, {parameters: Parameter[], description: string, replyArgs: string[]}> => {
      const metadataByCommand = new Map<string, {parameters: Parameter[], description: string, replyArgs: string[]}>();
      for (const domain of domains) {
        for (const command of Object.keys(domain.metadata)) {
          metadataByCommand.set(command, domain.metadata[command]);
        }
      }
      return metadataByCommand;
    };

const metadataByCommand = buildProtocolMetadata(
    ProtocolClient.InspectorBackend.inspectorBackend.agentPrototypes.values() as Iterable<ProtocolDomain>);
const typesByName = ProtocolClient.InspectorBackend.inspectorBackend.typeMap as Map<string, Parameter[]>;
const enumsByName = ProtocolClient.InspectorBackend.inspectorBackend.enumMap as Map<string, Record<string, string>>;
export interface Message {
  id?: number;
  method: string;
  error?: Record<string, unknown>;
  result?: Record<string, unknown>;
  params?: Record<string, unknown>;
  requestTime: number;
  elapsedTime?: number;
  sessionId?: string;
  target?: SDK.Target.Target;
}
export interface LogMessage {
  id?: number;
  domain: string;
  method: string;
  params: Object;
  type: 'send'|'recv';
}

export interface ProtocolDomain {
  readonly domain: string;
  readonly metadata: Record<string, {parameters: Parameter[], description: string, replyArgs: string[]}>;
}

export interface ViewInput {
  messages: Message[];
  selectedMessage?: Message;
  sidebarVisible: boolean;
  command: string;
  commandSuggestions: string[];
  filterKeys: string[];
  filter: string;
  parseFilter: (filter: string) => TextUtils.TextUtils.ParsedFilter[];
  onRecord: (record: boolean) => void;
  onClear: () => void;
  onSave: () => void;
  onSplitChange: (onlyMain: boolean) => void;
  onSelect: (e: Message|undefined) => void;
  onContextMenu: (message: Message, menu: UI.ContextMenu.ContextMenu) => void;
  onFilterChanged: (filter: string) => void;
  onCommandChange: (command: string) => void;
  onCommandSubmitted: (input: string) => void;
  onTargetChange: (targetId: string) => void;
  onToggleSidebar: () => void;
  targets: SDK.Target.Target[];
  selectedTargetId: string;
}

export interface ViewOutput {
  editorWidget: JSONEditor;
}

export type View = (input: ViewInput, output: ViewOutput, target: HTMLElement) => void;

export const DEFAULT_VIEW: View = (input, output, target) => {
  // clang-format off
    render(html`
        <style>${UI.inspectorCommonStyles}</style>
        <style>${protocolMonitorStyles}</style>
        <devtools-split-view name="protocol-monitor-split-container"
                             direction="column"
                             sidebar-initial-size="400"
                             sidebar-visibility=${input.sidebarVisible ? 'visible' : 'hidden'}
                             @change=${(e: CustomEvent<string>) => input.onSplitChange(e.detail === 'OnlyMain')}>
          <div slot="main" class="vbox protocol-monitor-main">
            <devtools-toolbar class="protocol-monitor-toolbar"
                               jslog=${VisualLogging.toolbar('top')}>
               <devtools-button title=${i18nString(UIStrings.record)}
                                .iconName=${'record-start'}
                                .toggledIconName=${'record-stop'}
                                .jslogContext=${'protocol-monitor.toggle-recording'}
                                .variant=${Buttons.Button.Variant.ICON_TOGGLE}
                                .toggleType=${Buttons.Button.ToggleType.RED}
                                .toggled=${true}
                                @click=${(e: Event) => input.onRecord((e.target as Buttons.Button.Button).toggled)}>
               </devtools-button>
              <devtools-button title=${i18nString(UIStrings.clearAll)}
                               .iconName=${'clear'}
                               .variant=${Buttons.Button.Variant.TOOLBAR}
                               .jslogContext=${'protocol-monitor.clear-all'}
                               @click=${() => input.onClear()}></devtools-button>
              <devtools-button title=${i18nString(UIStrings.save)}
                               .iconName=${'download'}
                               .variant=${Buttons.Button.Variant.TOOLBAR}
                               .jslogContext=${'protocol-monitor.save'}
                               @click=${() => input.onSave()}></devtools-button>
              <devtools-toolbar-input type="filter"
                                      list="filter-suggestions"
                                      style="flex-grow: 1"
                                      value=${input.filter}
                                      @change=${(e: CustomEvent<string>) => input.onFilterChanged(e.detail)}>
                <datalist id="filter-suggestions">
                  ${input.filterKeys.map(key => html`
                        <option value=${key + ':'}></option>
                        <option value=${'-' + key + ':'}></option>`)}
                </datalist>
              </devtools-toolbar-input>
            </devtools-toolbar>
            <devtools-split-view direction="column" sidebar-position="second"
                                 name="protocol-monitor-panel-split" sidebar-initial-size="250">
              <devtools-data-grid
                  striped
                  slot="main"
                  .filters=${input.parseFilter(input.filter)}>
                <table>
                    <tr>
                      <th id="type" sortable style="text-align: center" hideable weight="1">
                        ${i18nString(UIStrings.type)}
                      </th>
                      <th id="method" weight="5">
                        ${i18nString(UIStrings.method)}
                      </th>
                      <th id="request" hideable weight="5">
                        ${i18nString(UIStrings.request)}
                      </th>
                      <th id="response" hideable weight="5">
                        ${i18nString(UIStrings.response)}
                      </th>
                      <th id="elapsed-time" sortable hideable weight="2">
                        ${i18nString(UIStrings.elapsedTime)}
                      </th>
                      <th id="timestamp" sortable hideable weight="5">
                        ${i18nString(UIStrings.timestamp)}
                      </th>
                      <th id="target" sortable hideable weight="5">
                        ${i18nString(UIStrings.target)}
                      </th>
                      <th id="session" sortable hideable weight="5">
                        ${i18nString(UIStrings.session)}
                      </th>
                    </tr>
                    ${
            input.messages.map(
                message => html`
                      <tr @select=${() => input.onSelect(message)}
                          @contextmenu=${(e: CustomEvent<UI.ContextMenu.ContextMenu>) => input.onContextMenu(message, e.detail)}
                          style="--override-data-grid-row-background-color: var(--sys-color-surface3)">
                        ${'id' in message ? html`
                          <td title="sent">
                            <devtools-icon name="arrow-up-down" class="medium" style="color: var(--icon-request-response);">
                            </devtools-icon>
                          </td>` : html`
                          <td title="received">
                            <devtools-icon name="arrow-down" class="medium" style="color: var(--icon-request);">
                            </devtools-icon>
                          </td>`}
                        <td>${message.method}</td>
                        <td>${message.params ? html`<code>${JSON.stringify(message.params)}</code>` : ''}</td>
                        <td>
                          ${message.result    ? html`<code>${JSON.stringify(message.result)}</code>` :
                                message.error ? html`<code>${JSON.stringify(message.error)}</code>` :
                              'id' in message ? '(pending)' : ''}
                        </td>
                        <td data-value=${message.elapsedTime || 0}>
                          ${!('id' in message)  ? '' :
                            message.elapsedTime ? i18nString(UIStrings.sMs, {PH1: String(message.elapsedTime)})
                                                : '(pending)'}
                        </td>
                        <td data-value=${message.requestTime}>${i18nString(UIStrings.sMs, {PH1: String(message.requestTime)})}</td>
                        <td>${targetToString(message.target)}</td>
                        <td>${message.sessionId || ''}</td>
                      </tr>`)}
                  </table>
              </devtools-data-grid>
              <devtools-widget ${widget(InfoWidget, {
                    request: input.selectedMessage?.params,
                    response: input.selectedMessage?.result || input.selectedMessage?.error,
                    type: !input.selectedMessage           ? undefined :
                          ('id' in input?.selectedMessage) ? 'sent'
                                                           : 'received',
                  })}
                  class="protocol-monitor-info"
                  slot="sidebar"></devtools-widget>
            </devtools-split-view>
            <devtools-toolbar class="protocol-monitor-bottom-toolbar"
               jslog=${VisualLogging.toolbar('bottom')}>
              <devtools-button .title=${input.sidebarVisible ? i18nString(UIStrings.hideCDPCommandEditor) : i18nString(UIStrings.showCDPCommandEditor)}
                               .iconName=${input.sidebarVisible ? 'left-panel-close' : 'left-panel-open'}
                               .variant=${Buttons.Button.Variant.TOOLBAR}
                               .jslogContext=${'protocol-monitor.toggle-command-editor'}
                               @click=${() => input.onToggleSidebar()}></devtools-button>
              </devtools-button>
              <devtools-toolbar-input id="command-input"
                                      style=${styleMap({
                                        'flex-grow': 1,
                                        display: input.sidebarVisible ? 'none' : 'flex'})}
                                      value=${input.command}
                                      list="command-input-suggestions"
                                      placeholder=${i18nString(UIStrings.sendRawCDPCommand)}
                                      title=${i18nString(UIStrings.sendRawCDPCommandExplanation)}
                                      @change=${(e: CustomEvent<string>) => input.onCommandChange(e.detail)}
                                      @submit=${(e: CustomEvent<string>) => input.onCommandSubmitted(e.detail)}>
                <datalist id="command-input-suggestions">
                  ${input.commandSuggestions.map(c => html`<option value=${c}></option>`)}
                </datalist>
              </devtools-toolbar-input>
              <select class="target-selector"
                      title=${i18nString(UIStrings.selectTarget)}
                      style=${styleMap({display: input.sidebarVisible ? 'none' : 'flex'})}
                      jslog=${VisualLogging.dropDown('target-selector').track({change: true})}
                      @change=${(e: Event) => input.onTargetChange((e.target as HTMLSelectElement).value)}>
                ${input.targets.map(target => html`
                  <option jslog=${VisualLogging.item('target').track({click: true})}
                          value=${target.id()} ?selected=${target.id() === input.selectedTargetId}>
                    ${target.name()} (${target.inspectedURL()})
                  </option>`)}
              </select>
            </devtools-toolbar>
          </div>
          <devtools-widget slot="sidebar"
              ${widget(JSONEditor, { metadataByCommand, typesByName, enumsByName})}
              ${widgetRef(JSONEditor, e => {output.editorWidget = e;})}>
          </devtools-widget>
        </devtools-split-view>`,
        target
    );
  // clang-format on
};

export class ProtocolMonitorImpl extends UI.Panel.Panel implements SDK.TargetManager.Observer {
  private started: boolean;
  private startTime: number;
  private readonly messageForId = new Map<number, Message>();
  private readonly filterParser: TextUtils.TextUtils.FilterParser;
  #filterKeys = ['method', 'request', 'response', 'target', 'session'];
  #commandAutocompleteSuggestionProvider = new CommandAutocompleteSuggestionProvider();
  #selectedTargetId: string;
  #command = '';
  #sidebarVisible = false;
  #view: View;
  #messages: Message[] = [];
  #selectedMessage: Message|undefined;
  #filter = '';
  #editorWidget!: JSONEditor;
  #targetsBySessionId = new Map<string, SDK.Target.Target>();
  constructor(view: View = DEFAULT_VIEW) {
    super('protocol-monitor', true);
    this.#view = view;
    this.started = false;
    this.startTime = 0;

    this.#filterKeys = ['method', 'request', 'response', 'type', 'target', 'session'];
    this.filterParser = new TextUtils.TextUtils.FilterParser(this.#filterKeys);

    this.#selectedTargetId = 'main';
    this.performUpdate();
    this.#editorWidget.addEventListener(JSONEditorEvents.SUBMIT_EDITOR, event => {
      this.onCommandSend(event.data.command, event.data.parameters, event.data.targetId);
    });
    SDK.TargetManager.TargetManager.instance().addEventListener(
        SDK.TargetManager.Events.AVAILABLE_TARGETS_CHANGED, () => {
          this.requestUpdate();
        });
    SDK.TargetManager.TargetManager.instance().observeTargets(this);
  }

  targetAdded(target: SDK.Target.Target): void {
    this.#targetsBySessionId.set(target.sessionId, target);
  }

  targetRemoved(target: SDK.Target.Target): void {
    this.#targetsBySessionId.delete(target.sessionId);
  }

  #populateToolbarInput(): void {
    const commandJson = this.#editorWidget.getCommandJson();
    const targetId = this.#editorWidget.targetId;
    if (targetId) {
      this.#selectedTargetId = targetId;
    }
    if (commandJson) {
      this.#command = commandJson;
      this.requestUpdate();
    }
  }

  override performUpdate(): void {
    const viewInput = {
      messages: this.#messages,
      selectedMessage: this.#selectedMessage,
      sidebarVisible: this.#sidebarVisible,
      command: this.#command,
      commandSuggestions: this.#commandAutocompleteSuggestionProvider.allSuggestions(),
      filterKeys: this.#filterKeys,
      filter: this.#filter,
      parseFilter: this.filterParser.parse.bind(this.filterParser),
      onSplitChange: (onlyMain: boolean) => {
        if (onlyMain) {
          this.#populateToolbarInput();
          this.#sidebarVisible = false;
        } else {
          const {command, parameters} = parseCommandInput(this.#command);
          this.#editorWidget.displayCommand(command, parameters, this.#selectedTargetId);
          this.#sidebarVisible = true;
        }
        this.requestUpdate();
      },
      onRecord: (recording: boolean) => {
        this.setRecording(recording);
      },
      onClear: () => {
        this.#messages = [];
        this.messageForId.clear();
        this.requestUpdate();
      },
      onSave: () => {
        void this.saveAsFile();
      },
      onSelect: (message: Message|undefined) => {
        this.#selectedMessage = message;
        this.requestUpdate();
      },
      onContextMenu: this.#populateContextMenu.bind(this),
      onCommandChange: (command: string) => {
        this.#command = command;
      },
      onCommandSubmitted: (input: string) => {
        this.#commandAutocompleteSuggestionProvider.addEntry(input);
        const {command, parameters} = parseCommandInput(input);
        this.onCommandSend(command, parameters, this.#selectedTargetId);
      },
      onFilterChanged: (filter: string) => {
        this.#filter = filter;
        this.requestUpdate();
      },
      onTargetChange: (targetId: string) => {
        this.#selectedTargetId = targetId;
      },
      onToggleSidebar: () => {
        this.#sidebarVisible = !this.#sidebarVisible;
        this.requestUpdate();
      },
      targets: SDK.TargetManager.TargetManager.instance().targets(),
      selectedTargetId: this.#selectedTargetId,
    };
    const that = this;
    const viewOutput = {
      set editorWidget(value: JSONEditor) {
        that.#editorWidget = value;
      }
    };
    this.#view(viewInput, viewOutput, this.contentElement);
  }

  #populateContextMenu(message: Message, menu: UI.ContextMenu.ContextMenu): void {
    /**
     * You can click the "Edit and resend" item in the context menu to be
     * taken to the CDP editor with the filled with the selected command.
     */
    menu.editSection().appendItem(i18nString(UIStrings.editAndResend), () => {
      if (!this.#selectedMessage) {
        return;
      }
      const parameters = this.#selectedMessage.params as Record<string, unknown>;
      const targetId = this.#selectedMessage.target?.id() || '';
      const command = message.method;
      this.#command = JSON.stringify({command, parameters});
      if (!this.#sidebarVisible) {
        this.#sidebarVisible = true;
        this.requestUpdate();
      } else {
        this.#editorWidget.displayCommand(command, parameters, targetId);
      }
    }, {jslogContext: 'edit-and-resend', disabled: !('id' in message)});

    /**
     * You can click the "Filter" item in the context menu to filter the
     * protocol monitor entries to those that match the method of the
     * current row.
     */
    menu.editSection().appendItem(i18nString(UIStrings.filter), () => {
      this.#filter = `method:${message.method}`;
      this.requestUpdate();
    }, {jslogContext: 'filter'});

    /**
     * You can click the "Documentation" item in the context menu to be
     * taken to the CDP Documentation site entry for the given method.
     */
    menu.footerSection().appendItem(i18nString(UIStrings.documentation), () => {
      const [domain, method] = message.method.split('.');
      const type = 'id' in message ? 'method' : 'event';
      Host.InspectorFrontendHost.InspectorFrontendHostInstance.openInNewTab(
          `https://chromedevtools.github.io/devtools-protocol/tot/${domain}#${type}-${method}` as
          Platform.DevToolsPath.UrlString);
    }, {jslogContext: 'documentation'});
  }

  onCommandSend(command: string, parameters: object, target?: string): void {
    const test = ProtocolClient.InspectorBackend.test;
    const targetManager = SDK.TargetManager.TargetManager.instance();
    const selectedTarget = target ? targetManager.targetById(target) : null;
    const sessionId = selectedTarget ? selectedTarget.sessionId : '';
    // TS thinks that properties are read-only because
    // in TS test is defined as a namespace.
    // @ts-expect-error
    test.sendRawMessage(command, parameters, () => {}, sessionId);
  }

  override wasShown(): void {
    super.wasShown();
    if (this.started) {
      return;
    }
    this.started = true;
    this.startTime = Date.now();
    this.setRecording(true);
  }

  private setRecording(recording: boolean): void {
    const test = ProtocolClient.InspectorBackend.test;
    if (recording) {
      // @ts-expect-error
      test.onMessageSent = this.messageSent.bind(this);
      // @ts-expect-error
      test.onMessageReceived = this.messageReceived.bind(this);
    } else {
      test.onMessageSent = null;
      test.onMessageReceived = null;
    }
  }

  private messageReceived(message: Message): void {
    if ('id' in message && message.id) {
      const existingMessage = this.messageForId.get(message.id);
      if (!existingMessage) {
        return;
      }
      existingMessage.result = message.result;
      existingMessage.error = message.error;
      existingMessage.elapsedTime = Date.now() - this.startTime - existingMessage.requestTime;

      // Now we've updated the message, it won't be updated again, so we can delete it from the tracking map.
      this.messageForId.delete(message.id);
      this.requestUpdate();
      return;
    }

    const target = message.sessionId !== undefined ? this.#targetsBySessionId.get(message.sessionId) : undefined;
    this.#messages.push({
      method: message.method,
      sessionId: message.sessionId,
      target,
      requestTime: Date.now() - this.startTime,
      result: message.params,
    });

    this.requestUpdate();
  }

  private messageSent(
      message: {domain: string, method: string, params: Record<string, unknown>, id: number, sessionId?: string}):
      void {
    const target = message.sessionId !== undefined ? this.#targetsBySessionId.get(message.sessionId) : undefined;
    const messageRecord = {
      method: message.method,
      params: message.params,
      id: message.id,
      sessionId: message.sessionId,
      target,
      requestTime: Date.now() - this.startTime,
    };
    this.#messages.push(messageRecord);
    this.requestUpdate();
    this.messageForId.set(message.id, messageRecord);
  }

  private async saveAsFile(): Promise<void> {
    const now = new Date();
    const fileName = 'ProtocolMonitor-' + Platform.DateUtilities.toISO8601Compact(now) + '.json' as
        Platform.DevToolsPath.RawPathString;
    const stream = new Bindings.FileUtils.FileOutputStream();

    const accepted = await stream.open(fileName);
    if (!accepted) {
      return;
    }

    const rowEntries = this.#messages.map(m => ({...m, target: m.target?.id()}));

    void stream.write(JSON.stringify(rowEntries, null, '  '));
    void stream.close();
  }
}

export class CommandAutocompleteSuggestionProvider {
  #maxHistorySize = 200;
  #commandHistory = new Set<string>();

  constructor(maxHistorySize?: number) {
    if (maxHistorySize !== undefined) {
      this.#maxHistorySize = maxHistorySize;
    }
  }

  allSuggestions(): string[] {
    const newestToOldest = [...this.#commandHistory].reverse();
    newestToOldest.push(...metadataByCommand.keys());
    return newestToOldest;
  }

  buildTextPromptCompletions =
      async(expression: string, prefix: string, force?: boolean): Promise<UI.SuggestBox.Suggestions> => {
    if (!prefix && !force && expression) {
      return [];
    }

    const newestToOldest = this.allSuggestions();
    return newestToOldest.filter(cmd => cmd.startsWith(prefix)).map(text => ({
                                                                      text,
                                                                    }));
  };

  addEntry(value: string): void {
    if (this.#commandHistory.has(value)) {
      this.#commandHistory.delete(value);
    }
    this.#commandHistory.add(value);
    if (this.#commandHistory.size > this.#maxHistorySize) {
      const earliestEntry = this.#commandHistory.values().next().value as string;
      this.#commandHistory.delete(earliestEntry);
    }
  }
}

interface InfoWidgetViewInput {
  request: Record<string, unknown>|undefined;
  response: Record<string, unknown>|undefined;
  type: 'sent'|'received'|undefined;
  selectedTab: 'request'|'response'|undefined;
}

type InfoWidgetView = (input: InfoWidgetViewInput, output: undefined, target: HTMLElement) => void;

const INFO_WIDGET_VIEW: InfoWidgetView = (input, _output, target) => {
  // clang-format off
  render(html`
    <devtools-tabbed-pane>${input.type === undefined ? html`
      <devtools-widget
          id="request" title=${i18nString(UIStrings.request)}
          ?selected=${input.selectedTab === 'request'} disabled
          ${widget(UI.EmptyWidget.EmptyWidget, {
              header: i18nString(UIStrings.noMessageSelected),
              text: i18nString(UIStrings.selectAMessageToView)})}>
      </devtools-widget>
      <devtools-widget
          id="response" title=${i18nString(UIStrings.response)}
          ?selected=${input.selectedTab === 'response'}
          ${widget(UI.EmptyWidget.EmptyWidget, {
              header: i18nString(UIStrings.noMessageSelected),
              text: i18nString(UIStrings.selectAMessageToView)})}>
      </devtools-widget>`: html`
      <devtools-widget
          id="request" title=${i18nString(UIStrings.request)}
          ?selected=${input.selectedTab === 'request'} ?disabled=${input.type !== 'sent'}
          ${widget(SourceFrame.JSONView.SearchableJsonView, {jsonObject: input.request})}>
      </devtools-widget>
      <devtools-widget
          id="response" title=${i18nString(UIStrings.response)}
          ?selected=${input.selectedTab === 'response'}
          ${widget(SourceFrame.JSONView.SearchableJsonView, {jsonObject: input.response})}>
      </devtools-widget>`}
    </devtools-tabbed-pane>`, target);
  // clang-format on
};

export class InfoWidget extends UI.Widget.VBox {
  #view: InfoWidgetView;
  request: Record<string, unknown>|undefined;
  response: Record<string, unknown>|undefined;
  type: 'sent'|'received'|undefined;
  constructor(element: HTMLElement, view = INFO_WIDGET_VIEW) {
    super(element);
    this.#view = view;
    this.requestUpdate();
  }

  override performUpdate(): void {
    this.#view(
        {
          request: this.request,
          response: this.response,
          type: this.type,
          selectedTab: this.type !== 'sent' ? 'response' : undefined,
        },
        undefined, this.contentElement);
  }
}

export function parseCommandInput(input: string): {command: string, parameters: Record<string, unknown>} {
  // If input cannot be parsed as json, we assume it's the command name
  // for a command without parameters. Otherwise, we expect an object
  // with "command"/"method"/"cmd" and "parameters"/"params"/"args"/"arguments" attributes.
  let json = null;
  try {
    json = JSON.parse(input);
  } catch {
  }

  const command = json ? json.command || json.method || json.cmd || '' : input;
  const parameters = json?.parameters || json?.params || json?.args || json?.arguments || {};

  return {command, parameters};
}

function targetToString(target: SDK.Target.Target|undefined): string {
  if (!target) {
    return '';
  }
  return target.decorateLabel(
      `${target.name()} ${target === SDK.TargetManager.TargetManager.instance().rootTarget() ? '' : target.id()}`);
}
