/**
 * @license
 * Copyright (C) 2016 The Android Open Source Project
 *
 * 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 '../../shared/gr-rest-api-interface/gr-rest-api-interface';
import '../../../styles/shared-styles';
import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
import '../../plugins/gr-endpoint-param/gr-endpoint-param';
import '../../plugins/gr-endpoint-slot/gr-endpoint-slot';
import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-related-changes-list_html';
import {GerritNav} from '../../core/gr-navigation/gr-navigation';
import {ChangeStatus} from '../../../constants/constants';
import {patchNumEquals} from '../../../utils/patch-set-util';
import {changeIsOpen} from '../../../utils/change-util';
import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
import {customElement, observe, property} from '@polymer/decorators';
import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
import {
  ChangeId,
  ChangeInfo,
  CommitId,
  NumericChangeId,
  PatchSetNum,
  RelatedChangeAndCommitInfo,
  RelatedChangesInfo,
  RepoName,
  SubmittedTogetherInfo,
} from '../../../types/common';
import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';

export interface GrRelatedChangesList {
  $: {
    restAPI: RestApiService & Element;
  };
}

function getEmptySubmitTogetherInfo(): SubmittedTogetherInfo {
  return {changes: [], non_visible_changes: 0};
}

function isChangeInfo(
  x: ChangeInfo | RelatedChangeAndCommitInfo
): x is ChangeInfo {
  return (x as ChangeInfo)._number !== undefined;
}

@customElement('gr-related-changes-list')
export class GrRelatedChangesList extends GestureEventListeners(
  LegacyElementMixin(PolymerElement)
) {
  static get template() {
    return htmlTemplate;
  }

  /**
   * Fired when a new section is loaded so that the change view can determine
   * a show more button is needed, sometimes before all the sections finish
   * loading.
   *
   * @event new-section-loaded
   */

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

  @property({type: Boolean, notify: true})
  hasParent = false;

  @property({type: String})
  patchNum?: PatchSetNum;

  @property({type: Boolean, reflectToAttribute: true})
  hidden = false;

  @property({type: Boolean, notify: true})
  loading?: boolean;

  @property({type: Boolean})
  mergeable?: boolean;

  @property({
    type: Array,
    computed:
      '_computeConnectedRevisions(change, patchNum, ' +
      '_relatedResponse.changes)',
  })
  _connectedRevisions?: CommitId[];

  @property({type: Object})
  _relatedResponse: RelatedChangesInfo = {changes: []};

  @property({type: Object})
  _submittedTogether?: SubmittedTogetherInfo = getEmptySubmitTogetherInfo();

  @property({type: Array})
  _conflicts: ChangeInfo[] = [];

  @property({type: Array})
  _cherryPicks: ChangeInfo[] = [];

  @property({type: Array})
  _sameTopic?: ChangeInfo[] = [];

  clear() {
    this.loading = true;
    this.hidden = true;

    this._relatedResponse = {changes: []};
    this._submittedTogether = getEmptySubmitTogetherInfo();
    this._conflicts = [];
    this._cherryPicks = [];
    this._sameTopic = [];
  }

  reload() {
    if (!this.change || !this.patchNum) {
      return Promise.resolve();
    }
    const change = this.change;
    this.loading = true;
    const promises: Array<Promise<void>> = [
      this.$.restAPI
        .getRelatedChanges(change._number, this.patchNum)
        .then(response => {
          if (!response) {
            throw new Error('getRelatedChanges returned undefined response');
          }
          this._relatedResponse = response;
          this._fireReloadEvent();
          this.hasParent = this._calculateHasParent(
            change.change_id,
            response.changes
          );
        }),
      this.$.restAPI
        .getChangesSubmittedTogether(change._number)
        .then(response => {
          this._submittedTogether = response;
          this._fireReloadEvent();
        }),
      this.$.restAPI
        .getChangeCherryPicks(change.project, change.change_id, change._number)
        .then(response => {
          this._cherryPicks = response || [];
          this._fireReloadEvent();
        }),
    ];

    // Get conflicts if change is open and is mergeable.
    if (changeIsOpen(change) && this.mergeable) {
      promises.push(
        this.$.restAPI.getChangeConflicts(change._number).then(response => {
          // Because the server doesn't always return a response and the
          // template expects an array, always return an array.
          this._conflicts = response ? response : [];
          this._fireReloadEvent();
        })
      );
    }

    promises.push(
      this._getServerConfig().then(config => {
        if (change.topic) {
          if (!config) {
            throw new Error('_getServerConfig returned undefined ');
          }
          if (!config.change.submit_whole_topic) {
            return this.$.restAPI
              .getChangesWithSameTopic(change.topic, change._number)
              .then(response => {
                this._sameTopic = response;
              });
          }
        }
        this._sameTopic = [];
        return Promise.resolve();
      })
    );

    return Promise.all(promises).then(() => {
      this.loading = false;
    });
  }

  _fireReloadEvent() {
    // The listener on the change computes height of the related changes
    // section, so they have to be rendered first, and inside a dom-repeat,
    // that requires a flush.
    flush();
    this.dispatchEvent(new CustomEvent('new-section-loaded'));
  }

  /**
   * Determines whether or not the given change has a parent change. If there
   * is a relation chain, and the change id is not the last item of the
   * relation chain, there is a parent.
   */
  _calculateHasParent(
    currentChangeId: ChangeId,
    relatedChanges: RelatedChangeAndCommitInfo[]
  ) {
    return (
      relatedChanges.length > 0 &&
      relatedChanges[relatedChanges.length - 1].change_id !== currentChangeId
    );
  }

  _getServerConfig() {
    return this.$.restAPI.getConfig();
  }

  _computeChangeURL(
    changeNum: NumericChangeId,
    project: RepoName,
    patchNum?: PatchSetNum
  ) {
    return GerritNav.getUrlForChangeById(changeNum, project, patchNum);
  }

  /**
   * Do the given objects describe the same change? Compares the changes by
   * their numbers.
   */
  _changesEqual(
    a: ChangeInfo | RelatedChangeAndCommitInfo,
    b: ChangeInfo | RelatedChangeAndCommitInfo
  ) {
    const aNum = this._getChangeNumber(a);
    const bNum = this._getChangeNumber(b);
    return aNum === bNum;
  }

  /**
   * Get the change number from either a ChangeInfo (such as those included in
   * SubmittedTogetherInfo responses) or get the change number from a
   * RelatedChangeAndCommitInfo (such as those included in a
   * RelatedChangesInfo response).
   */
  _getChangeNumber(change?: ChangeInfo | RelatedChangeAndCommitInfo) {
    // Default to 0 if change property is not defined.
    if (!change) return 0;

    if (isChangeInfo(change)) {
      return change._number;
    }
    return change._change_number;
  }

  _computeLinkClass(change: ParsedChangeInfo) {
    const statuses = [];
    if (change.status === ChangeStatus.ABANDONED) {
      statuses.push('strikethrough');
    }
    if (change.submittable) {
      statuses.push('submittable');
    }
    return statuses.join(' ');
  }

  _computeChangeStatusClass(change: RelatedChangeAndCommitInfo) {
    const classes = ['status'];
    if (change._revision_number !== change._current_revision_number) {
      classes.push('notCurrent');
    } else if (this._isIndirectAncestor(change)) {
      classes.push('indirectAncestor');
    } else if (change.submittable) {
      classes.push('submittable');
    } else if (change.status === ChangeStatus.NEW) {
      classes.push('hidden');
    }
    return classes.join(' ');
  }

  _computeChangeStatus(change: RelatedChangeAndCommitInfo) {
    switch (change.status) {
      case ChangeStatus.MERGED:
        return 'Merged';
      case ChangeStatus.ABANDONED:
        return 'Abandoned';
    }
    if (change._revision_number !== change._current_revision_number) {
      return 'Not current';
    } else if (this._isIndirectAncestor(change)) {
      return 'Indirect ancestor';
    } else if (change.submittable) {
      return 'Submittable';
    }
    return '';
  }

  /** @override */
  attached() {
    super.attached();
    // We listen to `new-section-loaded` events to allow plugins to trigger
    // visibility computations, if their content or visibility changed.
    this.addEventListener('new-section-loaded', () =>
      this._handleNewSectionLoaded()
    );
  }

  _handleNewSectionLoaded() {
    // A plugin sent a `new-section-loaded` event, so its visibility likely
    // changed. Hence, we update our visibility if needed.
    this._resultsChanged(
      this._relatedResponse,
      this._submittedTogether,
      this._conflicts,
      this._cherryPicks,
      this._sameTopic
    );
  }

  @observe(
    '_relatedResponse',
    '_submittedTogether',
    '_conflicts',
    '_cherryPicks',
    '_sameTopic'
  )
  _resultsChanged(
    related: RelatedChangesInfo,
    submittedTogether: SubmittedTogetherInfo | undefined,
    conflicts: ChangeInfo[],
    cherryPicks: ChangeInfo[],
    sameTopic?: ChangeInfo[]
  ) {
    if (!submittedTogether || !sameTopic) {
      return;
    }
    const submittedTogetherChangesCount =
      (submittedTogether.changes || []).length +
      (submittedTogether.non_visible_changes || 0);
    const results = [
      related && related.changes,
      // If there are either visible or non-visible changes, we need a
      // non-empty list to fire the event and set visibility.
      submittedTogetherChangesCount ? [{}] : [],
      conflicts,
      cherryPicks,
      sameTopic,
    ];
    for (let i = 0; i < results.length; i++) {
      if (results[i] && results[i].length > 0) {
        this.hidden = false;
        this.dispatchEvent(
          new CustomEvent('update', {
            composed: true,
            bubbles: false,
          })
        );
        return;
      }
    }

    this._computeHidden();
  }

  _computeHidden() {
    // None of the built-in change lists had elements. So all of them are
    // hidden. But since plugins might have injected visible content, we need
    // to check for that and stay visible if we find any such visible content.
    // (We consider plugins visible except if it's main element has the hidden
    // attribute set to true.)
    const plugins = getPluginEndpoints().getDetails('related-changes-section');
    this.hidden = !plugins.some(
      plugin =>
        !plugin.domHook ||
        plugin.domHook.getAllAttached().some(instance => !instance.hidden)
    );
  }

  _isIndirectAncestor(change: RelatedChangeAndCommitInfo) {
    return (
      this._connectedRevisions &&
      !this._connectedRevisions.includes(change.commit.commit)
    );
  }

  _computeConnectedRevisions(
    change?: ParsedChangeInfo,
    patchNum?: PatchSetNum,
    relatedChanges?: RelatedChangeAndCommitInfo[]
  ) {
    // Polymer 2: check for undefined
    if (
      change === undefined ||
      patchNum === undefined ||
      relatedChanges === undefined
    ) {
      return undefined;
    }

    const connected: CommitId[] = [];
    let changeRevision;
    if (!change) {
      return [];
    }
    for (const rev in change.revisions) {
      if (patchNumEquals(change.revisions[rev]._number, patchNum)) {
        changeRevision = rev;
      }
    }
    const commits = relatedChanges.map(c => c.commit);
    let pos = commits.length - 1;

    while (pos >= 0) {
      const commit: CommitId = commits[pos].commit;
      connected.push(commit);
      // TODO(TS): Ensure that both (commit and changeRevision) are string and use === instead
      // eslint-disable-next-line eqeqeq
      if (commit == changeRevision) {
        break;
      }
      pos--;
    }
    while (pos >= 0) {
      for (let i = 0; i < commits[pos].parents.length; i++) {
        if (connected.includes(commits[pos].parents[i].commit)) {
          connected.push(commits[pos].commit);
          break;
        }
      }
      --pos;
    }
    return connected;
  }

  _computeSubmittedTogetherClass(submittedTogether?: SubmittedTogetherInfo) {
    if (
      !submittedTogether ||
      (submittedTogether.changes.length === 0 &&
        !submittedTogether.non_visible_changes)
    ) {
      return 'hidden';
    }
    return '';
  }

  _computeNonVisibleChangesNote(n: number) {
    const noun = n === 1 ? 'change' : 'changes';
    return `(+ ${n} non-visible ${noun})`;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'gr-related-changes-list': GrRelatedChangesList;
  }
}
