/**
 * @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.
 */
/* NB: Order is important, because of namespaced classes. */

import {PolymerElement} from '@polymer/polymer/polymer-element';
import {GrEtagDecorator} from './gr-etag-decorator';
import {
  FetchJSONRequest,
  FetchParams,
  FetchPromisesCache,
  GrRestApiHelper,
  parsePrefixedJSON,
  readResponsePayload,
  SendJSONRequest,
  SendRequest,
  SiteBasedCache,
} from './gr-rest-apis/gr-rest-api-helper';
import {GrReviewerUpdatesParser} from './gr-reviewer-updates-parser';
import {parseDate} from '../../../utils/date-util';
import {getBaseUrl} from '../../../utils/url-util';
import {appContext} from '../../../services/app-context';
import {getParentIndex, isMergeParent} from '../../../utils/patch-set-util';
import {
  ListChangesOption,
  listChangesOptionsToHex,
} from '../../../utils/change-util';
import {assertNever, hasOwnProperty} from '../../../utils/common-util';
import {customElement} from '@polymer/decorators';
import {AuthRequestInit, AuthService} from '../../../services/gr-auth/gr-auth';
import {
  AccountCapabilityInfo,
  AccountDetailInfo,
  AccountExternalIdInfo,
  AccountId,
  AccountInfo,
  ActionNameToActionInfoMap,
  AssigneeInput,
  Base64File,
  Base64FileContent,
  Base64ImageFile,
  BasePatchSetNum,
  BlameInfo,
  BranchInfo,
  BranchInput,
  BranchName,
  CapabilityInfoMap,
  ChangeId,
  ChangeInfo,
  ChangeMessageId,
  ChangeViewChangeInfo,
  CommentInfo,
  CommentInput,
  CommitId,
  CommitInfo,
  ConfigInfo,
  ConfigInput,
  ContributorAgreementInfo,
  ContributorAgreementInput,
  DashboardId,
  DashboardInfo,
  DeleteDraftCommentsInput,
  DiffPreferenceInput,
  DocResult,
  EditInfo,
  EditPatchSetNum,
  EditPreferencesInfo,
  EmailAddress,
  EmailInfo,
  EncodedGroupId,
  FileNameToFileInfoMap,
  FilePathToDiffInfoMap,
  FixId,
  GitRef,
  GpgKeyId,
  GpgKeyInfo,
  GpgKeysInput,
  GroupAuditEventInfo,
  GroupId,
  GroupInfo,
  GroupInput,
  GroupName,
  GroupNameToGroupInfoMap,
  GroupOptionsInput,
  Hashtag,
  HashtagsInput,
  ImagesForDiff,
  IncludedInInfo,
  MergeableInfo,
  NameToProjectInfoMap,
  NumericChangeId,
  ParentPatchSetNum,
  ParsedJSON,
  Password,
  PatchRange,
  PatchSetNum,
  PathToCommentsInfoMap,
  PathToRobotCommentsInfoMap,
  PluginInfo,
  PreferencesInfo,
  PreferencesInput,
  ProjectAccessInfo,
  ProjectAccessInfoMap,
  ProjectAccessInput,
  ProjectInfo,
  ProjectInfoWithName,
  ProjectInput,
  ProjectWatchInfo,
  RelatedChangesInfo,
  RepoName,
  RequestPayload,
  ReviewInput,
  RevisionId,
  ServerInfo,
  SshKeyInfo,
  SubmittedTogetherInfo,
  SuggestedReviewerInfo,
  TagInfo,
  TagInput,
  TopMenuEntryInfo,
  UrlEncodedCommentId,
} from '../../../types/common';
import {
  DiffInfo,
  DiffPreferencesInfo,
  IgnoreWhitespaceType,
} from '../../../types/diff';
import {
  CancelConditionCallback,
  GetDiffCommentsOutput,
  GetDiffRobotCommentsOutput,
  RestApiService,
} from '../../../services/gr-rest-api/gr-rest-api';
import {
  CommentSide,
  createDefaultDiffPrefs,
  createDefaultEditPrefs,
  createDefaultPreferences,
  DiffViewMode,
  HttpMethod,
  ReviewerState,
} from '../../../constants/constants';
import {firePageError, fireServerError} from '../../../utils/event-util';
import {ParsedChangeInfo} from '../../../types/types';
import {ErrorCallback} from '../../../api/rest';

const MAX_PROJECT_RESULTS = 25;
// This value is somewhat arbitrary and not based on research or calculations.
const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 850;

const Requests = {
  SEND_DIFF_DRAFT: 'sendDiffDraft',
};

const CREATE_DRAFT_UNEXPECTED_STATUS_MESSAGE =
  'Saving draft resulted in HTTP 200 (OK) but expected HTTP 201 (Created)';
const HEADER_REPORTING_BLOCK_REGEX = /^set-cookie$/i;

const ANONYMIZED_CHANGE_BASE_URL = '/changes/*~*';
const ANONYMIZED_REVISION_BASE_URL =
  ANONYMIZED_CHANGE_BASE_URL + '/revisions/*';

let siteBasedCache = new SiteBasedCache(); // Shared across instances.
let fetchPromisesCache = new FetchPromisesCache(); // Shared across instances.
let pendingRequest: {[promiseName: string]: Array<Promise<unknown>>} = {}; // Shared across instances.
let grEtagDecorator = new GrEtagDecorator(); // Shared across instances.
let projectLookup: {[changeNum: string]: RepoName} = {}; // Shared across instances.

interface FetchChangeJSON {
  reportEndpointAsIs?: boolean;
  endpoint: string;
  anonymizedEndpoint?: string;
  revision?: RevisionId;
  changeNum: NumericChangeId;
  errFn?: ErrorCallback;
  params?: FetchParams;
  fetchOptions?: AuthRequestInit;
  // TODO(TS): The following properties are not used, however some methods
  // set them to true. They should be either changed to reportEndpointAsIs: true
  // or deleted. This should be done carefully case by case.
  reportEndpointAsId?: true;
}

interface SendChangeRequestBase {
  patchNum?: PatchSetNum;
  reportEndpointAsIs?: boolean;
  endpoint: string;
  anonymizedEndpoint?: string;
  changeNum: NumericChangeId;
  method: HttpMethod | undefined;
  errFn?: ErrorCallback;
  headers?: Record<string, string>;
  contentType?: string;
  body?: string | object;

  // TODO(TS): The following properties are not used, however some methods
  // set them to true. They should be either changed to reportEndpointAsIs: true
  // or deleted. This should be done carefully case by case.
  reportUrlAsIs?: true;
  reportEndpointAsId?: true;
}

interface SendRawChangeRequest extends SendChangeRequestBase {
  parseResponse?: false | null;
}

interface SendJSONChangeRequest extends SendChangeRequestBase {
  parseResponse: true;
}

interface QueryChangesParams {
  [paramName: string]: string | undefined | number | string[];
  O?: string; // options
  S: number; // start
  n?: number; // changes per page
  q?: string | string[]; // query/queries
}

interface QueryAccountsParams {
  [paramName: string]: string | undefined | null | number;
  suggest: null;
  q: string;
  n?: number;
}

interface QueryGroupsParams {
  [paramName: string]: string | undefined | null | number;
  s: string;
  n?: number;
}

interface QuerySuggestedReviewersParams {
  [paramName: string]: string | undefined | null | number;
  n: number;
  q?: string;
  'reviewer-state': ReviewerState;
}

interface GetDiffParams {
  [paramName: string]: string | undefined | null | number | boolean;
  intraline?: boolean | null;
  whitespace?: IgnoreWhitespaceType;
  parent?: number;
  base?: PatchSetNum;
}

type SendChangeRequest = SendRawChangeRequest | SendJSONChangeRequest;

export function _testOnlyResetGrRestApiSharedObjects() {
  siteBasedCache = new SiteBasedCache();
  fetchPromisesCache = new FetchPromisesCache();
  pendingRequest = {};
  grEtagDecorator = new GrEtagDecorator();
  projectLookup = {};
  appContext.authService.clearCache();
}

declare global {
  interface HTMLElementTagNameMap {
    'gr-rest-api-interface': GrRestApiInterface;
  }
}

@customElement('gr-rest-api-interface')
export class GrRestApiInterface
  extends PolymerElement
  implements RestApiService {
  readonly _cache = siteBasedCache; // Shared across instances.

  readonly _sharedFetchPromises = fetchPromisesCache; // Shared across instances.

  readonly _pendingRequests = pendingRequest; // Shared across instances.

  readonly _etags = grEtagDecorator; // Shared across instances.

  readonly _projectLookup = projectLookup; // Shared across instances.

  // The value is set in created, before any other actions
  private authService: AuthService;

  // The value is set in created, before any other actions
  private readonly _restApiHelper: GrRestApiHelper;

  constructor(authService?: AuthService) {
    super();
    // TODO: Make the authService constructor parameter required when we have
    // changed all usages of this class to not instantiate via createElement().
    this.authService = authService ?? appContext.authService;
    this._restApiHelper = new GrRestApiHelper(
      this._cache,
      this.authService,
      this._sharedFetchPromises
    );
  }

  _fetchSharedCacheURL(req: FetchJSONRequest): Promise<ParsedJSON | undefined> {
    // Cache is shared across instances
    return this._restApiHelper.fetchCacheURL(req);
  }

  getResponseObject(response: Response): Promise<ParsedJSON> {
    return this._restApiHelper.getResponseObject(response);
  }

  getConfig(noCache?: boolean): Promise<ServerInfo | undefined> {
    if (!noCache) {
      return this._fetchSharedCacheURL({
        url: '/config/server/info',
        reportUrlAsIs: true,
      }) as Promise<ServerInfo | undefined>;
    }

    return this._restApiHelper.fetchJSON({
      url: '/config/server/info',
      reportUrlAsIs: true,
    }) as Promise<ServerInfo | undefined>;
  }

  getRepo(
    repo: RepoName,
    errFn?: ErrorCallback
  ): Promise<ProjectInfo | undefined> {
    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
    // supports it.
    return this._fetchSharedCacheURL({
      url: '/projects/' + encodeURIComponent(repo),
      errFn,
      anonymizedUrl: '/projects/*',
    }) as Promise<ProjectInfo | undefined>;
  }

  getProjectConfig(
    repo: RepoName,
    errFn?: ErrorCallback
  ): Promise<ConfigInfo | undefined> {
    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
    // supports it.
    return this._fetchSharedCacheURL({
      url: '/projects/' + encodeURIComponent(repo) + '/config',
      errFn,
      anonymizedUrl: '/projects/*/config',
    }) as Promise<ConfigInfo | undefined>;
  }

  getRepoAccess(repo: RepoName): Promise<ProjectAccessInfoMap | undefined> {
    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
    // supports it.
    return this._fetchSharedCacheURL({
      url: '/access/?project=' + encodeURIComponent(repo),
      anonymizedUrl: '/access/?project=*',
    }) as Promise<ProjectAccessInfoMap | undefined>;
  }

  getRepoDashboards(
    repo: RepoName,
    errFn?: ErrorCallback
  ): Promise<DashboardInfo[] | undefined> {
    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
    // supports it.
    return this._fetchSharedCacheURL({
      url: `/projects/${encodeURIComponent(repo)}/dashboards?inherited`,
      errFn,
      anonymizedUrl: '/projects/*/dashboards?inherited',
    }) as Promise<DashboardInfo[] | undefined>;
  }

  saveRepoConfig(repo: RepoName, config: ConfigInput): Promise<Response> {
    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
    // supports it.
    const url = `/projects/${encodeURIComponent(repo)}/config`;
    this._cache.delete(url);
    return this._restApiHelper.send({
      method: HttpMethod.PUT,
      url,
      body: config,
      anonymizedUrl: '/projects/*/config',
    });
  }

  runRepoGC(repo: RepoName): Promise<Response> {
    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
    // supports it.
    const encodeName = encodeURIComponent(repo);
    return this._restApiHelper.send({
      method: HttpMethod.POST,
      url: `/projects/${encodeName}/gc`,
      body: '',
      anonymizedUrl: '/projects/*/gc',
    });
  }

  createRepo(config: ProjectInput & {name: RepoName}): Promise<Response> {
    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
    // supports it.
    const encodeName = encodeURIComponent(config.name);
    return this._restApiHelper.send({
      method: HttpMethod.PUT,
      url: `/projects/${encodeName}`,
      body: config,
      anonymizedUrl: '/projects/*',
    });
  }

  createGroup(config: GroupInput & {name: string}): Promise<Response> {
    const encodeName = encodeURIComponent(config.name);
    return this._restApiHelper.send({
      method: HttpMethod.PUT,
      url: `/groups/${encodeName}`,
      body: config,
      anonymizedUrl: '/groups/*',
    });
  }

  getGroupConfig(
    group: GroupId | GroupName,
    errFn?: ErrorCallback
  ): Promise<GroupInfo | undefined> {
    return this._restApiHelper.fetchJSON({
      url: `/groups/${encodeURIComponent(group)}/detail`,
      errFn,
      anonymizedUrl: '/groups/*/detail',
    }) as Promise<GroupInfo | undefined>;
  }

  deleteRepoBranches(repo: RepoName, ref: GitRef): Promise<Response> {
    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
    // supports it.
    const encodeName = encodeURIComponent(repo);
    const encodeRef = encodeURIComponent(ref);
    return this._restApiHelper.send({
      method: HttpMethod.DELETE,
      url: `/projects/${encodeName}/branches/${encodeRef}`,
      body: '',
      anonymizedUrl: '/projects/*/branches/*',
    });
  }

  deleteRepoTags(repo: RepoName, ref: GitRef): Promise<Response> {
    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
    // supports it.
    const encodeName = encodeURIComponent(repo);
    const encodeRef = encodeURIComponent(ref);
    return this._restApiHelper.send({
      method: HttpMethod.DELETE,
      url: `/projects/${encodeName}/tags/${encodeRef}`,
      body: '',
      anonymizedUrl: '/projects/*/tags/*',
    });
  }

  createRepoBranch(
    name: RepoName,
    branch: BranchName,
    revision: BranchInput
  ): Promise<Response> {
    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
    // supports it.
    const encodeName = encodeURIComponent(name);
    const encodeBranch = encodeURIComponent(branch);
    return this._restApiHelper.send({
      method: HttpMethod.PUT,
      url: `/projects/${encodeName}/branches/${encodeBranch}`,
      body: revision,
      anonymizedUrl: '/projects/*/branches/*',
    });
  }

  createRepoTag(
    name: RepoName,
    tag: string,
    revision: TagInput
  ): Promise<Response> {
    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
    // supports it.
    const encodeName = encodeURIComponent(name);
    const encodeTag = encodeURIComponent(tag);
    return this._restApiHelper.send({
      method: HttpMethod.PUT,
      url: `/projects/${encodeName}/tags/${encodeTag}`,
      body: revision,
      anonymizedUrl: '/projects/*/tags/*',
    });
  }

  getIsGroupOwner(groupName: GroupName): Promise<boolean> {
    const encodeName = encodeURIComponent(groupName);
    const req = {
      url: `/groups/?owned&g=${encodeName}`,
      anonymizedUrl: '/groups/owned&g=*',
    };
    return this._fetchSharedCacheURL(req).then(configs =>
      hasOwnProperty(configs, groupName)
    );
  }

  getGroupMembers(groupName: GroupId | GroupName): Promise<AccountInfo[]> {
    const encodeName = encodeURIComponent(groupName);
    return (this._restApiHelper.fetchJSON({
      url: `/groups/${encodeName}/members/`,
      anonymizedUrl: '/groups/*/members',
    }) as unknown) as Promise<AccountInfo[]>;
  }

  getIncludedGroup(
    groupName: GroupId | GroupName
  ): Promise<GroupInfo[] | undefined> {
    return this._restApiHelper.fetchJSON({
      url: `/groups/${encodeURIComponent(groupName)}/groups/`,
      anonymizedUrl: '/groups/*/groups',
    }) as Promise<GroupInfo[] | undefined>;
  }

  saveGroupName(groupId: GroupId | GroupName, name: string): Promise<Response> {
    const encodeId = encodeURIComponent(groupId);
    return this._restApiHelper.send({
      method: HttpMethod.PUT,
      url: `/groups/${encodeId}/name`,
      body: {name},
      anonymizedUrl: '/groups/*/name',
    });
  }

  saveGroupOwner(
    groupId: GroupId | GroupName,
    ownerId: string
  ): Promise<Response> {
    const encodeId = encodeURIComponent(groupId);
    return this._restApiHelper.send({
      method: HttpMethod.PUT,
      url: `/groups/${encodeId}/owner`,
      body: {owner: ownerId},
      anonymizedUrl: '/groups/*/owner',
    });
  }

  saveGroupDescription(
    groupId: GroupId | GroupName,
    description: string
  ): Promise<Response> {
    const encodeId = encodeURIComponent(groupId);
    return this._restApiHelper.send({
      method: HttpMethod.PUT,
      url: `/groups/${encodeId}/description`,
      body: {description},
      anonymizedUrl: '/groups/*/description',
    });
  }

  saveGroupOptions(
    groupId: GroupId | GroupName,
    options: GroupOptionsInput
  ): Promise<Response> {
    const encodeId = encodeURIComponent(groupId);
    return this._restApiHelper.send({
      method: HttpMethod.PUT,
      url: `/groups/${encodeId}/options`,
      body: options,
      anonymizedUrl: '/groups/*/options',
    });
  }

  getGroupAuditLog(
    group: EncodedGroupId,
    errFn?: ErrorCallback
  ): Promise<GroupAuditEventInfo[] | undefined> {
    return this._fetchSharedCacheURL({
      url: `/groups/${group}/log.audit`,
      errFn,
      anonymizedUrl: '/groups/*/log.audit',
    }) as Promise<GroupAuditEventInfo[] | undefined>;
  }

  saveGroupMember(
    groupName: GroupId | GroupName,
    groupMember: AccountId
  ): Promise<AccountInfo> {
    const encodeName = encodeURIComponent(groupName);
    const encodeMember = encodeURIComponent(`${groupMember}`);
    return (this._restApiHelper.send({
      method: HttpMethod.PUT,
      url: `/groups/${encodeName}/members/${encodeMember}`,
      parseResponse: true,
      anonymizedUrl: '/groups/*/members/*',
    }) as unknown) as Promise<AccountInfo>;
  }

  saveIncludedGroup(
    groupName: GroupId | GroupName,
    includedGroup: GroupId,
    errFn?: ErrorCallback
  ): Promise<GroupInfo | undefined> {
    const encodeName = encodeURIComponent(groupName);
    const encodeIncludedGroup = encodeURIComponent(includedGroup);
    const req = {
      method: HttpMethod.PUT,
      url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
      errFn,
      anonymizedUrl: '/groups/*/groups/*',
    };
    return this._restApiHelper.send(req).then(response => {
      if (response?.ok) {
        return (this.getResponseObject(
          response
        ) as unknown) as Promise<GroupInfo>;
      }
      return undefined;
    });
  }

  deleteGroupMember(
    groupName: GroupId | GroupName,
    groupMember: AccountId
  ): Promise<Response> {
    const encodeName = encodeURIComponent(groupName);
    const encodeMember = encodeURIComponent(`${groupMember}`);
    return this._restApiHelper.send({
      method: HttpMethod.DELETE,
      url: `/groups/${encodeName}/members/${encodeMember}`,
      anonymizedUrl: '/groups/*/members/*',
    });
  }

  deleteIncludedGroup(
    groupName: GroupId,
    includedGroup: GroupId | GroupName
  ): Promise<Response> {
    const encodeName = encodeURIComponent(groupName);
    const encodeIncludedGroup = encodeURIComponent(includedGroup);
    return this._restApiHelper.send({
      method: HttpMethod.DELETE,
      url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
      anonymizedUrl: '/groups/*/groups/*',
    });
  }

  getVersion(): Promise<string | undefined> {
    return this._fetchSharedCacheURL({
      url: '/config/server/version',
      reportUrlAsIs: true,
    }) as Promise<string | undefined>;
  }

  getDiffPreferences(): Promise<DiffPreferencesInfo | undefined> {
    return this.getLoggedIn().then(loggedIn => {
      if (loggedIn) {
        return this._fetchSharedCacheURL({
          url: '/accounts/self/preferences.diff',
          reportUrlAsIs: true,
        }) as Promise<DiffPreferencesInfo | undefined>;
      }
      return Promise.resolve(createDefaultDiffPrefs());
    });
  }

  getEditPreferences(): Promise<EditPreferencesInfo | undefined> {
    return this.getLoggedIn().then(loggedIn => {
      if (loggedIn) {
        return this._fetchSharedCacheURL({
          url: '/accounts/self/preferences.edit',
          reportUrlAsIs: true,
        }) as Promise<EditPreferencesInfo | undefined>;
      }
      return Promise.resolve(createDefaultEditPrefs());
    });
  }

  savePreferences(prefs: PreferencesInput): Promise<Response> {
    // Note (Issue 5142): normalize the download scheme with lower case before
    // saving.
    if (prefs.download_scheme) {
      prefs.download_scheme = prefs.download_scheme.toLowerCase();
    }

    return this._restApiHelper.send({
      method: HttpMethod.PUT,
      url: '/accounts/self/preferences',
      body: prefs,
      reportUrlAsIs: true,
    });
  }

  saveDiffPreferences(prefs: DiffPreferenceInput): Promise<Response> {
    // Invalidate the cache.
    this._cache.delete('/accounts/self/preferences.diff');
    return this._restApiHelper.send({
      method: HttpMethod.PUT,
      url: '/accounts/self/preferences.diff',
      body: prefs,
      reportUrlAsIs: true,
    });
  }

  saveEditPreferences(prefs: EditPreferencesInfo): Promise<Response> {
    // Invalidate the cache.
    this._cache.delete('/accounts/self/preferences.edit');
    return this._restApiHelper.send({
      method: HttpMethod.PUT,
      url: '/accounts/self/preferences.edit',
      body: prefs,
      reportUrlAsIs: true,
    });
  }

  getAccount(): Promise<AccountDetailInfo | undefined> {
    return this._fetchSharedCacheURL({
      url: '/accounts/self/detail',
      reportUrlAsIs: true,
      errFn: resp => {
        if (!resp || resp.status === 403) {
          this._cache.delete('/accounts/self/detail');
        }
      },
    }) as Promise<AccountDetailInfo | undefined>;
  }

  getAvatarChangeUrl() {
    return this._fetchSharedCacheURL({
      url: '/accounts/self/avatar.change.url',
      reportUrlAsIs: true,
      errFn: resp => {
        if (!resp || resp.status === 403) {
          this._cache.delete('/accounts/self/avatar.change.url');
        }
      },
    }) as Promise<string | undefined>;
  }

  getExternalIds() {
    return this._restApiHelper.fetchJSON({
      url: '/accounts/self/external.ids',
      reportUrlAsIs: true,
    }) as Promise<AccountExternalIdInfo[] | undefined>;
  }

  deleteAccountIdentity(id: string[]) {
    return this._restApiHelper.send({
      method: HttpMethod.POST,
      url: '/accounts/self/external.ids:delete',
      body: id,
      parseResponse: true,
      reportUrlAsIs: true,
    }) as Promise<unknown>;
  }

  getAccountDetails(userId: AccountId): Promise<AccountDetailInfo | undefined> {
    return this._restApiHelper.fetchJSON({
      url: `/accounts/${encodeURIComponent(userId)}/detail`,
      anonymizedUrl: '/accounts/*/detail',
    }) as Promise<AccountDetailInfo | undefined>;
  }

  getAccountEmails() {
    return this._fetchSharedCacheURL({
      url: '/accounts/self/emails',
      reportUrlAsIs: true,
    }) as Promise<EmailInfo[] | undefined>;
  }

  addAccountEmail(email: string): Promise<Response> {
    return this._restApiHelper.send({
      method: HttpMethod.PUT,
      url: '/accounts/self/emails/' + encodeURIComponent(email),
      anonymizedUrl: '/account/self/emails/*',
    });
  }

  deleteAccountEmail(email: string): Promise<Response> {
    return this._restApiHelper.send({
      method: HttpMethod.DELETE,
      url: '/accounts/self/emails/' + encodeURIComponent(email),
      anonymizedUrl: '/accounts/self/email/*',
    });
  }

  setPreferredAccountEmail(email: string): Promise<void> {
    // TODO(TS): add correct error handling
    const encodedEmail = encodeURIComponent(email);
    const req = {
      method: HttpMethod.PUT,
      url: `/accounts/self/emails/${encodedEmail}/preferred`,
      anonymizedUrl: '/accounts/self/emails/*/preferred',
    };
    return this._restApiHelper.send(req).then(() => {
      // If result of getAccountEmails is in cache, update it in the cache
      // so we don't have to invalidate it.
      const cachedEmails = this._cache.get('/accounts/self/emails');
      if (cachedEmails) {
        const emails = cachedEmails.map(entry => {
          if (entry.email === email) {
            return {email, preferred: true};
          } else {
            return {email};
          }
        });
        this._cache.set('/accounts/self/emails', emails);
      }
    });
  }

  _updateCachedAccount(obj: Partial<AccountDetailInfo>): void {
    // If result of getAccount is in cache, update it in the cache
    // so we don't have to invalidate it.
    const cachedAccount = this._cache.get('/accounts/self/detail');
    if (cachedAccount) {
      // Replace object in cache with new object to force UI updates.
      this._cache.set('/accounts/self/detail', {...cachedAccount, ...obj});
    }
  }

  setAccountName(name: string): Promise<void> {
    // TODO(TS): add correct error handling
    const req: SendJSONRequest = {
      method: HttpMethod.PUT,
      url: '/accounts/self/name',
      body: {name},
      parseResponse: true,
      reportUrlAsIs: true,
    };
    return this._restApiHelper
      .send(req)
      .then(newName =>
        this._updateCachedAccount({name: (newName as unknown) as string})
      );
  }

  setAccountUsername(username: string): Promise<void> {
    // TODO(TS): add correct error handling
    const req: SendJSONRequest = {
      method: HttpMethod.PUT,
      url: '/accounts/self/username',
      body: {username},
      parseResponse: true,
      reportUrlAsIs: true,
    };
    return this._restApiHelper
      .send(req)
      .then(newName =>
        this._updateCachedAccount({username: (newName as unknown) as string})
      );
  }

  setAccountDisplayName(displayName: string): Promise<void> {
    // TODO(TS): add correct error handling
    const req: SendJSONRequest = {
      method: HttpMethod.PUT,
      url: '/accounts/self/displayname',
      body: {display_name: displayName},
      parseResponse: true,
      reportUrlAsIs: true,
    };
    return this._restApiHelper.send(req).then(newName =>
      this._updateCachedAccount({
        display_name: (newName as unknown) as string,
      })
    );
  }

  setAccountStatus(status: string): Promise<void> {
    // TODO(TS): add correct error handling
    const req: SendJSONRequest = {
      method: HttpMethod.PUT,
      url: '/accounts/self/status',
      body: {status},
      parseResponse: true,
      reportUrlAsIs: true,
    };
    return this._restApiHelper
      .send(req)
      .then(newStatus =>
        this._updateCachedAccount({status: (newStatus as unknown) as string})
      );
  }

  getAccountStatus(userId: AccountId) {
    return this._restApiHelper.fetchJSON({
      url: `/accounts/${encodeURIComponent(userId)}/status`,
      anonymizedUrl: '/accounts/*/status',
    }) as Promise<string | undefined>;
  }

  // https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#list-groups
  getAccountGroups() {
    return this._restApiHelper.fetchJSON({
      url: '/accounts/self/groups',
      reportUrlAsIs: true,
    }) as Promise<GroupInfo[] | undefined>;
  }

  getAccountAgreements() {
    return this._restApiHelper.fetchJSON({
      url: '/accounts/self/agreements',
      reportUrlAsIs: true,
    }) as Promise<ContributorAgreementInfo[] | undefined>;
  }

  saveAccountAgreement(name: ContributorAgreementInput): Promise<Response> {
    return this._restApiHelper.send({
      method: HttpMethod.PUT,
      url: '/accounts/self/agreements',
      body: name,
      reportUrlAsIs: true,
    });
  }

  getAccountCapabilities(
    params?: string[]
  ): Promise<AccountCapabilityInfo | undefined> {
    let queryString = '';
    if (params) {
      queryString =
        '?q=' + params.map(param => encodeURIComponent(param)).join('&q=');
    }
    return this._fetchSharedCacheURL({
      url: '/accounts/self/capabilities' + queryString,
      anonymizedUrl: '/accounts/self/capabilities?q=*',
    }) as Promise<AccountCapabilityInfo | undefined>;
  }

  getLoggedIn() {
    return this.authService.authCheck();
  }

  getIsAdmin() {
    return this.getLoggedIn()
      .then(isLoggedIn => {
        if (isLoggedIn) {
          return this.getAccountCapabilities();
        } else {
          return;
        }
      })
      .then(
        (capabilities: AccountCapabilityInfo | undefined) =>
          capabilities && capabilities.administrateServer
      );
  }

  getDefaultPreferences(): Promise<PreferencesInfo | undefined> {
    return this._fetchSharedCacheURL({
      url: '/config/server/preferences',
      reportUrlAsIs: true,
    }) as Promise<PreferencesInfo | undefined>;
  }

  getPreferences(): Promise<PreferencesInfo | undefined> {
    return this.getLoggedIn().then(loggedIn => {
      if (loggedIn) {
        const req = {url: '/accounts/self/preferences', reportUrlAsIs: true};
        return this._fetchSharedCacheURL(req).then(res => {
          if (!res) {
            return res;
          }
          const prefInfo = (res as unknown) as PreferencesInfo;
          if (this._isNarrowScreen()) {
            // Note that this can be problematic, because the diff will stay
            // unified even after increasing the window width.
            prefInfo.default_diff_view = DiffViewMode.UNIFIED;
          } else {
            prefInfo.default_diff_view = prefInfo.diff_view;
          }
          return prefInfo;
        });
      }
      return createDefaultPreferences();
    });
  }

  getWatchedProjects() {
    return (this._fetchSharedCacheURL({
      url: '/accounts/self/watched.projects',
      reportUrlAsIs: true,
    }) as unknown) as Promise<ProjectWatchInfo[] | undefined>;
  }

  saveWatchedProjects(
    projects: ProjectWatchInfo[]
  ): Promise<ProjectWatchInfo[]> {
    return (this._restApiHelper.send({
      method: HttpMethod.POST,
      url: '/accounts/self/watched.projects',
      body: projects,
      parseResponse: true,
      reportUrlAsIs: true,
    }) as unknown) as Promise<ProjectWatchInfo[]>;
  }

  deleteWatchedProjects(projects: ProjectWatchInfo[]): Promise<Response> {
    return this._restApiHelper.send({
      method: HttpMethod.POST,
      url: '/accounts/self/watched.projects:delete',
      body: projects,
      reportUrlAsIs: true,
    });
  }

  _isNarrowScreen() {
    return window.innerWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX;
  }

  getChanges(
    changesPerPage?: number,
    query?: string,
    offset?: 'n,z' | number,
    options?: string
  ): Promise<ChangeInfo[] | undefined>;

  getChanges(
    changesPerPage?: number,
    query?: string[],
    offset?: 'n,z' | number,
    options?: string
  ): Promise<ChangeInfo[][] | undefined>;

  /**
   * @return If opt_query is an
   * array, _fetchJSON will return an array of arrays of changeInfos. If it
   * is unspecified or a string, _fetchJSON will return an array of
   * changeInfos.
   */
  getChanges(
    changesPerPage?: number,
    query?: string | string[],
    offset?: 'n,z' | number,
    options?: string
  ): Promise<ChangeInfo[] | ChangeInfo[][] | undefined> {
    return this.getConfig(false)
      .then(config => {
        // TODO(TS): config can be null/undefined. Need some checks
        options = options || this._getChangesOptionsHex(config);
        // Issue 4524: respect legacy token with max sortkey.
        if (offset === 'n,z') {
          offset = 0;
        }
        const params: QueryChangesParams = {
          O: options,
          S: offset || 0,
        };
        if (changesPerPage) {
          params.n = changesPerPage;
        }
        if (query && query.length > 0) {
          params.q = query;
        }
        return {
          url: '/changes/',
          params,
          reportUrlAsIs: true,
        };
      })
      .then(
        req =>
          this._restApiHelper.fetchJSON(req, true) as Promise<
            ChangeInfo[] | ChangeInfo[][] | undefined
          >
      )
      .then(response => {
        if (!response) {
          return;
        }
        const iterateOverChanges = (arr: ChangeInfo[]) => {
          for (const change of arr) {
            this._maybeInsertInLookup(change);
          }
        };
        // Response may be an array of changes OR an array of arrays of
        // changes.
        if (query instanceof Array) {
          // Normalize the response to look like a multi-query response
          // when there is only one query.
          const responseArray: Array<ChangeInfo[]> =
            query.length === 1
              ? [response as ChangeInfo[]]
              : (response as ChangeInfo[][]);
          for (const arr of responseArray) {
            iterateOverChanges(arr);
          }
          return responseArray;
        } else {
          iterateOverChanges(response as ChangeInfo[]);
          return response as ChangeInfo[];
        }
      });
  }

  /**
   * Inserts a change into _projectLookup iff it has a valid structure.
   */
  _maybeInsertInLookup(change: ChangeInfo): void {
    if (change?.project && change._number) {
      this.setInProjectLookup(change._number, change.project);
    }
  }

  getChangeActionURL(
    changeNum: NumericChangeId,
    revisionId: RevisionId | undefined,
    endpoint: string
  ): Promise<string> {
    return this._changeBaseURL(changeNum, revisionId).then(
      url => url + endpoint
    );
  }

  getChangeDetail(
    changeNum: NumericChangeId,
    errFn?: ErrorCallback,
    cancelCondition?: CancelConditionCallback
  ): Promise<ParsedChangeInfo | null | undefined> {
    return this.getConfig(false).then(config => {
      const optionsHex = this._getChangeOptionsHex(config);
      return this._getChangeDetail(
        changeNum,
        optionsHex,
        errFn,
        cancelCondition
      ).then(detail =>
        // detail has ChangeViewChangeInfo type because the optionsHex always
        // includes ALL_REVISIONS flag.
        GrReviewerUpdatesParser.parse(detail as ChangeViewChangeInfo)
      );
    });
  }

  _getChangesOptionsHex(config?: ServerInfo) {
    if (
      window.DEFAULT_DETAIL_HEXES &&
      window.DEFAULT_DETAIL_HEXES.dashboardPage
    ) {
      return window.DEFAULT_DETAIL_HEXES.dashboardPage;
    }
    const options = [
      ListChangesOption.LABELS,
      ListChangesOption.DETAILED_ACCOUNTS,
    ];
    if (!config?.change?.enable_attention_set) {
      options.push(ListChangesOption.REVIEWED);
    }

    return listChangesOptionsToHex(...options);
  }

  _getChangeOptionsHex(config?: ServerInfo) {
    if (
      window.DEFAULT_DETAIL_HEXES &&
      window.DEFAULT_DETAIL_HEXES.changePage &&
      (!config || !(config.receive && config.receive.enable_signed_push))
    ) {
      return window.DEFAULT_DETAIL_HEXES.changePage;
    }

    // This list MUST be kept in sync with
    // ChangeIT#changeDetailsDoesNotRequireIndex
    const options = [
      ListChangesOption.ALL_COMMITS,
      ListChangesOption.ALL_REVISIONS,
      ListChangesOption.CHANGE_ACTIONS,
      ListChangesOption.DETAILED_LABELS,
      ListChangesOption.DOWNLOAD_COMMANDS,
      ListChangesOption.MESSAGES,
      ListChangesOption.SUBMITTABLE,
      ListChangesOption.WEB_LINKS,
      ListChangesOption.SKIP_DIFFSTAT,
    ];
    if (config?.receive?.enable_signed_push) {
      options.push(ListChangesOption.PUSH_CERTIFICATES);
    }
    return listChangesOptionsToHex(...options);
  }

  getDiffChangeDetail(changeNum: NumericChangeId) {
    let optionsHex = '';
    if (window.DEFAULT_DETAIL_HEXES?.diffPage) {
      optionsHex = window.DEFAULT_DETAIL_HEXES.diffPage;
    } else {
      optionsHex = listChangesOptionsToHex(
        ListChangesOption.ALL_COMMITS,
        ListChangesOption.ALL_REVISIONS,
        ListChangesOption.SKIP_DIFFSTAT
      );
    }
    return this._getChangeDetail(changeNum, optionsHex);
  }

  /**
   * @param optionsHex list changes options in hex
   */
  _getChangeDetail(
    changeNum: NumericChangeId,
    optionsHex: string,
    errFn?: ErrorCallback,
    cancelCondition?: CancelConditionCallback
  ): Promise<ChangeInfo | undefined | null> {
    return this.getChangeActionURL(changeNum, undefined, '/detail').then(
      url => {
        const params: FetchParams = {O: optionsHex};
        const urlWithParams = this._restApiHelper.urlWithParams(url, params);
        const req: FetchJSONRequest = {
          url,
          errFn,
          cancelCondition,
          params,
          fetchOptions: this._etags.getOptions(urlWithParams),
          anonymizedUrl: '/changes/*~*/detail?O=' + optionsHex,
        };
        return this._restApiHelper.fetchRawJSON(req).then(response => {
          if (response?.status === 304) {
            return (parsePrefixedJSON(
              // urlWithParams already cached
              this._etags.getCachedPayload(urlWithParams)!
            ) as unknown) as ChangeInfo;
          }

          if (response && !response.ok) {
            if (errFn) {
              errFn.call(null, response);
            } else {
              fireServerError(response, req);
            }
            return undefined;
          }

          if (!response) {
            return Promise.resolve(null);
          }

          return readResponsePayload(response).then(payload => {
            if (!payload) {
              return null;
            }
            this._etags.collect(urlWithParams, response, payload.raw);
            // TODO(TS): Why it is always change info?
            this._maybeInsertInLookup(
              (payload.parsed as unknown) as ChangeInfo
            );

            return (payload.parsed as unknown) as ChangeInfo;
          });
        });
      }
    );
  }

  getChangeCommitInfo(changeNum: NumericChangeId, patchNum: PatchSetNum) {
    return this._getChangeURLAndFetch({
      changeNum,
      endpoint: '/commit?links',
      revision: patchNum,
      reportEndpointAsIs: true,
    }) as Promise<CommitInfo | undefined>;
  }

  getChangeFiles(
    changeNum: NumericChangeId,
    patchRange: PatchRange
  ): Promise<FileNameToFileInfoMap | undefined> {
    let params = undefined;
    if (isMergeParent(patchRange.basePatchNum)) {
      params = {parent: getParentIndex(patchRange.basePatchNum)};
    } else if (patchRange.basePatchNum !== ParentPatchSetNum) {
      params = {base: patchRange.basePatchNum};
    }
    return this._getChangeURLAndFetch({
      changeNum,
      endpoint: '/files',
      revision: patchRange.patchNum,
      params,
      reportEndpointAsIs: true,
    }) as Promise<FileNameToFileInfoMap | undefined>;
  }

  // TODO(TS): The output type is unclear
  getChangeEditFiles(
    changeNum: NumericChangeId,
    patchRange: PatchRange
  ): Promise<{files: FileNameToFileInfoMap} | undefined> {
    let endpoint = '/edit?list';
    let anonymizedEndpoint = endpoint;
    if (patchRange.basePatchNum !== ParentPatchSetNum) {
      endpoint += '&base=' + encodeURIComponent(`${patchRange.basePatchNum}`);
      anonymizedEndpoint += '&base=*';
    }
    return this._getChangeURLAndFetch({
      changeNum,
      endpoint,
      anonymizedEndpoint,
    }) as Promise<{files: FileNameToFileInfoMap} | undefined>;
  }

  queryChangeFiles(
    changeNum: NumericChangeId,
    patchNum: PatchSetNum,
    query: string
  ) {
    return this._getChangeURLAndFetch({
      changeNum,
      endpoint: `/files?q=${encodeURIComponent(query)}`,
      revision: patchNum,
      anonymizedEndpoint: '/files?q=*',
    }) as Promise<string[] | undefined>;
  }

  getChangeOrEditFiles(
    changeNum: NumericChangeId,
    patchRange: PatchRange
  ): Promise<FileNameToFileInfoMap | undefined> {
    if (patchRange.patchNum === EditPatchSetNum) {
      return this.getChangeEditFiles(changeNum, patchRange).then(
        res => res && res.files
      );
    }
    return this.getChangeFiles(changeNum, patchRange);
  }

  getChangeRevisionActions(
    changeNum: NumericChangeId,
    patchNum: PatchSetNum
  ): Promise<ActionNameToActionInfoMap | undefined> {
    const req: FetchChangeJSON = {
      changeNum,
      endpoint: '/actions',
      revision: patchNum,
      reportEndpointAsIs: true,
    };
    return this._getChangeURLAndFetch(req) as Promise<
      ActionNameToActionInfoMap | undefined
    >;
  }

  getChangeSuggestedReviewers(changeNum: NumericChangeId, inputVal: string) {
    return this._getChangeSuggestedGroup(
      ReviewerState.REVIEWER,
      changeNum,
      inputVal
    );
  }

  getChangeSuggestedCCs(changeNum: NumericChangeId, inputVal: string) {
    return this._getChangeSuggestedGroup(ReviewerState.CC, changeNum, inputVal);
  }

  _getChangeSuggestedGroup(
    reviewerState: ReviewerState,
    changeNum: NumericChangeId,
    inputVal: string
  ): Promise<SuggestedReviewerInfo[] | undefined> {
    // More suggestions may obscure content underneath in the reply dialog,
    // see issue 10793.
    const params: QuerySuggestedReviewersParams = {
      n: 6,
      'reviewer-state': reviewerState,
    };
    if (inputVal) {
      params.q = inputVal;
    }
    return this._getChangeURLAndFetch({
      changeNum,
      endpoint: '/suggest_reviewers',
      params,
      reportEndpointAsIs: true,
    }) as Promise<SuggestedReviewerInfo[] | undefined>;
  }

  getChangeIncludedIn(
    changeNum: NumericChangeId
  ): Promise<IncludedInInfo | undefined> {
    return this._getChangeURLAndFetch({
      changeNum,
      endpoint: '/in',
      reportEndpointAsIs: true,
    }) as Promise<IncludedInInfo | undefined>;
  }

  _computeFilter(filter: string) {
    if (filter?.startsWith('^')) {
      filter = '&r=' + encodeURIComponent(filter);
    } else if (filter) {
      filter = '&m=' + encodeURIComponent(filter);
    } else {
      filter = '';
    }
    return filter;
  }

  _getGroupsUrl(filter: string, groupsPerPage: number, offset?: number) {
    offset = offset || 0;

    return (
      `/groups/?n=${groupsPerPage + 1}&S=${offset}` +
      this._computeFilter(filter)
    );
  }

  _getReposUrl(
    filter: string | undefined,
    reposPerPage: number,
    offset?: number
  ) {
    const defaultFilter = 'state:active OR state:read-only';
    const namePartDelimiters = /[@.\-\s/_]/g;
    offset = offset || 0;

    if (filter && !filter.includes(':') && filter.match(namePartDelimiters)) {
      // The query language specifies hyphens as operators. Split the string
      // by hyphens and 'AND' the parts together as 'inname:' queries.
      // If the filter includes a semicolon, the user is using a more complex
      // query so we trust them and don't do any magic under the hood.
      const originalFilter = filter;
      filter = '';
      originalFilter.split(namePartDelimiters).forEach(part => {
        if (part) {
          filter += (filter === '' ? 'inname:' : ' AND inname:') + part;
        }
      });
    }
    // Check if filter is now empty which could be either because the user did
    // not provide it or because the user provided only a split character.
    if (!filter) {
      filter = defaultFilter;
    }

    filter = filter.trim();
    const encodedFilter = encodeURIComponent(filter);

    return (
      `/projects/?n=${reposPerPage + 1}&S=${offset}` + `&query=${encodedFilter}`
    );
  }

  invalidateGroupsCache() {
    this._restApiHelper.invalidateFetchPromisesPrefix('/groups/?');
  }

  invalidateReposCache() {
    this._restApiHelper.invalidateFetchPromisesPrefix('/projects/?');
  }

  invalidateAccountsCache() {
    this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/');
  }

  getGroups(filter: string, groupsPerPage: number, offset?: number) {
    const url = this._getGroupsUrl(filter, groupsPerPage, offset);

    return this._fetchSharedCacheURL({
      url,
      anonymizedUrl: '/groups/?*',
    }) as Promise<GroupNameToGroupInfoMap | undefined>;
  }

  getRepos(
    filter: string | undefined,
    reposPerPage: number,
    offset?: number
  ): Promise<ProjectInfoWithName[] | undefined> {
    const url = this._getReposUrl(filter, reposPerPage, offset);

    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
    // supports it.
    return this._fetchSharedCacheURL({
      url, // The url contains query,so the response is an array, not map
      anonymizedUrl: '/projects/?*',
    }) as Promise<ProjectInfoWithName[] | undefined>;
  }

  setRepoHead(repo: RepoName, ref: GitRef) {
    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
    // supports it.
    return this._restApiHelper.send({
      method: HttpMethod.PUT,
      url: `/projects/${encodeURIComponent(repo)}/HEAD`,
      body: {ref},
      anonymizedUrl: '/projects/*/HEAD',
    });
  }

  getRepoBranches(
    filter: string,
    repo: RepoName,
    reposBranchesPerPage: number,
    offset?: number,
    errFn?: ErrorCallback
  ): Promise<BranchInfo[] | undefined> {
    offset = offset || 0;
    const count = reposBranchesPerPage + 1;
    filter = this._computeFilter(filter);
    const encodedRepo = encodeURIComponent(repo);
    const url = `/projects/${encodedRepo}/branches?n=${count}&S=${offset}${filter}`;
    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
    // supports it.
    return this._restApiHelper.fetchJSON({
      url,
      errFn,
      anonymizedUrl: '/projects/*/branches?*',
    }) as Promise<BranchInfo[] | undefined>;
  }

  getRepoTags(
    filter: string,
    repo: RepoName,
    reposTagsPerPage: number,
    offset?: number,
    errFn?: ErrorCallback
  ) {
    offset = offset || 0;
    const encodedRepo = encodeURIComponent(repo);
    const n = reposTagsPerPage + 1;
    const encodedFilter = this._computeFilter(filter);
    const url =
      `/projects/${encodedRepo}/tags` + `?n=${n}&S=${offset}` + encodedFilter;
    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
    // supports it.
    return (this._restApiHelper.fetchJSON({
      url,
      errFn,
      anonymizedUrl: '/projects/*/tags',
    }) as unknown) as Promise<TagInfo[]>;
  }

  getPlugins(
    filter: string,
    pluginsPerPage: number,
    offset?: number,
    errFn?: ErrorCallback
  ): Promise<{[pluginName: string]: PluginInfo} | undefined> {
    offset = offset || 0;
    const encodedFilter = this._computeFilter(filter);
    const n = pluginsPerPage + 1;
    const url = `/plugins/?all&n=${n}&S=${offset}${encodedFilter}`;
    return this._restApiHelper.fetchJSON({
      url,
      errFn,
      anonymizedUrl: '/plugins/?all',
    });
  }

  getRepoAccessRights(
    repoName: RepoName,
    errFn?: ErrorCallback
  ): Promise<ProjectAccessInfo | undefined> {
    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
    // supports it.
    return this._restApiHelper.fetchJSON({
      url: `/projects/${encodeURIComponent(repoName)}/access`,
      errFn,
      anonymizedUrl: '/projects/*/access',
    }) as Promise<ProjectAccessInfo | undefined>;
  }

  setRepoAccessRights(
    repoName: RepoName,
    repoInfo: ProjectAccessInput
  ): Promise<Response> {
    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
    // supports it.
    return this._restApiHelper.send({
      method: HttpMethod.POST,
      url: `/projects/${encodeURIComponent(repoName)}/access`,
      body: repoInfo,
      anonymizedUrl: '/projects/*/access',
    });
  }

  setRepoAccessRightsForReview(
    projectName: RepoName,
    projectInfo: ProjectAccessInput
  ): Promise<ChangeInfo> {
    return (this._restApiHelper.send({
      method: HttpMethod.PUT,
      url: `/projects/${encodeURIComponent(projectName)}/access:review`,
      body: projectInfo,
      parseResponse: true,
      anonymizedUrl: '/projects/*/access:review',
    }) as unknown) as Promise<ChangeInfo>;
  }

  getSuggestedGroups(
    inputVal: string,
    n?: number
  ): Promise<GroupNameToGroupInfoMap | undefined> {
    const params: QueryGroupsParams = {s: inputVal};
    if (n) {
      params.n = n;
    }
    return this._restApiHelper.fetchJSON({
      url: '/groups/',
      params,
      reportUrlAsIs: true,
    }) as Promise<GroupNameToGroupInfoMap | undefined>;
  }

  getSuggestedProjects(
    inputVal: string,
    n?: number
  ): Promise<NameToProjectInfoMap | undefined> {
    const params = {
      m: inputVal,
      n: MAX_PROJECT_RESULTS,
      type: 'ALL',
    };
    if (n) {
      params.n = n;
    }
    return this._restApiHelper.fetchJSON({
      url: '/projects/',
      params,
      reportUrlAsIs: true,
    });
  }

  getSuggestedAccounts(
    inputVal: string,
    n?: number
  ): Promise<AccountInfo[] | undefined> {
    if (!inputVal) {
      return Promise.resolve([]);
    }
    const params: QueryAccountsParams = {suggest: null, q: inputVal};
    if (n) {
      params.n = n;
    }
    return this._restApiHelper.fetchJSON({
      url: '/accounts/',
      params,
      anonymizedUrl: '/accounts/?n=*',
    }) as Promise<AccountInfo[] | undefined>;
  }

  addChangeReviewer(
    changeNum: NumericChangeId,
    reviewerID: AccountId | EmailAddress | GroupId
  ) {
    return this._sendChangeReviewerRequest(
      HttpMethod.POST,
      changeNum,
      reviewerID
    );
  }

  removeChangeReviewer(
    changeNum: NumericChangeId,
    reviewerID: AccountId | EmailAddress | GroupId
  ) {
    return this._sendChangeReviewerRequest(
      HttpMethod.DELETE,
      changeNum,
      reviewerID
    );
  }

  _sendChangeReviewerRequest(
    method: HttpMethod.POST | HttpMethod.DELETE,
    changeNum: NumericChangeId,
    reviewerID: AccountId | EmailAddress | GroupId
  ) {
    return this.getChangeActionURL(changeNum, undefined, '/reviewers').then(
      url => {
        let body;
        switch (method) {
          case HttpMethod.POST:
            body = {reviewer: reviewerID};
            break;
          case HttpMethod.DELETE:
            url += '/' + encodeURIComponent(reviewerID);
            break;
          default:
            assertNever(method, `Unsupported HTTP method: ${method}`);
        }

        return this._restApiHelper.send({method, url, body});
      }
    );
  }

  getRelatedChanges(
    changeNum: NumericChangeId,
    patchNum: PatchSetNum
  ): Promise<RelatedChangesInfo | undefined> {
    return this._getChangeURLAndFetch({
      changeNum,
      endpoint: '/related',
      revision: patchNum,
      reportEndpointAsIs: true,
    }) as Promise<RelatedChangesInfo | undefined>;
  }

  getChangesSubmittedTogether(
    changeNum: NumericChangeId
  ): Promise<SubmittedTogetherInfo | undefined> {
    return this._getChangeURLAndFetch({
      changeNum,
      endpoint: '/submitted_together?o=NON_VISIBLE_CHANGES',
      reportEndpointAsIs: true,
    }) as Promise<SubmittedTogetherInfo | undefined>;
  }

  getChangeConflicts(
    changeNum: NumericChangeId
  ): Promise<ChangeInfo[] | undefined> {
    const options = listChangesOptionsToHex(
      ListChangesOption.CURRENT_REVISION,
      ListChangesOption.CURRENT_COMMIT
    );
    const params = {
      O: options,
      q: `status:open conflicts:${changeNum}`,
    };
    return this._restApiHelper.fetchJSON({
      url: '/changes/',
      params,
      anonymizedUrl: '/changes/conflicts:*',
    }) as Promise<ChangeInfo[] | undefined>;
  }

  getChangeCherryPicks(
    project: RepoName,
    changeID: ChangeId,
    changeNum: NumericChangeId
  ): Promise<ChangeInfo[] | undefined> {
    const options = listChangesOptionsToHex(
      ListChangesOption.CURRENT_REVISION,
      ListChangesOption.CURRENT_COMMIT
    );
    const query = [
      `project:${project}`,
      `change:${changeID}`,
      `-change:${changeNum}`,
      '-is:abandoned',
    ].join(' ');
    const params = {
      O: options,
      q: query,
    };
    return this._restApiHelper.fetchJSON({
      url: '/changes/',
      params,
      anonymizedUrl: '/changes/change:*',
    }) as Promise<ChangeInfo[] | undefined>;
  }

  getChangesWithSameTopic(
    topic: string,
    changeNum: NumericChangeId
  ): Promise<ChangeInfo[] | undefined> {
    const options = listChangesOptionsToHex(
      ListChangesOption.LABELS,
      ListChangesOption.CURRENT_REVISION,
      ListChangesOption.CURRENT_COMMIT,
      ListChangesOption.DETAILED_LABELS
    );
    const query = [
      'status:open',
      `-change:${changeNum}`,
      `topic:"${topic}"`,
    ].join(' ');
    const params = {
      O: options,
      q: query,
    };
    return this._restApiHelper.fetchJSON({
      url: '/changes/',
      params,
      anonymizedUrl: '/changes/topic:*',
    }) as Promise<ChangeInfo[] | undefined>;
  }

  getChangesWithSimilarTopic(topic: string): Promise<ChangeInfo[] | undefined> {
    const query = [`intopic:"${topic}"`].join(' ');
    return this._restApiHelper.fetchJSON({
      url: '/changes/',
      params: {q: query},
      anonymizedUrl: '/changes/intopic:*',
    }) as Promise<ChangeInfo[] | undefined>;
  }

  getReviewedFiles(
    changeNum: NumericChangeId,
    patchNum: PatchSetNum
  ): Promise<string[] | undefined> {
    return this._getChangeURLAndFetch({
      changeNum,
      endpoint: '/files?reviewed',
      revision: patchNum,
      reportEndpointAsIs: true,
    }) as Promise<string[] | undefined>;
  }

  saveFileReviewed(
    changeNum: NumericChangeId,
    patchNum: PatchSetNum,
    path: string,
    reviewed: boolean
  ): Promise<Response> {
    return this._getChangeURLAndSend({
      changeNum,
      method: reviewed ? HttpMethod.PUT : HttpMethod.DELETE,
      patchNum,
      endpoint: `/files/${encodeURIComponent(path)}/reviewed`,
      anonymizedEndpoint: '/files/*/reviewed',
    });
  }

  saveChangeReview(
    changeNum: NumericChangeId,
    patchNum: PatchSetNum,
    review: ReviewInput
  ): Promise<Response>;

  saveChangeReview(
    changeNum: NumericChangeId,
    patchNum: PatchSetNum,
    review: ReviewInput,
    errFn: ErrorCallback
  ): Promise<Response | undefined>;

  saveChangeReview(
    changeNum: NumericChangeId,
    patchNum: PatchSetNum,
    review: ReviewInput,
    errFn?: ErrorCallback
  ) {
    const promises: [Promise<void>, Promise<string>] = [
      this.awaitPendingDiffDrafts(),
      this.getChangeActionURL(changeNum, patchNum, '/review'),
    ];
    return Promise.all(promises).then(([, url]) =>
      this._restApiHelper.send({
        method: HttpMethod.POST,
        url,
        body: review,
        errFn,
      })
    );
  }

  getChangeEdit(
    changeNum: NumericChangeId,
    downloadCommands?: boolean
  ): Promise<false | EditInfo | undefined> {
    const params = downloadCommands ? {'download-commands': true} : undefined;
    return this.getLoggedIn().then(loggedIn => {
      if (!loggedIn) {
        return Promise.resolve(false);
      }
      return this._getChangeURLAndFetch(
        {
          changeNum,
          endpoint: '/edit/',
          params,
          reportEndpointAsIs: true,
        },
        true
      ) as Promise<EditInfo | false | undefined>;
    });
  }

  createChange(
    project: RepoName,
    branch: BranchName,
    subject: string,
    topic?: string,
    isPrivate?: boolean,
    workInProgress?: boolean,
    baseChange?: ChangeId,
    baseCommit?: string
  ) {
    return (this._restApiHelper.send({
      method: HttpMethod.POST,
      url: '/changes/',
      body: {
        project,
        branch,
        subject,
        topic,
        is_private: isPrivate,
        work_in_progress: workInProgress,
        base_change: baseChange,
        base_commit: baseCommit,
      },
      parseResponse: true,
      reportUrlAsIs: true,
    }) as unknown) as Promise<ChangeInfo | undefined>;
  }

  getFileContent(
    changeNum: NumericChangeId,
    path: string,
    patchNum: PatchSetNum
  ): Promise<Response | Base64FileContent | undefined> {
    // 404s indicate the file does not exist yet in the revision, so suppress
    // them.
    const suppress404s: ErrorCallback = res => {
      if (res && res?.status !== 404) {
        fireServerError(res);
      }
      return res;
    };
    const promise =
      patchNum === EditPatchSetNum
        ? this._getFileInChangeEdit(changeNum, path)
        : this._getFileInRevision(changeNum, path, patchNum, suppress404s);

    return promise.then(res => {
      if (!res || !res.ok) {
        return res;
      }

      // The file type (used for syntax highlighting) is identified in the
      // X-FYI-Content-Type header of the response.
      const type = res.headers.get('X-FYI-Content-Type');
      return this.getResponseObject(res).then(content => {
        const strContent = (content as unknown) as string | null;
        return {content: strContent, type, ok: true};
      });
    });
  }

  /**
   * Gets a file in a specific change and revision.
   */
  _getFileInRevision(
    changeNum: NumericChangeId,
    path: string,
    patchNum: PatchSetNum,
    errFn?: ErrorCallback
  ) {
    return this._getChangeURLAndSend({
      changeNum,
      method: HttpMethod.GET,
      patchNum,
      endpoint: `/files/${encodeURIComponent(path)}/content`,
      errFn,
      headers: {Accept: 'application/json'},
      anonymizedEndpoint: '/files/*/content',
    });
  }

  /**
   * Gets a file in a change edit.
   */
  _getFileInChangeEdit(changeNum: NumericChangeId, path: string) {
    return this._getChangeURLAndSend({
      changeNum,
      method: HttpMethod.GET,
      endpoint: '/edit/' + encodeURIComponent(path),
      headers: {Accept: 'application/json'},
      anonymizedEndpoint: '/edit/*',
    });
  }

  rebaseChangeEdit(changeNum: NumericChangeId) {
    return this._getChangeURLAndSend({
      changeNum,
      method: HttpMethod.POST,
      endpoint: '/edit:rebase',
      reportEndpointAsIs: true,
    });
  }

  deleteChangeEdit(changeNum: NumericChangeId) {
    return this._getChangeURLAndSend({
      changeNum,
      method: HttpMethod.DELETE,
      endpoint: '/edit',
      reportEndpointAsIs: true,
    });
  }

  restoreFileInChangeEdit(changeNum: NumericChangeId, restore_path: string) {
    return this._getChangeURLAndSend({
      changeNum,
      method: HttpMethod.POST,
      endpoint: '/edit',
      body: {restore_path},
      reportEndpointAsIs: true,
    });
  }

  renameFileInChangeEdit(
    changeNum: NumericChangeId,
    old_path: string,
    new_path: string
  ) {
    return this._getChangeURLAndSend({
      changeNum,
      method: HttpMethod.POST,
      endpoint: '/edit',
      body: {old_path, new_path},
      reportEndpointAsIs: true,
    });
  }

  deleteFileInChangeEdit(changeNum: NumericChangeId, path: string) {
    return this._getChangeURLAndSend({
      changeNum,
      method: HttpMethod.DELETE,
      endpoint: '/edit/' + encodeURIComponent(path),
      anonymizedEndpoint: '/edit/*',
    });
  }

  saveChangeEdit(changeNum: NumericChangeId, path: string, contents: string) {
    return this._getChangeURLAndSend({
      changeNum,
      method: HttpMethod.PUT,
      endpoint: '/edit/' + encodeURIComponent(path),
      body: contents,
      contentType: 'text/plain',
      anonymizedEndpoint: '/edit/*',
    });
  }

  saveFileUploadChangeEdit(
    changeNum: NumericChangeId,
    path: string,
    content: string
  ) {
    return this._getChangeURLAndSend({
      changeNum,
      method: HttpMethod.PUT,
      endpoint: '/edit/' + encodeURIComponent(path),
      body: {binary_content: content},
      anonymizedEndpoint: '/edit/*',
    });
  }

  getRobotCommentFixPreview(
    changeNum: NumericChangeId,
    patchNum: PatchSetNum,
    fixId: FixId
  ): Promise<FilePathToDiffInfoMap | undefined> {
    return this._getChangeURLAndFetch({
      changeNum,
      revision: patchNum,
      endpoint: `/fixes/${encodeURIComponent(fixId)}/preview`,
      reportEndpointAsId: true,
    }) as Promise<FilePathToDiffInfoMap | undefined>;
  }

  applyFixSuggestion(
    changeNum: NumericChangeId,
    patchNum: PatchSetNum,
    fixId: string
  ): Promise<Response> {
    return this._getChangeURLAndSend({
      method: HttpMethod.POST,
      changeNum,
      patchNum,
      endpoint: `/fixes/${encodeURIComponent(fixId)}/apply`,
      reportEndpointAsId: true,
    });
  }

  // Deprecated, prefer to use putChangeCommitMessage instead.
  saveChangeCommitMessageEdit(changeNum: NumericChangeId, message: string) {
    return this._getChangeURLAndSend({
      changeNum,
      method: HttpMethod.PUT,
      endpoint: '/edit:message',
      body: {message},
      reportEndpointAsIs: true,
    });
  }

  publishChangeEdit(changeNum: NumericChangeId) {
    return this._getChangeURLAndSend({
      changeNum,
      method: HttpMethod.POST,
      endpoint: '/edit:publish',
      reportEndpointAsIs: true,
    });
  }

  putChangeCommitMessage(changeNum: NumericChangeId, message: string) {
    return this._getChangeURLAndSend({
      changeNum,
      method: HttpMethod.PUT,
      endpoint: '/message',
      body: {message},
      reportEndpointAsIs: true,
    });
  }

  deleteChangeCommitMessage(
    changeNum: NumericChangeId,
    messageId: ChangeMessageId
  ) {
    return this._getChangeURLAndSend({
      changeNum,
      method: HttpMethod.DELETE,
      endpoint: `/messages/${messageId}`,
      reportEndpointAsIs: true,
    });
  }

  saveChangeStarred(
    changeNum: NumericChangeId,
    starred: boolean
  ): Promise<Response> {
    // Some servers may require the project name to be provided
    // alongside the change number, so resolve the project name
    // first.
    return this.getFromProjectLookup(changeNum).then(project => {
      const encodedRepoName = project ? encodeURIComponent(project) + '~' : '';
      const url = `/accounts/self/starred.changes/${encodedRepoName}${changeNum}`;
      return this._restApiHelper.send({
        method: starred ? HttpMethod.PUT : HttpMethod.DELETE,
        url,
        anonymizedUrl: '/accounts/self/starred.changes/*',
      });
    });
  }

  saveChangeReviewed(
    changeNum: NumericChangeId,
    reviewed: boolean
  ): Promise<Response | undefined> {
    return this.getConfig().then(config => {
      const isAttentionSetEnabled =
        !!config && !!config.change && config.change.enable_attention_set;
      if (isAttentionSetEnabled) return;
      return this._getChangeURLAndSend({
        changeNum,
        method: HttpMethod.PUT,
        endpoint: reviewed ? '/reviewed' : '/unreviewed',
      });
    });
  }

  send(
    method: HttpMethod,
    url: string,
    body?: RequestPayload,
    errFn?: undefined,
    contentType?: string,
    headers?: Record<string, string>
  ): Promise<Response>;

  send(
    method: HttpMethod,
    url: string,
    body: RequestPayload | undefined,
    errFn: ErrorCallback,
    contentType?: string,
    headers?: Record<string, string>
  ): Promise<Response | undefined>;

  /**
   * Public version of the _restApiHelper.send method preserved for plugins.
   *
   * @param body passed as null sometimes
   * and also apparently a number. TODO (beckysiegel) remove need for
   * number at least.
   */
  send(
    method: HttpMethod,
    url: string,
    body?: RequestPayload,
    errFn?: ErrorCallback,
    contentType?: string,
    headers?: Record<string, string>
  ): Promise<Response | undefined> {
    return this._restApiHelper.send({
      method,
      url,
      body,
      errFn,
      contentType,
      headers,
    });
  }

  /**
   * @param basePatchNum Negative values specify merge parent
   * index.
   * @param whitespace the ignore-whitespace level for the diff
   * algorithm.
   */
  getDiff(
    changeNum: NumericChangeId,
    basePatchNum: PatchSetNum,
    patchNum: PatchSetNum,
    path: string,
    whitespace?: IgnoreWhitespaceType,
    errFn?: ErrorCallback
  ) {
    const params: GetDiffParams = {
      intraline: null,
      whitespace: whitespace || 'IGNORE_NONE',
    };
    if (isMergeParent(basePatchNum)) {
      params.parent = getParentIndex(basePatchNum);
    } else if (basePatchNum !== ParentPatchSetNum) {
      params.base = basePatchNum;
    }
    const endpoint = `/files/${encodeURIComponent(path)}/diff`;
    const req: FetchChangeJSON = {
      changeNum,
      endpoint,
      revision: patchNum,
      errFn,
      params,
      anonymizedEndpoint: '/files/*/diff',
    };

    // Invalidate the cache if its edit patch to make sure we always get latest.
    if (patchNum === EditPatchSetNum) {
      if (!req.fetchOptions) req.fetchOptions = {};
      if (!req.fetchOptions.headers) req.fetchOptions.headers = new Headers();
      req.fetchOptions.headers.append('Cache-Control', 'no-cache');
    }

    return this._getChangeURLAndFetch(req) as Promise<DiffInfo | undefined>;
  }

  getDiffComments(
    changeNum: NumericChangeId
  ): Promise<PathToCommentsInfoMap | undefined>;

  getDiffComments(
    changeNum: NumericChangeId,
    basePatchNum: BasePatchSetNum,
    patchNum: PatchSetNum,
    path: string
  ): Promise<GetDiffCommentsOutput>;

  getDiffComments(
    changeNum: NumericChangeId,
    basePatchNum?: BasePatchSetNum,
    patchNum?: PatchSetNum,
    path?: string
  ) {
    if (!basePatchNum && !patchNum && !path) {
      return this._getDiffComments(changeNum, '/comments', {
        'enable-context': true,
        'context-padding': 3,
      });
    }
    return this._getDiffComments(
      changeNum,
      '/comments',
      {'enable-context': true, 'context-padding': 3},
      basePatchNum,
      patchNum,
      path
    );
  }

  getDiffRobotComments(
    changeNum: NumericChangeId
  ): Promise<PathToRobotCommentsInfoMap | undefined>;

  getDiffRobotComments(
    changeNum: NumericChangeId,
    basePatchNum: BasePatchSetNum,
    patchNum: PatchSetNum,
    path: string
  ): Promise<GetDiffRobotCommentsOutput>;

  getDiffRobotComments(
    changeNum: NumericChangeId,
    basePatchNum?: BasePatchSetNum,
    patchNum?: PatchSetNum,
    path?: string
  ) {
    if (!basePatchNum && !patchNum && !path) {
      return this._getDiffComments(changeNum, '/robotcomments');
    }

    return this._getDiffComments(
      changeNum,
      '/robotcomments',
      undefined,
      basePatchNum,
      patchNum,
      path
    );
  }

  /**
   * If the user is logged in, fetch the user's draft diff comments. If there
   * is no logged in user, the request is not made and the promise yields an
   * empty object.
   */
  getDiffDrafts(
    changeNum: NumericChangeId
  ): Promise<PathToCommentsInfoMap | undefined>;

  getDiffDrafts(
    changeNum: NumericChangeId,
    basePatchNum: BasePatchSetNum,
    patchNum: PatchSetNum,
    path: string
  ): Promise<GetDiffCommentsOutput>;

  getDiffDrafts(
    changeNum: NumericChangeId,
    basePatchNum?: BasePatchSetNum,
    patchNum?: PatchSetNum,
    path?: string
  ) {
    return this.getLoggedIn().then(loggedIn => {
      if (!loggedIn) {
        return {};
      }
      if (!basePatchNum && !patchNum && !path) {
        return this._getDiffComments(changeNum, '/drafts');
      }
      return this._getDiffComments(
        changeNum,
        '/drafts',
        undefined,
        basePatchNum,
        patchNum,
        path
      );
    });
  }

  _setRange(comments: CommentInfo[], comment: CommentInfo) {
    if (comment.in_reply_to && !comment.range) {
      for (let i = 0; i < comments.length; i++) {
        if (comments[i].id === comment.in_reply_to) {
          comment.range = comments[i].range;
          break;
        }
      }
    }
    return comment;
  }

  _setRanges(comments?: CommentInfo[]) {
    comments = comments || [];
    comments.sort(
      (a, b) => parseDate(a.updated).valueOf() - parseDate(b.updated).valueOf()
    );
    for (const comment of comments) {
      this._setRange(comments, comment);
    }
    return comments;
  }

  _getDiffComments(
    changeNum: NumericChangeId,
    endpoint: '/comments' | '/drafts',
    params?: FetchParams
  ): Promise<PathToCommentsInfoMap | undefined>;

  _getDiffComments(
    changeNum: NumericChangeId,
    endpoint: '/robotcomments'
  ): Promise<PathToRobotCommentsInfoMap | undefined>;

  _getDiffComments(
    changeNum: NumericChangeId,
    endpoint: '/comments' | '/drafts',
    params?: FetchParams,
    basePatchNum?: BasePatchSetNum,
    patchNum?: PatchSetNum,
    path?: string
  ): Promise<GetDiffCommentsOutput>;

  _getDiffComments(
    changeNum: NumericChangeId,
    endpoint: '/robotcomments',
    params?: FetchParams,
    basePatchNum?: BasePatchSetNum,
    patchNum?: PatchSetNum,
    path?: string
  ): Promise<GetDiffRobotCommentsOutput>;

  _getDiffComments(
    changeNum: NumericChangeId,
    endpoint: string,
    params?: FetchParams,
    basePatchNum?: BasePatchSetNum,
    patchNum?: PatchSetNum,
    path?: string
  ): Promise<
    | GetDiffCommentsOutput
    | GetDiffRobotCommentsOutput
    | PathToCommentsInfoMap
    | PathToRobotCommentsInfoMap
    | undefined
  > {
    /**
     * Fetches the comments for a given patchNum.
     * Helper function to make promises more legible.
     */
    // We don't want to add accept header, since preloading of comments is
    // working only without accept header.
    const noAcceptHeader = true;
    const fetchComments = (patchNum?: PatchSetNum) =>
      this._getChangeURLAndFetch(
        {
          changeNum,
          endpoint,
          revision: patchNum,
          reportEndpointAsIs: true,
          params,
        },
        noAcceptHeader
      ) as Promise<
        PathToCommentsInfoMap | PathToRobotCommentsInfoMap | undefined
      >;

    if (!basePatchNum && !patchNum && !path) {
      return fetchComments();
    }
    function onlyParent(c: CommentInfo) {
      return c.side === CommentSide.PARENT;
    }
    function withoutParent(c: CommentInfo) {
      return c.side !== CommentSide.PARENT;
    }
    function setPath(c: CommentInfo) {
      c.path = path;
    }

    const promises = [];
    let comments: CommentInfo[];
    let baseComments: CommentInfo[];
    let fetchPromise;
    fetchPromise = fetchComments(patchNum).then(response => {
      comments = (response && path && response[path]) || [];
      // TODO(kaspern): Implement this on in the backend so this can
      // be removed.
      // Sort comments by date so that parent ranges can be propagated
      // in a single pass.
      comments = this._setRanges(comments);

      if (basePatchNum === ParentPatchSetNum) {
        baseComments = comments.filter(onlyParent);
        baseComments.forEach(setPath);
      }
      comments = comments.filter(withoutParent);

      comments.forEach(setPath);
    });
    promises.push(fetchPromise);

    if (basePatchNum !== ParentPatchSetNum) {
      fetchPromise = fetchComments(basePatchNum).then(response => {
        baseComments = ((response && path && response[path]) || []).filter(
          withoutParent
        );
        baseComments = this._setRanges(baseComments);
        baseComments.forEach(setPath);
      });
      promises.push(fetchPromise);
    }

    return Promise.all(promises).then(() =>
      Promise.resolve({
        baseComments,
        comments,
      })
    );
  }

  _getDiffCommentsFetchURL(
    changeNum: NumericChangeId,
    endpoint: string,
    patchNum?: RevisionId
  ) {
    return this._changeBaseURL(changeNum, patchNum).then(url => url + endpoint);
  }

  getPortedComments(
    changeNum: NumericChangeId,
    revision: RevisionId
  ): Promise<PathToCommentsInfoMap | undefined> {
    // maintaining a custom error function so that errors do not surface in UI
    const errFn: ErrorCallback = (response?: Response | null) => {
      if (response)
        console.info(`Fetching ported comments failed, ${response.status}`);
    };
    return this._getChangeURLAndFetch({
      changeNum,
      endpoint: '/ported_comments/',
      revision,
      errFn,
    });
  }

  getPortedDrafts(
    changeNum: NumericChangeId,
    revision: RevisionId
  ): Promise<PathToCommentsInfoMap | undefined> {
    // maintaining a custom error function so that errors do not surface in UI
    const errFn: ErrorCallback = (response?: Response | null) => {
      if (response)
        console.info(`Fetching ported drafts failed, ${response.status}`);
    };
    return this.getLoggedIn().then(loggedIn => {
      if (!loggedIn) return {};
      return this._getChangeURLAndFetch({
        changeNum,
        endpoint: '/ported_drafts/',
        revision,
        errFn,
      });
    });
  }

  saveDiffDraft(
    changeNum: NumericChangeId,
    patchNum: PatchSetNum,
    draft: CommentInput
  ) {
    return this._sendDiffDraftRequest(
      HttpMethod.PUT,
      changeNum,
      patchNum,
      draft
    );
  }

  deleteDiffDraft(
    changeNum: NumericChangeId,
    patchNum: PatchSetNum,
    draft: {id: UrlEncodedCommentId}
  ) {
    return this._sendDiffDraftRequest(
      HttpMethod.DELETE,
      changeNum,
      patchNum,
      draft
    );
  }

  /**
   * @returns Whether there are pending diff draft sends.
   */
  hasPendingDiffDrafts(): number {
    const promises = this._pendingRequests[Requests.SEND_DIFF_DRAFT];
    return promises && promises.length;
  }

  /**
   * @returns A promise that resolves when all pending
   * diff draft sends have resolved.
   */
  awaitPendingDiffDrafts(): Promise<void> {
    return Promise.all(
      this._pendingRequests[Requests.SEND_DIFF_DRAFT] || []
    ).then(() => {
      this._pendingRequests[Requests.SEND_DIFF_DRAFT] = [];
    });
  }

  _sendDiffDraftRequest(
    method: HttpMethod.PUT,
    changeNum: NumericChangeId,
    patchNum: PatchSetNum,
    draft: CommentInput
  ): Promise<Response>;

  _sendDiffDraftRequest(
    method: HttpMethod.GET | HttpMethod.DELETE,
    changeNum: NumericChangeId,
    patchNum: PatchSetNum,
    draft: {id?: UrlEncodedCommentId}
  ): Promise<Response>;

  _sendDiffDraftRequest(
    method: HttpMethod,
    changeNum: NumericChangeId,
    patchNum: PatchSetNum,
    draft: CommentInput | {id: UrlEncodedCommentId}
  ): Promise<Response> {
    const isCreate = !draft.id && method === HttpMethod.PUT;
    let endpoint = '/drafts';
    let anonymizedEndpoint = endpoint;
    if (draft.id) {
      endpoint += `/${draft.id}`;
      anonymizedEndpoint += '/*';
    }
    let body;
    if (method === HttpMethod.PUT) {
      body = draft;
    }

    if (!this._pendingRequests[Requests.SEND_DIFF_DRAFT]) {
      this._pendingRequests[Requests.SEND_DIFF_DRAFT] = [];
    }

    const req = {
      changeNum,
      method,
      patchNum,
      endpoint,
      body,
      anonymizedEndpoint,
    };

    const promise = this._getChangeURLAndSend(req);
    this._pendingRequests[Requests.SEND_DIFF_DRAFT].push(promise);

    if (isCreate) {
      return this._failForCreate200(promise);
    }

    return promise;
  }

  getCommitInfo(
    project: RepoName,
    commit: CommitId
  ): Promise<CommitInfo | undefined> {
    return this._restApiHelper.fetchJSON({
      url:
        '/projects/' +
        encodeURIComponent(project) +
        '/commits/' +
        encodeURIComponent(commit),
      anonymizedUrl: '/projects/*/comments/*',
    }) as Promise<CommitInfo | undefined>;
  }

  _fetchB64File(url: string): Promise<Base64File> {
    return this._restApiHelper
      .fetch({url: getBaseUrl() + url})
      .then(response => {
        if (!response.ok) {
          return Promise.reject(new Error(response.statusText));
        }
        const type = response.headers.get('X-FYI-Content-Type');
        return response.text().then(text => {
          return {body: text, type};
        });
      });
  }

  getB64FileContents(
    changeId: NumericChangeId,
    patchNum: RevisionId,
    path: string,
    parentIndex?: number
  ) {
    const parent =
      typeof parentIndex === 'number' ? `?parent=${parentIndex}` : '';
    return this._changeBaseURL(changeId, patchNum).then(url => {
      url = `${url}/files/${encodeURIComponent(path)}/content${parent}`;
      return this._fetchB64File(url);
    });
  }

  getImagesForDiff(
    changeNum: NumericChangeId,
    diff: DiffInfo,
    patchRange: PatchRange
  ): Promise<ImagesForDiff> {
    let promiseA;
    let promiseB;

    if (diff.meta_a?.content_type.startsWith('image/')) {
      if (patchRange.basePatchNum === ParentPatchSetNum) {
        // Note: we only attempt to get the image from the first parent.
        promiseA = this.getB64FileContents(
          changeNum,
          patchRange.patchNum,
          diff.meta_a.name,
          1
        );
      } else {
        promiseA = this.getB64FileContents(
          changeNum,
          patchRange.basePatchNum,
          diff.meta_a.name
        );
      }
    } else {
      promiseA = Promise.resolve(null);
    }

    if (diff.meta_b?.content_type.startsWith('image/')) {
      promiseB = this.getB64FileContents(
        changeNum,
        patchRange.patchNum,
        diff.meta_b.name
      );
    } else {
      promiseB = Promise.resolve(null);
    }

    return Promise.all([promiseA, promiseB]).then(results => {
      // Sometimes the server doesn't send back the content type.
      const baseImage: Base64ImageFile | null = results[0]
        ? {
            ...results[0],
            _expectedType: diff.meta_a.content_type,
            _name: diff.meta_a.name,
          }
        : null;
      const revisionImage: Base64ImageFile | null = results[1]
        ? {
            ...results[1],
            _expectedType: diff.meta_b.content_type,
            _name: diff.meta_b.name,
          }
        : null;
      const imagesForDiff: ImagesForDiff = {baseImage, revisionImage};
      return imagesForDiff;
    });
  }

  _changeBaseURL(
    changeNum: NumericChangeId,
    revisionId?: RevisionId,
    project?: RepoName
  ): Promise<string> {
    // TODO(kaspern): For full slicer migration, app should warn with a call
    // stack every time _changeBaseURL is called without a project.
    const projectPromise = project
      ? Promise.resolve(project)
      : this.getFromProjectLookup(changeNum);
    return projectPromise.then(project => {
      // TODO(TS): unclear why project can't be null here. Fix it
      let url = `/changes/${encodeURIComponent(
        project as RepoName
      )}~${changeNum}`;
      if (revisionId) {
        url += `/revisions/${revisionId}`;
      }
      return url;
    });
  }

  addToAttentionSet(
    changeNum: NumericChangeId,
    user: AccountId | undefined | null,
    reason: string
  ) {
    return this._getChangeURLAndSend({
      changeNum,
      method: HttpMethod.POST,
      endpoint: '/attention',
      body: {user, reason},
      reportUrlAsIs: true,
    });
  }

  removeFromAttentionSet(
    changeNum: NumericChangeId,
    user: AccountId,
    reason: string
  ) {
    return this._getChangeURLAndSend({
      changeNum,
      method: HttpMethod.DELETE,
      endpoint: `/attention/${user}`,
      anonymizedEndpoint: '/attention/*',
      body: {reason},
    });
  }

  setChangeTopic(changeNum: NumericChangeId, topic?: string): Promise<string> {
    return (this._getChangeURLAndSend({
      changeNum,
      method: HttpMethod.PUT,
      endpoint: '/topic',
      body: {topic},
      parseResponse: true,
      reportUrlAsIs: true,
    }) as unknown) as Promise<string>;
  }

  setChangeHashtag(
    changeNum: NumericChangeId,
    hashtag: HashtagsInput
  ): Promise<Hashtag[]> {
    return (this._getChangeURLAndSend({
      changeNum,
      method: HttpMethod.POST,
      endpoint: '/hashtags',
      body: hashtag,
      parseResponse: true,
      reportUrlAsIs: true,
    }) as unknown) as Promise<Hashtag[]>;
  }

  deleteAccountHttpPassword() {
    return this._restApiHelper.send({
      method: HttpMethod.DELETE,
      url: '/accounts/self/password.http',
      reportUrlAsIs: true,
    });
  }

  generateAccountHttpPassword(): Promise<Password> {
    return (this._restApiHelper.send({
      method: HttpMethod.PUT,
      url: '/accounts/self/password.http',
      body: {generate: true},
      parseResponse: true,
      reportUrlAsIs: true,
    }) as Promise<unknown>) as Promise<Password>;
  }

  getAccountSSHKeys() {
    return (this._fetchSharedCacheURL({
      url: '/accounts/self/sshkeys',
      reportUrlAsIs: true,
    }) as Promise<unknown>) as Promise<SshKeyInfo[] | undefined>;
  }

  addAccountSSHKey(key: string): Promise<SshKeyInfo> {
    const req = {
      method: HttpMethod.POST,
      url: '/accounts/self/sshkeys',
      body: key,
      contentType: 'text/plain',
      reportUrlAsIs: true,
    };
    return this._restApiHelper
      .send(req)
      .then((response: Response | undefined) => {
        if (!response || (response.status < 200 && response.status >= 300)) {
          return Promise.reject(new Error('error'));
        }
        return (this.getResponseObject(
          response
        ) as unknown) as Promise<SshKeyInfo>;
      })
      .then(obj => {
        if (!obj || !obj.valid) {
          return Promise.reject(new Error('error'));
        }
        return obj;
      });
  }

  deleteAccountSSHKey(id: string) {
    return this._restApiHelper.send({
      method: HttpMethod.DELETE,
      url: '/accounts/self/sshkeys/' + id,
      anonymizedUrl: '/accounts/self/sshkeys/*',
    });
  }

  getAccountGPGKeys() {
    return (this._restApiHelper.fetchJSON({
      url: '/accounts/self/gpgkeys',
      reportUrlAsIs: true,
    }) as Promise<unknown>) as Promise<Record<string, GpgKeyInfo>>;
  }

  addAccountGPGKey(key: GpgKeysInput) {
    const req = {
      method: HttpMethod.POST,
      url: '/accounts/self/gpgkeys',
      body: key,
      reportUrlAsIs: true,
    };
    return this._restApiHelper
      .send(req)
      .then(response => {
        if (!response || (response.status < 200 && response.status >= 300)) {
          return Promise.reject(new Error('error'));
        }
        return this.getResponseObject(response);
      })
      .then(obj => {
        if (!obj) {
          return Promise.reject(new Error('error'));
        }
        return obj;
      });
  }

  deleteAccountGPGKey(id: GpgKeyId) {
    return this._restApiHelper.send({
      method: HttpMethod.DELETE,
      url: `/accounts/self/gpgkeys/${id}`,
      anonymizedUrl: '/accounts/self/gpgkeys/*',
    });
  }

  deleteVote(changeNum: NumericChangeId, account: AccountId, label: string) {
    return this._getChangeURLAndSend({
      changeNum,
      method: HttpMethod.DELETE,
      endpoint: `/reviewers/${account}/votes/${encodeURIComponent(label)}`,
      anonymizedEndpoint: '/reviewers/*/votes/*',
    });
  }

  setDescription(
    changeNum: NumericChangeId,
    patchNum: PatchSetNum,
    desc: string
  ) {
    return this._getChangeURLAndSend({
      changeNum,
      method: HttpMethod.PUT,
      patchNum,
      endpoint: '/description',
      body: {description: desc},
      reportUrlAsIs: true,
    });
  }

  confirmEmail(token: string): Promise<string | null> {
    const req = {
      method: HttpMethod.PUT,
      url: '/config/server/email.confirm',
      body: {token},
      reportUrlAsIs: true,
    };
    return this._restApiHelper.send(req).then(response => {
      if (response?.status === 204) {
        return 'Email confirmed successfully.';
      }
      return null;
    });
  }

  getCapabilities(
    errFn?: ErrorCallback
  ): Promise<CapabilityInfoMap | undefined> {
    return this._restApiHelper.fetchJSON({
      url: '/config/server/capabilities',
      errFn,
      reportUrlAsIs: true,
    }) as Promise<CapabilityInfoMap | undefined>;
  }

  getTopMenus(): Promise<TopMenuEntryInfo[] | undefined> {
    return this._fetchSharedCacheURL({
      url: '/config/server/top-menus',
      reportUrlAsIs: true,
    }) as Promise<TopMenuEntryInfo[] | undefined>;
  }

  setAssignee(
    changeNum: NumericChangeId,
    assignee: AccountId
  ): Promise<Response> {
    const body: AssigneeInput = {assignee};
    return this._getChangeURLAndSend({
      changeNum,
      method: HttpMethod.PUT,
      endpoint: '/assignee',
      body,
      reportUrlAsIs: true,
    });
  }

  deleteAssignee(changeNum: NumericChangeId): Promise<Response> {
    return this._getChangeURLAndSend({
      changeNum,
      method: HttpMethod.DELETE,
      endpoint: '/assignee',
      reportUrlAsIs: true,
    });
  }

  probePath(path: string) {
    return fetch(new Request(path, {method: HttpMethod.HEAD})).then(
      response => response.ok
    );
  }

  startWorkInProgress(
    changeNum: NumericChangeId,
    message?: string
  ): Promise<string | undefined> {
    const body = message ? {message} : {};
    const req: SendRawChangeRequest = {
      changeNum,
      method: HttpMethod.POST,
      endpoint: '/wip',
      body,
      reportUrlAsIs: true,
    };
    return this._getChangeURLAndSend(req).then(response => {
      if (response?.status === 204) {
        return 'Change marked as Work In Progress.';
      }
      return undefined;
    });
  }

  deleteComment(
    changeNum: NumericChangeId,
    patchNum: PatchSetNum,
    commentID: UrlEncodedCommentId,
    reason: string
  ) {
    return (this._getChangeURLAndSend({
      changeNum,
      method: HttpMethod.POST,
      patchNum,
      endpoint: `/comments/${commentID}/delete`,
      body: {reason},
      parseResponse: true,
      anonymizedEndpoint: '/comments/*/delete',
    }) as unknown) as Promise<CommentInfo>;
  }

  /**
   * Given a changeNum, gets the change.
   */
  getChange(
    changeNum: ChangeId | NumericChangeId,
    errFn: ErrorCallback
  ): Promise<ChangeInfo | null> {
    // Cannot use _changeBaseURL, as this function is used by _projectLookup.
    return this._restApiHelper
      .fetchJSON({
        url: `/changes/?q=change:${changeNum}`,
        errFn,
        anonymizedUrl: '/changes/?q=change:*',
      })
      .then(res => {
        const changeInfos = res as ChangeInfo[] | undefined;
        if (!changeInfos || !changeInfos.length) {
          return null;
        }
        return changeInfos[0];
      });
  }

  setInProjectLookup(changeNum: NumericChangeId, project: RepoName) {
    if (
      this._projectLookup[changeNum] &&
      this._projectLookup[changeNum] !== project
    ) {
      console.warn(
        'Change set with multiple project nums.' +
          'One of them must be invalid.'
      );
    }
    this._projectLookup[changeNum] = project;
  }

  /**
   * Checks in _projectLookup for the changeNum. If it exists, returns the
   * project. If not, calls the restAPI to get the change, populates
   * _projectLookup with the project for that change, and returns the project.
   */
  getFromProjectLookup(
    changeNum: NumericChangeId
  ): Promise<RepoName | undefined> {
    const project = this._projectLookup[`${changeNum}`];
    if (project) {
      return Promise.resolve(project);
    }

    const onError = (response?: Response | null) => firePageError(response);

    return this.getChange(changeNum, onError).then(change => {
      if (!change || !change.project) {
        return;
      }
      this.setInProjectLookup(changeNum, change.project);
      return change.project;
    });
  }

  // if errFn is not set, then only Response possible
  _getChangeURLAndSend(
    req: SendRawChangeRequest & {errFn?: undefined}
  ): Promise<Response>;

  _getChangeURLAndSend(
    req: SendRawChangeRequest
  ): Promise<Response | undefined>;

  _getChangeURLAndSend(req: SendJSONChangeRequest): Promise<ParsedJSON>;

  /**
   * Alias for _changeBaseURL.then(send).
   */
  _getChangeURLAndSend(
    req: SendChangeRequest
  ): Promise<ParsedJSON | Response | undefined> {
    const anonymizedBaseUrl = req.patchNum
      ? ANONYMIZED_REVISION_BASE_URL
      : ANONYMIZED_CHANGE_BASE_URL;
    const anonymizedEndpoint = req.reportEndpointAsIs
      ? req.endpoint
      : req.anonymizedEndpoint;

    return this._changeBaseURL(req.changeNum, req.patchNum).then(url => {
      const request: SendRequest = {
        method: req.method,
        url: url + req.endpoint,
        body: req.body,
        errFn: req.errFn,
        contentType: req.contentType,
        headers: req.headers,
        parseResponse: req.parseResponse,
        anonymizedUrl: anonymizedEndpoint
          ? `${anonymizedBaseUrl}${anonymizedEndpoint}`
          : undefined,
      };
      return this._restApiHelper.send(request);
    });
  }

  /**
   * Alias for _changeBaseURL.then(_fetchJSON).
   */
  _getChangeURLAndFetch(
    req: FetchChangeJSON,
    noAcceptHeader?: boolean
  ): Promise<ParsedJSON | undefined> {
    const anonymizedEndpoint = req.reportEndpointAsIs
      ? req.endpoint
      : req.anonymizedEndpoint;
    const anonymizedBaseUrl = req.revision
      ? ANONYMIZED_REVISION_BASE_URL
      : ANONYMIZED_CHANGE_BASE_URL;
    return this._changeBaseURL(req.changeNum, req.revision).then(url =>
      this._restApiHelper.fetchJSON(
        {
          url: url + req.endpoint,
          errFn: req.errFn,
          params: req.params,
          fetchOptions: req.fetchOptions,
          anonymizedUrl: anonymizedEndpoint
            ? anonymizedBaseUrl + anonymizedEndpoint
            : undefined,
        },
        noAcceptHeader
      )
    );
  }

  executeChangeAction(
    changeNum: NumericChangeId,
    method: HttpMethod | undefined,
    endpoint: string,
    patchNum?: PatchSetNum,
    payload?: RequestPayload
  ): Promise<Response>;

  executeChangeAction(
    changeNum: NumericChangeId,
    method: HttpMethod | undefined,
    endpoint: string,
    patchNum: PatchSetNum | undefined,
    payload: RequestPayload | undefined,
    errFn: ErrorCallback
  ): Promise<Response | undefined>;

  /**
   * Execute a change action or revision action on a change.
   */
  executeChangeAction(
    changeNum: NumericChangeId,
    method: HttpMethod | undefined,
    endpoint: string,
    patchNum?: PatchSetNum,
    payload?: RequestPayload,
    errFn?: ErrorCallback
  ) {
    return this._getChangeURLAndSend({
      changeNum,
      method,
      patchNum,
      endpoint,
      body: payload,
      errFn,
    });
  }

  /**
   * Get blame information for the given diff.
   *
   * @param base If true, requests blame for the base of the
   *     diff, rather than the revision.
   */
  getBlame(
    changeNum: NumericChangeId,
    patchNum: PatchSetNum,
    path: string,
    base?: boolean
  ) {
    const encodedPath = encodeURIComponent(path);
    return this._getChangeURLAndFetch({
      changeNum,
      endpoint: `/files/${encodedPath}/blame`,
      revision: patchNum,
      params: base ? {base: 't'} : undefined,
      anonymizedEndpoint: '/files/*/blame',
    }) as Promise<BlameInfo[] | undefined>;
  }

  /**
   * Modify the given create draft request promise so that it fails and throws
   * an error if the response bears HTTP status 200 instead of HTTP 201.
   *
   * @see Issue 7763
   * @param promise The original promise.
   * @return The modified promise.
   */
  _failForCreate200(promise: Promise<Response>): Promise<Response> {
    return promise.then(result => {
      if (result.status === 200) {
        // Read the response headers into an object representation.
        const headers = Array.from(result.headers.entries()).reduce(
          (obj, [key, val]) => {
            if (!HEADER_REPORTING_BLOCK_REGEX.test(key)) {
              obj[key] = val;
            }
            return obj;
          },
          {} as Record<string, string>
        );
        const err = new Error(
          [
            CREATE_DRAFT_UNEXPECTED_STATUS_MESSAGE,
            JSON.stringify(headers),
          ].join('\n')
        );
        // Throw the error so that it is caught by gr-reporting.
        throw err;
      }
      return result;
    });
  }

  /**
   * Fetch a project dashboard definition.
   * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-dashboard
   */
  getDashboard(
    project: RepoName,
    dashboard: DashboardId,
    errFn?: ErrorCallback
  ): Promise<DashboardInfo | undefined> {
    const url =
      '/projects/' +
      encodeURIComponent(project) +
      '/dashboards/' +
      encodeURIComponent(dashboard);
    return this._fetchSharedCacheURL({
      url,
      errFn,
      anonymizedUrl: '/projects/*/dashboards/*',
    }) as Promise<DashboardInfo | undefined>;
  }

  getDocumentationSearches(filter: string): Promise<DocResult[] | undefined> {
    filter = filter.trim();
    const encodedFilter = encodeURIComponent(filter);

    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
    // supports it.
    return this._fetchSharedCacheURL({
      url: `/Documentation/?q=${encodedFilter}`,
      anonymizedUrl: '/Documentation/?*',
    }) as Promise<DocResult[] | undefined>;
  }

  getMergeable(changeNum: NumericChangeId) {
    return this._getChangeURLAndFetch({
      changeNum,
      endpoint: '/revisions/current/mergeable',
      reportEndpointAsIs: true,
    }) as Promise<MergeableInfo | undefined>;
  }

  deleteDraftComments(query: string): Promise<Response> {
    const body: DeleteDraftCommentsInput = {query};
    return this._restApiHelper.send({
      method: HttpMethod.POST,
      url: '/accounts/self/drafts:delete',
      body,
    });
  }
}
