/**
 * @license
 * Copyright (C) 2016 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {
  page,
  PageContext,
  PageNextCallback,
} from '../../../utils/page-wrapper-utils';
import {htmlTemplate} from './gr-router_html';
import {encodeURL, getBaseUrl} from '../../../utils/url-util';
import {
  DashboardSection,
  GenerateUrlChangeViewParameters,
  GenerateUrlDashboardViewParameters,
  GenerateUrlDiffViewParameters,
  GenerateUrlEditViewParameters,
  GenerateUrlGroupViewParameters,
  GenerateUrlParameters,
  GenerateUrlRepoViewParameters,
  GenerateUrlSearchViewParameters,
  GenerateWebLinksChangeParameters,
  GenerateWebLinksFileParameters,
  GenerateWebLinksParameters,
  GenerateWebLinksPatchsetParameters,
  GerritView,
  isGenerateUrlDiffViewParameters,
  RepoDetailView,
  WeblinkType,
  GroupDetailView,
  GerritNav,
  GeneratedWebLink,
} from '../gr-navigation/gr-navigation';
import {appContext} from '../../../services/app-context';
import {
  patchNumEquals,
  convertToPatchSetNum,
} from '../../../utils/patch-set-util';
import {customElement, property} from '@polymer/decorators';
import {assertNever} from '../../../utils/common-util';
import {
  DashboardId,
  GroupId,
  NumericChangeId,
  PatchSetNum,
  RepoName,
  ServerInfo,
  UrlEncodedCommentId,
} from '../../../types/common';
import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
import {
  AppElement,
  AppElementParams,
  AppElementAgreementParam,
} from '../../gr-app-types';
import {LocationChangeEventDetail} from '../../../types/events';

const RoutePattern = {
  ROOT: '/',

  DASHBOARD: /^\/dashboard\/(.+)$/,
  CUSTOM_DASHBOARD: /^\/dashboard\/?$/,
  PROJECT_DASHBOARD: /^\/p\/(.+)\/\+\/dashboard\/(.+)/,
  LEGACY_PROJECT_DASHBOARD: /^\/projects\/(.+),dashboards\/(.+)/,

  AGREEMENTS: /^\/settings\/agreements\/?/,
  NEW_AGREEMENTS: /^\/settings\/new-agreement\/?/,
  REGISTER: /^\/register(\/.*)?$/,

  // Pattern for login and logout URLs intended to be passed-through. May
  // include a return URL.
  LOG_IN_OR_OUT: /\/log(in|out)(\/(.+))?$/,

  // Pattern for a catchall route when no other pattern is matched.
  DEFAULT: /.*/,

  // Matches /admin/groups/[uuid-]<group>
  GROUP: /^\/admin\/groups\/(?:uuid-)?([^,]+)$/,

  // Redirects /groups/self to /settings/#Groups for GWT compatibility
  GROUP_SELF: /^\/groups\/self/,

  // Matches /admin/groups/[uuid-]<group>,info (backwords compat with gwtui)
  // Redirects to /admin/groups/[uuid-]<group>
  GROUP_INFO: /^\/admin\/groups\/(?:uuid-)?(.+),info$/,

  // Matches /admin/groups/<group>,audit-log
  GROUP_AUDIT_LOG: /^\/admin\/groups\/(?:uuid-)?(.+),audit-log$/,

  // Matches /admin/groups/[uuid-]<group>,members
  GROUP_MEMBERS: /^\/admin\/groups\/(?:uuid-)?(.+),members$/,

  // Matches /admin/groups[,<offset>][/].
  GROUP_LIST_OFFSET: /^\/admin\/groups(,(\d+))?(\/)?$/,
  GROUP_LIST_FILTER: '/admin/groups/q/filter::filter',
  GROUP_LIST_FILTER_OFFSET: '/admin/groups/q/filter::filter,:offset',

  // Matches /admin/create-project
  LEGACY_CREATE_PROJECT: /^\/admin\/create-project\/?$/,

  // Matches /admin/create-project
  LEGACY_CREATE_GROUP: /^\/admin\/create-group\/?$/,

  PROJECT_OLD: /^\/admin\/(projects)\/?(.+)?$/,

  // Matches /admin/repos/<repo>
  REPO: /^\/admin\/repos\/([^,]+)$/,

  // Matches /admin/repos/<repo>,commands.
  REPO_COMMANDS: /^\/admin\/repos\/(.+),commands$/,

  // Matches /admin/repos/<repos>,access.
  REPO_ACCESS: /^\/admin\/repos\/(.+),access$/,

  // Matches /admin/repos/<repos>,access.
  REPO_DASHBOARDS: /^\/admin\/repos\/(.+),dashboards$/,

  // Matches /admin/repos[,<offset>][/].
  REPO_LIST_OFFSET: /^\/admin\/repos(,(\d+))?(\/)?$/,
  REPO_LIST_FILTER: '/admin/repos/q/filter::filter',
  REPO_LIST_FILTER_OFFSET: '/admin/repos/q/filter::filter,:offset',

  // Matches /admin/repos/<repo>,branches[,<offset>].
  BRANCH_LIST_OFFSET: /^\/admin\/repos\/(.+),branches(,(.+))?$/,
  BRANCH_LIST_FILTER: '/admin/repos/:repo,branches/q/filter::filter',
  BRANCH_LIST_FILTER_OFFSET:
    '/admin/repos/:repo,branches/q/filter::filter,:offset',

  // Matches /admin/repos/<repo>,tags[,<offset>].
  TAG_LIST_OFFSET: /^\/admin\/repos\/(.+),tags(,(.+))?$/,
  TAG_LIST_FILTER: '/admin/repos/:repo,tags/q/filter::filter',
  TAG_LIST_FILTER_OFFSET: '/admin/repos/:repo,tags/q/filter::filter,:offset',

  PLUGINS: /^\/plugins\/(.+)$/,

  PLUGIN_LIST: /^\/admin\/plugins(\/)?$/,

  // Matches /admin/plugins[,<offset>][/].
  PLUGIN_LIST_OFFSET: /^\/admin\/plugins(,(\d+))?(\/)?$/,
  PLUGIN_LIST_FILTER: '/admin/plugins/q/filter::filter',
  PLUGIN_LIST_FILTER_OFFSET: '/admin/plugins/q/filter::filter,:offset',

  QUERY: /^\/q\/([^,]+)(,(\d+))?$/,

  /**
   * Support vestigial params from GWT UI.
   *
   * @see Issue 7673.
   * @type {!RegExp}
   */
  QUERY_LEGACY_SUFFIX: /^\/q\/.+,n,z$/,

  CHANGE_ID_QUERY: /^\/id\/(I[0-9a-f]{40})$/,

  // Matches /c/<changeNum>/[<basePatchNum>..][<patchNum>][/].
  CHANGE_LEGACY: /^\/c\/(\d+)\/?(((-?\d+|edit)(\.\.(\d+|edit))?))?\/?$/,
  CHANGE_NUMBER_LEGACY: /^\/(\d+)\/?/,

  // Matches
  // /c/<project>/+/<changeNum>/[<basePatchNum|edit>..][<patchNum|edit>].
  // TODO(kaspern): Migrate completely to project based URLs, with backwards
  // compatibility for change-only.
  CHANGE: /^\/c\/(.+)\/\+\/(\d+)(\/?((-?\d+|edit)(\.\.(\d+|edit))?))?\/?$/,

  // Matches /c/<project>/+/<changeNum>/[<patchNum|edit>],edit
  CHANGE_EDIT: /^\/c\/(.+)\/\+\/(\d+)(\/(\d+))?,edit\/?$/,

  // Matches /c/<project>/+/<changeNum>/comment/<commentId>/
  // Navigates to the diff view
  // This route is needed to resolve to patchNum vs latestPatchNum used in the
  // links generated in the emails.
  COMMENT: /^\/c\/(.+)\/\+\/(\d+)\/comment\/(\w+)\/?$/,

  // Matches
  // /c/<project>/+/<changeNum>/[<basePatchNum|edit>..]<patchNum|edit>/<path>.
  // TODO(kaspern): Migrate completely to project based URLs, with backwards
  // compatibility for change-only.
  // eslint-disable-next-line max-len
  DIFF: /^\/c\/(.+)\/\+\/(\d+)(\/((-?\d+|edit)(\.\.(\d+|edit))?(\/(.+))))\/?$/,

  // Matches /c/<project>/+/<changeNum>/[<patchNum|edit>]/<path>,edit[#lineNum]
  DIFF_EDIT: /^\/c\/(.+)\/\+\/(\d+)\/(\d+|edit)\/(.+),edit(#\d+)?$/,

  // Matches non-project-relative
  // /c/<changeNum>/[<basePatchNum>..]<patchNum>/<path>.
  DIFF_LEGACY: /^\/c\/(\d+)\/((-?\d+|edit)(\.\.(\d+|edit))?)\/(.+)/,

  // Matches diff routes using @\d+ to specify a file name (whether or not
  // the project name is included).
  // eslint-disable-next-line max-len
  DIFF_LEGACY_LINENUM: /^\/c\/((.+)\/\+\/)?(\d+)(\/?((-?\d+|edit)(\.\.(\d+|edit))?\/(.+))?)@[ab]?\d+$/,

  SETTINGS: /^\/settings\/?/,
  SETTINGS_LEGACY: /^\/settings\/VE\/(\S+)/,

  // Matches /c/<changeNum>/ /<URL tail>
  // Catches improperly encoded URLs (context: Issue 7100)
  IMPROPERLY_ENCODED_PLUS: /^\/c\/(.+)\/ \/(.+)$/,

  PLUGIN_SCREEN: /^\/x\/([\w-]+)\/([\w-]+)\/?/,

  DOCUMENTATION_SEARCH_FILTER: '/Documentation/q/filter::filter',
  DOCUMENTATION_SEARCH: /^\/Documentation\/q\/(.*)$/,
  DOCUMENTATION: /^\/Documentation(\/)?(.+)?/,
};

export const _testOnly_RoutePattern = RoutePattern;

/**
 * Pattern to recognize and parse the diff line locations as they appear in
 * the hash of diff URLs. In this format, a number on its own indicates that
 * line number in the revision of the diff. A number prefixed by either an 'a'
 * or a 'b' indicates that line number of the base of the diff.
 *
 * @type {RegExp}
 */
const LINE_ADDRESS_PATTERN = /^([ab]?)(\d+)$/;

/**
 * Pattern to recognize '+' in url-encoded strings for replacement with ' '.
 */
const PLUS_PATTERN = /\+/g;

/**
 * Pattern to recognize leading '?' in window.location.search, for stripping.
 */
const QUESTION_PATTERN = /^\?*/;

/**
 * GWT UI would use @\d+ at the end of a path to indicate linenum.
 */
const LEGACY_LINENUM_PATTERN = /@([ab]?\d+)$/;

const LEGACY_QUERY_SUFFIX_PATTERN = /,n,z$/;

const REPO_TOKEN_PATTERN = /\${(project|repo)}/g;

// Polymer makes `app` intrinsically defined on the window by virtue of the
// custom element having the id "app", but it is made explicit here.
// If you move this code to other place, please update comment about
// gr-router and gr-app in the PolyGerritIndexHtml.soy file if needed
const app = document.querySelector('#app');
if (!app) {
  console.info('No gr-app found (running tests)');
}

// Setup listeners outside of the router component initialization.
(function () {
  window.addEventListener('WebComponentsReady', () => {
    appContext.reportingService.timeEnd('WebComponentsReady');
  });
})();

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

export interface PageContextWithQueryMap extends PageContext {
  queryMap: Map<string, string> | URLSearchParams;
}

type QueryStringItem = [string, string]; // [key, value]

type GenerateUrlLegacyChangeViewParameters = Omit<
  GenerateUrlChangeViewParameters,
  'project'
>;
type GenerateUrlLegacyDiffViewParameters = Omit<
  GenerateUrlDiffViewParameters,
  'project'
>;

interface PatchRangeParams {
  patchNum?: PatchSetNum | null;
  basePatchNum?: PatchSetNum | null;
}

@customElement('gr-router')
export class GrRouter extends GestureEventListeners(
  LegacyElementMixin(PolymerElement)
) {
  static get template() {
    return htmlTemplate;
  }

  @property({type: Object})
  readonly _app = app;

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

  // This variable is to differentiate between internal navigation (false)
  // and for first navigation in app after loaded from server (true).
  @property({type: Boolean})
  _isInitialLoad = true;

  private readonly reporting = appContext.reportingService;

  constructor() {
    super();
  }

  start() {
    if (!this._app) {
      return;
    }
    this._startRouter();
  }

  _setParams(params: AppElementParams | GenerateUrlParameters) {
    this._appElement().params = params;
  }

  _appElement(): AppElement {
    // In Polymer2 you have to reach through the shadow root of the app
    // element. This obviously breaks encapsulation.
    // TODO(brohlfs): Make this more elegant, e.g. by exposing app-element
    // explicitly in app, or by delegating to it.

    // It is expected that application has a GrAppElement(id=='app-element')
    // at the document level or inside the shadow root of the GrApp (id='app')
    // element.
    return (document.getElementById('app-element') ||
      document
        .getElementById('app')!
        .shadowRoot!.getElementById('app-element')!) as AppElement;
  }

  _redirect(url: string) {
    this._isRedirecting = true;
    page.redirect(url);
  }

  _generateUrl(params: GenerateUrlParameters) {
    const base = getBaseUrl();
    let url = '';

    if (params.view === GerritView.SEARCH) {
      url = this._generateSearchUrl(params);
    } else if (params.view === GerritView.CHANGE) {
      url = this._generateChangeUrl(params);
    } else if (params.view === GerritView.DASHBOARD) {
      url = this._generateDashboardUrl(params);
    } else if (
      params.view === GerritView.DIFF ||
      params.view === GerritView.EDIT
    ) {
      url = this._generateDiffOrEditUrl(params);
    } else if (params.view === GerritView.GROUP) {
      url = this._generateGroupUrl(params);
    } else if (params.view === GerritView.REPO) {
      url = this._generateRepoUrl(params);
    } else if (params.view === GerritView.ROOT) {
      url = '/';
    } else if (params.view === GerritView.SETTINGS) {
      url = this._generateSettingsUrl();
    } else {
      assertNever(params, "Can't generate");
    }

    return base + url;
  }

  _generateWeblinks(
    params: GenerateWebLinksParameters
  ): GeneratedWebLink[] | GeneratedWebLink {
    switch (params.type) {
      case WeblinkType.FILE:
        return this._getFileWebLinks(params);
      case WeblinkType.CHANGE:
        return this._getChangeWeblinks(params);
      case WeblinkType.PATCHSET:
        return this._getPatchSetWeblink(params);
      default:
        console.warn(`Unsupported weblink ${(params as any).type}!`);
        // TODO(TS): use assertNever(params.type)
        return [];
    }
  }

  _getPatchSetWeblink(
    params: GenerateWebLinksPatchsetParameters
  ): GeneratedWebLink {
    const {commit, options} = params;
    const {weblinks, config} = options || {};
    const name = commit && commit.slice(0, 7);
    const weblink = this._getBrowseCommitWeblink(weblinks, config);
    if (!weblink || !weblink.url) {
      return {name};
    } else {
      return {name, url: weblink.url};
    }
  }

  _firstCodeBrowserWeblink(weblinks: GeneratedWebLink[]) {
    // This is an ordered allowed list of web link types that provide direct
    // links to the commit in the url property.
    const codeBrowserLinks = ['gitiles', 'browse', 'gitweb'];
    for (let i = 0; i < codeBrowserLinks.length; i++) {
      const weblink = weblinks.find(
        weblink => weblink.name === codeBrowserLinks[i]
      );
      if (weblink) {
        return weblink;
      }
    }
    return null;
  }

  _getBrowseCommitWeblink(weblinks?: GeneratedWebLink[], config?: ServerInfo) {
    if (!weblinks) {
      return null;
    }
    let weblink;
    // Use primary weblink if configured and exists.
    if (config?.gerrit?.primary_weblink_name) {
      const primaryWeblinkName = config.gerrit.primary_weblink_name;
      weblink = weblinks.find(weblink => weblink.name === primaryWeblinkName);
    }
    if (!weblink) {
      weblink = this._firstCodeBrowserWeblink(weblinks);
    }
    if (!weblink) {
      return null;
    }
    return weblink;
  }

  _getChangeWeblinks(
    params: GenerateWebLinksChangeParameters
  ): GeneratedWebLink[] {
    const weblinks = params.options?.weblinks;
    const config = params.options?.config;
    if (!weblinks || !weblinks.length) return [];
    const commitWeblink = this._getBrowseCommitWeblink(weblinks, config);
    return weblinks.filter(
      weblink =>
        !commitWeblink ||
        !commitWeblink.name ||
        weblink.name !== commitWeblink.name
    );
  }

  _getFileWebLinks(params: GenerateWebLinksFileParameters): GeneratedWebLink[] {
    return params.options?.weblinks || [];
  }

  _generateSearchUrl(params: GenerateUrlSearchViewParameters) {
    let offsetExpr = '';
    if (params.offset && params.offset > 0) {
      offsetExpr = `,${params.offset}`;
    }

    if (params.query) {
      return '/q/' + encodeURL(params.query, true) + offsetExpr;
    }

    const operators: string[] = [];
    if (params.owner) {
      operators.push('owner:' + encodeURL(params.owner, false));
    }
    if (params.project) {
      operators.push('project:' + encodeURL(params.project, false));
    }
    if (params.branch) {
      operators.push('branch:' + encodeURL(params.branch, false));
    }
    if (params.topic) {
      operators.push('topic:"' + encodeURL(params.topic, false) + '"');
    }
    if (params.hashtag) {
      operators.push(
        'hashtag:"' + encodeURL(params.hashtag.toLowerCase(), false) + '"'
      );
    }
    if (params.statuses) {
      if (params.statuses.length === 1) {
        operators.push('status:' + encodeURL(params.statuses[0], false));
      } else if (params.statuses.length > 1) {
        operators.push(
          '(' +
            params.statuses
              .map(s => `status:${encodeURL(s, false)}`)
              .join(' OR ') +
            ')'
        );
      }
    }

    return '/q/' + operators.join('+') + offsetExpr;
  }

  _generateChangeUrl(params: GenerateUrlChangeViewParameters) {
    let range = this._getPatchRangeExpression(params);
    if (range.length) {
      range = '/' + range;
    }
    let suffix = `${range}`;
    if (params.querystring) {
      suffix += '?' + params.querystring;
    } else if (params.edit) {
      suffix += ',edit';
    }
    if (params.messageHash) {
      suffix += params.messageHash;
    }
    if (params.project) {
      const encodedProject = encodeURL(params.project, true);
      return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
    } else {
      return `/c/${params.changeNum}${suffix}`;
    }
  }

  _generateDashboardUrl(params: GenerateUrlDashboardViewParameters) {
    const repoName = params.repo || params.project || undefined;
    if (params.sections) {
      // Custom dashboard.
      const queryParams = this._sectionsToEncodedParams(
        params.sections,
        repoName
      );
      if (params.title) {
        queryParams.push('title=' + encodeURIComponent(params.title));
      }
      const user = params.user ? params.user : '';
      return `/dashboard/${user}?${queryParams.join('&')}`;
    } else if (repoName) {
      // Project dashboard.
      const encodedRepo = encodeURL(repoName, true);
      return `/p/${encodedRepo}/+/dashboard/${params.dashboard}`;
    } else {
      // User dashboard.
      return `/dashboard/${params.user || 'self'}`;
    }
  }

  _sectionsToEncodedParams(sections: DashboardSection[], repoName?: RepoName) {
    return sections.map(section => {
      // If there is a repo name provided, make sure to substitute it into the
      // ${repo} (or legacy ${project}) query tokens.
      const query = repoName
        ? section.query.replace(REPO_TOKEN_PATTERN, repoName)
        : section.query;
      return encodeURIComponent(section.name) + '=' + encodeURIComponent(query);
    });
  }

  _generateDiffOrEditUrl(
    params: GenerateUrlDiffViewParameters | GenerateUrlEditViewParameters
  ) {
    let range = this._getPatchRangeExpression(params);
    if (range.length) {
      range = '/' + range;
    }

    let suffix = `${range}/${encodeURL(params.path || '', true)}`;

    if (params.view === GerritView.EDIT) {
      suffix += ',edit';
    }

    if (params.lineNum) {
      suffix += '#';
      if (isGenerateUrlDiffViewParameters(params) && params.leftSide) {
        suffix += 'b';
      }
      suffix += params.lineNum;
    }

    if (isGenerateUrlDiffViewParameters(params) && params.commentId) {
      suffix = `/comment/${params.commentId}` + suffix;
    }

    if (params.project) {
      const encodedProject = encodeURL(params.project, true);
      return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
    } else {
      return `/c/${params.changeNum}${suffix}`;
    }
  }

  _generateGroupUrl(params: GenerateUrlGroupViewParameters) {
    let url = `/admin/groups/${encodeURL(`${params.groupId}`, true)}`;
    if (params.detail === GroupDetailView.MEMBERS) {
      url += ',members';
    } else if (params.detail === GroupDetailView.LOG) {
      url += ',audit-log';
    }
    return url;
  }

  _generateRepoUrl(params: GenerateUrlRepoViewParameters) {
    let url = `/admin/repos/${encodeURL(`${params.repoName}`, true)}`;
    if (params.detail === RepoDetailView.ACCESS) {
      url += ',access';
    } else if (params.detail === RepoDetailView.BRANCHES) {
      url += ',branches';
    } else if (params.detail === RepoDetailView.TAGS) {
      url += ',tags';
    } else if (params.detail === RepoDetailView.COMMANDS) {
      url += ',commands';
    } else if (params.detail === RepoDetailView.DASHBOARDS) {
      url += ',dashboards';
    }
    return url;
  }

  _generateSettingsUrl() {
    return '/settings';
  }

  /**
   * Given an object of parameters, potentially including a `patchNum` or a
   * `basePatchNum` or both, return a string representation of that range. If
   * no range is indicated in the params, the empty string is returned.
   */
  _getPatchRangeExpression(params: PatchRangeParams) {
    let range = '';
    if (params.patchNum) {
      range = `${params.patchNum}`;
    }
    if (params.basePatchNum) {
      range = `${params.basePatchNum}..${range}`;
    }
    return range;
  }

  /**
   * Given a set of params without a project, gets the project from the rest
   * API project lookup and then sets the app params.
   */
  _normalizeLegacyRouteParams(
    params: Readonly<
      | GenerateUrlLegacyChangeViewParameters
      | GenerateUrlLegacyDiffViewParameters
    >
  ) {
    if (!params.changeNum) {
      return Promise.resolve();
    }

    return this.$.restAPI
      .getFromProjectLookup(params.changeNum)
      .then(project => {
        // Show a 404 and terminate if the lookup request failed. Attempting
        // to redirect after failing to get the project loops infinitely.
        if (!project) {
          this._show404();
          return;
        }
        const updatedParams:
          | GenerateUrlChangeViewParameters
          | GenerateUrlDiffViewParameters = {...params, project};
        this._normalizePatchRangeParams(updatedParams);
        this._redirect(this._generateUrl(updatedParams));
      });
  }

  /**
   * Normalizes the params object, and determines if the URL needs to be
   * modified to fit the proper schema.
   *
   */
  _normalizePatchRangeParams(params: PatchRangeParams) {
    if (params.basePatchNum === null || params.basePatchNum === undefined) {
      return false;
    }
    const hasPatchNum =
      params.patchNum !== null && params.patchNum !== undefined;
    let needsRedirect = false;

    // Diffing a patch against itself is invalid, so if the base and revision
    // patches are equal clear the base.
    if (
      params.patchNum &&
      patchNumEquals(params.basePatchNum, params.patchNum)
    ) {
      needsRedirect = true;
      params.basePatchNum = null;
    } else if (!hasPatchNum) {
      // Regexes set basePatchNum instead of patchNum when only one is
      // specified. Redirect is not needed in this case.
      params.patchNum = params.basePatchNum;
      params.basePatchNum = null;
    }
    return needsRedirect;
  }

  /**
   * Redirect the user to login using the given return-URL for redirection
   * after authentication success.
   */
  _redirectToLogin(returnUrl: string) {
    const basePath = getBaseUrl() || '';
    page('/login/' + encodeURIComponent(returnUrl.substring(basePath.length)));
  }

  /**
   * Hashes parsed by page.js exclude "inner" hashes, so a URL like "/a#b#c"
   * is parsed to have a hash of "b" rather than "b#c". Instead, this method
   * parses hashes correctly. Will return an empty string if there is no hash.
   *
   * @return Everything after the first '#' ("a#b#c" -> "b#c").
   */
  _getHashFromCanonicalPath(canonicalPath: string) {
    return canonicalPath.split('#').slice(1).join('#');
  }

  _parseLineAddress(hash: string) {
    const match = hash.match(LINE_ADDRESS_PATTERN);
    if (!match) {
      return null;
    }
    return {
      leftSide: !!match[1],
      lineNum: Number(match[2]),
    };
  }

  /**
   * Check to see if the user is logged in and return a promise that only
   * resolves if the user is logged in. If the user us not logged in, the
   * promise is rejected and the page is redirected to the login flow.
   *
   * @return A promise yielding the original route data
   * (if it resolves).
   */
  _redirectIfNotLoggedIn(data: PageContext) {
    return this.$.restAPI.getLoggedIn().then(loggedIn => {
      if (loggedIn) {
        return Promise.resolve();
      } else {
        this._redirectToLogin(data.canonicalPath);
        return Promise.reject(new Error());
      }
    });
  }

  /**  Page.js middleware that warms the REST API's logged-in cache line. */
  _loadUserMiddleware(_: PageContext, next: PageNextCallback) {
    this.$.restAPI.getLoggedIn().then(() => {
      next();
    });
  }

  /**  Page.js middleware that try parse the querystring into queryMap. */
  _queryStringMiddleware(ctx: PageContext, next: PageNextCallback) {
    let queryMap: Map<string, string> | URLSearchParams = new Map();
    if (ctx.querystring) {
      // https://caniuse.com/#search=URLSearchParams
      if (window.URLSearchParams) {
        queryMap = new URLSearchParams(ctx.querystring);
      } else {
        queryMap = new Map(this._parseQueryString(ctx.querystring));
      }
    }
    (ctx as PageContextWithQueryMap).queryMap = queryMap;
    next();
  }

  /**
   * Map a route to a method on the router.
   *
   * @param pattern The page.js pattern for the route.
   * @param handlerName The method name for the handler. If the
   * route is matched, the handler will be executed with `this` referring
   * to the component. Its return value will be discarded so that it does
   * not interfere with page.js.
   * @param authRedirect If true, then auth is checked before
   * executing the handler. If the user is not logged in, it will redirect
   * to the login flow and the handler will not be executed. The login
   * redirect specifies the matched URL to be used after successfull auth.
   */
  _mapRoute(
    pattern: string | RegExp,
    handlerName: keyof GrRouter,
    authRedirect?: boolean
  ) {
    if (!this[handlerName]) {
      console.error('Attempted to map route to unknown method: ', handlerName);
      return;
    }
    page(
      pattern,
      (ctx, next) => this._loadUserMiddleware(ctx, next),
      (ctx, next) => this._queryStringMiddleware(ctx, next),
      data => {
        this.reporting.locationChanged(handlerName);
        const promise = authRedirect
          ? this._redirectIfNotLoggedIn(data)
          : Promise.resolve();
        promise.then(() => {
          this[handlerName](data as PageContextWithQueryMap);
        });
      }
    );
  }

  _startRouter() {
    const base = getBaseUrl();
    if (base) {
      page.base(base);
    }

    GerritNav.setup(
      (url, redirect?) => {
        if (redirect) {
          page.redirect(url);
        } else {
          page.show(url);
        }
      },
      params => this._generateUrl(params),
      params => this._generateWeblinks(params),
      x => x
    );

    page.exit('*', (_, next) => {
      if (!this._isRedirecting) {
        this.reporting.beforeLocationChanged();
      }
      this._isRedirecting = false;
      this._isInitialLoad = false;
      next();
    });

    // Middleware
    page((ctx, next) => {
      document.body.scrollTop = 0;

      if (ctx.hash.match(RoutePattern.PLUGIN_SCREEN)) {
        // Redirect all urls using hash #/x/plugin/screen to /x/plugin/screen
        // This is needed to allow plugins to add basic #/x/ screen links to
        // any location.
        this._redirect(ctx.hash);
        return;
      }

      // Fire asynchronously so that the URL is changed by the time the event
      // is processed.
      this.async(() => {
        const detail: LocationChangeEventDetail = {
          hash: window.location.hash,
          pathname: window.location.pathname,
        };
        this.dispatchEvent(
          new CustomEvent('location-change', {
            detail,
            composed: true,
            bubbles: true,
          })
        );
      }, 1);
      next();
    });

    this._mapRoute(RoutePattern.ROOT, '_handleRootRoute');

    this._mapRoute(RoutePattern.DASHBOARD, '_handleDashboardRoute');

    this._mapRoute(
      RoutePattern.CUSTOM_DASHBOARD,
      '_handleCustomDashboardRoute'
    );

    this._mapRoute(
      RoutePattern.PROJECT_DASHBOARD,
      '_handleProjectDashboardRoute'
    );

    this._mapRoute(
      RoutePattern.LEGACY_PROJECT_DASHBOARD,
      '_handleLegacyProjectDashboardRoute'
    );

    this._mapRoute(RoutePattern.GROUP_INFO, '_handleGroupInfoRoute', true);

    this._mapRoute(
      RoutePattern.GROUP_AUDIT_LOG,
      '_handleGroupAuditLogRoute',
      true
    );

    this._mapRoute(
      RoutePattern.GROUP_MEMBERS,
      '_handleGroupMembersRoute',
      true
    );

    this._mapRoute(
      RoutePattern.GROUP_LIST_OFFSET,
      '_handleGroupListOffsetRoute',
      true
    );

    this._mapRoute(
      RoutePattern.GROUP_LIST_FILTER_OFFSET,
      '_handleGroupListFilterOffsetRoute',
      true
    );

    this._mapRoute(
      RoutePattern.GROUP_LIST_FILTER,
      '_handleGroupListFilterRoute',
      true
    );

    this._mapRoute(
      RoutePattern.GROUP_SELF,
      '_handleGroupSelfRedirectRoute',
      true
    );

    this._mapRoute(RoutePattern.GROUP, '_handleGroupRoute', true);

    this._mapRoute(RoutePattern.PROJECT_OLD, '_handleProjectsOldRoute');

    this._mapRoute(
      RoutePattern.REPO_COMMANDS,
      '_handleRepoCommandsRoute',
      true
    );

    this._mapRoute(RoutePattern.REPO_ACCESS, '_handleRepoAccessRoute');

    this._mapRoute(RoutePattern.REPO_DASHBOARDS, '_handleRepoDashboardsRoute');

    this._mapRoute(
      RoutePattern.BRANCH_LIST_OFFSET,
      '_handleBranchListOffsetRoute'
    );

    this._mapRoute(
      RoutePattern.BRANCH_LIST_FILTER_OFFSET,
      '_handleBranchListFilterOffsetRoute'
    );

    this._mapRoute(
      RoutePattern.BRANCH_LIST_FILTER,
      '_handleBranchListFilterRoute'
    );

    this._mapRoute(RoutePattern.TAG_LIST_OFFSET, '_handleTagListOffsetRoute');

    this._mapRoute(
      RoutePattern.TAG_LIST_FILTER_OFFSET,
      '_handleTagListFilterOffsetRoute'
    );

    this._mapRoute(RoutePattern.TAG_LIST_FILTER, '_handleTagListFilterRoute');

    this._mapRoute(
      RoutePattern.LEGACY_CREATE_GROUP,
      '_handleCreateGroupRoute',
      true
    );

    this._mapRoute(
      RoutePattern.LEGACY_CREATE_PROJECT,
      '_handleCreateProjectRoute',
      true
    );

    this._mapRoute(RoutePattern.REPO_LIST_OFFSET, '_handleRepoListOffsetRoute');

    this._mapRoute(
      RoutePattern.REPO_LIST_FILTER_OFFSET,
      '_handleRepoListFilterOffsetRoute'
    );

    this._mapRoute(RoutePattern.REPO_LIST_FILTER, '_handleRepoListFilterRoute');

    this._mapRoute(RoutePattern.REPO, '_handleRepoRoute');

    this._mapRoute(RoutePattern.PLUGINS, '_handlePassThroughRoute');

    this._mapRoute(
      RoutePattern.PLUGIN_LIST_OFFSET,
      '_handlePluginListOffsetRoute',
      true
    );

    this._mapRoute(
      RoutePattern.PLUGIN_LIST_FILTER_OFFSET,
      '_handlePluginListFilterOffsetRoute',
      true
    );

    this._mapRoute(
      RoutePattern.PLUGIN_LIST_FILTER,
      '_handlePluginListFilterRoute',
      true
    );

    this._mapRoute(RoutePattern.PLUGIN_LIST, '_handlePluginListRoute', true);

    this._mapRoute(
      RoutePattern.QUERY_LEGACY_SUFFIX,
      '_handleQueryLegacySuffixRoute'
    );

    this._mapRoute(RoutePattern.QUERY, '_handleQueryRoute');

    this._mapRoute(RoutePattern.CHANGE_ID_QUERY, '_handleChangeIdQueryRoute');

    this._mapRoute(RoutePattern.DIFF_LEGACY_LINENUM, '_handleLegacyLinenum');

    this._mapRoute(
      RoutePattern.CHANGE_NUMBER_LEGACY,
      '_handleChangeNumberLegacyRoute'
    );

    this._mapRoute(RoutePattern.DIFF_EDIT, '_handleDiffEditRoute', true);

    this._mapRoute(RoutePattern.CHANGE_EDIT, '_handleChangeEditRoute', true);

    this._mapRoute(RoutePattern.COMMENT, '_handleCommentRoute');

    this._mapRoute(RoutePattern.DIFF, '_handleDiffRoute');

    this._mapRoute(RoutePattern.CHANGE, '_handleChangeRoute');

    this._mapRoute(RoutePattern.CHANGE_LEGACY, '_handleChangeLegacyRoute');

    this._mapRoute(RoutePattern.DIFF_LEGACY, '_handleDiffLegacyRoute');

    this._mapRoute(RoutePattern.AGREEMENTS, '_handleAgreementsRoute', true);

    this._mapRoute(
      RoutePattern.NEW_AGREEMENTS,
      '_handleNewAgreementsRoute',
      true
    );

    this._mapRoute(
      RoutePattern.SETTINGS_LEGACY,
      '_handleSettingsLegacyRoute',
      true
    );

    this._mapRoute(RoutePattern.SETTINGS, '_handleSettingsRoute', true);

    this._mapRoute(RoutePattern.REGISTER, '_handleRegisterRoute');

    this._mapRoute(RoutePattern.LOG_IN_OR_OUT, '_handlePassThroughRoute');

    this._mapRoute(
      RoutePattern.IMPROPERLY_ENCODED_PLUS,
      '_handleImproperlyEncodedPlusRoute'
    );

    this._mapRoute(RoutePattern.PLUGIN_SCREEN, '_handlePluginScreen');

    this._mapRoute(
      RoutePattern.DOCUMENTATION_SEARCH_FILTER,
      '_handleDocumentationSearchRoute'
    );

    // redirects /Documentation/q/* to /Documentation/q/filter:*
    this._mapRoute(
      RoutePattern.DOCUMENTATION_SEARCH,
      '_handleDocumentationSearchRedirectRoute'
    );

    // Makes sure /Documentation/* links work (doin't return 404)
    this._mapRoute(
      RoutePattern.DOCUMENTATION,
      '_handleDocumentationRedirectRoute'
    );

    // Note: this route should appear last so it only catches URLs unmatched
    // by other patterns.
    this._mapRoute(RoutePattern.DEFAULT, '_handleDefaultRoute');

    page.start();
  }

  /**
   * @return if handling the route involves asynchrony, then a
   * promise is returned. Otherwise, synchronous handling returns null.
   */
  _handleRootRoute(data: PageContextWithQueryMap) {
    if (data.querystring.match(/^closeAfterLogin/)) {
      // Close child window on redirect after login.
      window.close();
      return null;
    }
    let hash = this._getHashFromCanonicalPath(data.canonicalPath);
    // For backward compatibility with GWT links.
    if (hash) {
      // In certain login flows the server may redirect to a hash without
      // a leading slash, which page.js doesn't handle correctly.
      if (hash[0] !== '/') {
        hash = '/' + hash;
      }
      if (hash.includes('/ /') && data.canonicalPath.includes('/+/')) {
        // Path decodes all '+' to ' ' -- this breaks project-based URLs.
        // See Issue 6888.
        hash = hash.replace('/ /', '/+/');
      }
      const base = getBaseUrl();
      let newUrl = base + hash;
      if (hash.startsWith('/VE/')) {
        newUrl = base + '/settings' + hash;
      }
      this._redirect(newUrl);
      return null;
    }
    return this.$.restAPI.getLoggedIn().then(loggedIn => {
      if (loggedIn) {
        this._redirect('/dashboard/self');
      } else {
        this._redirect('/q/status:open+-is:wip');
      }
    });
  }

  /**
   * Decode an application/x-www-form-urlencoded string.
   *
   * @param qs The application/x-www-form-urlencoded string.
   * @return The decoded string.
   */
  _decodeQueryString(qs: string) {
    return decodeURIComponent(qs.replace(PLUS_PATTERN, ' '));
  }

  /**
   * Parse a query string (e.g. window.location.search) into an array of
   * name/value pairs.
   *
   * @param qs The application/x-www-form-urlencoded query string.
   * @return An array of name/value pairs, where each
   * element is a 2-element array.
   */
  _parseQueryString(qs: string): Array<QueryStringItem> {
    qs = qs.replace(QUESTION_PATTERN, '');
    if (!qs) {
      return [];
    }
    const params: Array<[string, string]> = [];
    qs.split('&').forEach(param => {
      const idx = param.indexOf('=');
      let name;
      let value;
      if (idx < 0) {
        name = this._decodeQueryString(param);
        value = '';
      } else {
        name = this._decodeQueryString(param.substring(0, idx));
        value = this._decodeQueryString(param.substring(idx + 1));
      }
      if (name) {
        params.push([name, value]);
      }
    });
    return params;
  }

  /**
   * Handle dashboard routes. These may be user, or project dashboards.
   */
  _handleDashboardRoute(data: PageContextWithQueryMap) {
    // User dashboard. We require viewing user to be logged in, else we
    // redirect to login for self dashboard or simple owner search for
    // other user dashboard.
    return this.$.restAPI.getLoggedIn().then(loggedIn => {
      if (!loggedIn) {
        if (data.params[0].toLowerCase() === 'self') {
          this._redirectToLogin(data.canonicalPath);
        } else {
          this._redirect('/q/owner:' + encodeURIComponent(data.params[0]));
        }
      } else {
        this._setParams({
          view: GerritView.DASHBOARD,
          user: data.params[0],
        });
      }
    });
  }

  /**
   * Handle custom dashboard routes.
   *
   * @param qs Optional query string associated with the route.
   * If not given, window.location.search is used. (Used by tests).
   */
  _handleCustomDashboardRoute(
    _: PageContextWithQueryMap,
    qs: string = window.location.search
  ) {
    const queryParams = this._parseQueryString(qs);
    let title = 'Custom Dashboard';
    const titleParam = queryParams.find(
      elem => elem[0].toLowerCase() === 'title'
    );
    if (titleParam) {
      title = titleParam[1];
    }
    // Dashboards support a foreach param which adds a base query to any
    // additional query.
    const forEachParam = queryParams.find(
      elem => elem[0].toLowerCase() === 'foreach'
    );
    let forEachQuery: string | null = null;
    if (forEachParam) {
      forEachQuery = forEachParam[1];
    }
    const sectionParams = queryParams.filter(
      elem =>
        elem[0] &&
        elem[1] &&
        elem[0].toLowerCase() !== 'title' &&
        elem[0].toLowerCase() !== 'foreach'
    );
    const sections = sectionParams.map(elem => {
      const query = forEachQuery ? `${forEachQuery} ${elem[1]}` : elem[1];
      return {
        name: elem[0],
        query,
      };
    });

    if (sections.length > 0) {
      // Custom dashboard view.
      this._setParams({
        view: GerritView.DASHBOARD,
        user: 'self',
        sections,
        title,
      });
      return Promise.resolve();
    }

    // Redirect /dashboard/ -> /dashboard/self.
    this._redirect('/dashboard/self');
    return Promise.resolve();
  }

  _handleProjectDashboardRoute(data: PageContextWithQueryMap) {
    const project = data.params[0] as RepoName;
    this._setParams({
      view: GerritView.DASHBOARD,
      project,
      dashboard: decodeURIComponent(data.params[1]) as DashboardId,
    });
    this.reporting.setRepoName(project);
  }

  _handleLegacyProjectDashboardRoute(data: PageContextWithQueryMap) {
    this._redirect('/p/' + data.params[0] + '/+/dashboard/' + data.params[1]);
  }

  _handleGroupInfoRoute(data: PageContextWithQueryMap) {
    this._redirect('/admin/groups/' + encodeURIComponent(data.params[0]));
  }

  _handleGroupSelfRedirectRoute(_: PageContextWithQueryMap) {
    this._redirect('/settings/#Groups');
  }

  _handleGroupRoute(data: PageContextWithQueryMap) {
    this._setParams({
      view: GerritView.GROUP,
      groupId: data.params[0] as GroupId,
    });
  }

  _handleGroupAuditLogRoute(data: PageContextWithQueryMap) {
    this._setParams({
      view: GerritView.GROUP,
      detail: GroupDetailView.LOG,
      groupId: data.params[0] as GroupId,
    });
  }

  _handleGroupMembersRoute(data: PageContextWithQueryMap) {
    this._setParams({
      view: GerritView.GROUP,
      detail: GroupDetailView.MEMBERS,
      groupId: data.params[0] as GroupId,
    });
  }

  _handleGroupListOffsetRoute(data: PageContextWithQueryMap) {
    this._setParams({
      view: GerritView.ADMIN,
      adminView: 'gr-admin-group-list',
      offset: data.params[1] || 0,
      filter: null,
      openCreateModal: data.hash === 'create',
    });
  }

  _handleGroupListFilterOffsetRoute(data: PageContextWithQueryMap) {
    this._setParams({
      view: GerritView.ADMIN,
      adminView: 'gr-admin-group-list',
      offset: data.params['offset'],
      filter: data.params['filter'],
    });
  }

  _handleGroupListFilterRoute(data: PageContextWithQueryMap) {
    this._setParams({
      view: GerritView.ADMIN,
      adminView: 'gr-admin-group-list',
      filter: data.params['filter'] || null,
    });
  }

  _handleProjectsOldRoute(data: PageContextWithQueryMap) {
    let params = '';
    if (data.params[1]) {
      params = encodeURIComponent(data.params[1]);
      if (data.params[1].includes(',')) {
        params = encodeURIComponent(data.params[1]).replace('%2C', ',');
      }
    }

    this._redirect(`/admin/repos/${params}`);
  }

  _handleRepoCommandsRoute(data: PageContextWithQueryMap) {
    const repo = data.params[0] as RepoName;
    this._setParams({
      view: GerritView.REPO,
      detail: RepoDetailView.COMMANDS,
      repo,
    });
    this.reporting.setRepoName(repo);
  }

  _handleRepoAccessRoute(data: PageContextWithQueryMap) {
    const repo = data.params[0] as RepoName;
    this._setParams({
      view: GerritView.REPO,
      detail: RepoDetailView.ACCESS,
      repo,
    });
    this.reporting.setRepoName(repo);
  }

  _handleRepoDashboardsRoute(data: PageContextWithQueryMap) {
    const repo = data.params[0] as RepoName;
    this._setParams({
      view: GerritView.REPO,
      detail: RepoDetailView.DASHBOARDS,
      repo,
    });
    this.reporting.setRepoName(repo);
  }

  _handleBranchListOffsetRoute(data: PageContextWithQueryMap) {
    this._setParams({
      view: GerritView.REPO,
      detail: RepoDetailView.BRANCHES,
      repo: data.params[0] as RepoName,
      offset: data.params[2] || 0,
      filter: null,
    });
  }

  _handleBranchListFilterOffsetRoute(data: PageContextWithQueryMap) {
    this._setParams({
      view: GerritView.REPO,
      detail: RepoDetailView.BRANCHES,
      repo: data.params['repo'] as RepoName,
      offset: data.params['offset'],
      filter: data.params['filter'],
    });
  }

  _handleBranchListFilterRoute(data: PageContextWithQueryMap) {
    this._setParams({
      view: GerritView.REPO,
      detail: RepoDetailView.BRANCHES,
      repo: data.params['repo'] as RepoName,
      filter: data.params['filter'] || null,
    });
  }

  _handleTagListOffsetRoute(data: PageContextWithQueryMap) {
    this._setParams({
      view: GerritView.REPO,
      detail: RepoDetailView.TAGS,
      repo: data.params[0] as RepoName,
      offset: data.params[2] || 0,
      filter: null,
    });
  }

  _handleTagListFilterOffsetRoute(data: PageContextWithQueryMap) {
    this._setParams({
      view: GerritView.REPO,
      detail: RepoDetailView.TAGS,
      repo: data.params['repo'] as RepoName,
      offset: data.params['offset'],
      filter: data.params['filter'],
    });
  }

  _handleTagListFilterRoute(data: PageContextWithQueryMap) {
    this._setParams({
      view: GerritView.REPO,
      detail: RepoDetailView.TAGS,
      repo: data.params['repo'] as RepoName,
      filter: data.params['filter'] || null,
    });
  }

  _handleRepoListOffsetRoute(data: PageContextWithQueryMap) {
    this._setParams({
      view: GerritView.ADMIN,
      adminView: 'gr-repo-list',
      offset: data.params[1] || 0,
      filter: null,
      openCreateModal: data.hash === 'create',
    });
  }

  _handleRepoListFilterOffsetRoute(data: PageContextWithQueryMap) {
    this._setParams({
      view: GerritView.ADMIN,
      adminView: 'gr-repo-list',
      offset: data.params['offset'],
      filter: data.params['filter'],
    });
  }

  _handleRepoListFilterRoute(data: PageContextWithQueryMap) {
    this._setParams({
      view: GerritView.ADMIN,
      adminView: 'gr-repo-list',
      filter: data.params['filter'] || null,
    });
  }

  _handleCreateProjectRoute(_: PageContextWithQueryMap) {
    // Redirects the legacy route to the new route, which displays the project
    // list with a hash 'create'.
    this._redirect('/admin/repos#create');
  }

  _handleCreateGroupRoute(_: PageContextWithQueryMap) {
    // Redirects the legacy route to the new route, which displays the group
    // list with a hash 'create'.
    this._redirect('/admin/groups#create');
  }

  _handleRepoRoute(data: PageContextWithQueryMap) {
    const repo = data.params[0] as RepoName;
    this._setParams({
      view: GerritView.REPO,
      repo,
    });
    this.reporting.setRepoName(repo);
  }

  _handlePluginListOffsetRoute(data: PageContextWithQueryMap) {
    this._setParams({
      view: GerritView.ADMIN,
      adminView: 'gr-plugin-list',
      offset: data.params[1] || 0,
      filter: null,
    });
  }

  _handlePluginListFilterOffsetRoute(data: PageContextWithQueryMap) {
    this._setParams({
      view: GerritView.ADMIN,
      adminView: 'gr-plugin-list',
      offset: data.params['offset'],
      filter: data.params['filter'],
    });
  }

  _handlePluginListFilterRoute(data: PageContextWithQueryMap) {
    this._setParams({
      view: GerritView.ADMIN,
      adminView: 'gr-plugin-list',
      filter: data.params['filter'] || null,
    });
  }

  _handlePluginListRoute(_: PageContextWithQueryMap) {
    this._setParams({
      view: GerritView.ADMIN,
      adminView: 'gr-plugin-list',
    });
  }

  _handleQueryRoute(data: PageContextWithQueryMap) {
    this._setParams({
      view: GerritView.SEARCH,
      query: data.params[0],
      offset: data.params[2],
    });
  }

  _handleChangeIdQueryRoute(data: PageContextWithQueryMap) {
    // TODO(pcc): This will need to indicate that this was a change ID query if
    // standard queries gain the ability to search places like commit messages
    // for change IDs.
    this._setParams({
      view: GerritNav.View.SEARCH,
      query: data.params[0],
    });
  }

  _handleQueryLegacySuffixRoute(ctx: PageContextWithQueryMap) {
    this._redirect(ctx.path.replace(LEGACY_QUERY_SUFFIX_PATTERN, ''));
  }

  _handleChangeNumberLegacyRoute(ctx: PageContextWithQueryMap) {
    this._redirect('/c/' + encodeURIComponent(ctx.params[0]));
  }

  _handleChangeRoute(ctx: PageContextWithQueryMap) {
    // Parameter order is based on the regex group number matched.
    const params: GenerateUrlChangeViewParameters = {
      project: ctx.params[0] as RepoName,
      // TODO(TS): remove as unknown
      changeNum: (ctx.params[1] as unknown) as NumericChangeId,
      basePatchNum: convertToPatchSetNum(ctx.params[4]),
      patchNum: convertToPatchSetNum(ctx.params[6]),
      view: GerritView.CHANGE,
      queryMap: ctx.queryMap,
    };

    this.reporting.setRepoName(params.project);
    this._redirectOrNavigate(params);
  }

  _handleCommentRoute(ctx: PageContextWithQueryMap) {
    const params: GenerateUrlDiffViewParameters = {
      project: ctx.params[0] as RepoName,
      changeNum: (ctx.params[1] as unknown) as NumericChangeId,
      commentId: ctx.params[2] as UrlEncodedCommentId,
      view: GerritView.DIFF,
      commentLink: true,
    };
    this.reporting.setRepoName(params.project);
    this._redirectOrNavigate(params);
  }

  _handleDiffRoute(ctx: PageContextWithQueryMap) {
    // Parameter order is based on the regex group number matched.
    const params: GenerateUrlDiffViewParameters = {
      project: ctx.params[0] as RepoName,
      changeNum: (ctx.params[1] as unknown) as NumericChangeId,
      basePatchNum: convertToPatchSetNum(ctx.params[4]),
      patchNum: convertToPatchSetNum(ctx.params[6]),
      path: ctx.params[8],
      view: GerritView.DIFF,
    };
    const address = this._parseLineAddress(ctx.hash);
    if (address) {
      params.leftSide = address.leftSide;
      params.lineNum = address.lineNum;
    }
    this.reporting.setRepoName(params.project);
    this._redirectOrNavigate(params);
  }

  _handleChangeLegacyRoute(ctx: PageContextWithQueryMap) {
    // Parameter order is based on the regex group number matched.
    const params: GenerateUrlLegacyChangeViewParameters = {
      changeNum: (ctx.params[0] as unknown) as NumericChangeId,
      basePatchNum: convertToPatchSetNum(ctx.params[3]),
      patchNum: convertToPatchSetNum(ctx.params[5]),
      view: GerritView.CHANGE,
      querystring: ctx.querystring,
    };

    this._normalizeLegacyRouteParams(params);
  }

  _handleLegacyLinenum(ctx: PageContextWithQueryMap) {
    this._redirect(ctx.path.replace(LEGACY_LINENUM_PATTERN, '#$1'));
  }

  _handleDiffLegacyRoute(ctx: PageContextWithQueryMap) {
    // Parameter order is based on the regex group number matched.
    const params: GenerateUrlLegacyDiffViewParameters = {
      // TODO(TS): remove "as unknown"
      changeNum: (ctx.params[0] as unknown) as NumericChangeId,
      basePatchNum: convertToPatchSetNum(ctx.params[2]),
      patchNum: convertToPatchSetNum(ctx.params[4]),
      path: ctx.params[5],
      view: GerritView.DIFF,
    };

    const address = this._parseLineAddress(ctx.hash);
    if (address) {
      params.leftSide = address.leftSide;
      params.lineNum = address.lineNum;
    }

    this._normalizeLegacyRouteParams(params);
  }

  _handleDiffEditRoute(ctx: PageContextWithQueryMap) {
    // Parameter order is based on the regex group number matched.
    const project = ctx.params[0] as RepoName;
    this._redirectOrNavigate({
      project,
      changeNum: (ctx.params[1] as unknown) as NumericChangeId,
      // for edit view params, patchNum cannot be undefined
      patchNum: convertToPatchSetNum(ctx.params[2])!,
      path: ctx.params[3],
      lineNum: ctx.hash,
      view: GerritView.EDIT,
    });
    this.reporting.setRepoName(project);
  }

  _handleChangeEditRoute(ctx: PageContextWithQueryMap) {
    // Parameter order is based on the regex group number matched.
    const project = ctx.params[0] as RepoName;
    this._redirectOrNavigate({
      project,
      // TODO(TS): remove "as unknown"
      changeNum: (ctx.params[1] as unknown) as NumericChangeId,
      patchNum: convertToPatchSetNum(ctx.params[3]),
      view: GerritView.CHANGE,
      edit: true,
    });
    this.reporting.setRepoName(project);
  }

  /**
   * Normalize the patch range params for a the change or diff view and
   * redirect if URL upgrade is needed.
   */
  _redirectOrNavigate(params: GenerateUrlParameters & PatchRangeParams) {
    const needsRedirect = this._normalizePatchRangeParams(params);
    if (needsRedirect) {
      this._redirect(this._generateUrl(params));
    } else {
      this._setParams(params);
    }
  }

  _handleAgreementsRoute() {
    this._redirect('/settings/#Agreements');
  }

  _handleNewAgreementsRoute(data: PageContextWithQueryMap) {
    data.params['view'] = GerritView.AGREEMENTS;
    // TODO(TS): create valid object
    this._setParams((data.params as unknown) as AppElementAgreementParam);
  }

  _handleSettingsLegacyRoute(data: PageContextWithQueryMap) {
    // email tokens may contain '+' but no space.
    // The parameter parsing replaces all '+' with a space,
    // undo that to have valid tokens.
    const token = data.params[0].replace(/ /g, '+');
    this._setParams({
      view: GerritView.SETTINGS,
      emailToken: token,
    });
  }

  _handleSettingsRoute(_: PageContextWithQueryMap) {
    this._setParams({view: GerritView.SETTINGS});
  }

  _handleRegisterRoute(ctx: PageContextWithQueryMap) {
    this._setParams({justRegistered: true});
    let path = ctx.params[0] || '/';

    // Prevent redirect looping.
    if (path.startsWith('/register')) {
      path = '/';
    }

    if (path[0] !== '/') {
      return;
    }
    this._redirect(getBaseUrl() + path);
  }

  /**
   * Handler for routes that should pass through the router and not be caught
   * by the catchall _handleDefaultRoute handler.
   */
  _handlePassThroughRoute() {
    location.reload();
  }

  /**
   * URL may sometimes have /+/ encoded to / /.
   * Context: Issue 6888, Issue 7100
   */
  _handleImproperlyEncodedPlusRoute(ctx: PageContextWithQueryMap) {
    let hash = this._getHashFromCanonicalPath(ctx.canonicalPath);
    if (hash.length) {
      hash = '#' + hash;
    }
    this._redirect(`/c/${ctx.params[0]}/+/${ctx.params[1]}${hash}`);
  }

  _handlePluginScreen(ctx: PageContextWithQueryMap) {
    const view = GerritView.PLUGIN_SCREEN;
    const plugin = ctx.params[0];
    const screen = ctx.params[1];
    this._setParams({view, plugin, screen});
  }

  _handleDocumentationSearchRoute(data: PageContextWithQueryMap) {
    this._setParams({
      view: GerritView.DOCUMENTATION_SEARCH,
      filter: data.params['filter'] || null,
    });
  }

  _handleDocumentationSearchRedirectRoute(data: PageContextWithQueryMap) {
    this._redirect(
      '/Documentation/q/filter:' + encodeURIComponent(data.params[0])
    );
  }

  _handleDocumentationRedirectRoute(data: PageContextWithQueryMap) {
    if (data.params[1]) {
      location.reload();
    } else {
      // Redirect /Documentation to /Documentation/index.html
      this._redirect('/Documentation/index.html');
    }
  }

  /**
   * Catchall route for when no other route is matched.
   */
  _handleDefaultRoute() {
    if (this._isInitialLoad) {
      // Server recognized this route as polygerrit, so we show 404.
      this._show404();
    } else {
      // Route can be recognized by server, so we pass it to server.
      this._handlePassThroughRoute();
    }
  }

  _show404() {
    // Note: the app's 404 display is tightly-coupled with catching 404
    // network responses, so we simulate a 404 response status to display it.
    // TODO: Decouple the gr-app error view from network responses.
    this._appElement().dispatchEvent(
      new CustomEvent('page-error', {detail: {response: {status: 404}}})
    );
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'gr-router': GrRouter;
  }
}
