/**
 * Copyright 2026 Google LLC
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import {
  SlInput,
  SlDropdown,
  type SlMenu,
  type SlMenuItem,
} from '@shoelace-style/shoelace';
import {css, html, LitElement, type TemplateResult, nothing} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
import {live} from 'lit/directives/live.js';
import {createRef, ref, Ref} from 'lit/directives/ref.js';
import {SHARED_STYLES} from '../css/shared-css.js';

/* This file consists of 3 classes that together implement a "typeahead"
   text field with autocomplete:

   1. Chromedash-typeahead represents the overall UI widget, accepts a
   `vocabulary` list of words, exposes a `value` string, and emits a
   `sl-change` event when the user hits enter to submit the value.
   Internally, it is responsible for narrowing the vocabulary down to a list
   of candidates based on the prefix that the user has typed.

   2. Private class ChromedashTypeaheadDropdown subclasses SlDropdown and
   removes code that would change the keyboard focus.

   3. Private class ChromedashTypeaheadItem renders a single item in the
   typeahead menu.  We do not use SlMenuItem because it steals keyboard focus.
*/
export interface Candidate {
  group: string;
  name: string;
  doc: string;
}

@customElement('chromedash-typeahead')
export class ChromedashTypeahead extends LitElement {
  slDropdownRef: Ref<ChromedashTypeaheadDropdown> = createRef();
  slInputRef: Ref<SlInput> = createRef();

  @property()
  value = '';
  @state()
  candidates: Candidate[] = [];
  @state()
  prefix: string | null = null;
  @state()
  chunkStart = 0;
  @state()
  chunkEnd = 0;
  @state()
  wasDismissed = false;
  @state()
  termWasCompleted = false;

  @property({type: String})
  placeholder = '';
  @property({type: Array, attribute: false})
  vocabulary!: Candidate[];

  static get styles() {
    return [
      ...SHARED_STYLES,
      css`
        chromedash-typeahead-dropdown {
          width: 100%;
        }
        #inputfield::part(base) {
          background: #eee;
          border: none;
          border-radius: 8px;
        }
      `,
    ];
  }

  _fireEvent(eventName, detail) {
    const event = new CustomEvent(eventName, {
      bubbles: true,
      composed: true,
      detail,
    });
    this.dispatchEvent(event);
  }

  reflectValue(event?) {
    if (event) {
      event.stopPropagation();
    }
    const slInput = this.slInputRef.value!;
    this.value = slInput.value;
  }

  hide() {
    this.slDropdownRef.value!.hide();
  }

  show() {
    this.slDropdownRef.value!.show();
  }

  focus() {
    const slInput: SlInput = this.slInputRef.value as SlInput;
    slInput?.focus();
  }

  blur() {
    const slInput: SlInput = this.slInputRef.value as SlInput;
    slInput?.blur();
  }

  findPrefix() {
    const inputEl = this.slInputRef.value?.input;
    if (!inputEl) return;
    const wholeStr = inputEl.value;
    const caret = inputEl.selectionStart || 0;
    if (caret != inputEl.selectionEnd) {
      // User has a range selected.
      this.prefix = null;
      return;
    }
    this.chunkStart = wholeStr.lastIndexOf(' ', caret! - 1) + 1;
    this.chunkEnd = wholeStr.indexOf(' ', caret!);
    if (this.chunkEnd === -1) this.chunkEnd = wholeStr.length;
    this.prefix = wholeStr.substring(this.chunkStart, caret!);
  }

  shouldShowCandidate(candidate, prefix) {
    if (prefix === null) return false;
    prefix = prefix.toLowerCase();
    const lowerName = candidate.name.toLowerCase();
    const lowerDoc = candidate.doc.toLowerCase();
    return (
      lowerName.split(/\s+/).some(w => w.startsWith(prefix)) ||
      lowerDoc.split(/\s+/).some(w => w.startsWith(prefix)) ||
      lowerName.split(/\W+/).some(w => w.startsWith(prefix)) ||
      lowerDoc.split(/\W+/).some(w => w.startsWith(prefix))
    );
  }

  // Return true if the user is still entering the keyword and is not
  // ready to enter an enum value yet.
  shouldGroup(s: string | null): boolean {
    if (s === null) {
      return true;
    }
    const COMPARE_OPS = ['=', ':', '<', '>'];
    return !COMPARE_OPS.some(op => s.includes(op));
  }

  groupCandidates(candidates: Candidate[]): Candidate[] {
    const groupsSeen = new Set();
    const groupsSeenTwice = new Set();
    for (const c of candidates) {
      if (groupsSeen.has(c.group)) {
        groupsSeenTwice.add(c.group);
      } else {
        groupsSeen.add(c.group);
      }
    }

    const groupsSeenTwiceProcessed = new Set();
    const result: Candidate[] = [];
    for (const c of candidates) {
      if (!groupsSeenTwice.has(c.group)) {
        result.push(c);
      } else if (!groupsSeenTwiceProcessed.has(c.group)) {
        result.push({group: c.group, name: c.group + ':', doc: c.doc});
        groupsSeenTwiceProcessed.add(c.group);
      }
    }
    return result;
  }

  async handleCandidateSelected(e: {detail: {item: SlMenuItem}}) {
    const candidateValue = e.detail.item.value;
    const inputEl = this.slInputRef.value?.renderRoot.querySelector('input');
    if (!inputEl) return;
    const wholeStr = inputEl.value;
    // Don't add a space after the completed value: let the user type it.
    const newWholeStr =
      wholeStr.substring(0, this.chunkStart) +
      candidateValue +
      wholeStr.substring(this.chunkEnd, wholeStr.length);
    this.slInputRef.value!.value = newWholeStr;
    this.reflectValue();
    // Wait for the sl-input to propagate its new value to its <input> before
    // setting or accessing the text selection.
    await this.updateComplete;

    this.chunkStart = this.chunkStart + candidateValue.length;
    this.chunkEnd = this.chunkStart;
    inputEl.selectionStart = this.chunkStart;
    inputEl.selectionEnd = this.chunkEnd;

    // A term was completed iff there is no other term that the user could
    // further complete by typing or selecting.
    const possibleExtensions = this.vocabulary.filter(c =>
      c.name.startsWith(candidateValue)
    );
    this.termWasCompleted = possibleExtensions.length <= 1;

    this.calcCandidates();
    // The user may have clicked a menu item, causing the sl-input to lose
    // keyboard focus.  So, focus on the sl-input again.
    inputEl.focus();
  }

  // Check if the user is pressing Enter to send a query.  This is detected
  // on keyDown so that the handler is run before the dropdown keyDown is run.
  // If stopPropagation() is not called, the event will cause a completion.
  handleInputFieldKeyDown(event) {
    if (event.key === 'Enter') {
      const slDropdown = this.slDropdownRef.value;
      if (!slDropdown) return;
      const currentItem = slDropdown.getCurrentItem();
      if (!slDropdown.open || !currentItem) {
        // User wants to send the query to the server.
        this._fireEvent('sl-change', this);
        event.stopPropagation();
      }
    }
  }

  // As the user types and moves the caret, keep recalculating a-c choices.
  // Left and right movement is handled on keyUp so that caret has already been
  // moved to its new position before this handler is run.
  handleInputFieldKeyUp(event) {
    if (['Escape'].includes(event.key)) {
      this.wasDismissed = true;
      return;
    }
    if (['ArrowDown', 'ArrowUp', 'Enter'].includes(event.key)) {
      this.wasDismissed = false;
      return;
    }
    this.termWasCompleted = false;
    this.calcCandidates();
  }

  calcCandidates(event?) {
    if (event) {
      event.stopPropagation();
    }
    this.findPrefix();
    this.candidates = this.vocabulary.filter(c =>
      this.shouldShowCandidate(c, this.prefix)
    );
    if (this.shouldGroup(this.prefix)) {
      this.candidates = this.groupCandidates(this.candidates);
    }
    const slDropdown = this.slDropdownRef.value;
    if (!slDropdown) return;
    if (
      this.candidates.length > 0 &&
      !this.wasDismissed &&
      !this.termWasCompleted
    ) {
      slDropdown.show();
    } else {
      slDropdown.hide();
    }
  }

  renderInputField() {
    return html`
      <sl-input
        id="inputfield"
        slot="trigger"
        placeholder=${this.placeholder}
        value=${live(this.value)}
        ${ref(this.slInputRef)}
        autocomplete="off"
        spellcheck="false"
        @keydown="${this.handleInputFieldKeyDown}"
        @keyup="${this.handleInputFieldKeyUp}"
        @focus="${this.calcCandidates}"
        @click="${this.calcCandidates}"
        @sl-change="${this.reflectValue}"
        @sl-input="${this.reflectValue}"
      >
        <slot name="prefix" slot="prefix"></slot>
        <slot name="suffix" slot="suffix"></slot>
      </sl-input>
    `;
  }

  renderAutocompleteMenu() {
    // sl-menu automatically selects the first menu item.  This nerfs
    // that undesired behavior by adding an invisible 0th menu item.
    const nonselectableItem = html`
      <chromedash-typeahead-invisible-item></chromedash-typeahead-invisible-item>
    `;
    return html`
      <sl-menu
        @click=${e => e.preventDefault()}
        @sl-select=${this.handleCandidateSelected}
      >
        ${nonselectableItem}
        ${this.candidates.map(
          c => html`
            <chromedash-typeahead-item
              value=${c.name}
              doc=${c.doc}
              prefix=${this.prefix ?? nothing}
            ></chromedash-typeahead-item>
          `
        )}
      </sl-menu>
    `;
  }

  render() {
    return html`
      <chromedash-typeahead-dropdown
        stay-open-on-select
        sync="width"
        ${ref(this.slDropdownRef)}
      >
        ${this.renderInputField()} ${this.renderAutocompleteMenu()}
      </chromedash-typeahead-dropdown>
    `;
  }
}

@customElement('chromedash-typeahead-dropdown')
export class ChromedashTypeaheadDropdown extends SlDropdown {
  getCurrentItem(): SlMenuItem | undefined {
    const item = this.getMenu()!.getCurrentItem();
    if (!item || item instanceof ChromedashTypeaheadInvisibleItem) {
      return undefined;
    }
    return item;
  }

  setCurrentItem(newCurrentItem: SlMenuItem) {
    const menu = this.getMenu();
    menu!.setCurrentItem(newCurrentItem);
    newCurrentItem.scrollIntoView({block: 'nearest', behavior: 'smooth'});
  }

  getAllVisibleItems(menu: SlMenu): SlMenuItem[] {
    const items = menu.getAllItems();
    return items.filter(
      item => !(item instanceof ChromedashTypeaheadInvisibleItem)
    );
  }

  resetSelection() {
    const currentItem = this.getCurrentItem();
    currentItem?.setAttribute('tabindex', '-1');
  }

  async handleTriggerKeyDown(event: KeyboardEvent) {
    const menu = this.getMenu();
    if (!menu) {
      return;
    }
    const menuItems = this.getAllVisibleItems(menu);
    if (menuItems.length === 0) {
      return;
    }
    const currentItem = menu.getCurrentItem();

    // Handle menu selection keys.
    if (['Enter'].includes(event.key)) {
      event.preventDefault();

      if (this.open && currentItem) {
        currentItem.click();
        this.resetSelection();
      }
    }

    // Handle menu navigation keys.
    if (['ArrowDown', 'ArrowUp'].includes(event.key)) {
      event.preventDefault();
      event.stopPropagation();

      // Show the menu if it's not already open
      if (!this.open) {
        this.show();
      }

      if (currentItem) {
        const currentItemIndex = menuItems.indexOf(currentItem);
        if (event.key === 'ArrowDown' && menuItems[currentItemIndex + 1]) {
          this.setCurrentItem(menuItems[currentItemIndex + 1]);
        }
        if (event.key === 'ArrowUp' && menuItems[currentItemIndex - 1]) {
          this.setCurrentItem(menuItems[currentItemIndex - 1]);
        }
      } else {
        if (event.key === 'ArrowDown') {
          this.setCurrentItem(menuItems[1]);
        }
        if (event.key === 'ArrowUp') {
          this.setCurrentItem(menuItems[menuItems.length - 1]);
        }
      }
      // Note: We keep keyboard focus on #inputfield.
    }
  }
}

@customElement('chromedash-typeahead-item')
export class ChromedashTypeaheadItem extends LitElement {
  @property({type: String})
  value = '';
  @property({type: String})
  doc = '';
  @property({type: String})
  prefix = '';
  // tabindex is initially -1 for menu items so that the tab key does not
  // navigate among menu items.  Instead, sl-menu handles arrow keys and
  // mouseover.  Shoelace uses tabindex==0 only for the current menu item.
  // https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex
  @property({type: Number})
  tabindex = -1;
  @property({type: String, reflect: true})
  role = 'menuitem';

  static get styles() {
    return [
      ...SHARED_STYLES,
      css`
        .menu-item {
          display: flex;
          flex-wrap: wrap;
          font-family: var(--sl-font-sans);
          font-size: var(--sl-font-size-medium);
          font-weight: var(--sl-font-weight-normal);
          line-height: var(--sl-line-height-normal);
          letter-spacing: var(--sl-letter-spacing-normal);
          color: var(--sl-color-neutral-700);
          padding: var(--sl-spacing-2x-small) var(--sl-spacing-2x-small);
          transition: var(--sl-transition-fast) fill;
          user-select: none;
          -webkit-user-select: none;
          white-space: nowrap;
          cursor: pointer;
        }

        .active {
          outline: none;
          background-color: var(--sl-color-primary-200);
          opacity: 1;
        }
        #value {
          width: 24em;
          overflow-x: hidden;
        }
        code {
          font-size: 85%;
          background: #eee;
          padding: var(--content-padding-quarter);
        }
      `,
    ];
  }

  handleMouseOver(event: Event) {
    if (this.parentElement) {
      (this.parentElement as SlMenu).setCurrentItem(
        this as unknown as SlMenuItem
      );
    }
    event.stopPropagation();
  }

  highlight(s) {
    const start = s.toLowerCase().indexOf(this.prefix.toLowerCase());
    if (start === -1) return s;
    const before = s.substring(0, start);
    const matching = s.substring(start, start + this.prefix.length);
    const after = s.substring(start + this.prefix.length);
    return html`${before}<b>${matching}</b>${after}`;
  }

  render(): TemplateResult {
    const highlightedValue = this.highlight(this.value);
    const highlightedDoc = this.highlight(this.doc);
    return html`
      <div
        class="menu-item ${this.tabindex === 0 ? 'active' : ''}"
        @mouseover=${this.handleMouseOver}
      >
        <span id="value"><code>${highlightedValue}</code></span>
        <span id="doc">${highlightedDoc}</span>
      </div>
    `;
  }
}

@customElement('chromedash-typeahead-invisible-item')
export class ChromedashTypeaheadInvisibleItem extends ChromedashTypeaheadItem {
  override render(): TemplateResult {
    return html`${nothing}`;
  }
}
