// Copyright 2022 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-lit-render-outside-of-view */

import '../../../ui/kit/kit.js';
import '../../../ui/legacy/legacy.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 SDK from '../../../core/sdk/sdk.js';
import type * as Protocol from '../../../generated/protocol.js';
import * as ClientVariations from '../../../third_party/chromium/client-variations/client-variations.js';
import * as Buttons from '../../../ui/components/buttons/buttons.js';
import * as ComponentHelpers from '../../../ui/components/helpers/helpers.js';
import * as Lit from '../../../ui/lit/lit.js';
import * as VisualLogging from '../../../ui/visual_logging/visual_logging.js';

import type {EditableSpan} from './EditableSpan.js';
import headerSectionRowStyles from './HeaderSectionRow.css.js';

const {render, html} = Lit;

const UIStrings = {
  /**
   * @description Comment used in decoded X-Client-Data HTTP header output in Headers View of the Network panel
   */
  activeClientExperimentVariation: 'Active `client experiment variation IDs`.',
  /**
   * @description Comment used in decoded X-Client-Data HTTP header output in Headers View of the Network panel
   */
  activeClientExperimentVariationIds: 'Active `client experiment variation IDs` that trigger server-side behavior.',
  /**
   * @description Text in Headers View of the Network panel for X-Client-Data HTTP headers
   */
  decoded: 'Decoded:',
  /**
   * @description The title of a button to enable overriding a HTTP header.
   */
  editHeader: 'Override header',
  /**
   * @description Description of which letters the name of an HTTP header may contain (a-z, A-Z, 0-9, '-', or '_').
   */
  headerNamesOnlyLetters: 'Header names should contain only letters, digits, hyphens or underscores',
  /**
   * @description Text that is usually a hyperlink to more documentation
   */
  learnMore: 'Learn more',
  /**
   * @description Text for a link to the issues panel
   */
  learnMoreInTheIssuesTab: 'Learn more in the issues tab',
  /**
   * @description Hover text prompting the user to reload the whole page or refresh the particular request, so that the changes they made take effect.
   */
  reloadPrompt: 'Refresh the page/request for these changes to take effect',
  /**
   * @description The title of a button which removes a HTTP header override.
   */
  removeOverride: 'Remove this header override',
} as const;

const str_ = i18n.i18n.registerUIStrings('panels/network/components/HeaderSectionRow.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);

export const isValidHeaderName = (headerName: string): boolean => {
  return /^[a-z0-9_\-]+$/i.test(headerName);
};

export const compareHeaders = (first: string|null|undefined, second: string|null|undefined): boolean => {
  // Replaces all whitespace characters with regular spaces.
  // When working with contenteditables, their content can contain (non-obvious)
  // non-breaking spaces (NBSPs). It would be tricky to get rid of NBSPs during
  // editing and saving, so we just handle them after reading them in.
  // Tab characters are invalid in headers (and DevTools shows a warning for
  // them), the replacement here ensures that headers containing tabs are not
  // incorrectly marked as being overridden.
  return first?.replaceAll(/\s/g, ' ') === second?.replaceAll(/\s/g, ' ');
};

export class HeaderEditedEvent extends Event {
  static readonly eventName = 'headeredited';
  headerName: Platform.StringUtilities.LowerCaseString;
  headerValue: string;

  constructor(headerName: Platform.StringUtilities.LowerCaseString, headerValue: string) {
    super(HeaderEditedEvent.eventName, {});
    this.headerName = headerName;
    this.headerValue = headerValue;
  }
}

export class HeaderRemovedEvent extends Event {
  static readonly eventName = 'headerremoved';
  headerName: Platform.StringUtilities.LowerCaseString;
  headerValue: string;

  constructor(headerName: Platform.StringUtilities.LowerCaseString, headerValue: string) {
    super(HeaderRemovedEvent.eventName, {});
    this.headerName = headerName;
    this.headerValue = headerValue;
  }
}

export class EnableHeaderEditingEvent extends Event {
  static readonly eventName = 'enableheaderediting';

  constructor() {
    super(EnableHeaderEditingEvent.eventName, {});
  }
}

export interface HeaderSectionRowData {
  header: HeaderDescriptor;
}

export class HeaderSectionRow extends HTMLElement {
  readonly #shadow = this.attachShadow({mode: 'open'});
  #header: HeaderDescriptor|null = null;
  #isHeaderValueEdited = false;
  #isValidHeaderName = true;

  set data(data: HeaderSectionRowData) {
    this.#header = data.header;
    this.#isHeaderValueEdited =
        this.#header.originalValue !== undefined && this.#header.value !== this.#header.originalValue;
    this.#isValidHeaderName = isValidHeaderName(this.#header.name);
    void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
  }

  #render(): void {
    if (!ComponentHelpers.ScheduledRender.isScheduledRender(this)) {
      throw new Error('HeaderSectionRow render was not scheduled');
    }

    if (!this.#header) {
      return;
    }

    const rowClasses = Lit.Directives.classMap({
      row: true,
      'header-highlight': Boolean(this.#header.highlight),
      'header-overridden': Boolean(this.#header.isOverride) || this.#isHeaderValueEdited,
      'header-editable': this.#header.valueEditable === EditingAllowedStatus.ENABLED,
      'header-deleted': Boolean(this.#header.isDeleted),
    });

    const headerNameClasses = Lit.Directives.classMap({
      'header-name': true,
      'pseudo-header': this.#header.name.startsWith(':'),
    });

    const headerValueClasses = Lit.Directives.classMap({
      'header-value': true,
      'header-warning': Boolean(this.#header.headerValueIncorrect),
      'flex-columns': this.#header.name === 'x-client-data' && !this.#header.isResponseHeader,
    });

    // The header name is only editable when the header value is editable as well.
    // This ensures the header name's editability reacts correctly to enabling or
    // disabling local overrides.
    const isHeaderNameEditable =
        this.#header.nameEditable && this.#header.valueEditable === EditingAllowedStatus.ENABLED;

    // Case 1: Headers which were just now added via the 'Add header button'.
    //         'nameEditable' is true only for such headers.
    // Case 2: Headers for which the user clicked the 'remove' button.
    // Case 3: Headers for which there is a mismatch between original header
    //         value and current header value.
    const showReloadInfoIcon = this.#header.nameEditable || this.#header.isDeleted || this.#isHeaderValueEdited;

    // Disabled until https://crbug.com/1079231 is fixed.
    // clang-format off
    render(html`
      <style>${headerSectionRowStyles}</style>
      <div class=${rowClasses}>
        <div class=${headerNameClasses}>
          ${this.#header.headerNotSet ?
            html`<div class="header-badge header-badge-text">${i18n.i18n.lockedString('not-set')}</div> ` :
            Lit.nothing
          }
          ${isHeaderNameEditable && !this.#isValidHeaderName ?
            html`<devtools-icon class="inline-icon disallowed-characters medium" title=${UIStrings.headerNamesOnlyLetters} name='cross-circle-filled'>
            </devtools-icon>` : Lit.nothing
          }
          ${isHeaderNameEditable && !this.#header.isDeleted ?
            html`<devtools-editable-span
              @focusout=${this.#onHeaderNameFocusOut}
              @keydown=${this.#onKeyDown}
              @input=${this.#onHeaderNameEdit}
              @paste=${this.#onHeaderNamePaste}
              .data=${{value: this.#header.name}}
            ></devtools-editable-span>` :
            this.#header.name}
        </div>
        <div
          class=${headerValueClasses}
          @copy=${():void => Host.userMetrics.actionTaken(Host.UserMetrics.Action.NetworkPanelCopyValue)}
        >
          ${this.#renderHeaderValue()}
        </div>
        ${showReloadInfoIcon ?
          html`<devtools-icon name="info" class="row-flex-icon flex-right medium" title=${UIStrings.reloadPrompt}>
          </devtools-icon>` : Lit.nothing
        }
      </div>
      ${this.#maybeRenderBlockedDetails(this.#header.blockedDetails)}
    `, this.#shadow, {host: this});
    // clang-format on

    if (this.#header.highlight) {
      this.scrollIntoView({behavior: 'auto'});
    }
  }

  #renderHeaderValue(): Lit.LitTemplate {
    if (!this.#header) {
      return Lit.nothing;
    }
    if (this.#header.name === 'x-client-data' && !this.#header.isResponseHeader) {
      return this.#renderXClientDataHeader(this.#header);
    }

    if (this.#header.isDeleted || this.#header.valueEditable !== EditingAllowedStatus.ENABLED) {
      const showEditHeaderButton = this.#header.isResponseHeader && !this.#header.isDeleted &&
          this.#header.valueEditable !== EditingAllowedStatus.FORBIDDEN;
      // clang-format off
      return html`
      ${this.#header.value || ''}
      ${this.#maybeRenderHeaderValueSuffix(this.#header)}
      ${showEditHeaderButton ? html`
        <devtools-button
          title=${i18nString(UIStrings.editHeader)}
          .accessibleLabel=${i18nString(UIStrings.editHeader)}
          .size=${Buttons.Button.Size.SMALL}
          .iconName=${'edit'}
          .variant=${Buttons.Button.Variant.ICON}
          @click=${() => {
            this.dispatchEvent(new EnableHeaderEditingEvent());
          }}
          jslog=${VisualLogging.action('enable-header-overrides').track({click: true})}
          class="enable-editing inline-button"
        ></devtools-button>
      ` : Lit.nothing}
    `;
    }
    return html`
      <devtools-editable-span
        @focusout=${this.#onHeaderValueFocusOut}
        @input=${this.#onHeaderValueEdit}
        @paste=${this.#onHeaderValueEdit}
        @keydown=${this.#onKeyDown}
        .data=${{value: this.#header.value || ''}}
      ></devtools-editable-span>
      ${this.#maybeRenderHeaderValueSuffix(this.#header)}
      <devtools-button
        title=${i18nString(UIStrings.removeOverride)}
        .size=${Buttons.Button.Size.SMALL}
        .iconName=${'bin'}
        .variant=${Buttons.Button.Variant.ICON}
        class="remove-header inline-button"
        @click=${this.#onRemoveOverrideClick}
        jslog=${VisualLogging.action('remove-header-override').track({click: true})}
      ></devtools-button>
    `;
    // clang-format on
  }

  #renderXClientDataHeader(header: HeaderDescriptor): Lit.LitTemplate {
    const data = ClientVariations.parseClientVariations(header.value || '');
    const output = ClientVariations.formatClientVariations(
        data, i18nString(UIStrings.activeClientExperimentVariation),
        i18nString(UIStrings.activeClientExperimentVariationIds));
    // clang-format off
    return html`
      <div>${header.value || ''}</div>
      <div>${i18nString(UIStrings.decoded)}</div>
      <code>${output}</code>
    `;
    // clang-format on
  }

  override focus(): void {
    requestAnimationFrame(() => {
      const editableName = this.#shadow.querySelector<HTMLElement>('.header-name devtools-editable-span');
      editableName?.focus();
    });
  }

  #maybeRenderHeaderValueSuffix(header: HeaderDescriptor): Lit.LitTemplate {
    if (header.name === 'set-cookie' && header.setCookieBlockedReasons) {
      const titleText =
          header.setCookieBlockedReasons.map(SDK.NetworkRequest.setCookieBlockedReasonToUiString).join('\n');
      // Disabled until https://crbug.com/1079231 is fixed.
      // clang-format off
      return html`
        <devtools-icon class="row-flex-icon medium" title=${titleText} name='warning-filled'>
        </devtools-icon>
      `;
      // clang-format on
    }
    return Lit.nothing;
  }

  #maybeRenderBlockedDetails(blockedDetails?: BlockedDetailsDescriptor): Lit.LitTemplate {
    if (!blockedDetails) {
      return Lit.nothing;
    }
    // Disabled until https://crbug.com/1079231 is fixed.
    // clang-format off
    return html`
      <div class="call-to-action">
        <div class="call-to-action-body">
          <div class="explanation">${blockedDetails.explanation()}</div>
          ${blockedDetails.examples.map(example => html`
            <div class="example">
              <code>${example.codeSnippet}</code> ${example.comment ? html`<span class="comment"> ${example.comment()}</span>` : ''}
           </div>`)} ${this.#maybeRenderBlockedDetailsLink(blockedDetails)}
        </div>
      </div>
    `;
    // clang-format on
  }

  #maybeRenderBlockedDetailsLink(blockedDetails?: BlockedDetailsDescriptor): Lit.LitTemplate {
    if (blockedDetails?.reveal) {
      // Disabled until https://crbug.com/1079231 is fixed.
      // clang-format off
      return html`
        <div class="devtools-link" @click=${blockedDetails.reveal}>
          <devtools-icon name="issue-exclamation-filled" class="inline-icon medium">
          </devtools-icon
          >${i18nString(UIStrings.learnMoreInTheIssuesTab)}
        </div>
      `;
      // clang-format on
    }
    if (blockedDetails?.link) {
      // Disabled until https://crbug.com/1079231 is fixed.
      // clang-format off
      return html`
        <devtools-link href=${blockedDetails.link.url} class="link">
          <devtools-icon name="open-externally" class="inline-icon extra-large" style="color: var(--icon-link);">
          </devtools-icon
          >${i18nString(UIStrings.learnMore)}
        </devtools-link>
      `;
      // clang-format on
    }
    return Lit.nothing;
  }

  #onHeaderValueFocusOut(event: Event): void {
    const target = event.target as EditableSpan;
    if (!this.#header) {
      return;
    }
    const headerValue = target.value.trim();
    if (!compareHeaders(headerValue, this.#header.value?.trim())) {
      this.#header.value = headerValue;
      this.dispatchEvent(new HeaderEditedEvent(this.#header.name, headerValue));
      void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
    }

    // Clear selection (needed when pressing 'enter' in editable span).
    const selection = window.getSelection();
    selection?.removeAllRanges();

    // Reset pasted header name
    this.#header.originalName = '';
  }

  #onHeaderNameFocusOut(event: Event): void {
    const target = event.target as EditableSpan;
    if (!this.#header) {
      return;
    }
    const headerName = Platform.StringUtilities.toLowerCaseString(target.value.trim());
    // If the header name has been edited to '', reset it to its previous value.
    if (headerName === '') {
      target.value = this.#header.name;
    } else if (!compareHeaders(headerName, this.#header.name.trim())) {
      this.#header.name = headerName;
      this.dispatchEvent(new HeaderEditedEvent(headerName, this.#header.value || ''));
      void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
    }

    // Clear selection (needed when pressing 'enter' in editable span).
    const selection = window.getSelection();
    selection?.removeAllRanges();
  }

  #onRemoveOverrideClick(): void {
    if (!this.#header) {
      return;
    }
    const headerValueElement = this.#shadow.querySelector('.header-value devtools-editable-span') as EditableSpan;
    if (this.#header.originalValue) {
      headerValueElement.value = this.#header?.originalValue;
    }
    this.dispatchEvent(new HeaderRemovedEvent(this.#header.name, this.#header.value || ''));
  }

  #onKeyDown(event: KeyboardEvent): void {
    const target = event.target as EditableSpan;
    if (event.key === 'Escape') {
      event.consume();
      if (target.matches('.header-name devtools-editable-span')) {
        target.value = this.#header?.name || '';
        this.#onHeaderNameEdit(event);
      } else if (target.matches('.header-value devtools-editable-span')) {
        target.value = this.#header?.value || '';
        this.#onHeaderValueEdit(event);

        if (this.#header?.originalName) {
          const headerNameElement = this.#shadow.querySelector('.header-name devtools-editable-span') as EditableSpan;
          headerNameElement.value = this.#header.originalName;
          this.#header.originalName = '';
          headerNameElement.dispatchEvent(new Event('input'));
          headerNameElement.focus();
          return;
        }
      }
      target.blur();
    }
  }

  #onHeaderNameEdit(event: Event): void {
    const editable = event.target as EditableSpan;
    const isValidName = isValidHeaderName(editable.value);
    if (this.#isValidHeaderName !== isValidName) {
      this.#isValidHeaderName = isValidName;
      void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
    }
  }

  #onHeaderValueEdit(event: Event): void {
    const editable = event.target as EditableSpan;
    const isEdited =
        this.#header?.originalValue !== undefined && !compareHeaders(this.#header?.originalValue || '', editable.value);
    if (this.#isHeaderValueEdited !== isEdited) {
      this.#isHeaderValueEdited = isEdited;
      if (this.#header) {
        this.#header.highlight = false;
      }
      void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
    }
  }

  #onHeaderNamePaste(event: ClipboardEvent): void {
    if (!event.clipboardData) {
      return;
    }

    const nameEl = event.target as EditableSpan;
    const clipboardText = event.clipboardData.getData('text/plain') || '';
    const separatorPosition = clipboardText.indexOf(':');

    if (separatorPosition < 1) {
      // Not processing further either case 'abc' or ':abc'
      nameEl.value = clipboardText;
      event.preventDefault();
      nameEl.dispatchEvent(new Event('input', {bubbles: true}));
      return;
    }

    if (this.#header) {
      this.#header.originalName = this.#header.name;
    }

    const headerValue = clipboardText.substring(separatorPosition + 1, clipboardText.length).trim();
    const headerName = clipboardText.substring(0, separatorPosition);

    nameEl.value = headerName;
    nameEl.dispatchEvent(new Event('input'));

    const valueEL = this.#shadow.querySelector<HTMLElement>('.header-value devtools-editable-span');
    if (valueEL) {
      valueEL.focus();
      (valueEL as EditableSpan).value = headerValue;
      valueEL.dispatchEvent(new Event('input'));
    }
    event.preventDefault();
  }
}

customElements.define('devtools-header-section-row', HeaderSectionRow);

declare global {
  interface HTMLElementTagNameMap {
    'devtools-header-section-row': HeaderSectionRow;
    'devtools-editable-span': EditableSpan;
  }

  interface HTMLElementEventMap {
    [HeaderEditedEvent.eventName]: HeaderEditedEvent;
  }
}

interface BlockedDetailsDescriptor {
  explanation: () => string;
  examples: Array<{
    codeSnippet: string,
    comment?: () => string,
  }>;
  link: {
    url: string,
  }|null;
  reveal?: () => void;
}

export const enum EditingAllowedStatus {
  DISABLED = 0,   // Local overrides are currently disabled.
  ENABLED = 1,    // The header is free to be edited.
  FORBIDDEN = 2,  // Editing this header is forbidden even when local overrides are enabled.
}

export interface HeaderDetailsDescriptor {
  name: Platform.StringUtilities.LowerCaseString;
  value: string|null;
  headerValueIncorrect?: boolean;
  blockedDetails?: BlockedDetailsDescriptor;
  headerNotSet?: boolean;
  setCookieBlockedReasons?: Protocol.Network.SetCookieBlockedReason[];
  highlight?: boolean;
  isResponseHeader?: boolean;
}

export interface HeaderEditorDescriptor {
  name: Platform.StringUtilities.LowerCaseString;
  value: string|null;
  originalName?: string|null;
  originalValue?: string|null;
  isOverride?: boolean;
  valueEditable: EditingAllowedStatus;
  nameEditable?: boolean;
  isDeleted?: boolean;
}

export type HeaderDescriptor = HeaderDetailsDescriptor&HeaderEditorDescriptor;
