// Copyright 2023 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/kit/kit.js';
import '../../ui/components/menus/menus.js';

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 SDK from '../../core/sdk/sdk.js';
import * as Buttons from '../../ui/components/buttons/buttons.js';
import * as SuggestionInput from '../../ui/components/suggestion_input/suggestion_input.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as Lit from '../../ui/lit/lit.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
import * as ElementsComponents from '../elements/components/components.js';

import editorWidgetStyles from './JSONEditor.css.js';

const {html, render, Directives, nothing} = Lit;
const {live, classMap, repeat} = Directives;

const UIStrings = {
  /**
   * @description The title of a button that deletes a parameter.
   */
  deleteParameter: 'Delete parameter',
  /**
   * @description The title of a button that adds a parameter.
   */
  addParameter: 'Add a parameter',
  /**
   * @description The title of a button that reset the value of a parameters to its default value.
   */
  resetDefaultValue: 'Reset to default value',
  /**
   * @description The title of a button to add custom key/value pairs to object parameters with no keys defined
   */
  addCustomProperty: 'Add custom property',
  /**
   * @description The title of a button that sends a CDP command.
   */
  sendCommandCtrlEnter: 'Send command - Ctrl+Enter',
  /**
   * @description The title of a button that sends a CDP command.
   */
  sendCommandCmdEnter: 'Send command - ⌘+Enter',
  /**
   * @description The title of a button that copies a CDP command.
   */
  copyCommand: 'Copy command',
  /**
   * @description A label for a select input that allows selecting a CDP target to send the commands to.
   */
  selectTarget: 'Select a target',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/protocol_monitor/JSONEditor.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);

export const enum ParameterType {
  STRING = 'string',
  NUMBER = 'number',
  BOOLEAN = 'boolean',
  ARRAY = 'array',
  OBJECT = 'object',
  UNKNOWN = 'unknown',
}

interface BaseParameter {
  optional: boolean;
  name: string;
  typeRef?: string;
  description: string;
  isCorrectType?: boolean;
  isKeyEditable?: boolean;
}

interface ArrayParameter extends BaseParameter {
  type: ParameterType.ARRAY;
  value?: Parameter[];
}

interface NumberParameter extends BaseParameter {
  type: ParameterType.NUMBER;
  value?: number;
}

interface StringParameter extends BaseParameter {
  type: ParameterType.STRING;
  value?: string;
}

interface BooleanParameter extends BaseParameter {
  type: ParameterType.BOOLEAN;
  value?: boolean;
}

interface ObjectParameter extends BaseParameter {
  type: ParameterType.OBJECT;
  value?: Parameter[];
}

interface UnknownParameter extends BaseParameter {
  type: ParameterType.UNKNOWN;
  value?: string;
}

export type Parameter =
    ArrayParameter|NumberParameter|StringParameter|BooleanParameter|ObjectParameter|UnknownParameter;

export interface Command {
  command: string;
  parameters: Record<string, unknown>;
  targetId?: string;
}

interface ViewInput {
  onKeydown: (event: KeyboardEvent) => void;
  metadataByCommand: Map<string, {parameters: Parameter[], description: string, replyArgs: string[]}>;
  command: string;
  parameters: Parameter[];
  typesByName: Map<string, Parameter[]>;
  onCommandInputBlur: (event: Event) => void;
  onCommandSend: () => void;
  onCopyToClipboard: () => void;
  targets: SDK.Target.Target[];
  targetId: string|undefined;
  onAddParameter: (parameterId: string) => void;
  onClearParameter: (parameter: Parameter, isParentArray?: boolean) => void;
  onDeleteParameter: (parameter: Parameter, parentParameter: Parameter) => void;
  onTargetSelected: (event: Event) => void;
  computeDropdownValues: (parameter: Parameter) => string[];
  onParameterFocus: (event: Event) => void;
  onParameterKeydown: (event: KeyboardEvent) => void;
  onParameterKeyBlur: (event: Event) => void;
  onParameterValueBlur: (event: Event) => void;
  displayTargetSelector?: boolean;
  displayCommandInput?: boolean;
}

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

const splitDescription = (description: string): [string, string] => {
  // If the description is too long we make the UI a bit better by highlighting the first sentence
  // which contains the most information.
  // The number 150 has been chosen arbitrarily
  if (description.length > 150) {
    const [firstSentence, restOfDescription] = description.split('.');
    // To make the UI nicer, we add a dot at the end of the first sentence.
    firstSentence + '.';
    return [firstSentence, restOfDescription];
  }
  return [description, ''];
};

const defaultValueByType = new Map<string, string|number|boolean>([
  ['string', ''],
  ['number', 0],
  ['boolean', false],
  ['unknown', ''],
]);

const DUMMY_DATA = 'dummy';
const EMPTY_STRING = '<empty_string>';

export function suggestionFilter(option: string, query: string): boolean {
  return option.toLowerCase().includes(query.toLowerCase());
}

export const enum Events {
  SUBMIT_EDITOR = 'submiteditor',
}

export interface EventTypes {
  [Events.SUBMIT_EDITOR]: Command;
}

export class JSONEditor extends Common.ObjectWrapper.eventMixin<EventTypes, typeof UI.Widget.VBox>(UI.Widget.VBox) {
  #metadataByCommand = new Map<string, {parameters: Parameter[], description: string, replyArgs: string[]}>();
  #typesByName = new Map<string, Parameter[]>();
  #enumsByName = new Map<string, Record<string, string>>();
  #parameters: Parameter[] = [];
  #targets: SDK.Target.Target[] = [];
  #command = '';
  #targetId?: string;
  #hintPopoverHelper?: UI.PopoverHelper.PopoverHelper;
  #view: View;
  displayTargetSelector = true;
  displayCommandInput = true;

  constructor(element: HTMLElement, view = DEFAULT_VIEW) {
    super(element, {useShadowDom: true});
    this.#view = view;
    this.registerRequiredCSS(editorWidgetStyles);
  }

  get metadataByCommand(): Map<string, {parameters: Parameter[], description: string, replyArgs: string[]}> {
    return this.#metadataByCommand;
  }

  set metadataByCommand(
      metadataByCommand: Map<string, {parameters: Parameter[], description: string, replyArgs: string[]}>) {
    this.#metadataByCommand = metadataByCommand;
    this.requestUpdate();
  }

  get typesByName(): Map<string, Parameter[]> {
    return this.#typesByName;
  }

  set typesByName(typesByName: Map<string, Parameter[]>) {
    this.#typesByName = typesByName;
    this.requestUpdate();
  }

  get enumsByName(): Map<string, Record<string, string>> {
    return this.#enumsByName;
  }

  set enumsByName(enumsByName: Map<string, Record<string, string>>) {
    this.#enumsByName = enumsByName;
    this.requestUpdate();
  }

  get parameters(): Parameter[] {
    return this.#parameters;
  }

  set parameters(parameters: Parameter[]) {
    this.#parameters = parameters;
    this.requestUpdate();
  }

  get targets(): SDK.Target.Target[] {
    return this.#targets;
  }

  set targets(targets: SDK.Target.Target[]) {
    this.#targets = targets;
    this.requestUpdate();
  }

  get command(): string {
    return this.#command;
  }

  set command(command: string) {
    if (this.#command !== command) {
      this.#command = command;
      this.requestUpdate();
    }
  }

  set commandToDisplay(command: string) {
    this.displayCommand(command, {});
  }

  get targetId(): string|undefined {
    return this.#targetId;
  }

  set targetId(targetId: string|undefined) {
    if (this.#targetId !== targetId) {
      this.#targetId = targetId;
      this.requestUpdate();
    }
  }

  override wasShown(): void {
    super.wasShown();
    this.#hintPopoverHelper = new UI.PopoverHelper.PopoverHelper(
        this.contentElement, event => this.#handlePopoverDescriptions(event), 'protocol-monitor.hint');
    this.#hintPopoverHelper.setDisableOnClick(true);
    this.#hintPopoverHelper.setTimeout(300);
    const targetManager = SDK.TargetManager.TargetManager.instance();
    targetManager.addEventListener(
        SDK.TargetManager.Events.AVAILABLE_TARGETS_CHANGED, this.#handleAvailableTargetsChanged, this);
    this.#handleAvailableTargetsChanged();
    this.requestUpdate();
  }

  override willHide(): void {
    super.willHide();
    this.#hintPopoverHelper?.hidePopover();
    this.#hintPopoverHelper?.dispose();
    const targetManager = SDK.TargetManager.TargetManager.instance();
    targetManager.removeEventListener(
        SDK.TargetManager.Events.AVAILABLE_TARGETS_CHANGED, this.#handleAvailableTargetsChanged, this);
  }

  #handleAvailableTargetsChanged(): void {
    this.targets = SDK.TargetManager.TargetManager.instance().targets();
    if (this.targets.length && this.targetId === undefined) {
      this.targetId = this.targets[0].id();
    }
  }

  getParameters(): Record<string, unknown> {
    const formatParameterValue = (parameter: Parameter): unknown => {
      if (parameter.value === undefined) {
        return;
      }
      switch (parameter.type) {
        case ParameterType.NUMBER: {
          return Number(parameter.value);
        }
        case ParameterType.BOOLEAN: {
          return Boolean(parameter.value);
        }
        case ParameterType.OBJECT: {
          const nestedParameters: Record<string, unknown> = {};
          for (const subParameter of parameter.value) {
            const formattedValue = formatParameterValue(subParameter);
            if (formattedValue !== undefined) {
              nestedParameters[subParameter.name] = formatParameterValue(subParameter);
            }
          }
          if (Object.keys(nestedParameters).length === 0) {
            return undefined;
          }
          return nestedParameters;
        }
        case ParameterType.ARRAY: {
          const nestedArrayParameters = [];
          for (const subParameter of parameter.value) {
            nestedArrayParameters.push(formatParameterValue(subParameter));
          }
          return nestedArrayParameters.length === 0 ? [] : nestedArrayParameters;
        }
        case ParameterType.UNKNOWN: {
          try {
            return JSON.parse(parameter.value as string);
          } catch {
            return parameter.value;
          }
        }
        default: {
          return parameter.value;
        }
      }
    };

    const formattedParameters: Record<string, unknown> = {};
    for (const parameter of this.parameters) {
      formattedParameters[parameter.name] = formatParameterValue(parameter);
    }
    return formatParameterValue({
             type: ParameterType.OBJECT,
             name: DUMMY_DATA,
             optional: true,
             value: this.parameters,
             description: '',
           }) as Record<string, unknown>;
  }

  // Displays a command entered in the input bar inside the editor
  displayCommand(command: string, parameters: Record<string, unknown>, targetId?: string): void {
    this.targetId = targetId;
    this.command = command;
    const schema = this.metadataByCommand.get(this.command);
    if (!schema?.parameters) {
      return;
    }
    this.populateParametersForCommandWithDefaultValues();

    const displayedParameters = this.#convertObjectToParameterSchema(
                                        '', parameters, {
                                          typeRef: DUMMY_DATA,
                                          type: ParameterType.OBJECT,
                                          name: '',
                                          description: '',
                                          optional: true,
                                          value: [],
                                        },
                                        schema.parameters)
                                    .value as Parameter[];

    const valueByName = new Map(this.parameters.map(param => [param.name, param]));
    for (const param of displayedParameters) {
      const existingParam = valueByName.get(param.name);
      if (existingParam) {
        existingParam.value = param.value;
      }
    }

    this.requestUpdate();
  }

  #convertObjectToParameterSchema(key: string, value: unknown, schema?: Parameter, initialSchema?: Parameter[]):
      Parameter {
    const type = schema?.type || typeof value;
    const description = schema?.description ?? '';
    const optional = schema?.optional ?? true;

    switch (type) {
      case ParameterType.STRING:
      case ParameterType.BOOLEAN:
      case ParameterType.NUMBER:
        return this.#convertPrimitiveParameter(key, value, schema);
      case ParameterType.OBJECT:
        return this.#convertObjectParameter(key, value, schema, initialSchema);
      case ParameterType.ARRAY:
        return this.#convertArrayParameter(key, value, schema);
    }
    return {
      type,
      name: key,
      optional,
      typeRef: schema?.typeRef,
      value,
      description,
    } as Parameter;
  }

  #convertPrimitiveParameter(key: string, value: unknown, schema?: Parameter): Parameter {
    const type = schema?.type || typeof value;
    const description = schema?.description ?? '';
    const optional = schema?.optional ?? true;
    return {
      type,
      name: key,
      optional,
      typeRef: schema?.typeRef,
      value,
      description,
      isCorrectType: schema ? this.#isValueOfCorrectType(schema, String(value)) : true,
    } as Parameter;
  }

  #convertObjectParameter(key: string, value: unknown, schema?: Parameter, initialSchema?: Parameter[]): Parameter {
    const description = schema?.description ?? '';
    if (typeof value !== 'object' || value === null) {
      throw new Error('The value is not an object');
    }
    const typeRef = schema?.typeRef;
    if (!typeRef) {
      throw new Error('Every object parameters should have a type ref');
    }

    const nestedType = typeRef === DUMMY_DATA ? initialSchema : this.typesByName.get(typeRef);

    if (!nestedType) {
      throw new Error('No nested type for keys were found');
    }
    const objectValues = [];
    for (const objectKey of Object.keys(value)) {
      const objectType = nestedType.find(param => param.name === objectKey);
      objectValues.push(
          this.#convertObjectToParameterSchema(objectKey, (value as Record<string, unknown>)[objectKey], objectType));
    }
    return {
      type: ParameterType.OBJECT,
      name: key,
      optional: schema.optional,
      typeRef: schema.typeRef,
      value: objectValues,
      description,
      isCorrectType: true,
    };
  }

  #convertArrayParameter(key: string, value: unknown, schema?: Parameter): Parameter {
    const description = schema?.description ?? '';
    const optional = schema?.optional ?? true;
    const typeRef = schema?.typeRef;
    if (!typeRef) {
      throw new Error('Every array parameters should have a type ref');
    }

    if (!Array.isArray(value)) {
      throw new Error('The value is not an array');
    }
    const nestedType = isTypePrimitive(typeRef) ? undefined : {
      optional: true,
      type: ParameterType.OBJECT as ParameterType.OBJECT,
      value: [],
      typeRef,
      description: '',
      name: '',
    };

    const objectValues = [];

    for (let i = 0; i < value.length; i++) {
      const temp = this.#convertObjectToParameterSchema(`${i}`, value[i], nestedType);
      objectValues.push(temp);
    }
    return {
      type: ParameterType.ARRAY,
      name: key,
      optional,
      typeRef: schema?.typeRef,
      value: objectValues,
      description,
      isCorrectType: true,
    };
  }

  #handlePopoverDescriptions(event: MouseEvent|KeyboardEvent):
      {box: AnchorBox, show: (popover: UI.GlassPane.GlassPane) => Promise<boolean>}|null {
    const hintElement = event.composedPath()[0] as HTMLElement;
    const elementData = this.#getDescriptionAndTypeForElement(hintElement);
    if (!elementData?.description) {
      return null;
    }
    const [head, tail] = splitDescription(elementData.description);
    const type = elementData.type;
    const replyArgs = elementData.replyArgs;
    let popupContent = '';
    // replyArgs and type cannot get into conflict because replyArgs is attached to a command and type to a parameter
    if (replyArgs && replyArgs.length > 0) {
      popupContent = tail + `Returns: ${replyArgs}<br>`;
    } else if (type) {
      popupContent = tail + `<br>Type: ${type}<br>`;
    } else {
      popupContent = tail;
    }

    return {
      box: hintElement.boxInWindow(),
      show: async (popover: UI.GlassPane.GlassPane) => {
        const popupElement = new ElementsComponents.CSSHintDetailsView.CSSHintDetailsView({
          getMessage: () => `<span>${head}</span>`,
          getPossibleFixMessage: () => popupContent,
          getLearnMoreLink: () =>
              `https://chromedevtools.github.io/devtools-protocol/tot/${this.command.split('.')[0]}/`,
        });
        popover.contentElement.appendChild(popupElement);
        return true;
      },
    };
  }

  #getDescriptionAndTypeForElement(hintElement: HTMLElement):
      {description: string, type?: ParameterType, replyArgs?: string[]}|undefined {
    if (hintElement.matches('.command')) {
      const metadata = this.metadataByCommand.get(this.command);
      if (metadata) {
        return {description: metadata.description, replyArgs: metadata.replyArgs};
      }
    }
    if (hintElement.matches('.parameter')) {
      const id = hintElement.dataset.paramid;
      if (!id) {
        return;
      }
      const pathArray = id.split('.');
      const {parameter} = this.#getChildByPath(pathArray);
      if (!parameter.description) {
        return;
      }
      return {description: parameter.description, type: parameter.type};
    }
    return;
  }

  getCommandJson(): string {
    return this.command !== '' ? JSON.stringify({command: this.command, parameters: this.getParameters()}) : '';
  }

  #copyToClipboard(): void {
    const commandJson = this.getCommandJson();
    Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(commandJson);
  }

  #handleCommandSend(): void {
    this.dispatchEventToListeners(Events.SUBMIT_EDITOR, {
      command: this.command,
      parameters: this.getParameters(),
      targetId: this.targetId,
    });
  }

  populateParametersForCommandWithDefaultValues(): void {
    const commandParameters = this.metadataByCommand.get(this.command)?.parameters;
    if (!commandParameters) {
      return;
    }

    this.parameters = commandParameters.map((parameter: Parameter) => {
      return this.#populateParameterDefaults(parameter);
    });
  }

  #populateParameterDefaults(parameter: Parameter): Parameter {
    if (parameter.type === ParameterType.OBJECT) {
      let typeRef = parameter.typeRef;
      if (!typeRef) {
        typeRef = DUMMY_DATA;
      }

      // Fallback to empty array is extremely rare.
      // It happens when the keys for an object are not registered like for Tracing.MemoryDumpConfig or headers for instance.
      const nestedTypes = this.typesByName.get(typeRef) ?? [];

      const nestedParameters = nestedTypes.map(nestedType => {
        return this.#populateParameterDefaults(nestedType);
      });

      return {
        ...parameter,
        value: parameter.optional ? undefined : nestedParameters,
        isCorrectType: true,
      } as Parameter;
    }
    if (parameter.type === ParameterType.ARRAY) {
      return {
        ...parameter,
        value: parameter?.optional ? undefined :
                                     parameter.value?.map(param => this.#populateParameterDefaults(param)) || [],
        isCorrectType: true,
      };
    }
    return {
      ...parameter,
      value: parameter.optional ? undefined : defaultValueByType.get(parameter.type),
      isCorrectType: true,
    } as Parameter;
  }

  #getChildByPath(pathArray: string[]): {parameter: Parameter, parentParameter: Parameter} {
    let parameters = this.parameters;
    let parentParameter;
    for (let i = 0; i < pathArray.length; i++) {
      const name = pathArray[i];
      const parameter = parameters.find(param => param.name === name);
      if (i === pathArray.length - 1) {
        return {parameter, parentParameter} as {parameter: Parameter, parentParameter: Parameter};
      }
      if (parameter?.type === ParameterType.ARRAY || parameter?.type === ParameterType.OBJECT) {
        if (parameter.value) {
          parameters = parameter.value;
        }
      } else {
        throw new Error('Parameter on the path in not an object or an array');
      }
      parentParameter = parameter;
    }
    throw new Error('Not found');
  }

  #isValueOfCorrectType(parameter: Parameter, value: string): boolean {
    if (parameter.type === ParameterType.NUMBER && isNaN(Number(value))) {
      return false;
    }
    // For boolean or array parameters, this will create an array of the values the user can enter
    const acceptedValues = this.#computeDropdownValues(parameter);
    // Check to see if the entered value by the user is indeed part of the values accepted by the enum or boolean parameter
    if (acceptedValues.length !== 0 && !acceptedValues.includes(value)) {
      return false;
    }

    return true;
  }

  #saveParameterValue = (event: Event): void => {
    if (!(event.target instanceof SuggestionInput.SuggestionInput.SuggestionInput)) {
      return;
    }
    let value: string;
    if (event instanceof KeyboardEvent) {
      const editableContent = event.target.renderRoot.querySelector('devtools-editable-content');
      if (!editableContent) {
        return;
      }
      value = editableContent.innerText;
    } else {
      value = event.target.value;
    }
    const paramId = event.target.getAttribute('data-paramid');
    if (!paramId) {
      return;
    }
    const pathArray = paramId.split('.');
    const object = this.#getChildByPath(pathArray).parameter;
    if (value === '') {
      object.value = defaultValueByType.get(object.type);
    } else {
      object.value = value;
      object.isCorrectType = this.#isValueOfCorrectType(object, value);
    }
    // Needed to render the delete button for object parameters
    this.requestUpdate();
  };

  #saveNestedObjectParameterKey = (event: Event): void => {
    if (!(event.target instanceof SuggestionInput.SuggestionInput.SuggestionInput)) {
      return;
    }
    const value = event.target.value;
    const paramId = event.target.getAttribute('data-paramid');
    if (!paramId) {
      return;
    }
    const pathArray = paramId.split('.');
    const {parameter} = this.#getChildByPath(pathArray);
    parameter.name = value;
    // Needed to render the delete button for object parameters
    this.requestUpdate();
  };

  #handleParameterInputKeydown = (event: KeyboardEvent): void => {
    if (!(event.target instanceof SuggestionInput.SuggestionInput.SuggestionInput)) {
      return;
    }
    if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
      this.#saveParameterValue(event);
    }
  };

  #handleFocusParameter(event: Event): void {
    if (!(event.target instanceof SuggestionInput.SuggestionInput.SuggestionInput)) {
      return;
    }
    const paramId = event.target.getAttribute('data-paramid');
    if (!paramId) {
      return;
    }
    const pathArray = paramId.split('.');
    const object = this.#getChildByPath(pathArray).parameter;
    object.isCorrectType = true;

    this.requestUpdate();
  }

  #handleCommandInputBlur = async(event: Event): Promise<void> => {
    if (event.target instanceof SuggestionInput.SuggestionInput.SuggestionInput) {
      this.command = event.target.value;
    }
    this.populateParametersForCommandWithDefaultValues();
    const target = event.target as HTMLElement;
    await this.updateComplete;
    this.#focusNextElement(target);
  };

  /**
   * When devtools-suggestion-input closes, it blurs itself resulting in
   * the focus shifting to the overall DevTools window.
   *
   * This method focuses on the next focusable element (button or input)
   * so that the focus remains in the Editor and Ctrl + Shift works.
   */
  #focusNextElement(target: HTMLElement): void {
    // FIXME: can we do this via view output?
    const elements =
        this.contentElement.querySelectorAll('devtools-suggestion-input,.add-button') as NodeListOf<HTMLElement>;
    const element = [...elements].findIndex(value => value === target.shadowRoot?.host);
    if (element >= 0 && element + 1 < elements.length) {
      elements[element + 1].focus();
    } else {
      (this.contentElement.querySelector('devtools-button[jslogcontext="protocol-monitor.send-command"]') as
           HTMLElement |
       undefined)
          ?.focus();
    }
  }

  #createNestedParameter(type: Parameter, name: string): Parameter {
    if (type.type === ParameterType.OBJECT) {
      let typeRef = type.typeRef;
      if (!typeRef) {
        typeRef = DUMMY_DATA;
      }
      const nestedTypes = this.typesByName.get(typeRef) ?? [];

      const nestedValue: Parameter[] =
          nestedTypes.map(nestedType => this.#createNestedParameter(nestedType, nestedType.name));

      return {
        type: ParameterType.OBJECT,
        name,
        optional: type.optional,
        typeRef,
        value: nestedValue,
        isCorrectType: true,
        description: type.description,
      };
    }
    return {
      type: type.type,
      name,
      optional: type.optional,
      isCorrectType: true,
      typeRef: type.typeRef,
      value: type.optional ? undefined : defaultValueByType.get(type.type),
      description: type.description,
    } as Parameter;
  }

  #handleAddParameter(parameterId: string): void {
    const pathArray = parameterId.split('.');
    const {parameter, parentParameter} = this.#getChildByPath(pathArray);
    if (!parameter) {
      return;
    }

    switch (parameter.type) {
      case ParameterType.ARRAY: {
        const typeRef = parameter.typeRef;
        if (!typeRef) {
          throw new Error('Every array parameter must have a typeRef');
        }

        const nestedType = this.typesByName.get(typeRef) ?? [];
        const nestedValue: Parameter[] = nestedType.map(type => this.#createNestedParameter(type, type.name));

        let type = isTypePrimitive(typeRef) ? typeRef : ParameterType.OBJECT;

        // If the typeRef is actually a ref to an enum type, the type of the nested param should be a string
        if (nestedType.length === 0) {
          if (this.enumsByName.get(typeRef)) {
            type = ParameterType.STRING;
          }
        }
        // In case the parameter is an optional array, its value will be undefined so before pushing new value inside,
        // we reset it to empty array
        if (!parameter.value) {
          parameter.value = [];
        }
        parameter.value.push({
          type,
          name: String(parameter.value.length),
          optional: true,
          typeRef,
          value: nestedValue.length !== 0 ? nestedValue : '',
          description: '',
          isCorrectType: true,
        } as Parameter);
        break;
      }
      case ParameterType.OBJECT: {
        let typeRef = parameter.typeRef;
        if (!typeRef) {
          typeRef = DUMMY_DATA;
        }
        if (!parameter.value) {
          parameter.value = [];
        }
        if (!this.typesByName.get(typeRef)) {
          parameter.value.push({
            type: ParameterType.STRING,
            name: '',
            optional: true,
            value: '',
            isCorrectType: true,
            description: '',
            isKeyEditable: true,
          });
          break;
        }
        const nestedTypes = this.typesByName.get(typeRef) ?? [];
        const nestedValue: Parameter[] =
            nestedTypes.map(nestedType => this.#createNestedParameter(nestedType, nestedType.name));
        const nestedParameters = nestedTypes.map(nestedType => {
          return this.#populateParameterDefaults(nestedType);
        });

        if (parentParameter) {
          parameter.value.push({
            type: ParameterType.OBJECT,
            name: '',
            optional: true,
            typeRef,
            value: nestedValue,
            isCorrectType: true,
            description: '',
          });
        } else {
          parameter.value = nestedParameters;
        }
        break;
      }
      default:
        // For non-array and non-object parameters, set the value to the default value if available.
        parameter.value = defaultValueByType.get(parameter.type);
        break;
    }
    this.requestUpdate();
  }

  #handleClearParameter(parameter: Parameter, isParentArray?: boolean): void {
    if (parameter?.value === undefined) {
      return;
    }

    switch (parameter.type) {
      case ParameterType.OBJECT:
        if (parameter.optional && !isParentArray) {
          parameter.value = undefined;
          break;
        }
        if (!parameter.typeRef || !this.typesByName.get(parameter.typeRef)) {
          parameter.value = [];
        } else {
          parameter.value.forEach(param => this.#handleClearParameter(param, isParentArray));
        }
        break;

      case ParameterType.ARRAY:
        parameter.value = parameter.optional ? undefined : [];
        break;

      default:
        parameter.value = parameter.optional ? undefined : defaultValueByType.get(parameter.type);
        parameter.isCorrectType = true;
        break;
    }

    this.requestUpdate();
  }

  #handleDeleteParameter(parameter: Parameter, parentParameter: Parameter): void {
    if (!parameter) {
      return;
    }
    if (!Array.isArray(parentParameter.value)) {
      return;
    }
    parentParameter.value.splice(parentParameter.value.findIndex(p => p === parameter), 1);

    if (parentParameter.type === ParameterType.ARRAY) {
      for (let i = 0; i < parentParameter.value.length; i++) {
        parentParameter.value[i].name = String(i);
      }
    }
    this.requestUpdate();
  }

  #onTargetSelected(event: Event): void {
    if (event.target instanceof HTMLSelectElement) {
      this.targetId = event.target.value;
    }
    this.requestUpdate();
  }

  #computeDropdownValues(parameter: Parameter): string[] {
    // The suggestion box should only be shown for parameters of type string and boolean
    if (parameter.type === ParameterType.STRING) {
      const enums = this.enumsByName.get(`${parameter.typeRef}`) ?? {};
      return Object.values(enums);
    }
    if (parameter.type === ParameterType.BOOLEAN) {
      return ['true', 'false'];
    }
    return [];
  }

  override performUpdate(): void {
    const viewInput = {
      onParameterValueBlur: (event: Event): void => {
        this.#saveParameterValue(event);
      },
      onParameterKeydown: (event: KeyboardEvent): void => {
        this.#handleParameterInputKeydown(event);
      },
      onParameterFocus: (event: Event): void => {
        this.#handleFocusParameter(event);
      },
      onParameterKeyBlur: (event: Event): void => {
        this.#saveNestedObjectParameterKey(event);
      },
      onKeydown: (event: KeyboardEvent): void => {
        if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
          this.#handleParameterInputKeydown(event);
          this.#handleCommandSend();
        }
      },
      parameters: this.parameters,
      metadataByCommand: this.metadataByCommand,
      command: this.command,
      typesByName: this.typesByName,
      onCommandInputBlur: (event: Event) => this.#handleCommandInputBlur(event),
      onCommandSend: () => this.#handleCommandSend(),
      onCopyToClipboard: () => this.#copyToClipboard(),
      targets: this.targets,
      targetId: this.targetId,
      onAddParameter: (parameterId: string) => {
        this.#handleAddParameter(parameterId);
      },
      onClearParameter: (parameter: Parameter, isParentArray?: boolean) => {
        this.#handleClearParameter(parameter, isParentArray);
      },
      onDeleteParameter: (parameter: Parameter, parentParameter: Parameter) => {
        this.#handleDeleteParameter(parameter, parentParameter);
      },
      onTargetSelected: (event: Event) => {
        this.#onTargetSelected(event);
      },
      computeDropdownValues: (parameter: Parameter) => {
        return this.#computeDropdownValues(parameter);
      },
      displayTargetSelector: this.displayTargetSelector,
      displayCommandInput: this.displayCommandInput,
    };
    const viewOutput = {};
    this.#view(viewInput, viewOutput, this.contentElement);
  }
}

function isTypePrimitive(type: string): boolean {
  if (type === ParameterType.STRING || type === ParameterType.BOOLEAN || type === ParameterType.NUMBER) {
    return true;
  }
  return false;
}

function renderTargetSelectorRow(input: ViewInput): Lit.TemplateResult|undefined {
  // clang-format off
  return html`
  <div class="row attribute padded">
    <div>target<span class="separator">:</span></div>
    <select class="target-selector"
            title=${i18nString(UIStrings.selectTarget)}
            jslog=${VisualLogging.dropDown('target-selector').track({change: true})}
            @change=${input.onTargetSelected}>
      ${input.targets.map(target => html`
        <option jslog=${VisualLogging.item('target').track({click: true, resize: true})}
                value=${target.id()} ?selected=${target.id() === input.targetId}>
          ${target.name()} (${target.inspectedURL()})
        </option>`)}
    </select>
  </div>
`;
  // clang-format on
}

function renderInlineButton(opts: {
  title: string,
  iconName: string,
  classMap: Record<string, string|boolean|number>,
  onClick: (event: MouseEvent) => void,
  jslogContext: string,
}): Lit.TemplateResult|undefined {
  return html`
          <devtools-button
            title=${opts.title}
            .size=${Buttons.Button.Size.SMALL}
            .iconName=${opts.iconName}
            .variant=${Buttons.Button.Variant.ICON}
            class=${classMap(opts.classMap)}
            @click=${opts.onClick}
            .jslogContext=${opts.jslogContext}
          ></devtools-button>
      `;
}

function renderWarningIcon(): Lit.TemplateResult|undefined {
  return html`<devtools-icon name='warning-filled' class='warning-icon small'>
  </devtools-icon>`;
}

/**
 * Renders the parameters list corresponding to a specific CDP command.
 */
function renderParameters(
    input: ViewInput, parameters: Parameter[], id?: string, parentParameter?: Parameter,
    parentParameterId?: string): Lit.TemplateResult|undefined {
  parameters.sort((a, b) => Number(a.optional) - Number(b.optional));

  // clang-format off
  return html`
    <ul>
      ${repeat(parameters, parameter => {
        const parameterId = parentParameter ? `${parentParameterId}` + '.' + `${parameter.name}` : parameter.name;
        const subparameters: Parameter[] = parameter.type === ParameterType.ARRAY || parameter.type === ParameterType.OBJECT ? (parameter.value ?? []) : [];
        const isPrimitive = isTypePrimitive(parameter.type);
        const isArray = parameter.type === ParameterType.ARRAY;
        const isParentArray = parentParameter && parentParameter.type === ParameterType.ARRAY;
        const isParentObject = parentParameter && parentParameter.type === ParameterType.OBJECT;

        const isObject = parameter.type === ParameterType.OBJECT;
        const isParamValueUndefined = parameter.value === undefined;
        const isParamOptional = parameter.optional;
        const hasTypeRef = isObject && parameter.typeRef && input.typesByName.get(parameter.typeRef) !== undefined;
        // This variable indicates that this parameter is a parameter nested inside an object parameter
        // that no keys defined inside the CDP documentation.
        const hasNoKeys = parameter.isKeyEditable;
        const isCustomEditorDisplayed = isObject && !hasTypeRef;
        const hasOptions = parameter.type === ParameterType.STRING || parameter.type === ParameterType.BOOLEAN;
        const canClearParameter = (isArray && !isParamValueUndefined && parameter.value?.length !== 0) || (isObject && !isParamValueUndefined);
        const parametersClasses = {
          'optional-parameter': parameter.optional,
          parameter: true,
          'undefined-parameter': parameter.value === undefined && parameter.optional,
        };
        const inputClasses = {
          'json-input': true,
        };
        return html`
              <li class="row">
                <div class="row-icons">
                    ${!parameter.isCorrectType ? html`${renderWarningIcon()}` : nothing}

                    <!-- If an object parameter has no predefined keys, show an input to enter the key, otherwise show the name of the parameter -->
                    <div class=${classMap(parametersClasses)} data-paramId=${parameterId}>
                        ${hasNoKeys ?
                          html`<devtools-suggestion-input
                            data-paramId=${parameterId}
                            .isKey=${true}
                            .isCorrectInput=${live(parameter.isCorrectType)}
                            .options=${hasOptions ? input.computeDropdownValues(parameter) : []}
                            .autocomplete=${false}
                            .value=${live(parameter.name ?? '')}
                            .placeholder=${parameter.value === '' ? EMPTY_STRING : `<${defaultValueByType.get(parameter.type)}>`}
                            @blur=${input.onParameterKeyBlur}
                            @focus=${input.onParameterFocus}
                            @keydown=${input.onParameterKeydown}
                          ></devtools-suggestion-input>`:
                          html`${parameter.name}`} <span class="separator">:</span>
                    </div>

                    <!-- Render button to add values inside an array parameter -->
                    ${isArray ? html`
                      ${renderInlineButton({
                          title: i18nString(UIStrings.addParameter),
                          iconName: 'plus',
                          onClick: () => input.onAddParameter(parameterId),
                          classMap: { 'add-button': true },
                          jslogContext: 'protocol-monitor.add-parameter',
                        })}
                    `: nothing}

                    <!-- Render button to complete reset an array parameter or an object parameter-->
                    ${canClearParameter ?
                    renderInlineButton({
                      title: i18nString(UIStrings.resetDefaultValue),
                      iconName: 'clear',
                      onClick: () => input.onClearParameter(parameter, isParentArray),
                      classMap: {'clear-button': true},
                      jslogContext: 'protocol-monitor.reset-to-default-value',
                    }) : nothing}

                    <!-- Render the buttons to change the value from undefined to empty string for optional primitive parameters -->
                    ${isPrimitive && !isParentArray && isParamOptional && isParamValueUndefined ?
                        html`  ${renderInlineButton({
                          title: i18nString(UIStrings.addParameter),
                          iconName: 'plus',
                          onClick: () => input.onAddParameter(parameterId),
                          classMap: { 'add-button': true },
                          jslogContext: 'protocol-monitor.add-parameter',
                    })}` : nothing}

                    <!-- Render the buttons to change the value from undefined to populate the values inside object with their default values -->
                    ${isObject && isParamOptional && isParamValueUndefined && hasTypeRef ?
                        html`  ${renderInlineButton({
                          title: i18nString(UIStrings.addParameter),
                          iconName: 'plus',
                          onClick: () => input.onAddParameter(parameterId),
                          classMap: { 'add-button': true },
                          jslogContext: 'protocol-monitor.add-parameter',
                        })}` : nothing}
                </div>

                <div class="row-icons">
                    <!-- If an object has no predefined keys, show an input to enter the value, and a delete icon to delete the whole key/value pair -->
                    ${hasNoKeys && isParentObject ?  html`
                    <!-- @ts-ignore -->
                    <devtools-suggestion-input
                        data-paramId=${parameterId}
                        .isCorrectInput=${live(parameter.isCorrectType)}
                        .options=${hasOptions ? input.computeDropdownValues(parameter) : []}
                        .autocomplete=${false}
                        .value=${live(parameter.value ?? '')}
                        .placeholder=${parameter.value === '' ? EMPTY_STRING : `<${defaultValueByType.get(parameter.type)}>`}
                        .jslogContext=${'parameter-value'}
                        @blur=${input.onParameterValueBlur}
                        @focus=${input.onParameterFocus}
                        @keydown=${input.onParameterKeydown}
                      ></devtools-suggestion-input>

                      ${renderInlineButton({
                      title: i18nString(UIStrings.deleteParameter),
                      iconName: 'bin',
                      onClick: () => input.onDeleteParameter(parameter, parentParameter),
                      classMap: { deleteButton: true, deleteIcon: true },
                      jslogContext: 'protocol-monitor.delete-parameter',
                    })}`: nothing}

                  <!-- In case  the parameter is not optional or its value is not undefined render the input -->
                  ${isPrimitive && !hasNoKeys && (!isParamValueUndefined || !isParamOptional) && (!isParentArray) ?
                    html`
                      <!-- @ts-ignore -->
                      <devtools-suggestion-input
                        data-paramId=${parameterId}
                        .strikethrough=${live(parameter.isCorrectType)}
                        .options=${hasOptions ? input.computeDropdownValues(parameter) : []}
                        .autocomplete=${false}
                        .value=${live(parameter.value ?? '')}
                        .placeholder=${parameter.value === '' ? EMPTY_STRING : `<${defaultValueByType.get(parameter.type)}>`}
                        .jslogContext=${'parameter-value'}
                        @blur=${input.onParameterValueBlur}
                        @focus=${input.onParameterFocus}
                        @keydown=${input.onParameterKeydown}
                      ></devtools-suggestion-input>` : nothing}

                  <!-- Render the buttons to change the value from empty string to undefined for optional primitive parameters -->
                  ${isPrimitive &&!hasNoKeys && !isParentArray && isParamOptional && !isParamValueUndefined ?
                      html`  ${renderInlineButton({
                        title: i18nString(UIStrings.resetDefaultValue),
                        iconName: 'clear',
                        onClick: () => input.onClearParameter(parameter),
                        classMap: { 'clear-button': true },
                        jslogContext: 'protocol-monitor.reset-to-default-value',
                      })}` : nothing}

                  <!-- If the parameter is an object with no predefined keys, renders a button to add key/value pairs to it's value -->
                  ${isCustomEditorDisplayed ? html`
                    ${renderInlineButton({
                      title: i18nString(UIStrings.addCustomProperty),
                      iconName: 'plus',
                      onClick: () => input.onAddParameter(parameterId),
                      classMap: { 'add-button': true },
                      jslogContext: 'protocol-monitor.add-custom-property',
                    })}
                  ` : nothing}

                  <!-- In case the parameter is nested inside an array we render the input field as well as a delete button -->
                  ${isParentArray ? html`
                  <!-- If the parameter is an object we don't want to display the input field we just want the delete button-->
                  ${!isObject ? html`
                  <!-- @ts-ignore -->
                  <devtools-suggestion-input
                    data-paramId=${parameterId}
                    .options=${hasOptions ? input.computeDropdownValues(parameter) : []}
                    .autocomplete=${false}
                    .value=${live(parameter.value ?? '')}
                    .placeholder=${parameter.value === '' ? EMPTY_STRING : `<${defaultValueByType.get(parameter.type)}>`}
                    .jslogContext=${'parameter'}
                    @blur=${input.onParameterValueBlur}
                    @keydown=${input.onParameterKeydown}
                    class=${classMap(inputClasses)}
                  ></devtools-suggestion-input>` : nothing}

                  ${renderInlineButton({
                      title: i18nString(UIStrings.deleteParameter),
                      iconName: 'bin',
                      onClick: () => input.onDeleteParameter(parameter, parentParameter),
                      classMap: { 'delete-button': true },
                      jslogContext: 'protocol-monitor.delete-parameter',
                    })}` : nothing}
                </div>
              </li>
              ${renderParameters(input, subparameters, id, parameter, parameterId)}
            `;
        })}
    </ul>
  `;
  // clang-format on
}

export const DEFAULT_VIEW: View = (input, _output, target) => {
  // clang-format off
  render(html`
    <div class="wrapper" @keydown=${input.onKeydown} jslog=${VisualLogging.pane('command-editor').track({resize: true})}>
      <div class="editor-wrapper">
        ${input.displayTargetSelector !== false ? renderTargetSelectorRow(input) : nothing}
        ${input.displayCommandInput !== false ? html`
        <div class="row attribute padded">
          <div class="command">command<span class="separator">:</span></div>
          <devtools-suggestion-input
            .options=${[...input.metadataByCommand.keys()]}
            .value=${input.command}
            .placeholder=${'Enter your command…'}
            .suggestionFilter=${suggestionFilter}
            .jslogContext=${'command'}
            @blur=${input.onCommandInputBlur}
            class=${classMap({'json-input': true})}
          ></devtools-suggestion-input>
        </div>` : nothing}
        ${input.parameters.length ? html`
        <div class="row attribute padded">
          <div>parameters<span class="separator">:</span></div>
        </div>
          ${renderParameters(input, input.parameters)}
        ` : nothing}
      </div>
      <devtools-toolbar class="protocol-monitor-sidebar-toolbar">
        <devtools-button title=${i18nString(UIStrings.copyCommand)}
                        .iconName=${'copy'}
                        .jslogContext=${'protocol-monitor.copy-command'}
                        .variant=${Buttons.Button.Variant.TOOLBAR}
                        @click=${input.onCopyToClipboard}></devtools-button>
          <div class=toolbar-spacer></div>
        <devtools-button title=${Host.Platform.isMac() ? i18nString(UIStrings.sendCommandCmdEnter) : i18nString(UIStrings.sendCommandCtrlEnter)}
                        .iconName=${'send'}
                        jslogContext="protocol-monitor.send-command"
                        .variant=${Buttons.Button.Variant.PRIMARY_TOOLBAR}
                        @click=${input.onCommandSend}></devtools-button>
      </devtools-toolbar>
    </div>`, target);
  // clang-format on
};
