/**
 * @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 '../../../styles/shared-styles';
import '../../../styles/gr-change-metadata-shared-styles';
import '../../../styles/gr-change-view-integration-shared-styles';
import '../../../styles/gr-voting-styles';
import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
import '../../plugins/gr-endpoint-param/gr-endpoint-param';
import '../../plugins/gr-external-style/gr-external-style';
import '../../shared/gr-account-chip/gr-account-chip';
import '../../shared/gr-date-formatter/gr-date-formatter';
import '../../shared/gr-editable-label/gr-editable-label';
import '../../shared/gr-icons/gr-icons';
import '../../shared/gr-limited-text/gr-limited-text';
import '../../shared/gr-linked-chip/gr-linked-chip';
import '../../shared/gr-tooltip-content/gr-tooltip-content';
import '../gr-change-requirements/gr-change-requirements';
import '../gr-commit-info/gr-commit-info';
import '../gr-reviewer-list/gr-reviewer-list';
import '../../shared/gr-account-list/gr-account-list';
import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-change-metadata_html';
import {
  GrReviewerSuggestionsProvider,
  SUGGESTIONS_PROVIDERS_USERS_TYPES,
} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
import {GerritNav} from '../../core/gr-navigation/gr-navigation';
import {
  ChangeStatus,
  GpgKeyInfoStatus,
  SubmitType,
} from '../../../constants/constants';
import {changeIsOpen} from '../../../utils/change-util';
import {customElement, property, observe} from '@polymer/decorators';
import {
  AccountDetailInfo,
  AccountInfo,
  BranchName,
  CommitId,
  CommitInfo,
  ElementPropertyDeepChange,
  GpgKeyInfo,
  Hashtag,
  LabelNameToInfoMap,
  NumericChangeId,
  ParentCommitInfo,
  PatchSetNum,
  RepoName,
  RevisionInfo,
  ServerInfo,
  TopicName,
} from '../../../types/common';
import {assertNever, unique} from '../../../utils/common-util';
import {GrEditableLabel} from '../../shared/gr-editable-label/gr-editable-label';
import {GrLinkedChip} from '../../shared/gr-linked-chip/gr-linked-chip';
import {appContext} from '../../../services/app-context';
import {
  Metadata,
  isSectionSet,
  DisplayRules,
} from '../../../utils/change-metadata-util';
import {fireEvent} from '../../../utils/event-util';
import {
  EditRevisionInfo,
  notUndefined,
  ParsedChangeInfo,
} from '../../../types/types';
import {
  AutocompleteQuery,
  AutocompleteSuggestion,
} from '../../shared/gr-autocomplete/gr-autocomplete';

const HASHTAG_ADD_MESSAGE = 'Add Hashtag';

enum ChangeRole {
  OWNER = 'owner',
  UPLOADER = 'uploader',
  AUTHOR = 'author',
  COMMITTER = 'committer',
}

export interface CommitInfoWithRequiredCommit extends CommitInfo {
  // gr-change-view always assigns commit to CommitInfo
  commit: CommitId;
}

const SubmitTypeLabel = new Map<SubmitType, string>([
  [SubmitType.FAST_FORWARD_ONLY, 'Fast Forward Only'],
  [SubmitType.MERGE_IF_NECESSARY, 'Merge if Necessary'],
  [SubmitType.REBASE_IF_NECESSARY, 'Rebase if Necessary'],
  [SubmitType.MERGE_ALWAYS, 'Always Merge'],
  [SubmitType.REBASE_ALWAYS, 'Rebase Always'],
  [SubmitType.CHERRY_PICK, 'Cherry Pick'],
]);

const NOT_CURRENT_MESSAGE = 'Not current - rebase possible';

interface PushCertificateValidationInfo {
  class: string;
  icon: string;
  message: string;
}

export interface GrChangeMetadata {
  $: {
    webLinks: HTMLElement;
  };
}

@customElement('gr-change-metadata')
export class GrChangeMetadata extends PolymerElement {
  static get template() {
    return htmlTemplate;
  }

  /**
   * Fired when the change topic is changed.
   *
   * @event topic-changed
   */

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

  @property({type: Object, notify: true})
  labels?: LabelNameToInfoMap;

  @property({type: Object})
  account?: AccountDetailInfo;

  @property({type: Object})
  revision?: RevisionInfo | EditRevisionInfo;

  @property({type: Object})
  commitInfo?: CommitInfoWithRequiredCommit;

  @property({type: Boolean, computed: '_computeIsMutable(account)'})
  _mutable = false;

  @property({type: Object})
  serverConfig?: ServerInfo;

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

  @property({type: String})
  readonly _notCurrentMessage = NOT_CURRENT_MESSAGE;

  @property({
    type: Boolean,
    computed: '_computeTopicReadOnly(_mutable, change)',
  })
  _topicReadOnly = true;

  @property({
    type: Boolean,
    computed: '_computeHashtagReadOnly(_mutable, change)',
  })
  _hashtagReadOnly = true;

  @property({
    type: Object,
    computed: '_computePushCertificateValidation(serverConfig, change)',
  })
  _pushCertificateValidation?: PushCertificateValidationInfo;

  @property({type: Boolean, computed: '_computeShowRequirements(change)'})
  _showRequirements = false;

  @property({type: Array})
  _assignee?: AccountInfo[];

  @property({type: Boolean, computed: '_computeIsWip(change)'})
  _isWip = false;

  @property({type: String})
  _newHashtag?: Hashtag;

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

  @property({type: Array, computed: '_computeParents(change, revision)'})
  _currentParents: ParentCommitInfo[] = [];

  @property({type: Object})
  _CHANGE_ROLE = ChangeRole;

  @property({type: Object})
  _SECTION = Metadata;

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

  @property({type: Object})
  queryTopic?: AutocompleteQuery;

  restApiService = appContext.restApiService;

  private readonly reporting = appContext.reportingService;

  /** @override */
  ready() {
    super.ready();
    this.queryTopic = (input: string) => this._getTopicSuggestions(input);
  }

  @observe('change.labels')
  _labelsChanged(labels?: LabelNameToInfoMap) {
    this.labels = {...labels};
  }

  @observe('change')
  _changeChanged(change?: ParsedChangeInfo) {
    this._assignee = change?.assignee ? [change.assignee] : [];
    this._settingTopic = false;
  }

  @observe('_assignee.*')
  _assigneeChanged(
    assigneeRecord: ElementPropertyDeepChange<GrChangeMetadata, '_assignee'>
  ) {
    if (!this.change || !this._isAssigneeEnabled(this.serverConfig)) {
      return;
    }
    const assignee = assigneeRecord.base;
    if (assignee?.length) {
      const acct = assignee[0];
      if (
        !acct._account_id ||
        (this.change.assignee &&
          acct._account_id === this.change.assignee._account_id)
      ) {
        return;
      }
      this.set(['change', 'assignee'], acct);
      this.restApiService.setAssignee(this.change._number, acct._account_id);
    } else {
      if (!this.change.assignee) {
        return;
      }
      this.set(['change', 'assignee'], undefined);
      this.restApiService.deleteAssignee(this.change._number);
    }
  }

  _computeHideStrategy(change?: ParsedChangeInfo) {
    return !changeIsOpen(change);
  }

  /**
   * @return If array is empty, returns undefined instead so
   * an existential check can be used to hide or show the webLinks
   * section.
   */
  _computeWebLinks(
    commitInfo?: CommitInfoWithRequiredCommit,
    serverConfig?: ServerInfo
  ) {
    if (!commitInfo) return undefined;
    const weblinks = GerritNav.getChangeWeblinks(
      this.change ? this.change.project : ('' as RepoName),
      commitInfo.commit,
      {
        weblinks: commitInfo.web_links,
        config: serverConfig,
      }
    );
    return weblinks.length ? weblinks : undefined;
  }

  _isChangeMerged(change?: ParsedChangeInfo) {
    return change?.status === ChangeStatus.MERGED;
  }

  _isAssigneeEnabled(serverConfig?: ServerInfo) {
    return !!serverConfig?.change?.enable_assignee;
  }

  _computeStrategy(change?: ParsedChangeInfo) {
    if (!change?.submit_type) {
      return '';
    }

    return SubmitTypeLabel.get(change.submit_type);
  }

  _computeLabelNames(labels?: LabelNameToInfoMap) {
    return labels ? Object.keys(labels).sort() : [];
  }

  _handleTopicChanged(e: CustomEvent<string>) {
    if (!this.change) {
      throw new Error('change must be set');
    }
    const lastTopic = this.change.topic;
    const topic = e.detail.length ? e.detail : undefined;
    this._settingTopic = true;
    const topicChangedForChangeNumber = this.change._number;
    this.restApiService
      .setChangeTopic(topicChangedForChangeNumber, topic)
      .then(newTopic => {
        if (this.change?._number !== topicChangedForChangeNumber) return;
        this._settingTopic = false;
        this.set(['change', 'topic'], newTopic);
        if (newTopic !== lastTopic) {
          fireEvent(this, 'topic-changed');
        }
      });
  }

  _showAddTopic(
    changeRecord?: ElementPropertyDeepChange<GrChangeMetadata, 'change'>,
    settingTopic?: boolean
  ) {
    const hasTopic = !!changeRecord?.base?.topic;
    return !hasTopic && !settingTopic;
  }

  _showTopicChip(
    changeRecord?: ElementPropertyDeepChange<GrChangeMetadata, 'change'>,
    settingTopic?: boolean
  ) {
    const hasTopic = !!changeRecord?.base?.topic;
    return hasTopic && !settingTopic;
  }

  _showCherryPickOf(
    changeRecord?: ElementPropertyDeepChange<GrChangeMetadata, 'change'>
  ) {
    const hasCherryPickOf =
      !!changeRecord?.base?.cherry_pick_of_change &&
      !!changeRecord?.base?.cherry_pick_of_patch_set;
    return hasCherryPickOf;
  }

  _handleHashtagChanged() {
    if (!this.change) {
      throw new Error('change must be set');
    }
    if (!this._newHashtag?.length) {
      return;
    }
    const newHashtag = this._newHashtag;
    this._newHashtag = '' as Hashtag;
    this.restApiService
      .setChangeHashtag(this.change._number, {add: [newHashtag]})
      .then(newHashtag => {
        this.set(['change', 'hashtags'], newHashtag);
        fireEvent(this, 'hashtag-changed');
      });
  }

  _computeTopicReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
    return !mutable || !change?.actions?.topic?.enabled;
  }

  _computeHashtagReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
    return !mutable || !change?.actions?.hashtags?.enabled;
  }

  _computeAssigneeReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
    return !mutable || !change?.actions?.assignee?.enabled;
  }

  _computeTopicPlaceholder(_topicReadOnly?: boolean) {
    // Action items in Material Design are uppercase -- placeholder label text
    // is sentence case.
    return _topicReadOnly ? 'No topic' : 'ADD TOPIC';
  }

  _computeHashtagPlaceholder(_hashtagReadOnly?: boolean) {
    return _hashtagReadOnly ? '' : HASHTAG_ADD_MESSAGE;
  }

  _computeShowRequirements(change?: ParsedChangeInfo) {
    if (!change) {
      return false;
    }
    if (change.status !== ChangeStatus.NEW) {
      // TODO(maximeg) change this to display the stored
      // requirements, once it is implemented server-side.
      return false;
    }
    const hasRequirements =
      !!change.requirements && Object.keys(change.requirements).length > 0;
    const hasLabels = !!change.labels && Object.keys(change.labels).length > 0;
    return hasRequirements || hasLabels || !!change.work_in_progress;
  }

  /**
   * @return object representing data for the push validation.
   */
  _computePushCertificateValidation(
    serverConfig?: ServerInfo,
    change?: ParsedChangeInfo
  ): PushCertificateValidationInfo | undefined {
    if (!change || !serverConfig?.receive?.enable_signed_push) return undefined;

    const rev = change.revisions[change.current_revision];
    if (!rev.push_certificate?.key) {
      return {
        class: 'help',
        icon: 'gr-icons:help',
        message: 'This patch set was created without a push certificate',
      };
    }

    const key = rev.push_certificate.key;
    switch (key.status) {
      case GpgKeyInfoStatus.BAD:
        return {
          class: 'invalid',
          icon: 'gr-icons:close',
          message: this._problems('Push certificate is invalid', key),
        };
      case GpgKeyInfoStatus.OK:
        return {
          class: 'notTrusted',
          icon: 'gr-icons:info',
          message: this._problems(
            'Push certificate is valid, but key is not trusted',
            key
          ),
        };
      case GpgKeyInfoStatus.TRUSTED:
        return {
          class: 'trusted',
          icon: 'gr-icons:check',
          message: this._problems(
            'Push certificate is valid and key is trusted',
            key
          ),
        };
      case undefined:
        // TODO(TS): Process it correctly
        throw new Error('deleted certificate');
      default:
        assertNever(key.status, `unknown certificate status: ${key.status}`);
    }
  }

  _problems(msg: string, key: GpgKeyInfo) {
    if (!key?.problems || key.problems.length === 0) {
      return msg;
    }

    return [msg + ':'].concat(key.problems).join('\n');
  }

  _computeShowRepoBranchTogether(repo?: RepoName, branch?: BranchName) {
    return !!repo && !!branch && repo.length + branch.length < 40;
  }

  _computeProjectUrl(project?: RepoName) {
    if (!project) return '';
    return GerritNav.getUrlForProjectChanges(project);
  }

  _computeBranchUrl(project?: RepoName, branch?: BranchName) {
    if (!project || !branch || !this.change || !this.change.status) return '';
    return GerritNav.getUrlForBranch(
      branch,
      project,
      this.change.status === ChangeStatus.NEW
        ? 'open'
        : this.change.status.toLowerCase()
    );
  }

  _computeCherryPickOfUrl(
    change?: NumericChangeId,
    patchset?: PatchSetNum,
    project?: RepoName
  ) {
    if (!change || !project) {
      return '';
    }
    return GerritNav.getUrlForChangeById(change, project, patchset);
  }

  _computeTopicUrl(topic: TopicName) {
    return GerritNav.getUrlForTopic(topic);
  }

  _computeHashtagUrl(hashtag: Hashtag) {
    return GerritNav.getUrlForHashtag(hashtag);
  }

  _handleTopicRemoved(e: CustomEvent) {
    if (!this.change) {
      throw new Error('change must be set');
    }
    const target = (dom(e) as EventApi).rootTarget as GrLinkedChip;
    target.disabled = true;
    this.restApiService
      .setChangeTopic(this.change._number)
      .then(() => {
        target.disabled = false;
        this.set(['change', 'topic'], '');
        fireEvent(this, 'topic-changed');
      })
      .catch(() => {
        target.disabled = false;
      });
  }

  _handleHashtagRemoved(e: CustomEvent) {
    e.preventDefault();
    if (!this.change) {
      throw new Error('change must be set');
    }
    const target = (dom(e) as EventApi).rootTarget as GrLinkedChip;
    target.disabled = true;
    this.restApiService
      .setChangeHashtag(this.change._number, {remove: [target.text as Hashtag]})
      .then(newHashtags => {
        target.disabled = false;
        this.set(['change', 'hashtags'], newHashtags);
      })
      .catch(() => {
        target.disabled = false;
      });
  }

  _computeIsWip(change?: ParsedChangeInfo) {
    return !!change?.work_in_progress;
  }

  _computeShowRoleClass(change?: ParsedChangeInfo, role?: ChangeRole) {
    return this._getNonOwnerRole(change, role) ? '' : 'hideDisplay';
  }

  _computeDisplayState(
    showAllSections: boolean,
    change: ParsedChangeInfo | undefined,
    section: Metadata
  ) {
    if (
      showAllSections ||
      DisplayRules.ALWAYS_SHOW.includes(section) ||
      (DisplayRules.SHOW_IF_SET.includes(section) &&
        isSectionSet(section, change))
    ) {
      return '';
    }
    return 'hideDisplay';
  }

  _computeMergedCommitInfo(
    current_revision: CommitId,
    revisions: {[revisionId: string]: RevisionInfo}
  ) {
    const rev = revisions[current_revision];
    if (!rev || !rev.commit) {
      return {};
    }
    // CommitInfo.commit is optional. Set commit in all cases to avoid error
    // in <gr-commit-info>. @see Issue 5337
    if (!rev.commit.commit) {
      rev.commit.commit = current_revision;
    }
    return rev.commit;
  }

  _computeShowAllLabelText(showAllSections: boolean) {
    if (showAllSections) {
      return 'Show less';
    } else {
      return 'Show all';
    }
  }

  _onShowAllClick() {
    this._showAllSections = !this._showAllSections;
    this.reporting.reportInteraction('toggle show all button', {
      sectionName: 'metadata',
      toState: this._showAllSections ? 'Show all' : 'Show less',
    });
  }

  /**
   * Get the user with the specified role on the change. Returns undefined if the
   * user with that role is the same as the owner.
   */
  _getNonOwnerRole(change?: ParsedChangeInfo, role?: ChangeRole) {
    if (!change?.revisions?.[change.current_revision]) return undefined;

    const rev = change.revisions[change.current_revision];
    if (!rev) return undefined;

    if (
      role === ChangeRole.UPLOADER &&
      rev.uploader &&
      change.owner._account_id !== rev.uploader._account_id
    ) {
      return rev.uploader;
    }

    if (
      role === ChangeRole.AUTHOR &&
      rev.commit?.author &&
      change.owner.email !== rev.commit.author.email
    ) {
      return rev.commit.author;
    }

    if (
      role === ChangeRole.COMMITTER &&
      rev.commit?.committer &&
      change.owner.email !== rev.commit.committer.email &&
      !(
        rev.uploader?.email && rev.uploader.email === rev.commit.committer.email
      )
    ) {
      return rev.commit.committer;
    }

    return undefined;
  }

  _computeParents(
    change?: ParsedChangeInfo,
    revision?: RevisionInfo | EditRevisionInfo
  ): ParentCommitInfo[] {
    if (!revision || !revision.commit) {
      if (!change || !change.current_revision) {
        return [];
      }
      revision = change.revisions[change.current_revision];
      if (!revision || !revision.commit) {
        return [];
      }
    }
    return revision.commit.parents;
  }

  _computeParentsLabel(parents?: ParentCommitInfo[]) {
    return parents && parents.length > 1 ? 'Parents' : 'Parent';
  }

  _computeParentListClass(
    parents?: ParentCommitInfo[],
    parentIsCurrent?: boolean
  ) {
    // Undefined check for polymer 2
    if (parents === undefined || parentIsCurrent === undefined) {
      return '';
    }

    return [
      'parentList',
      parents && parents.length > 1 ? 'merge' : 'nonMerge',
      parentIsCurrent ? 'current' : 'notCurrent',
    ].join(' ');
  }

  _computeIsMutable(account?: AccountDetailInfo) {
    return account && !!Object.keys(account).length;
  }

  editTopic() {
    if (this._topicReadOnly || !this.change || this.change.topic) {
      return;
    }
    // Cannot use `this.$.ID` syntax because the element exists inside of a
    // dom-if.
    (this.shadowRoot!.querySelector(
      '.topicEditableLabel'
    ) as GrEditableLabel).open();
  }

  _getReviewerSuggestionsProvider(change?: ParsedChangeInfo) {
    if (!change) {
      return undefined;
    }
    const provider = GrReviewerSuggestionsProvider.create(
      this.restApiService,
      change._number,
      SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY
    );
    provider.init();
    return provider;
  }

  _getTopicSuggestions(input: string): Promise<AutocompleteSuggestion[]> {
    return this.restApiService
      .getChangesWithSimilarTopic(input)
      .then(response =>
        (response ?? [])
          .map(change => change.topic)
          .filter(notUndefined)
          .filter(unique)
          .map(topic => {
            return {name: topic, value: topic};
          })
      );
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'gr-change-metadata': GrChangeMetadata;
  }
}
