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

import '../../ui/legacy/legacy.js';

import type * as Common from '../../core/common/common.js';
import * as i18n from '../../core/i18n/i18n.js';
import type * as Protocol from '../../generated/protocol.js';
import * as UI from '../../ui/legacy/legacy.js';
import {Directives, html, type LitTemplate, nothing, render} from '../../ui/lit/lit.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';

import playerMessagesViewStyles from './playerMessagesView.css.js';

const {classMap} = Directives;

const UIStrings = {
  /**
   * @description A context menu item in the Console view of the Console panel.
   */
  default: 'Default',
  /**
   * @description Text in Network throttling selector of the Network panel.
   */
  custom: 'Custom',
  /**
   * @description Text for everything.
   */
  all: 'All',
  /**
   * @description Text for errors.
   */
  error: 'Error',
  /**
   * @description Text to indicate an item is a warning.
   */
  warning: 'Warning',
  /**
   * @description Sdk console message level info in Console view of the Console panel.
   */
  info: 'Info',
  /**
   * @description Debug log level.
   */
  debug: 'Debug',
  /**
   * @description Label for selecting between the set of log levels to show.
   */
  logLevel: 'Log level:',
  /**
   * @description Default text for user text entry for searching log messages.
   */
  filterByLogMessages: 'Filter by log messages',
  /**
   * @description The label for the group name that this error belongs to.
   */
  errorGroupLabel: 'Error group:',
  /**
   * @description The label for the numeric code associated with this error.
   */
  errorCodeLabel: 'Error code:',
  /**
   * @description The label for extra data associated with an error.
   */
  errorDataLabel: 'Data:',
  /**
   * @description The label for the stack trace associated with the error.
   */
  errorStackLabel: 'Stack trace:',
  /**
   * @description The label for a root cause error associated with this error.
   */
  errorCauseLabel: 'Caused by:',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/media/PlayerMessagesView.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);

const enum MessageLevelBitfield {
  ERROR = 0b0001,
  WARNING = 0b0010,
  INFO = 0b0100,
  DEBUG = 0b1000,

  DEFAULT = 0b0111,  // Error, Warning, Info
  ALL = 0b1111,      // Error, Warning, Info, Debug
  CUSTOM = 0,
}

interface SelectableLevel {
  title: string;
  value: MessageLevelBitfield;
  stringValue: string;
  selectable?: boolean;
  overwrite?: boolean;
}

class MessageLevelSelector implements UI.SoftDropDown.Delegate<SelectableLevel> {
  private readonly items: UI.ListModel.ListModel<SelectableLevel>;
  private readonly view: PlayerMessagesView;
  private readonly itemMap: Map<number, SelectableLevel>;
  private hiddenLevels: string[];
  private bitFieldValue: MessageLevelBitfield;
  readonly #defaultTitle: Common.UIString.LocalizedString;
  private readonly customTitle: Common.UIString.LocalizedString;
  private readonly allTitle: Common.UIString.LocalizedString;
  elementsForItems: WeakMap<SelectableLevel, ShadowRoot>;

  constructor(items: UI.ListModel.ListModel<SelectableLevel>, view: PlayerMessagesView) {
    this.items = items;
    this.view = view;
    this.itemMap = new Map();

    this.hiddenLevels = [];

    this.bitFieldValue = MessageLevelBitfield.DEFAULT;

    this.#defaultTitle = i18nString(UIStrings.default);
    this.customTitle = i18nString(UIStrings.custom);
    this.allTitle = i18nString(UIStrings.all);

    this.elementsForItems = new WeakMap();
  }

  defaultTitle(): Common.UIString.LocalizedString {
    return this.#defaultTitle;
  }

  setDefault(dropdown: UI.SoftDropDown.SoftDropDown<SelectableLevel>): void {
    dropdown.selectItem(this.items.at(0));
  }

  populate(): void {
    const defaultLevel = {
      title: this.#defaultTitle,
      overwrite: true,
      stringValue: '',
      value: MessageLevelBitfield.DEFAULT,
    };
    this.items.insert(this.items.length, defaultLevel);
    this.itemMap.set(defaultLevel.value, defaultLevel);

    const allLevel = {
      title: this.allTitle,
      overwrite: true,
      stringValue: '',
      value: MessageLevelBitfield.ALL,
    };
    this.items.insert(this.items.length, allLevel);
    this.itemMap.set(allLevel.value, allLevel);

    const errorLevel = {
      title: i18nString(UIStrings.error),
      overwrite: false,
      stringValue: 'error',
      value: MessageLevelBitfield.ERROR,
    };
    this.items.insert(this.items.length, errorLevel);
    this.itemMap.set(errorLevel.value, errorLevel);

    const warningLevel = {
      title: i18nString(UIStrings.warning),
      overwrite: false,
      stringValue: 'warning',
      value: MessageLevelBitfield.WARNING,
    };
    this.items.insert(this.items.length, warningLevel);
    this.itemMap.set(warningLevel.value, warningLevel);

    const infoLevel = {
      title: i18nString(UIStrings.info),
      overwrite: false,
      stringValue: 'info',
      value: MessageLevelBitfield.INFO,
    };
    this.items.insert(this.items.length, infoLevel);
    this.itemMap.set(infoLevel.value, infoLevel);

    const debugLevel = {
      title: i18nString(UIStrings.debug),
      overwrite: false,
      stringValue: 'debug',
      value: MessageLevelBitfield.DEBUG,
    };
    this.items.insert(this.items.length, debugLevel);
    this.itemMap.set(debugLevel.value, debugLevel);
  }

  #renderItem(item: SelectableLevel, target: ShadowRoot|HTMLElement): void {
    const checked = Boolean(item.value & this.bitFieldValue);
    // clang-format off
    render(html`
      <div class="media-messages-level-dropdown-element">
        <div class="media-messages-level-dropdown-checkbox">
          ${!item.overwrite && checked ? html`<div>✓</div>` : nothing}
        </div>
        <span class="media-messages-level-dropdown-text">${item.title}</span>
      </div>
    `, target, {host: this});
    // clang-format on
  }

  private updateCheckMarks(): void {
    this.hiddenLevels = [];
    for (const [key, item] of this.itemMap) {
      if (!item.overwrite) {
        if (!(key & this.bitFieldValue)) {
          this.hiddenLevels.push(item.stringValue);
        }
        const target = this.elementsForItems.get(item);
        if (target) {
          this.#renderItem(item, target);
        }
      }
    }
  }

  titleFor(item: SelectableLevel): string {
    // This would make a lot more sense to have in |itemSelected|, but this
    // method gets called first.
    if (item.overwrite) {
      this.bitFieldValue = item.value;
    } else {
      this.bitFieldValue ^= item.value;
    }

    if (this.bitFieldValue === MessageLevelBitfield.DEFAULT) {
      return this.#defaultTitle;
    }

    if (this.bitFieldValue === MessageLevelBitfield.ALL) {
      return this.allTitle;
    }

    const potentialMatch = this.itemMap.get(this.bitFieldValue);
    if (potentialMatch) {
      return potentialMatch.title;
    }

    return this.customTitle;
  }

  createElementForItem(item: SelectableLevel): Element {
    const element = document.createElement('div');
    const shadowRoot = UI.UIUtils.createShadowRootWithCoreStyles(element, {cssFile: playerMessagesViewStyles});
    this.elementsForItems.set(item, shadowRoot);
    this.itemMap.set(item.value, item);
    this.#renderItem(item, shadowRoot);
    this.updateCheckMarks();
    this.view.regenerateMessageDisplayCss(this.hiddenLevels);
    return element;
  }

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

  itemSelected(_item: SelectableLevel|null): void {
    this.updateCheckMarks();
    this.view.regenerateMessageDisplayCss(this.hiddenLevels);
  }

  highlightedItemChanged(
      _from: SelectableLevel|null, _to: SelectableLevel|null, _fromElement: Element|null,
      _toElement: Element|null): void {
  }
}

interface MessageItem {
  type: 'message';
  level: string;
  message: Protocol.Media.PlayerMessage;
}

interface ErrorItem {
  type: 'error';
  level: 'error';
  error: Protocol.Media.PlayerError;
}

type MessageOrErrorItem = MessageItem|ErrorItem;

export class PlayerMessagesView extends UI.Widget.VBox {
  private readonly headerPanel: HTMLElement;
  private readonly bodyPanel: HTMLElement;
  private messageLevelSelector?: MessageLevelSelector;
  #items: MessageOrErrorItem[] = [];
  #hiddenLevels: string[] = [];
  #filterString = '';
  #dropDownItem?: UI.Toolbar.ToolbarItem;
  #filterInput?: UI.Toolbar.ToolbarInput;

  constructor() {
    super({jslog: `${VisualLogging.pane('messages')}`});
    this.registerRequiredCSS(playerMessagesViewStyles);

    this.headerPanel = this.contentElement.createChild('div', 'media-messages-header');
    this.bodyPanel = this.contentElement.createChild('div', 'media-messages-body');

    this.#dropDownItem = this.createDropdown();
    this.#filterInput = this.createFilterInput();
    this.performUpdate();
  }

  private createDropdown(): UI.Toolbar.ToolbarItem {
    const items = new UI.ListModel.ListModel<SelectableLevel>();
    this.messageLevelSelector = new MessageLevelSelector(items, this);
    const dropDown = new UI.SoftDropDown.SoftDropDown<SelectableLevel>(items, this.messageLevelSelector, 'log-level');
    dropDown.setRowHeight(18);

    this.messageLevelSelector.populate();
    this.messageLevelSelector.setDefault(dropDown);

    const dropDownItem = new UI.Toolbar.ToolbarItem(dropDown.element);
    dropDownItem.element.classList.add('toolbar-has-dropdown');
    dropDownItem.setEnabled(true);
    dropDownItem.setTitle(this.messageLevelSelector.defaultTitle());
    UI.ARIAUtils.setLabel(
        dropDownItem.element, `${i18nString(UIStrings.logLevel)} ${this.messageLevelSelector.defaultTitle()}`);
    return dropDownItem;
  }

  private createFilterInput(): UI.Toolbar.ToolbarInput {
    const filterInput = new UI.Toolbar.ToolbarFilter(i18nString(UIStrings.filterByLogMessages), 1, 1);
    filterInput.addEventListener(UI.Toolbar.ToolbarInput.Event.TEXT_CHANGED, (data: {data: string}) => {
      this.filterByString(data as {
        data: string,
      });
    }, this);
    return filterInput;
  }

  override performUpdate(): void {
    this.#renderToolbar();
    this.#renderMessages();
  }

  #renderToolbar(): void {
    // clang-format off
    render(html`
      <devtools-toolbar class="media-messages-toolbar">
        <div class="toolbar-text">${i18nString(UIStrings.logLevel)}</div>
        ${this.#dropDownItem?.element}
        <div class="toolbar-divider"></div>
        ${this.#filterInput?.element}
      </devtools-toolbar>
    `, this.headerPanel, {host: this});
    // clang-format on
  }

  #renderMessages(): void {
    // clang-format off
    render(html`
      ${this.#items.map(item => {
        const isUnselected = this.#isLevelHidden(item.level);
        let isFiltered = false;
        if (this.#filterString !== '') {
          if (item.type === 'message') {
            isFiltered = !item.message.message.includes(this.#filterString);
          } else {
            isFiltered = !this.#errorMatchesFilter(item.error, this.#filterString);
          }
        }
        const classes = {
          'media-messages-message-container': true,
          [`media-message-${item.level}`]: true,
          'media-messages-message-unselected': isUnselected,
          'media-messages-message-filtered': isFiltered,
        };
        return html`
          <div class=${classMap(classes)}>
            ${item.type === 'message' ? item.message.message : this.renderError(item.error)}
          </div>
        `;
      })}
    `, this.bodyPanel, {host: this});
    // clang-format on
  }

  #isLevelHidden(level: string): boolean {
    return this.#hiddenLevels.includes(level);
  }

  #errorMatchesFilter(error: Protocol.Media.PlayerError, filter: string): boolean {
    if (error.errorType.includes(filter) || error.code.toString().includes(filter)) {
      return true;
    }
    for (const [key, value] of Object.entries(error.data)) {
      if (`${key}: ${value}`.includes(filter)) {
        return true;
      }
    }
    for (const stackEntry of error.stack) {
      if (`${stackEntry.file}:${stackEntry.line}`.includes(filter)) {
        return true;
      }
    }
    for (const cause of error.cause) {
      if (this.#errorMatchesFilter(cause, filter)) {
        return true;
      }
    }
    return false;
  }

  regenerateMessageDisplayCss(hiddenLevels: string[]): void {
    this.#hiddenLevels = hiddenLevels;
    this.performUpdate();
  }

  private filterByString(userStringData: {data: string}): void {
    this.#filterString = userStringData.data;
    this.performUpdate();
  }

  addMessage(message: Protocol.Media.PlayerMessage): void {
    this.#items.push({type: 'message', level: message.level, message});
    this.performUpdate();
  }

  private renderError(error: Protocol.Media.PlayerError): LitTemplate {
    // clang-format off
    return html`
      <div class="status-error-box">
        <div class="status-error-field-labeled">
          <span class="status-error-field-label"
            >${i18nString(UIStrings.errorGroupLabel)}</span
          >
          <span>${error.errorType}</span>
        </div>
        <div class="status-error-field-labeled">
          <span class="status-error-field-label"
            >${i18nString(UIStrings.errorCodeLabel)}</span
          >
          <span>${error.code}</span>
        </div>
        <div class="status-error-field-labeled">
        ${
          Object.keys(error.data).length !== 0
            ? html`<span class="status-error-field-label"
                  >${i18nString(UIStrings.errorDataLabel)}</span
                >
                <div>
                  ${Object.entries(error.data).map(
                    ([key, value]) => html`<div>${key}: ${value}</div>`,
                  )}
                </div>`
            : nothing
        }
        </div>
        <div class="status-error-field-labeled">
          ${
            error.stack.length !== 0
              ? html`<span class="status-error-field-label"
                    >${i18nString(UIStrings.errorStackLabel)}</span
                  >
                  <div>
                    ${error.stack.map(
                      stackEntry =>
                        html`<div>${stackEntry.file}:${stackEntry.line}</div>`,
                    )}
                  </div>`
              : nothing
          }
        </div>
        <div class="status-error-field-labeled">
          ${
            error.cause.length !== 0
              ? html`
                  <span class="status-error-field-label"
                    >${i18nString(UIStrings.errorCauseLabel)}</span
                  >
                  <div>
                    ${error.cause.map(cause => this.renderError(cause))}
                  </div>
                `
              : nothing
          }
        </div>
      </div>
    `;
    // clang-format on
  }

  addError(error: Protocol.Media.PlayerError): void {
    this.#items.push({type: 'error', level: 'error', error});
    this.performUpdate();
  }
}
