// Copyright 2017 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import * as Common from '../../core/common/common.js';
import * as Host from '../../core/host/host.js';
import * as Platform from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as Protocol from '../../generated/protocol.js';
import * as Breakpoints from '../breakpoints/breakpoints.js';
import * as TextUtils from '../text_utils/text_utils.js';
import * as Workspace from '../workspace/workspace.js';

import {type FileSystem, FileSystemWorkspaceBinding} from './FileSystemWorkspaceBinding.js';
import {IsolatedFileSystemManager} from './IsolatedFileSystemManager.js';
import {PersistenceBinding, PersistenceImpl} from './PersistenceImpl.js';

let networkPersistenceManagerInstance: NetworkPersistenceManager|null;

const forbiddenUrls = ['chromewebstore.google.com', 'chrome.google.com'];

export class NetworkPersistenceManager extends Common.ObjectWrapper.ObjectWrapper<EventTypes> implements
    SDK.TargetManager.Observer {
  #bindings = new WeakMap<Workspace.UISourceCode.UISourceCode, PersistenceBinding>();
  readonly #originalResponseContentPromises = new WeakMap<Workspace.UISourceCode.UISourceCode, Promise<string|null>>();
  #savingForOverrides = new WeakSet<Workspace.UISourceCode.UISourceCode>();
  #enabledSetting = Common.Settings.Settings.instance().moduleSetting<boolean>('persistence-network-overrides-enabled');
  readonly #workspace: Workspace.Workspace.WorkspaceImpl;
  readonly #networkUISourceCodeForEncodedPath =
      new Map<Platform.DevToolsPath.EncodedPathString, Workspace.UISourceCode.UISourceCode>();
  readonly #interceptionHandlerBound: (interceptedRequest: SDK.NetworkManager.InterceptedRequest) => Promise<void>;
  readonly #updateInterceptionThrottler = new Common.Throttler.Throttler(50);
  #project: Workspace.Workspace.Project|null = null;
  #active = false;
  #enabled = false;
  #eventDescriptors: Common.EventTarget.EventDescriptor[] = [];
  #headerOverridesMap = new Map<Platform.DevToolsPath.EncodedPathString, HeaderOverrideWithRegex[]>();
  readonly #sourceCodeToBindProcessMutex = new WeakMap<Workspace.UISourceCode.UISourceCode, Common.Mutex.Mutex>();
  readonly #eventDispatchThrottler = new Common.Throttler.Throttler(50);
  #headerOverridesForEventDispatch = new Set<Workspace.UISourceCode.UISourceCode>();

  private constructor(workspace: Workspace.Workspace.WorkspaceImpl) {
    super();

    this.#enabledSetting.addChangeListener(this.enabledChanged, this);

    this.#workspace = workspace;

    this.#interceptionHandlerBound = this.interceptionHandler.bind(this);

    this.#workspace.addEventListener(Workspace.Workspace.Events.ProjectAdded, event => {
      void this.onProjectAdded(event.data);
    });
    this.#workspace.addEventListener(Workspace.Workspace.Events.ProjectRemoved, event => {
      void this.onProjectRemoved(event.data);
    });

    PersistenceImpl.instance().addNetworkInterceptor(this.canHandleNetworkUISourceCode.bind(this));
    Breakpoints.BreakpointManager.BreakpointManager.instance().addUpdateBindingsCallback(
        this.networkUISourceCodeAdded.bind(this));

    void this.enabledChanged();

    SDK.TargetManager.TargetManager.instance().observeTargets(this);
  }

  targetAdded(): void {
    void this.updateActiveProject();
  }
  targetRemoved(): void {
    void this.updateActiveProject();
  }

  static instance(opts: {
    forceNew: boolean|null,
    workspace: Workspace.Workspace.WorkspaceImpl|null,
  } = {forceNew: null, workspace: null}): NetworkPersistenceManager {
    const {forceNew, workspace} = opts;
    if (!networkPersistenceManagerInstance || forceNew) {
      if (!workspace) {
        throw new Error('Missing workspace for NetworkPersistenceManager');
      }
      networkPersistenceManagerInstance = new NetworkPersistenceManager(workspace);
    }

    return networkPersistenceManagerInstance;
  }

  active(): boolean {
    return this.#active;
  }

  project(): Workspace.Workspace.Project|null {
    return this.#project;
  }

  originalContentForUISourceCode(uiSourceCode: Workspace.UISourceCode.UISourceCode): Promise<string|null>|null {
    const binding = this.#bindings.get(uiSourceCode);
    if (!binding) {
      return null;
    }
    const fileSystemUISourceCode = binding.fileSystem;
    return this.#originalResponseContentPromises.get(fileSystemUISourceCode) || null;
  }

  private async enabledChanged(): Promise<void> {
    if (this.#enabled === this.#enabledSetting.get()) {
      return;
    }
    this.#enabled = this.#enabledSetting.get();
    if (this.#enabled) {
      Host.userMetrics.actionTaken(Host.UserMetrics.Action.PersistenceNetworkOverridesEnabled);
      this.#eventDescriptors = [
        Workspace.Workspace.WorkspaceImpl.instance().addEventListener(
            Workspace.Workspace.Events.UISourceCodeRenamed,
            event => {
              void this.uiSourceCodeRenamedListener(event);
            }),
        Workspace.Workspace.WorkspaceImpl.instance().addEventListener(
            Workspace.Workspace.Events.UISourceCodeAdded,
            event => {
              void this.uiSourceCodeAdded(event);
            }),
        Workspace.Workspace.WorkspaceImpl.instance().addEventListener(
            Workspace.Workspace.Events.UISourceCodeRemoved,
            event => {
              void this.uiSourceCodeRemovedListener(event);
            }),
        Workspace.Workspace.WorkspaceImpl.instance().addEventListener(
            Workspace.Workspace.Events.WorkingCopyCommitted,
            event => this.onUISourceCodeWorkingCopyCommitted(event.data.uiSourceCode)),
      ];
      await this.updateActiveProject();
    } else {
      Host.userMetrics.actionTaken(Host.UserMetrics.Action.PersistenceNetworkOverridesDisabled);
      Common.EventTarget.removeEventListeners(this.#eventDescriptors);
      await this.updateActiveProject();
    }
    this.dispatchEventToListeners(Events.LOCAL_OVERRIDES_PROJECT_UPDATED, this.#enabled);
  }

  private async uiSourceCodeRenamedListener(
      event: Common.EventTarget.EventTargetEvent<Workspace.Workspace.UISourceCodeRenamedEvent>): Promise<void> {
    const uiSourceCode = event.data.uiSourceCode;
    await this.onUISourceCodeRemoved(uiSourceCode);
    await this.onUISourceCodeAdded(uiSourceCode);
  }

  private async uiSourceCodeRemovedListener(
      event: Common.EventTarget.EventTargetEvent<Workspace.UISourceCode.UISourceCode>): Promise<void> {
    await this.onUISourceCodeRemoved(event.data);
  }

  private async uiSourceCodeAdded(event: Common.EventTarget.EventTargetEvent<Workspace.UISourceCode.UISourceCode>):
      Promise<void> {
    await this.onUISourceCodeAdded(event.data);
  }

  private async updateActiveProject(): Promise<void> {
    const wasActive = this.#active;
    this.#active =
        Boolean(this.#enabledSetting.get() && SDK.TargetManager.TargetManager.instance().rootTarget() && this.#project);
    if (this.#active === wasActive) {
      return;
    }

    if (this.#active && this.#project) {
      await Promise.all(
          [...this.#project.uiSourceCodes()].map(uiSourceCode => this.filesystemUISourceCodeAdded(uiSourceCode)));

      const networkProjects = this.#workspace.projectsForType(Workspace.Workspace.projectTypes.Network);
      for (const networkProject of networkProjects) {
        await Promise.all(
            [...networkProject.uiSourceCodes()].map(uiSourceCode => this.networkUISourceCodeAdded(uiSourceCode)));
      }
    } else if (this.#project) {
      await Promise.all(
          [...this.#project.uiSourceCodes()].map(uiSourceCode => this.filesystemUISourceCodeRemoved(uiSourceCode)));
      this.#networkUISourceCodeForEncodedPath.clear();
    }
    PersistenceImpl.instance().refreshAutomapping();
  }

  encodedPathFromUrl(url: Platform.DevToolsPath.UrlString, ignoreInactive?: boolean):
      Platform.DevToolsPath.EncodedPathString {
    return Common.ParsedURL.ParsedURL.rawPathToEncodedPathString(this.rawPathFromUrl(url, ignoreInactive));
  }

  rawPathFromUrl(url: Platform.DevToolsPath.UrlString, ignoreInactive?: boolean): Platform.DevToolsPath.RawPathString {
    if ((!this.#active && !ignoreInactive) || !this.#project) {
      return Platform.DevToolsPath.EmptyRawPathString;
    }
    let initialEncodedPath = Common.ParsedURL.ParsedURL.urlWithoutHash(url.replace(/^https?:\/\//, '')) as
        Platform.DevToolsPath.EncodedPathString;
    if (initialEncodedPath.endsWith('/') && initialEncodedPath.indexOf('?') === -1) {
      initialEncodedPath = Common.ParsedURL.ParsedURL.concatenate(initialEncodedPath, 'index.html');
    }
    let encodedPathParts = NetworkPersistenceManager.encodeEncodedPathToLocalPathParts(initialEncodedPath);
    const projectPath =
        FileSystemWorkspaceBinding.fileSystemPath(this.#project.id() as Platform.DevToolsPath.UrlString);
    const encodedPath = encodedPathParts.join('/');
    if (projectPath.length + encodedPath.length > 200) {
      const domain = encodedPathParts[0];
      const encodedFileName = encodedPathParts[encodedPathParts.length - 1];
      const shortFileName = encodedFileName ? encodedFileName.substr(0, 10) + '-' : '';
      const extension = Common.ParsedURL.ParsedURL.extractExtension(initialEncodedPath);
      const extensionPart = extension ? '.' + extension.substr(0, 10) : '';
      encodedPathParts = [
        domain,
        'longurls',
        shortFileName + Platform.StringUtilities.hashCode(encodedPath).toString(16) + extensionPart,
      ];
    }
    return Common.ParsedURL.ParsedURL.join(encodedPathParts as Platform.DevToolsPath.RawPathString[], '/');
  }

  static encodeEncodedPathToLocalPathParts(encodedPath: Platform.DevToolsPath.EncodedPathString): string[] {
    const encodedParts = [];
    for (const pathPart of this.#fileNamePartsFromEncodedPath(encodedPath)) {
      if (!pathPart) {
        continue;
      }
      // encodeURI() escapes all the unsafe filename characters except '/' and '*'
      let encodedName =
          encodeURI(pathPart).replace(/[\/\*]/g, match => '%' + match[0].charCodeAt(0).toString(16).toUpperCase());
      if (Host.Platform.isWin()) {
        // Windows does not allow ':' and '?' in filenames
        encodedName = encodedName.replace(/[:\?]/g, match => '%' + match[0].charCodeAt(0).toString(16).toUpperCase());
        // Windows does not allow a small set of filenames.
        if (RESERVED_FILENAMES.has(encodedName.toLowerCase())) {
          encodedName = encodedName.split('').map(char => '%' + char.charCodeAt(0).toString(16).toUpperCase()).join('');
        }
        // Windows does not allow the file to end in a space or dot (space should already be encoded).
        const lastChar = encodedName.charAt(encodedName.length - 1);
        if (lastChar === '.') {
          encodedName = encodedName.substr(0, encodedName.length - 1) + '%2E';
        }
      }
      encodedParts.push(encodedName);
    }
    return encodedParts;
  }

  static #fileNamePartsFromEncodedPath(encodedPath: Platform.DevToolsPath.EncodedPathString): string[] {
    encodedPath = Common.ParsedURL.ParsedURL.urlWithoutHash(encodedPath) as Platform.DevToolsPath.EncodedPathString;
    const queryIndex = encodedPath.indexOf('?');
    if (queryIndex === -1) {
      return encodedPath.split('/');
    }
    if (queryIndex === 0) {
      return [encodedPath];
    }
    const endSection = encodedPath.substr(queryIndex);
    const parts = encodedPath.substr(0, encodedPath.length - endSection.length).split('/');
    parts[parts.length - 1] += endSection;
    return parts;
  }

  fileUrlFromNetworkUrl(url: Platform.DevToolsPath.UrlString, ignoreInactive?: boolean):
      Platform.DevToolsPath.UrlString {
    if (!this.#project) {
      return Platform.DevToolsPath.EmptyUrlString;
    }
    return Common.ParsedURL.ParsedURL.concatenate(
        (this.#project as FileSystem).fileSystemPath(), '/', this.encodedPathFromUrl(url, ignoreInactive));
  }

  getHeadersUISourceCodeFromUrl(url: Platform.DevToolsPath.UrlString): Workspace.UISourceCode.UISourceCode|null {
    const fileUrlFromRequest = this.fileUrlFromNetworkUrl(url, /* ignoreNoActive */ true);
    const folderUrlFromRequest =
        Common.ParsedURL.ParsedURL.substring(fileUrlFromRequest, 0, fileUrlFromRequest.lastIndexOf('/'));
    const headersFileUrl = Common.ParsedURL.ParsedURL.concatenate(folderUrlFromRequest, '/', HEADERS_FILENAME);
    return Workspace.Workspace.WorkspaceImpl.instance().uiSourceCodeForURL(headersFileUrl);
  }

  async getOrCreateHeadersUISourceCodeFromUrl(url: Platform.DevToolsPath.UrlString):
      Promise<Workspace.UISourceCode.UISourceCode|null> {
    let uiSourceCode = this.getHeadersUISourceCodeFromUrl(url);
    if (!uiSourceCode && this.#project) {
      const encodedFilePath = this.encodedPathFromUrl(url, /* ignoreNoActive */ true);
      const encodedPath = Common.ParsedURL.ParsedURL.substring(encodedFilePath, 0, encodedFilePath.lastIndexOf('/'));
      uiSourceCode = await this.#project.createFile(encodedPath, HEADERS_FILENAME, '');
      Host.userMetrics.actionTaken(Host.UserMetrics.Action.HeaderOverrideFileCreated);
    }
    return uiSourceCode;
  }

  private decodeLocalPathToUrlPath(path: string): string {
    try {
      return unescape(path);
    } catch (e) {
      console.error(e);
    }
    return path;
  }

  async #unbind(uiSourceCode: Workspace.UISourceCode.UISourceCode): Promise<void> {
    const binding = this.#bindings.get(uiSourceCode);
    const headerBinding = uiSourceCode.url().endsWith(HEADERS_FILENAME);
    if (binding) {
      const mutex = this.#getOrCreateMutex(binding.network);
      await mutex.run(this.#innerUnbind.bind(this, binding));
    } else if (headerBinding) {
      this.dispatchEventToListeners(Events.REQUEST_FOR_HEADER_OVERRIDES_FILE_CHANGED, uiSourceCode);
    }
  }

  async #unbindUnguarded(uiSourceCode: Workspace.UISourceCode.UISourceCode): Promise<void> {
    const binding = this.#bindings.get(uiSourceCode);
    if (binding) {
      await this.#innerUnbind(binding);
    }
  }

  #innerUnbind(binding: PersistenceBinding): Promise<void> {
    this.#bindings.delete(binding.network);
    this.#bindings.delete(binding.fileSystem);
    return PersistenceImpl.instance().removeBinding(binding);
  }

  async #bind(
      networkUISourceCode: Workspace.UISourceCode.UISourceCode,
      fileSystemUISourceCode: Workspace.UISourceCode.UISourceCode): Promise<void> {
    const mutex = this.#getOrCreateMutex(networkUISourceCode);
    await mutex.run(async () => {
      const existingBinding = this.#bindings.get(networkUISourceCode);
      if (existingBinding) {
        const {network, fileSystem} = existingBinding;
        if (networkUISourceCode === network && fileSystemUISourceCode === fileSystem) {
          return;
        }
        await this.#unbindUnguarded(networkUISourceCode);
        await this.#unbindUnguarded(fileSystemUISourceCode);
      }

      await this.#innerAddBinding(networkUISourceCode, fileSystemUISourceCode);
    });
  }

  #getOrCreateMutex(networkUISourceCode: Workspace.UISourceCode.UISourceCode): Common.Mutex.Mutex {
    let mutex = this.#sourceCodeToBindProcessMutex.get(networkUISourceCode);
    if (!mutex) {
      mutex = new Common.Mutex.Mutex();
      this.#sourceCodeToBindProcessMutex.set(networkUISourceCode, mutex);
    }
    return mutex;
  }

  async #innerAddBinding(
      networkUISourceCode: Workspace.UISourceCode.UISourceCode,
      fileSystemUISourceCode: Workspace.UISourceCode.UISourceCode): Promise<void> {
    const binding = new PersistenceBinding(networkUISourceCode, fileSystemUISourceCode);
    this.#bindings.set(networkUISourceCode, binding);
    this.#bindings.set(fileSystemUISourceCode, binding);
    await PersistenceImpl.instance().addBinding(binding);
    const uiSourceCodeOfTruth =
        this.#savingForOverrides.has(networkUISourceCode) ? networkUISourceCode : fileSystemUISourceCode;
    const contentDataOrError = await uiSourceCodeOfTruth.requestContentData();
    const {content, isEncoded} = TextUtils.ContentData.ContentData.asDeferredContent(contentDataOrError);
    PersistenceImpl.instance().syncContent(uiSourceCodeOfTruth, content || '', isEncoded);
  }

  private onUISourceCodeWorkingCopyCommitted(uiSourceCode: Workspace.UISourceCode.UISourceCode): void {
    void this.saveUISourceCodeForOverrides(uiSourceCode);
    this.updateInterceptionPatterns();
  }

  isActiveHeaderOverrides(uiSourceCode: Workspace.UISourceCode.UISourceCode): boolean {
    // If this overridden file is actively in use at the moment.
    if (!this.#enabledSetting.get()) {
      return false;
    }
    return uiSourceCode.url().endsWith(HEADERS_FILENAME) &&
        this.hasMatchingNetworkUISourceCodeForHeaderOverridesFile(uiSourceCode);
  }

  isUISourceCodeOverridable(uiSourceCode: Workspace.UISourceCode.UISourceCode): boolean {
    return uiSourceCode.project().type() === Workspace.Workspace.projectTypes.Network &&
        !NetworkPersistenceManager.isForbiddenNetworkUrl(uiSourceCode.url());
  }

  #isUISourceCodeAlreadyOverridden(uiSourceCode: Workspace.UISourceCode.UISourceCode): boolean {
    return this.#bindings.has(uiSourceCode) || this.#savingForOverrides.has(uiSourceCode);
  }

  #shouldPromptSaveForOverridesDialog(uiSourceCode: Workspace.UISourceCode.UISourceCode): boolean {
    return this.isUISourceCodeOverridable(uiSourceCode) && !this.#isUISourceCodeAlreadyOverridden(uiSourceCode) &&
        !this.#active && !this.#project;
  }

  #canSaveUISourceCodeForOverrides(uiSourceCode: Workspace.UISourceCode.UISourceCode): boolean {
    return this.#active && this.isUISourceCodeOverridable(uiSourceCode) &&
        !this.#isUISourceCodeAlreadyOverridden(uiSourceCode);
  }

  async setupAndStartLocalOverrides(uiSourceCode: Workspace.UISourceCode.UISourceCode): Promise<boolean> {
    // No overrides folder, set it up
    if (this.#shouldPromptSaveForOverridesDialog(uiSourceCode)) {
      Host.userMetrics.actionTaken(Host.UserMetrics.Action.OverrideContentContextMenuSetup);
      await new Promise<void>(resolve => this.dispatchEventToListeners(Events.LOCAL_OVERRIDES_REQUESTED, resolve));
      await IsolatedFileSystemManager.instance().addFileSystem('overrides');
    }

    if (!this.project()) {
      Host.userMetrics.actionTaken(Host.UserMetrics.Action.OverrideContentContextMenuAbandonSetup);
      return false;
    }

    // Already have an overrides folder, enable setting
    if (!this.#enabledSetting.get()) {
      Host.userMetrics.actionTaken(Host.UserMetrics.Action.OverrideContentContextMenuActivateDisabled);
      this.#enabledSetting.set(true);
      await this.once(Events.LOCAL_OVERRIDES_PROJECT_UPDATED);
    }

    // Save new file
    if (!this.#isUISourceCodeAlreadyOverridden(uiSourceCode)) {
      Host.userMetrics.actionTaken(Host.UserMetrics.Action.OverrideContentContextMenuSaveNewFile);
      uiSourceCode.commitWorkingCopy();
      await this.saveUISourceCodeForOverrides(uiSourceCode);
    } else {
      Host.userMetrics.actionTaken(Host.UserMetrics.Action.OverrideContentContextMenuOpenExistingFile);
    }

    return true;
  }

  async saveUISourceCodeForOverrides(uiSourceCode: Workspace.UISourceCode.UISourceCode): Promise<void> {
    if (!this.#canSaveUISourceCodeForOverrides(uiSourceCode)) {
      return;
    }
    this.#savingForOverrides.add(uiSourceCode);
    let encodedPath = this.encodedPathFromUrl(uiSourceCode.url());
    const contentDataOrError = await uiSourceCode.requestContentData();
    const {content, isEncoded} = TextUtils.ContentData.ContentData.asDeferredContent(contentDataOrError);
    const lastIndexOfSlash = encodedPath.lastIndexOf('/');
    const encodedFileName = Common.ParsedURL.ParsedURL.substring(encodedPath, lastIndexOfSlash + 1);
    const rawFileName = Common.ParsedURL.ParsedURL.encodedPathToRawPathString(encodedFileName);
    encodedPath = Common.ParsedURL.ParsedURL.substr(encodedPath, 0, lastIndexOfSlash);
    if (this.#project) {
      await this.#project.createFile(encodedPath, rawFileName, content ?? '', isEncoded);
    }
    this.fileCreatedForTest(encodedPath, rawFileName);
    this.#savingForOverrides.delete(uiSourceCode);
  }

  private fileCreatedForTest(_path: Platform.DevToolsPath.EncodedPathString, _fileName: string): void {
  }

  private patternForFileSystemUISourceCode(uiSourceCode: Workspace.UISourceCode.UISourceCode): string {
    const relativePathParts = FileSystemWorkspaceBinding.relativePath(uiSourceCode);
    if (relativePathParts.length < 2) {
      return '';
    }
    if (relativePathParts[1] === 'longurls' && relativePathParts.length !== 2) {
      if (relativePathParts[0] === 'file:') {
        return 'file:///*';
      }
      return 'http?://' + relativePathParts[0] + '/*';
    }
    // 'relativePath' returns an encoded string of the local file name which itself is already encoded.
    // We therefore need to decode twice to get the raw path.
    const path = this.decodeLocalPathToUrlPath(this.decodeLocalPathToUrlPath(relativePathParts.join('/')));
    if (path.startsWith('file:/')) {
      // The file path of the override file looks like '/path/to/overrides/file:/path/to/local/files/index.html'.
      // The decoded relative path then starts with 'file:/' which we modify to start with 'file:///' instead.
      return 'file:///' + path.substring('file:/'.length);
    }
    return 'http?://' + path;
  }

  // 'chrome://'-URLs and the Chrome Web Store are privileged URLs. We don't want users
  // to be able to override those. Ideally we'd have a similar check in the backend,
  // because the fix here has no effect on non-DevTools CDP clients.
  private isForbiddenFileUrl(uiSourceCode: Workspace.UISourceCode.UISourceCode): boolean {
    const relativePathParts = FileSystemWorkspaceBinding.relativePath(uiSourceCode);
    // Decode twice to handle paths generated on Windows OS.
    const host = this.decodeLocalPathToUrlPath(this.decodeLocalPathToUrlPath(relativePathParts[0] || ''));
    return host === 'chrome:' || forbiddenUrls.includes(host);
  }

  static isForbiddenNetworkUrl(urlString: Platform.DevToolsPath.UrlString): boolean {
    const url = Common.ParsedURL.ParsedURL.fromString(urlString);
    if (!url) {
      return false;
    }
    return url.scheme === 'chrome' || forbiddenUrls.includes(url.host);
  }

  private async onUISourceCodeAdded(uiSourceCode: Workspace.UISourceCode.UISourceCode): Promise<void> {
    await this.networkUISourceCodeAdded(uiSourceCode);
    await this.filesystemUISourceCodeAdded(uiSourceCode);
  }

  private canHandleNetworkUISourceCode(uiSourceCode: Workspace.UISourceCode.UISourceCode): boolean {
    return this.#active && !Common.ParsedURL.schemeIs(uiSourceCode.url(), 'snippet:');
  }

  private async networkUISourceCodeAdded(uiSourceCode: Workspace.UISourceCode.UISourceCode): Promise<void> {
    if (uiSourceCode.project().type() !== Workspace.Workspace.projectTypes.Network ||
        !this.canHandleNetworkUISourceCode(uiSourceCode)) {
      return;
    }
    const url = Common.ParsedURL.ParsedURL.urlWithoutHash(uiSourceCode.url()) as Platform.DevToolsPath.UrlString;
    this.#networkUISourceCodeForEncodedPath.set(this.encodedPathFromUrl(url), uiSourceCode);

    const project = this.#project as FileSystem;
    const fileSystemUISourceCode = project.uiSourceCodeForURL(this.fileUrlFromNetworkUrl(url));
    if (fileSystemUISourceCode) {
      await this.#bind(uiSourceCode, fileSystemUISourceCode);
    }
    this.#maybeDispatchRequestsForHeaderOverridesFileChanged(uiSourceCode);
  }

  private async filesystemUISourceCodeAdded(uiSourceCode: Workspace.UISourceCode.UISourceCode): Promise<void> {
    if (!this.#active || uiSourceCode.project() !== this.#project) {
      return;
    }
    this.updateInterceptionPatterns();

    const relativePath = FileSystemWorkspaceBinding.relativePath(uiSourceCode);
    const networkUISourceCode =
        this.#networkUISourceCodeForEncodedPath.get(Common.ParsedURL.ParsedURL.join(relativePath, '/'));
    if (networkUISourceCode) {
      await this.#bind(networkUISourceCode, uiSourceCode);
    }
  }

  async #getHeaderOverridesFromUiSourceCode(uiSourceCode: Workspace.UISourceCode.UISourceCode):
      Promise<HeaderOverride[]> {
    const contentData =
        await uiSourceCode.requestContentData().then(TextUtils.ContentData.ContentData.contentDataOrEmpty);
    const content = contentData.text || '[]';
    let headerOverrides: HeaderOverride[] = [];
    try {
      headerOverrides = JSON.parse(content) as HeaderOverride[];
      if (!headerOverrides.every(isHeaderOverride)) {
        throw new Error('Type mismatch after parsing');
      }
    } catch {
      console.error('Failed to parse', uiSourceCode.url(), 'for locally overriding headers.');
      return [];
    }
    return headerOverrides;
  }

  #doubleDecodeEncodedPathString(relativePath: Platform.DevToolsPath.EncodedPathString):
      {singlyDecodedPath: Platform.DevToolsPath.EncodedPathString, decodedPath: Platform.DevToolsPath.RawPathString} {
    // 'relativePath' is an encoded string of a local file path, which is itself already encoded.
    // e.g. relativePath: 'www.example.com%253A443/path/.headers'
    // singlyDecodedPath: 'www.example.com%3A443/path/.headers'
    // decodedPath: 'www.example.com:443/path/.headers'
    const singlyDecodedPath = this.decodeLocalPathToUrlPath(relativePath) as Platform.DevToolsPath.EncodedPathString;
    const decodedPath = this.decodeLocalPathToUrlPath(singlyDecodedPath) as Platform.DevToolsPath.RawPathString;
    return {singlyDecodedPath, decodedPath};
  }

  async generateHeaderPatterns(uiSourceCode: Workspace.UISourceCode.UISourceCode): Promise<{
    headerPatterns: Set<string>,
    path: Platform.DevToolsPath.EncodedPathString,
    overridesWithRegex: HeaderOverrideWithRegex[],
  }> {
    const headerOverrides = await this.#getHeaderOverridesFromUiSourceCode(uiSourceCode);
    const relativePathParts = FileSystemWorkspaceBinding.relativePath(uiSourceCode);
    const relativePath = Common.ParsedURL.ParsedURL.slice(
        Common.ParsedURL.ParsedURL.join(relativePathParts, '/'), 0, -HEADERS_FILENAME.length);
    const {singlyDecodedPath, decodedPath} = this.#doubleDecodeEncodedPathString(relativePath);
    let patterns;

    // Long URLS are encoded as `[domain]/longurls/[hashed path]` by `rawPathFromUrl()`.
    if (relativePathParts.length > 2 && relativePathParts[1] === 'longurls' && headerOverrides.length) {
      patterns = this.#generateHeaderPatternsForLongUrl(decodedPath, headerOverrides, relativePathParts[0]);
    } else if (decodedPath.startsWith('file:/')) {
      patterns = this.#generateHeaderPatternsForFileUrl(
          Common.ParsedURL.ParsedURL.substring(decodedPath, 'file:/'.length), headerOverrides);
    } else {
      patterns = this.#generateHeaderPatternsForHttpUrl(decodedPath, headerOverrides);
    }
    return {...patterns, path: singlyDecodedPath};
  }

  #generateHeaderPatternsForHttpUrl(
      decodedPath: Platform.DevToolsPath.RawPathString, headerOverrides: HeaderOverride[]): {
    headerPatterns: Set<string>,
    overridesWithRegex: HeaderOverrideWithRegex[],
  } {
    const headerPatterns = new Set<string>();
    const overridesWithRegex: HeaderOverrideWithRegex[] = [];
    for (const headerOverride of headerOverrides) {
      headerPatterns.add('http?://' + decodedPath + headerOverride.applyTo);

      // Make 'global' overrides apply to file URLs as well.
      if (decodedPath === '') {
        headerPatterns.add('file:///' + headerOverride.applyTo);
        overridesWithRegex.push({
          applyToRegex: new RegExp('^file:\/\/\/' + escapeRegex(decodedPath + headerOverride.applyTo) + '$'),
          headers: headerOverride.headers,
        });
      }

      // Most servers have the concept of a "directory index", which is a
      // default resource name for a request targeting a "directory", e. g.
      // requesting "example.com/path/" would result in the same response as
      // requesting "example.com/path/index.html". To match this behavior we
      // generate an additional pattern without "index.html" as the longer
      // pattern would not match against a shorter request.
      const {head, tail} = extractDirectoryIndex(headerOverride.applyTo);
      if (tail) {
        headerPatterns.add('http?://' + decodedPath + head);

        overridesWithRegex.push({
          applyToRegex: new RegExp(`^${escapeRegex(decodedPath + head)}(${escapeRegex(tail)})?$`),
          headers: headerOverride.headers,
        });
      } else {
        overridesWithRegex.push({
          applyToRegex: new RegExp(`^${escapeRegex(decodedPath + headerOverride.applyTo)}$`),
          headers: headerOverride.headers,
        });
      }
    }
    return {headerPatterns, overridesWithRegex};
  }

  #generateHeaderPatternsForFileUrl(
      decodedPath: Platform.DevToolsPath.RawPathString, headerOverrides: HeaderOverride[]): {
    headerPatterns: Set<string>,
    overridesWithRegex: HeaderOverrideWithRegex[],
  } {
    const headerPatterns = new Set<string>();
    const overridesWithRegex: HeaderOverrideWithRegex[] = [];
    for (const headerOverride of headerOverrides) {
      headerPatterns.add('file:///' + decodedPath + headerOverride.applyTo);
      overridesWithRegex.push({
        applyToRegex: new RegExp(`^file:\/${escapeRegex(decodedPath + headerOverride.applyTo)}$`),
        headers: headerOverride.headers,
      });
    }
    return {headerPatterns, overridesWithRegex};
  }

  // For very long URLs, part of the URL is hashed for local overrides, so that
  // the URL appears shorter. This special case is handled here.
  #generateHeaderPatternsForLongUrl(
      decodedPath: Platform.DevToolsPath.RawPathString, headerOverrides: HeaderOverride[],
      relativePathPart: Platform.DevToolsPath.EncodedPathString): {
    headerPatterns: Set<string>,
    overridesWithRegex: HeaderOverrideWithRegex[],
  } {
    const headerPatterns = new Set<string>();

    // Use pattern with wildcard => every request which matches will be paused
    // and checked whether its hashed URL matches a stored local override in
    // `maybeMergeHeadersForPathSegment()`.
    let {decodedPath: decodedPattern} =
        this.#doubleDecodeEncodedPathString(Common.ParsedURL.ParsedURL.concatenate(relativePathPart, '/*'));

    const isFileUrl = decodedPath.startsWith('file:/');
    if (isFileUrl) {
      decodedPath = Common.ParsedURL.ParsedURL.substring(decodedPath, 'file:/'.length);
      decodedPattern = Common.ParsedURL.ParsedURL.substring(decodedPattern, 'file:/'.length);
    }
    headerPatterns.add((isFileUrl ? 'file:///' : 'http?://') + decodedPattern);

    const overridesWithRegex: HeaderOverrideWithRegex[] = [];
    for (const headerOverride of headerOverrides) {
      overridesWithRegex.push({
        applyToRegex: new RegExp(`^${isFileUrl ? 'file:\/' : ''}${escapeRegex(decodedPath + headerOverride.applyTo)}$`),
        headers: headerOverride.headers,
      });
    }
    return {headerPatterns, overridesWithRegex};
  }

  async updateInterceptionPatternsForTests(): Promise<void> {
    await this.#innerUpdateInterceptionPatterns();
  }

  updateInterceptionPatterns(): void {
    void this.#updateInterceptionThrottler.schedule(this.#innerUpdateInterceptionPatterns.bind(this));
  }

  async #innerUpdateInterceptionPatterns(): Promise<void> {
    this.#headerOverridesMap.clear();
    if (!this.#active || !this.#project) {
      return await SDK.NetworkManager.MultitargetNetworkManager.instance().setInterceptionHandlerForPatterns(
          [], this.#interceptionHandlerBound);
    }
    let patterns = new Set<string>();
    for (const uiSourceCode of this.#project.uiSourceCodes()) {
      if (this.isForbiddenFileUrl(uiSourceCode)) {
        continue;
      }
      const pattern = this.patternForFileSystemUISourceCode(uiSourceCode);
      if (uiSourceCode.name() === HEADERS_FILENAME) {
        const {headerPatterns, path, overridesWithRegex} = await this.generateHeaderPatterns(uiSourceCode);
        if (headerPatterns.size > 0) {
          patterns = new Set([...patterns, ...headerPatterns]);
          this.#headerOverridesMap.set(path, overridesWithRegex);
        }
      } else {
        patterns.add(pattern);
      }
      // Most servers have the concept of a "directory index", which is a
      // default resource name for a request targeting a "directory", e. g.
      // requesting "example.com/path/" would result in the same response as
      // requesting "example.com/path/index.html". To match this behavior we
      // generate an additional pattern without "index.html" as the longer
      // pattern would not match against a shorter request.
      const {head, tail} = extractDirectoryIndex(pattern);
      if (tail) {
        patterns.add(head);
      }
    }

    return await SDK.NetworkManager.MultitargetNetworkManager.instance().setInterceptionHandlerForPatterns(
        Array.from(patterns).map(
            pattern => ({urlPattern: pattern, requestStage: Protocol.Fetch.RequestStage.Response})),
        this.#interceptionHandlerBound);
  }

  private async onUISourceCodeRemoved(uiSourceCode: Workspace.UISourceCode.UISourceCode): Promise<void> {
    await this.networkUISourceCodeRemoved(uiSourceCode);
    await this.filesystemUISourceCodeRemoved(uiSourceCode);
  }

  private async networkUISourceCodeRemoved(uiSourceCode: Workspace.UISourceCode.UISourceCode): Promise<void> {
    if (uiSourceCode.project().type() === Workspace.Workspace.projectTypes.Network) {
      await this.#unbind(uiSourceCode);
      this.#sourceCodeToBindProcessMutex.delete(uiSourceCode);
      this.#networkUISourceCodeForEncodedPath.delete(this.encodedPathFromUrl(uiSourceCode.url()));
    }
    this.#maybeDispatchRequestsForHeaderOverridesFileChanged(uiSourceCode);
  }

  // We consider a header override file as active, if it matches (= potentially contains
  // header overrides for) some of the current page's requests.
  // The editors (in the Sources panel) of active header override files should have an
  // emphasized icon. For regular overrides we use bindings to determine which editors
  // are active. For header overrides we do not have a 1:1 matching between the file
  // defining the header overrides and the request matching the override definition,
  // because a single '.headers' file can contain header overrides for multiple requests.
  // For each request, we therefore look whether one or more matching header override
  // files exist, and if they do, for each of them we emit an event, which causes
  // potential matching editors to update their icon.
  #maybeDispatchRequestsForHeaderOverridesFileChanged(uiSourceCode: Workspace.UISourceCode.UISourceCode): void {
    if (!this.#project) {
      return;
    }
    const project = this.#project as FileSystem;
    const fileUrl = this.fileUrlFromNetworkUrl(uiSourceCode.url());

    for (let i = project.fileSystemPath().length; i < fileUrl.length; i++) {
      if (fileUrl[i] !== '/') {
        continue;
      }
      const headersFilePath =
          Common.ParsedURL.ParsedURL.concatenate(Common.ParsedURL.ParsedURL.substring(fileUrl, 0, i + 1), '.headers');
      const headersFileUiSourceCode = project.uiSourceCodeForURL(headersFilePath);
      if (!headersFileUiSourceCode) {
        continue;
      }
      this.#headerOverridesForEventDispatch.add(headersFileUiSourceCode);
      void this.#eventDispatchThrottler.schedule(this.#dispatchRequestsForHeaderOverridesFileChanged.bind(this));
    }
  }

  #dispatchRequestsForHeaderOverridesFileChanged(): Promise<void> {
    for (const headersFileUiSourceCode of this.#headerOverridesForEventDispatch) {
      this.dispatchEventToListeners(Events.REQUEST_FOR_HEADER_OVERRIDES_FILE_CHANGED, headersFileUiSourceCode);
    }
    this.#headerOverridesForEventDispatch.clear();
    return Promise.resolve();
  }

  hasMatchingNetworkUISourceCodeForHeaderOverridesFile(headersFile: Workspace.UISourceCode.UISourceCode): boolean {
    const relativePathParts = FileSystemWorkspaceBinding.relativePath(headersFile);
    const relativePath = Common.ParsedURL.ParsedURL.slice(
        Common.ParsedURL.ParsedURL.join(relativePathParts, '/'), 0, -HEADERS_FILENAME.length);

    for (const encodedNetworkPath of this.#networkUISourceCodeForEncodedPath.keys()) {
      if (encodedNetworkPath.startsWith(relativePath)) {
        return true;
      }
    }
    return false;
  }

  private async filesystemUISourceCodeRemoved(uiSourceCode: Workspace.UISourceCode.UISourceCode): Promise<void> {
    if (uiSourceCode.project() !== this.#project) {
      return;
    }
    this.updateInterceptionPatterns();
    this.#originalResponseContentPromises.delete(uiSourceCode);
    await this.#unbind(uiSourceCode);
  }

  async setProject(project: Workspace.Workspace.Project|null): Promise<void> {
    if (project === this.#project) {
      return;
    }

    if (this.#project) {
      await Promise.all(
          [...this.#project.uiSourceCodes()].map(uiSourceCode => this.filesystemUISourceCodeRemoved(uiSourceCode)));
    }

    this.#project = project;

    if (this.#project) {
      await Promise.all(
          [...this.#project.uiSourceCodes()].map(uiSourceCode => this.filesystemUISourceCodeAdded(uiSourceCode)));
    }

    await this.updateActiveProject();
    this.dispatchEventToListeners(Events.PROJECT_CHANGED, this.#project);
  }

  private async onProjectAdded(project: Workspace.Workspace.Project): Promise<void> {
    if (project.type() !== Workspace.Workspace.projectTypes.FileSystem ||
        FileSystemWorkspaceBinding.fileSystemType(project) !== 'overrides') {
      return;
    }
    const fileSystemPath = FileSystemWorkspaceBinding.fileSystemPath(project.id() as Platform.DevToolsPath.UrlString);
    if (!fileSystemPath) {
      return;
    }
    if (this.#project) {
      this.#project.remove();
    }

    await this.setProject(project);
  }

  private async onProjectRemoved(project: Workspace.Workspace.Project): Promise<void> {
    for (const uiSourceCode of project.uiSourceCodes()) {
      await this.networkUISourceCodeRemoved(uiSourceCode);
    }
    if (project === this.#project) {
      await this.setProject(null);
    }
  }

  mergeHeaders(baseHeaders: Protocol.Fetch.HeaderEntry[], overrideHeaders: Protocol.Fetch.HeaderEntry[]):
      Protocol.Fetch.HeaderEntry[] {
    const headerMap = new Platform.MapUtilities.Multimap<string, string>();
    for (const {name, value} of overrideHeaders) {
      if (name.toLowerCase() !== 'set-cookie') {
        headerMap.set(name.toLowerCase(), value);
      }
    }

    const overriddenHeaderNames = new Set(headerMap.keysArray());
    for (const {name, value} of baseHeaders) {
      const lowerCaseName = name.toLowerCase();
      if (!overriddenHeaderNames.has(lowerCaseName) && lowerCaseName !== 'set-cookie') {
        headerMap.set(lowerCaseName, value);
      }
    }

    const result: Protocol.Fetch.HeaderEntry[] = [];
    for (const headerName of headerMap.keysArray()) {
      for (const headerValue of headerMap.get(headerName)) {
        result.push({name: headerName, value: headerValue});
      }
    }

    const originalSetCookieHeaders = baseHeaders.filter(header => header.name.toLowerCase() === 'set-cookie') || [];
    const setCookieHeadersFromOverrides = overrideHeaders.filter(header => header.name.toLowerCase() === 'set-cookie');
    const mergedHeaders = SDK.NetworkManager.InterceptedRequest.mergeSetCookieHeaders(
        originalSetCookieHeaders, setCookieHeadersFromOverrides);
    result.push(...mergedHeaders);

    return result;
  }

  #maybeMergeHeadersForPathSegment(
      path: Platform.DevToolsPath.EncodedPathString, requestUrl: Platform.DevToolsPath.UrlString,
      headers: Protocol.Fetch.HeaderEntry[]): Protocol.Fetch.HeaderEntry[] {
    const headerOverrides = this.#headerOverridesMap.get(path) || [];
    for (const headerOverride of headerOverrides) {
      const requestUrlWithLongUrlReplacement = this.decodeLocalPathToUrlPath(this.rawPathFromUrl(requestUrl));
      if (headerOverride.applyToRegex.test(requestUrlWithLongUrlReplacement)) {
        headers = this.mergeHeaders(headers, headerOverride.headers);
      }
    }
    return headers;
  }

  handleHeaderInterception(interceptedRequest: SDK.NetworkManager.InterceptedRequest): Protocol.Fetch.HeaderEntry[] {
    let result: Protocol.Fetch.HeaderEntry[] = interceptedRequest.responseHeaders || [];
    // 'rawPathFromUrl()''s return value is already (singly-)encoded, so we can
    // treat it as an 'EncodedPathString' here.
    const urlSegments =
        this.rawPathFromUrl(interceptedRequest.request.url as Platform.DevToolsPath.UrlString).split('/') as
        Platform.DevToolsPath.EncodedPathString[];
    // Traverse the hierarchy of overrides from the most general to the most
    // specific. Check with empty string first to match overrides applying to
    // all domains.
    // e.g. '', 'www.example.com/', 'www.example.com/path/', ...
    let path = Platform.DevToolsPath.EmptyEncodedPathString;
    result = this.#maybeMergeHeadersForPathSegment(
        path, interceptedRequest.request.url as Platform.DevToolsPath.UrlString, result);
    for (const segment of urlSegments) {
      path = Common.ParsedURL.ParsedURL.concatenate(path, segment, '/');
      result = this.#maybeMergeHeadersForPathSegment(
          path, interceptedRequest.request.url as Platform.DevToolsPath.UrlString, result);
    }
    return result;
  }

  private async interceptionHandler(interceptedRequest: SDK.NetworkManager.InterceptedRequest): Promise<void> {
    const method = interceptedRequest.request.method;
    if (!this.#active || (method === 'OPTIONS')) {
      return;
    }
    const proj = this.#project as FileSystem;
    const path = this.fileUrlFromNetworkUrl(interceptedRequest.request.url as Platform.DevToolsPath.UrlString);
    const fileSystemUISourceCode = proj.uiSourceCodeForURL(path);
    let responseHeaders = this.handleHeaderInterception(interceptedRequest);
    if (!fileSystemUISourceCode && !responseHeaders.length) {
      return;
    }
    if (!responseHeaders.length) {
      responseHeaders = interceptedRequest.responseHeaders || [];
    }

    let {mimeType} = interceptedRequest.getMimeTypeAndCharset();
    if (!mimeType) {
      const expectedResourceType =
          Common.ResourceType.resourceTypes[interceptedRequest.resourceType] || Common.ResourceType.resourceTypes.Other;
      mimeType = fileSystemUISourceCode?.mimeType() || '';
      if (Common.ResourceType.ResourceType.fromMimeType(mimeType) !== expectedResourceType) {
        mimeType = expectedResourceType.canonicalMimeType();
      }
    }

    if (fileSystemUISourceCode) {
      this.#originalResponseContentPromises.set(
          fileSystemUISourceCode, interceptedRequest.responseBody().then(response => {
            if (TextUtils.ContentData.ContentData.isError(response) || !response.isTextContent) {
              return null;
            }
            return response.text;
          }));

      const project = fileSystemUISourceCode.project() as FileSystem;
      const blob = await project.requestFileBlob(fileSystemUISourceCode);
      if (blob) {
        void interceptedRequest.continueRequestWithContent(
            new Blob([blob], {type: mimeType}), /* encoded */ false, responseHeaders, /* isBodyOverridden */ true);
      }
    } else if (interceptedRequest.isRedirect()) {
      void interceptedRequest.continueRequestWithContent(
          new Blob([], {type: mimeType}), /* encoded */ true, responseHeaders, /* isBodyOverridden */ false);
    } else {
      const responseBody = await interceptedRequest.responseBody();
      if (!TextUtils.ContentData.ContentData.isError(responseBody)) {
        const content = responseBody.isTextContent ? responseBody.text : responseBody.base64;
        void interceptedRequest.continueRequestWithContent(
            new Blob([content], {type: mimeType}), /* encoded */ !responseBody.isTextContent, responseHeaders,
            /* isBodyOverridden */ false);
      }
    }
  }
}

const RESERVED_FILENAMES = new Set<string>([
  'con',  'prn',  'aux',  'nul',  'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7',
  'com8', 'com9', 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9',
]);

export const HEADERS_FILENAME = '.headers';

export const enum Events {
  PROJECT_CHANGED = 'ProjectChanged',
  REQUEST_FOR_HEADER_OVERRIDES_FILE_CHANGED = 'RequestsForHeaderOverridesFileChanged',
  LOCAL_OVERRIDES_PROJECT_UPDATED = 'LocalOverridesProjectUpdated',
  LOCAL_OVERRIDES_REQUESTED = 'LocalOverridesRequested',
}

export interface EventTypes {
  [Events.PROJECT_CHANGED]: Workspace.Workspace.Project|null;
  [Events.REQUEST_FOR_HEADER_OVERRIDES_FILE_CHANGED]: Workspace.UISourceCode.UISourceCode;
  [Events.LOCAL_OVERRIDES_PROJECT_UPDATED]: boolean;
  [Events.LOCAL_OVERRIDES_REQUESTED]: () => void;
}

export interface HeaderOverride {
  applyTo: string;
  headers: Protocol.Fetch.HeaderEntry[];
}

interface HeaderOverrideWithRegex {
  applyToRegex: RegExp;
  headers: Protocol.Fetch.HeaderEntry[];
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function isHeaderOverride(arg: any): arg is HeaderOverride {
  if (!(arg && typeof arg.applyTo === 'string' && arg.headers?.length && Array.isArray(arg.headers))) {
    return false;
  }
  return arg.headers.every(
      (header: Protocol.Fetch.HeaderEntry) => typeof header.name === 'string' && typeof header.value === 'string');
}

export function escapeRegex(pattern: string): string {
  return Platform.StringUtilities.escapeCharacters(pattern, '[]{}()\\.^$+|-,?').replaceAll('*', '.*');
}

export function extractDirectoryIndex(pattern: string): {head: string, tail?: string} {
  const lastSlash = pattern.lastIndexOf('/');
  const tail = lastSlash >= 0 ? pattern.slice(lastSlash + 1) : pattern;
  const head = lastSlash >= 0 ? pattern.slice(0, lastSlash + 1) : '';
  const regex = new RegExp('^' + escapeRegex(tail) + '$');
  if (tail !== '*' && (regex.test('index.html') || regex.test('index.htm') || regex.test('index.php'))) {
    return {head, tail};
  }
  return {head: pattern};
}
