/**
 * @license
 * Copyright 2018 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */
import '../../../styles/shared-styles';
import '../../shared/gr-comment-thread/gr-comment-thread';
import '../../shared/gr-dropdown-list/gr-dropdown-list';
import {SpecialFilePath} from '../../../constants/constants';
import {
  AccountDetailInfo,
  AccountInfo,
  NumericChangeId,
  UrlEncodedCommentId,
} from '../../../types/common';
import {ChangeMessageId} from '../../../api/rest-api';
import {
  CommentThread,
  getCommentAuthors,
  getMentionedThreads,
  hasHumanReply,
  isDraft,
  isDraftThread,
  isMentionedThread,
  isRobotThread,
  isUnresolved,
  lastUpdated,
} from '../../../utils/comment-util';
import {pluralize} from '../../../utils/string-util';
import {assertIsDefined} from '../../../utils/common-util';
import {CommentTabState, TabState} from '../../../types/events';
import {DropdownItem} from '../../shared/gr-dropdown-list/gr-dropdown-list';
import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
import {css, html, LitElement, PropertyValues} from 'lit';
import {customElement, property, queryAll, state} from 'lit/decorators.js';
import {sharedStyles} from '../../../styles/shared-styles';
import {subscribe} from '../../lit/subscription-controller';
import {ParsedChangeInfo} from '../../../types/types';
import {repeat} from 'lit/directives/repeat.js';
import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
import {getAppContext} from '../../../services/app-context';
import {resolve} from '../../../models/dependency';
import {changeModelToken} from '../../../models/change/change-model';
import {Interaction} from '../../../constants/reporting';
import {KnownExperimentId} from '../../../services/flags/flags';
import {HtmlPatched} from '../../../utils/lit-util';

enum SortDropdownState {
  TIMESTAMP = 'Latest timestamp',
  FILES = 'Files',
}

export const __testOnly_SortDropdownState = SortDropdownState;

/**
 * Order as follows:
 * - Patchset level threads (descending based on patchset number)
 * - unresolved
 * - comments with drafts
 * - comments without drafts
 * - resolved
 * - comments with drafts
 * - comments without drafts
 * - File name
 * - Line number
 * - Unresolved (descending based on patchset number)
 * - comments with drafts
 * - comments without drafts
 * - Resolved (descending based on patchset number)
 * - comments with drafts
 * - comments without drafts
 */
export function compareThreads(
  c1: CommentThread,
  c2: CommentThread,
  byTimestamp = false
) {
  if (byTimestamp) {
    const c1Time = lastUpdated(c1)?.getTime() ?? 0;
    const c2Time = lastUpdated(c2)?.getTime() ?? 0;
    const timeDiff = c2Time - c1Time;
    if (timeDiff !== 0) return c2Time - c1Time;
  }

  if (c1.path !== c2.path) {
    // '/PATCHSET' will not come before '/COMMIT' when sorting
    // alphabetically so move it to the front explicitly
    if (c1.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
      return -1;
    }
    if (c2.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
      return 1;
    }
    return c1.path.localeCompare(c2.path);
  }

  // Convert 'FILE' and 'LOST' to undefined.
  const line1 = typeof c1.line === 'number' ? c1.line : undefined;
  const line2 = typeof c2.line === 'number' ? c2.line : undefined;
  if (line1 !== line2) {
    // one of them is a FILE/LOST comment, show first
    if (line1 === undefined) return -1;
    if (line2 === undefined) return 1;
    // Lower line numbers first.
    return line1 < line2 ? -1 : 1;
  }

  if (c1.patchNum !== c2.patchNum) {
    // `patchNum` should be required, but show undefined first.
    if (c1.patchNum === undefined) return -1;
    if (c2.patchNum === undefined) return 1;
    // Higher patchset numbers first.
    return c1.patchNum > c2.patchNum ? -1 : 1;
  }

  // Sorting should not be based on the thread being unresolved or being a draft
  // thread, because that would be a surprising re-sort when the thread changes
  // state.

  const c1Time = lastUpdated(c1)?.getTime() ?? 0;
  const c2Time = lastUpdated(c2)?.getTime() ?? 0;
  if (c2Time !== c1Time) {
    // Newer comments first.
    return c2Time - c1Time;
  }

  return 0;
}

@customElement('gr-thread-list')
export class GrThreadList extends LitElement {
  @queryAll('gr-comment-thread')
  threadElements?: NodeListOf<GrCommentThread>;

  /**
   * Raw list of threads for the component to show.
   *
   * ATTENTION! this.threads should never be used directly within the component.
   *
   * Either use getAllThreads(), which applies filters that are inherent to what
   * the component is supposed to render,
   * e.g. onlyShowRobotCommentsWithHumanReply.
   *
   * Or use getDisplayedThreads(), which applies the currently selected filters
   * on top.
   */
  @property({type: Array})
  threads: CommentThread[] = [];

  @property({type: Boolean, attribute: 'show-comment-context'})
  showCommentContext = false;

  /** Along with `draftsOnly` is the currently selected filter. */
  @property({type: Boolean, attribute: 'unresolved-only'})
  unresolvedOnly = false;

  @property({
    type: Boolean,
    attribute: 'only-show-robot-comments-with-human-reply',
  })
  onlyShowRobotCommentsWithHumanReply = false;

  @property({type: Boolean, attribute: 'hide-dropdown'})
  hideDropdown = false;

  @property({type: Object, attribute: 'comment-tab-state'})
  commentTabState?: TabState;

  @property({type: String, attribute: 'scroll-comment-id'})
  scrollCommentId?: UrlEncodedCommentId;

  /**
   * Optional context information when threads are being displayed for a
   * specific change message. That influences which comments are expanded or
   * collapsed by default.
   */
  @property({type: String, attribute: 'message-id'})
  messageId?: ChangeMessageId;

  @state()
  changeNum?: NumericChangeId;

  @state()
  change?: ParsedChangeInfo;

  @state()
  account?: AccountDetailInfo;

  @state()
  selectedAuthors: AccountInfo[] = [];

  @state()
  sortDropdownValue: SortDropdownState = SortDropdownState.TIMESTAMP;

  /** Along with `unresolvedOnly` is the currently selected filter. */
  @state()
  draftsOnly = false;

  @state()
  mentionsOnly = false;

  private readonly getChangeModel = resolve(this, changeModelToken);

  private readonly reporting = getAppContext().reportingService;

  private readonly flagsService = getAppContext().flagsService;

  private readonly userModel = getAppContext().userModel;

  private readonly patched = new HtmlPatched(key => {
    this.reporting.reportInteraction(Interaction.AUTOCLOSE_HTML_PATCHED, {
      component: this.tagName,
      key: key.substring(0, 300),
    });
  });

  constructor() {
    super();
    subscribe(
      this,
      () => this.getChangeModel().changeNum$,
      x => (this.changeNum = x)
    );
    subscribe(
      this,
      () => this.getChangeModel().change$,
      x => (this.change = x)
    );
    subscribe(
      this,
      () => this.userModel.account$,
      x => (this.account = x)
    );
    // for COMMENTS_AUTOCLOSE logging purposes only
    this.reporting.reportInteraction(
      Interaction.COMMENTS_AUTOCLOSE_THREAD_LIST_CREATED
    );
  }

  override willUpdate(changed: PropertyValues) {
    if (changed.has('commentTabState')) this.onCommentTabStateUpdate();
    if (changed.has('scrollCommentId')) this.onScrollCommentIdUpdate();
  }

  private onCommentTabStateUpdate() {
    switch (this.commentTabState?.commentTab) {
      case CommentTabState.MENTIONS:
        this.handleOnlyMentions();
        break;
      case CommentTabState.UNRESOLVED:
        this.handleOnlyUnresolved();
        break;
      case CommentTabState.DRAFTS:
        this.handleOnlyDrafts();
        break;
      case CommentTabState.SHOW_ALL:
        this.handleAllComments();
        break;
    }
  }

  /**
   * When user wants to scroll to a comment, render all comments so that the
   * appropriate comment can be scrolled into view.
   */
  private onScrollCommentIdUpdate() {
    if (this.scrollCommentId) this.handleAllComments();
  }

  static override get styles() {
    return [
      sharedStyles,
      css`
        #threads {
          display: block;
        }
        gr-comment-thread {
          display: block;
          margin-bottom: var(--spacing-m);
        }
        .header {
          align-items: center;
          background-color: var(--background-color-primary);
          border-bottom: 1px solid var(--border-color);
          border-top: 1px solid var(--border-color);
          display: flex;
          justify-content: left;
          padding: var(--spacing-s) var(--spacing-l);
        }
        .draftsOnly:not(.unresolvedOnly) gr-comment-thread[has-draft],
        .unresolvedOnly:not(.draftsOnly) gr-comment-thread[unresolved],
        .draftsOnly.unresolvedOnly gr-comment-thread[has-draft][unresolved] {
          display: block;
        }
        .thread-separator {
          border-top: 1px solid var(--border-color);
          margin-top: var(--spacing-xl);
        }
        .show-resolved-comments {
          box-shadow: none;
          padding-left: var(--spacing-m);
        }
        .partypopper {
          margin-right: var(--spacing-s);
        }
        gr-dropdown-list {
          --trigger-style-text-color: var(--primary-text-color);
          --trigger-style-font-family: var(--font-family);
        }
        .filter-text,
        .sort-text,
        .author-text {
          margin-right: var(--spacing-s);
          color: var(--deemphasized-text-color);
        }
        .author-text {
          margin-left: var(--spacing-m);
        }
        gr-account-label {
          --account-max-length: 120px;
          display: inline-block;
          user-select: none;
          --label-border-radius: 8px;
          margin: 0 var(--spacing-xs);
          padding: var(--spacing-xs) var(--spacing-m);
          line-height: var(--line-height-normal);
          cursor: pointer;
        }
        gr-account-label:focus {
          outline: none;
        }
        gr-account-label:hover,
        gr-account-label:hover {
          box-shadow: var(--elevation-level-1);
          cursor: pointer;
        }
      `,
    ];
  }

  override updated(): void {
    // for COMMENTS_AUTOCLOSE logging purposes only
    const threads = this.shadowRoot!.querySelectorAll('gr-comment-thread');
    if (threads.length > 0) {
      this.reporting.reportInteraction(
        Interaction.COMMENTS_AUTOCLOSE_THREAD_LIST_UPDATED,
        {uid: threads[0].uid}
      );
    }
  }

  override render() {
    return html`
      ${this.renderDropdown()}
      <div id="threads" part="threads">
        ${this.renderEmptyThreadsMessage()} ${this.renderCommentThreads()}
      </div>
    `;
  }

  private renderDropdown() {
    if (this.hideDropdown) return;
    return html`
      <div class="header">
        <span class="sort-text">Sort By:</span>
        <gr-dropdown-list
          id="sortDropdown"
          .value=${this.sortDropdownValue}
          @value-change=${(e: CustomEvent) =>
            (this.sortDropdownValue = e.detail.value)}
          .items=${this.getSortDropdownEntries()}
        >
        </gr-dropdown-list>
        <span class="separator"></span>
        <span class="filter-text">Filter By:</span>
        <gr-dropdown-list
          id="filterDropdown"
          .value=${this.getCommentsDropdownValue()}
          @value-change=${this.handleCommentsDropdownValueChange}
          .items=${this.getCommentsDropdownEntries()}
        >
        </gr-dropdown-list>
        ${this.renderAuthorChips()}
      </div>
    `;
  }

  private renderEmptyThreadsMessage() {
    const threads = this.getAllThreads();
    const threadsEmpty = threads.length === 0;
    const displayedEmpty = this.getDisplayedThreads().length === 0;
    if (!displayedEmpty) return;
    const showPopper = this.unresolvedOnly && !threadsEmpty;
    const popper = html`<span class="partypopper">&#x1F389;</span>`;
    const showButton = this.unresolvedOnly && !threadsEmpty;
    const button = html`
      <gr-button
        class="show-resolved-comments"
        link
        @click=${this.handleAllComments}
        >Show ${pluralize(threads.length, 'resolved comment')}</gr-button
      >
    `;
    return html`
      <div>
        <span>
          ${showPopper ? popper : undefined}
          ${threadsEmpty ? 'No comments' : 'No unresolved comments'}
          ${showButton ? button : undefined}
        </span>
      </div>
    `;
  }

  private renderCommentThreads() {
    const threads = this.getDisplayedThreads();
    return repeat(
      threads,
      thread => thread.rootId,
      (thread, index) => {
        const isFirst =
          index === 0 || threads[index - 1].path !== threads[index].path;
        const separator =
          index !== 0 && isFirst
            ? this.patched.html`<div class="thread-separator"></div>`
            : undefined;
        const commentThread = this.renderCommentThread(thread, isFirst);
        return this.patched.html`${separator}${commentThread}`;
      }
    );
  }

  private renderCommentThread(thread: CommentThread, isFirst: boolean) {
    return this.patched.html`
      <gr-comment-thread
        .thread=${thread}
        show-file-path
        ?show-ported-comment=${thread.ported}
        ?show-comment-context=${this.showCommentContext}
        ?show-file-name=${isFirst}
        .messageId=${this.messageId}
        ?should-scroll-into-view=${thread.rootId === this.scrollCommentId}
        @comment-thread-editing-changed=${() => {
          this.requestUpdate();
        }}
      ></gr-comment-thread>
    `;
  }

  private renderAuthorChips() {
    const authors = getCommentAuthors(this.getDisplayedThreads(), this.account);
    if (authors.length === 0) return;
    return html`<span class="author-text">From:</span>${authors.map(author =>
        this.renderAccountChip(author)
      )}`;
  }

  private renderAccountChip(account: AccountInfo) {
    const selected = this.selectedAuthors.some(
      a => a._account_id === account._account_id
    );
    return html`
      <gr-account-label
        .account=${account}
        @click=${this.handleAccountClicked}
        selectionChipStyle
        noStatusIcons
        ?selected=${selected}
      ></gr-account-label>
    `;
  }

  private getCommentsDropdownValue() {
    if (this.mentionsOnly) return CommentTabState.MENTIONS;
    if (this.draftsOnly) return CommentTabState.DRAFTS;
    if (this.unresolvedOnly) return CommentTabState.UNRESOLVED;
    return CommentTabState.SHOW_ALL;
  }

  private getSortDropdownEntries() {
    return [
      {text: SortDropdownState.FILES, value: SortDropdownState.FILES},
      {text: SortDropdownState.TIMESTAMP, value: SortDropdownState.TIMESTAMP},
    ];
  }

  // private, but visible for testing
  getCommentsDropdownEntries() {
    const items: DropdownItem[] = [];
    const threads = this.getAllThreads();
    items.push({
      text: `Unresolved (${threads.filter(isUnresolved).length})`,
      value: CommentTabState.UNRESOLVED,
    });
    if (this.account) {
      if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
        items.push({
          text: `Mentions (${
            getMentionedThreads(threads, this.account).length
          })`,
          value: CommentTabState.MENTIONS,
        });
      }
      items.push({
        text: `Drafts (${threads.filter(isDraftThread).length})`,
        value: CommentTabState.DRAFTS,
      });
    }
    items.push({
      text: `All (${threads.length})`,
      value: CommentTabState.SHOW_ALL,
    });
    return items;
  }

  private handleAccountClicked(e: MouseEvent) {
    const account = (e.target as GrAccountChip).account;
    assertIsDefined(account, 'account');
    const predicate = (a: AccountInfo) => a._account_id === account._account_id;
    const found = this.selectedAuthors.find(predicate);
    if (found) {
      this.selectedAuthors = this.selectedAuthors.filter(a => !predicate(a));
    } else {
      this.selectedAuthors = [...this.selectedAuthors, account];
    }
  }

  // private, but visible for testing
  handleCommentsDropdownValueChange(e: CustomEvent) {
    const value = e.detail.value;
    switch (value) {
      case CommentTabState.UNRESOLVED:
        this.handleOnlyUnresolved();
        break;
      case CommentTabState.MENTIONS:
        this.handleOnlyMentions();
        break;
      case CommentTabState.DRAFTS:
        this.handleOnlyDrafts();
        break;
      default:
        this.handleAllComments();
    }
  }

  /**
   * Returns all threads that the list may show.
   */
  // private, but visible for testing
  getAllThreads() {
    return this.threads.filter(
      t =>
        !this.onlyShowRobotCommentsWithHumanReply ||
        !isRobotThread(t) ||
        hasHumanReply(t)
    );
  }

  /**
   * Returns all threads that are currently shown in the list, respecting the
   * currently selected filter.
   */
  // private, but visible for testing
  getDisplayedThreads() {
    const byTimestamp =
      this.sortDropdownValue === SortDropdownState.TIMESTAMP &&
      !this.hideDropdown;
    return this.getAllThreads()
      .sort((t1, t2) => compareThreads(t1, t2, byTimestamp))
      .filter(t => this.shouldShowThread(t));
  }

  private isASelectedAuthor(account?: AccountInfo) {
    if (!account) return false;
    return this.selectedAuthors.some(
      author => account._account_id === author._account_id
    );
  }

  private shouldShowThread(thread: CommentThread) {
    // Never make a thread disappear while the user is editing it.
    assertIsDefined(thread.rootId, 'thread.rootId');
    const el = this.queryThreadElement(thread.rootId);
    if (el?.editing) return true;

    if (this.selectedAuthors.length > 0) {
      const hasACommentFromASelectedAuthor = thread.comments.some(
        c =>
          (isDraft(c) && this.isASelectedAuthor(this.account)) ||
          this.isASelectedAuthor(c.author)
      );
      if (!hasACommentFromASelectedAuthor) return false;
    }

    // This is probably redundant, because getAllThreads() filters this out.
    if (this.onlyShowRobotCommentsWithHumanReply) {
      if (isRobotThread(thread) && !hasHumanReply(thread)) return false;
    }

    if (this.mentionsOnly && !isMentionedThread(thread, this.account))
      return false;

    if (this.draftsOnly && !isDraftThread(thread)) return false;
    if (this.unresolvedOnly && !isUnresolved(thread)) return false;

    return true;
  }

  private handleOnlyUnresolved() {
    this.unresolvedOnly = true;
    this.draftsOnly = false;
    this.mentionsOnly = false;
  }

  private handleOnlyMentions() {
    this.mentionsOnly = true;
    this.unresolvedOnly = true;
    this.draftsOnly = false;
  }

  private handleOnlyDrafts() {
    this.draftsOnly = true;
    this.unresolvedOnly = false;
    this.mentionsOnly = false;
  }

  private handleAllComments() {
    this.draftsOnly = false;
    this.unresolvedOnly = false;
    this.mentionsOnly = false;
  }

  private queryThreadElement(rootId: string): GrCommentThread | undefined {
    const els = [...(this.threadElements ?? [])] as GrCommentThread[];
    return els.find(el => el.rootId === rootId);
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'gr-thread-list': GrThreadList;
  }
}
