/**
 * @license
 * Copyright 2016 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */
import '../gr-account-chip/gr-account-chip';
import '../gr-account-entry/gr-account-entry';
import {getAppContext} from '../../../services/app-context';
import {
  ChangeInfo,
  Suggestion,
  AccountInfo,
  GroupInfo,
  EmailAddress,
  SuggestedReviewerGroupInfo,
  SuggestedReviewerAccountInfo,
  SuggestedReviewerInfo,
  isGroup,
} from '../../../types/common';
import {ReviewerSuggestionsProvider} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
import {GrAccountEntry} from '../gr-account-entry/gr-account-entry';
import {GrAccountChip} from '../gr-account-chip/gr-account-chip';
import {fire, fireAlert} from '../../../utils/event-util';
import {getUserId, isAccountNewlyAdded} from '../../../utils/account-util';
import {LitElement, css, html, PropertyValues} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
import {sharedStyles} from '../../../styles/shared-styles';
import {classMap} from 'lit/directives/class-map.js';
import {
  AutocompleteQuery,
  AutocompleteSuggestion,
  GrAutocomplete,
} from '../gr-autocomplete/gr-autocomplete';
import {ValueChangedEvent} from '../../../types/events';
import {difference, queryAndAssert} from '../../../utils/common-util';
import {PaperInputElement} from '@polymer/paper-input/paper-input';
import {IronInputElement} from '@polymer/iron-input';
import {ReviewerState} from '../../../api/rest-api';

const VALID_EMAIL_ALERT = 'Please input a valid email.';

declare global {
  interface HTMLElementEventMap {
    'accounts-changed': ValueChangedEvent<(AccountInfo | GroupInfo)[]>;
    'pending-confirmation-changed': ValueChangedEvent<SuggestedReviewerGroupInfo | null>;
  }
  interface HTMLElementTagNameMap {
    'gr-account-list': GrAccountList;
  }
  interface HTMLElementEventMap {
    'account-added': CustomEvent<AccountInputDetail>;
  }
}
export interface AccountInputDetail {
  account: AccountInput;
}

/** Supported input to be added */
export type RawAccountInput =
  | string
  | SuggestedReviewerAccountInfo
  | SuggestedReviewerGroupInfo;

// type guards for SuggestedReviewerAccountInfo and SuggestedReviewerGroupInfo
function isAccountObject(
  x: RawAccountInput
): x is SuggestedReviewerAccountInfo {
  return !!(x as SuggestedReviewerAccountInfo).account;
}

function isSuggestedReviewerGroupInfo(
  x: RawAccountInput
): x is SuggestedReviewerGroupInfo {
  return !!(x as SuggestedReviewerGroupInfo).group;
}

// Internal input type with account info
export interface AccountInfoInput extends AccountInfo {
  _account?: boolean;
  confirmed?: boolean;
}

// Internal input type with group info
export interface GroupInfoInput extends GroupInfo {
  _account?: boolean;
  confirmed?: boolean;
}

export type AccountInput = AccountInfoInput | GroupInfoInput;

@customElement('gr-account-list')
export class GrAccountList extends LitElement {
  /**
   * Fired when user inputs an invalid email address.
   *
   * @event show-alert
   */
  @query('#entry') entry?: GrAccountEntry;

  @property({type: Array})
  accounts: AccountInput[] = [];

  @property({type: Object})
  change?: ChangeInfo;

  @property({type: Object})
  filter?: (input: Suggestion) => boolean;

  @property()
  placeholder = '';

  @property({type: Boolean})
  disabled = false;

  @property({type: String})
  reviewerState?: ReviewerState;

  /**
   * Returns suggestions and convert them to list item
   */
  @property({type: Object})
  suggestionsProvider?: ReviewerSuggestionsProvider;

  /**
   * Needed for template checking since value is initially set to null.
   */
  @property({type: Object})
  pendingConfirmation: SuggestedReviewerGroupInfo | null = null;

  @property({type: Boolean})
  readonly = false;

  /**
   * When true, allows for non-suggested inputs to be added.
   */
  @property({type: Boolean, attribute: 'allow-any-input'})
  allowAnyInput = false;

  /**
   * Array of values (groups/accounts) that are removable. When this prop is
   * undefined, all values are removable.
   */
  @property({type: Array})
  removableValues?: AccountInput[];

  /**
   * Returns suggestion items
   */
  @state() private querySuggestions: AutocompleteQuery<SuggestedReviewerInfo>;

  private readonly reporting = getAppContext().reportingService;

  constructor() {
    super();
    this.querySuggestions = input => this.getSuggestions(input);
    this.addEventListener('remove', e =>
      this.handleRemove(e as CustomEvent<{account: AccountInput}>)
    );
  }

  static override styles = [
    sharedStyles,
    css`
      gr-account-chip {
        display: inline-block;
        margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xs) 0;
      }
      gr-account-entry {
        display: flex;
        flex: 1;
        min-width: 10em;
        margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xs) 0;
      }
      .group {
        --account-label-suffix: ' (group)';
      }
      .newlyAdded {
        font-style: italic;
      }
      .list {
        align-items: center;
        display: flex;
        flex-wrap: wrap;
      }
    `,
  ];

  override render() {
    return html`<div class="list">
        ${this.accounts.map(
          account => html`
            <gr-account-chip
              .account=${account}
              .change=${this.change}
              class=${classMap({
                group: isGroup(account),
                newlyAdded: isAccountNewlyAdded(
                  account,
                  this.reviewerState,
                  this.change
                ),
              })}
              ?removable=${this.computeRemovable(account)}
              @keydown=${this.handleChipKeydown}
              tabindex="-1"
            >
            </gr-account-chip>
          `
        )}
      </div>
      <gr-account-entry
        borderless=""
        ?hidden=${this.readonly}
        id="entry"
        .placeholder=${this.placeholder}
        @add=${this.handleAdd}
        @keydown=${this.handleInputKeydown}
        .allowAnyInput=${this.allowAnyInput}
        .querySuggestions=${this.querySuggestions}
      >
      </gr-account-entry>
      <slot></slot>`;
  }

  override willUpdate(changedProperties: PropertyValues) {
    if (changedProperties.has('pendingConfirmation')) {
      fire(this, 'pending-confirmation-changed', {
        value: this.pendingConfirmation,
      });
    }
  }

  get accountChips(): GrAccountChip[] {
    return Array.from(
      this.shadowRoot?.querySelectorAll('gr-account-chip') || []
    );
  }

  get focusStart() {
    // Entry is always defined and we cannot return undefined.
    return this.entry?.focusStart;
  }

  getSuggestions(
    input: string
  ): Promise<AutocompleteSuggestion<SuggestedReviewerInfo>[]> {
    const provider = this.suggestionsProvider;
    if (!provider) return Promise.resolve([]);
    return provider.getSuggestions(input).then(suggestions => {
      if (!suggestions) return [];
      if (this.filter) {
        suggestions = suggestions.filter(this.filter);
      }
      return suggestions.map(suggestion =>
        provider.makeSuggestionItem(suggestion)
      );
    });
  }

  // private but used in test
  handleAdd(e: ValueChangedEvent<string>) {
    // TODO(TS) this is temporary hack to avoid cascade of ts issues
    const item = e.detail.value as RawAccountInput;
    this.addAccountItem(item);
  }

  addAccountItem(item: RawAccountInput) {
    // Append new account or group to the accounts property. We add our own
    // internal properties to the account/group here, so we clone the object
    // to avoid cluttering up the shared change object.
    let account;
    let group;
    let itemTypeAdded = 'unknown';
    if (isAccountObject(item)) {
      account = {...item.account};
      this.accounts.push(account);
      itemTypeAdded = 'account';
    } else if (isSuggestedReviewerGroupInfo(item)) {
      if (item.confirm) {
        this.pendingConfirmation = item;
        return;
      }
      group = {...item.group};
      this.accounts.push(group);
      itemTypeAdded = 'group';
    } else if (this.allowAnyInput) {
      if (!item.includes('@')) {
        // Repopulate the input with what the user tried to enter and have
        // a toast tell them why they can't enter it.
        this.entry?.setText(item);
        fireAlert(this, VALID_EMAIL_ALERT);
        return false;
      } else {
        account = {email: item as EmailAddress};
        this.accounts.push(account);
        itemTypeAdded = 'email';
      }
    }
    fire(this, 'accounts-changed', {value: this.accounts.slice()});
    fire(this, 'account-added', {account: (account ?? group)! as AccountInput});
    this.reporting.reportInteraction(`Add to ${this.id}`, {itemTypeAdded});
    this.pendingConfirmation = null;
    this.requestUpdate();
    return true;
  }

  confirmGroup(group: GroupInfo) {
    this.accounts.push({
      ...group,
      confirmed: true,
    });
    this.pendingConfirmation = null;
    fire(this, 'accounts-changed', {value: this.accounts});
    this.requestUpdate();
  }

  // private but used in test
  computeRemovable(account: AccountInput) {
    if (this.readonly) {
      return false;
    }
    if (this.removableValues) {
      for (let i = 0; i < this.removableValues.length; i++) {
        if (getUserId(this.removableValues[i]) === getUserId(account)) {
          return true;
        }
      }
      return isAccountNewlyAdded(account, this.reviewerState, this.change);
    }
    return true;
  }

  private handleRemove(e: CustomEvent<{account: AccountInput}>) {
    const toRemove = e.detail.account;
    this.removeAccount(toRemove);
    this.entry?.focus();
  }

  removeAccount(toRemove?: AccountInput) {
    if (!toRemove || !this.computeRemovable(toRemove)) {
      return false;
    }
    for (let i = 0; i < this.accounts.length; i++) {
      if (getUserId(toRemove) === getUserId(this.accounts[i])) {
        this.accounts.splice(i, 1);
        this.reporting.reportInteraction(`Remove from ${this.id}`);
        this.requestUpdate();
        fire(this, 'accounts-changed', {value: this.accounts.slice()});
        return true;
      }
    }
    return false;
  }

  // private but used in test
  getOwnNativeInput(paperInput: PaperInputElement) {
    return (paperInput.inputElement as IronInputElement)
      .inputElement as HTMLInputElement;
  }

  private handleInputKeydown(e: KeyboardEvent) {
    const target = e.target as GrAccountEntry;
    const entryInput = queryAndAssert<GrAutocomplete>(target, '#input');
    const input = this.getOwnNativeInput(entryInput.input!);
    if (
      input.selectionStart !== input.selectionEnd ||
      input.selectionStart !== 0
    ) {
      return;
    }
    switch (e.key) {
      case 'Backspace':
        this.removeAccount(this.accounts[this.accounts.length - 1]);
        break;
      case 'ArrowLeft':
        if (this.accountChips[this.accountChips.length - 1]) {
          this.accountChips[this.accountChips.length - 1].focus();
        }
        break;
    }
  }

  private handleChipKeydown(e: KeyboardEvent) {
    const chip = e.target as GrAccountChip;
    const chips = this.accountChips;
    const index = chips.indexOf(chip);
    switch (e.key) {
      case 'Backspace':
      case 'Enter':
      case ' ':
      case 'Delete':
        this.removeAccount(chip.account);
        // Splice from this array to avoid inconsistent ordering of
        // event handling.
        chips.splice(index, 1);
        if (index < chips.length) {
          chips[index].focus();
        } else if (index > 0) {
          chips[index - 1].focus();
        } else {
          this.entry?.focus();
        }
        break;
      case 'ArrowLeft':
        if (index > 0) {
          chip.blur();
          chips[index - 1].focus();
        }
        break;
      case 'ArrowRight':
        chip.blur();
        if (index < chips.length - 1) {
          chips[index + 1].focus();
        } else {
          this.entry?.focus();
        }
        break;
    }
  }

  /**
   * Submit the text of the entry as a reviewer value, if it exists. If it is
   * a successful submit of the text, clear the entry value.
   *
   * @return If there is text in the entry, return true if the
   * submission was successful and false if not. If there is no text,
   * return true.
   */
  submitEntryText() {
    const text = this.entry?.getText();
    if (!text?.length) {
      return true;
    }
    const wasSubmitted = this.addAccountItem(text);
    if (wasSubmitted) {
      this.entry?.clear();
    }
    return wasSubmitted;
  }

  additions(): (AccountInfoInput | GroupInfoInput)[] {
    if (!this.change) return [];
    return this.accounts.filter(account =>
      isAccountNewlyAdded(account, this.reviewerState, this.change)
    );
  }

  removals(): AccountInfo[] {
    if (!this.reviewerState) return [];
    return difference(
      this.change?.reviewers[this.reviewerState] ?? [],
      this.accounts,
      (a, b) => getUserId(a) === getUserId(b)
    );
  }
}
