// Copyright 2016 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 type * as Platform from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as Bindings from '../bindings/bindings.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 {PersistenceImpl} from './PersistenceImpl.js';

export class Automapping {
  readonly #workspace: Workspace.Workspace.WorkspaceImpl;
  readonly #onStatusAdded: (arg0: AutomappingStatus) => Promise<void>;
  readonly #onStatusRemoved: (arg0: AutomappingStatus) => Promise<void>;
  // Used in web tests
  private readonly statuses = new Set<AutomappingStatus>();

  readonly #fileSystemUISourceCodes = new FileSystemUISourceCodes();

  // Used in web tests
  private readonly sweepThrottler = new Common.Throttler.Throttler(100);
  readonly #sourceCodeToProcessingPromiseMap = new WeakMap<Workspace.UISourceCode.UISourceCode, Promise<void>>();

  readonly #sourceCodeToAutoMappingStatusMap = new WeakMap<Workspace.UISourceCode.UISourceCode, AutomappingStatus>();

  readonly #sourceCodeToMetadataMap =
      new WeakMap<Workspace.UISourceCode.UISourceCode, Workspace.UISourceCode.UISourceCodeMetadata|null>();

  readonly #filesIndex: FilePathIndex = new FilePathIndex();
  readonly #projectFoldersIndex: FolderIndex = new FolderIndex();
  readonly #activeFoldersIndex: FolderIndex = new FolderIndex();
  readonly #interceptors: Array<(arg0: Workspace.UISourceCode.UISourceCode) => boolean> = [];

  constructor(
      workspace: Workspace.Workspace.WorkspaceImpl, onStatusAdded: (arg0: AutomappingStatus) => Promise<void>,
      onStatusRemoved: (arg0: AutomappingStatus) => Promise<void>) {
    this.#workspace = workspace;

    this.#onStatusAdded = onStatusAdded;
    this.#onStatusRemoved = onStatusRemoved;

    this.#workspace.addEventListener(
        Workspace.Workspace.Events.UISourceCodeAdded, event => this.#onUISourceCodeAdded(event.data));
    this.#workspace.addEventListener(
        Workspace.Workspace.Events.UISourceCodeRemoved, event => this.#onUISourceCodeRemoved(event.data));
    this.#workspace.addEventListener(Workspace.Workspace.Events.UISourceCodeRenamed, this.#onUISourceCodeRenamed, this);
    this.#workspace.addEventListener(
        Workspace.Workspace.Events.ProjectAdded, event => this.#onProjectAdded(event.data), this);
    this.#workspace.addEventListener(
        Workspace.Workspace.Events.ProjectRemoved, event => this.#onProjectRemoved(event.data), this);

    for (const fileSystem of workspace.projects()) {
      this.#onProjectAdded(fileSystem);
    }
    for (const uiSourceCode of workspace.uiSourceCodes()) {
      this.#onUISourceCodeAdded(uiSourceCode);
    }
  }

  addNetworkInterceptor(interceptor: (arg0: Workspace.UISourceCode.UISourceCode) => boolean): void {
    this.#interceptors.push(interceptor);
    this.scheduleRemap();
  }

  scheduleRemap(): void {
    for (const status of this.statuses.values()) {
      this.#clearNetworkStatus(status.network);
    }
    this.#scheduleSweep();
  }

  #scheduleSweep(): void {
    void this.sweepThrottler.schedule(sweepUnmapped.bind(this));

    function sweepUnmapped(this: Automapping): Promise<void> {
      const networkProjects = this.#workspace.projectsForType(Workspace.Workspace.projectTypes.Network);
      for (const networkProject of networkProjects) {
        for (const uiSourceCode of networkProject.uiSourceCodes()) {
          void this.computeNetworkStatus(uiSourceCode);
        }
      }
      this.onSweepHappenedForTest();
      return Promise.resolve();
    }
  }

  private onSweepHappenedForTest(): void {
  }

  #onProjectRemoved(project: Workspace.Workspace.Project): void {
    for (const uiSourceCode of project.uiSourceCodes()) {
      this.#onUISourceCodeRemoved(uiSourceCode);
    }
    if (project.type() !== Workspace.Workspace.projectTypes.FileSystem) {
      return;
    }
    const fileSystem = project as FileSystem;
    for (const gitFolder of fileSystem.initialGitFolders()) {
      this.#projectFoldersIndex.removeFolder(gitFolder);
    }
    this.#projectFoldersIndex.removeFolder(fileSystem.fileSystemPath());
    this.scheduleRemap();
  }

  #onProjectAdded(project: Workspace.Workspace.Project): void {
    if (project.type() !== Workspace.Workspace.projectTypes.FileSystem) {
      return;
    }
    const fileSystem = project as FileSystem;
    for (const gitFolder of fileSystem.initialGitFolders()) {
      this.#projectFoldersIndex.addFolder(gitFolder);
    }
    this.#projectFoldersIndex.addFolder(fileSystem.fileSystemPath());
    for (const uiSourceCode of project.uiSourceCodes()) {
      this.#onUISourceCodeAdded(uiSourceCode);
    }
    this.scheduleRemap();
  }

  #onUISourceCodeAdded(uiSourceCode: Workspace.UISourceCode.UISourceCode): void {
    const project = uiSourceCode.project();
    if (project.type() === Workspace.Workspace.projectTypes.FileSystem) {
      if (!FileSystemWorkspaceBinding.fileSystemSupportsAutomapping(project)) {
        return;
      }
      this.#filesIndex.addPath(uiSourceCode.url());
      this.#fileSystemUISourceCodes.add(uiSourceCode);
      this.#scheduleSweep();
    } else if (project.type() === Workspace.Workspace.projectTypes.Network) {
      void this.computeNetworkStatus(uiSourceCode);
    }
  }

  #onUISourceCodeRemoved(uiSourceCode: Workspace.UISourceCode.UISourceCode): void {
    if (uiSourceCode.project().type() === Workspace.Workspace.projectTypes.FileSystem) {
      this.#filesIndex.removePath(uiSourceCode.url());
      this.#fileSystemUISourceCodes.delete(uiSourceCode.url());
      const status = this.#sourceCodeToAutoMappingStatusMap.get(uiSourceCode);
      if (status) {
        this.#clearNetworkStatus(status.network);
      }
    } else if (uiSourceCode.project().type() === Workspace.Workspace.projectTypes.Network) {
      this.#clearNetworkStatus(uiSourceCode);
    }
  }

  #onUISourceCodeRenamed(event: Common.EventTarget.EventTargetEvent<Workspace.Workspace.UISourceCodeRenamedEvent>):
      void {
    const {uiSourceCode, oldURL} = event.data;
    if (uiSourceCode.project().type() !== Workspace.Workspace.projectTypes.FileSystem) {
      return;
    }

    this.#filesIndex.removePath(oldURL);
    this.#fileSystemUISourceCodes.delete(oldURL);
    const status = this.#sourceCodeToAutoMappingStatusMap.get(uiSourceCode);
    if (status) {
      this.#clearNetworkStatus(status.network);
    }

    this.#filesIndex.addPath(uiSourceCode.url());
    this.#fileSystemUISourceCodes.add(uiSourceCode);
    this.#scheduleSweep();
  }

  computeNetworkStatus(networkSourceCode: Workspace.UISourceCode.UISourceCode): Promise<void> {
    const processingPromise = this.#sourceCodeToProcessingPromiseMap.get(networkSourceCode);
    if (processingPromise) {
      return processingPromise;
    }
    if (this.#sourceCodeToAutoMappingStatusMap.has(networkSourceCode)) {
      return Promise.resolve();
    }
    if (this.#interceptors.some(interceptor => interceptor(networkSourceCode))) {
      return Promise.resolve();
    }
    if (Common.ParsedURL.schemeIs(networkSourceCode.url(), 'wasm:')) {
      return Promise.resolve();
    }
    const createBindingPromise =
        this.#createBinding(networkSourceCode).then(validateStatus.bind(this)).then(onStatus.bind(this));
    this.#sourceCodeToProcessingPromiseMap.set(networkSourceCode, createBindingPromise);
    return createBindingPromise;

    async function validateStatus(this: Automapping, status: AutomappingStatus|null): Promise<AutomappingStatus|null> {
      if (!status) {
        return null;
      }
      if (this.#sourceCodeToProcessingPromiseMap.get(networkSourceCode) !== createBindingPromise) {
        return null;
      }
      if (status.network.contentType().isFromSourceMap() || !status.fileSystem.contentType().isTextType()) {
        return status;
      }

      // At the time binding comes, there are multiple user scenarios:
      // 1. Both network and fileSystem files are **not** dirty.
      //    This is a typical scenario when user hasn't done any edits yet to the
      //    files in question.
      // 2. FileSystem file has unsaved changes, network is clear.
      //    This typically happens with CSS files editing. Consider the following
      //    scenario:
      //      - user edits file that has been successfully mapped before
      //      - user doesn't save the file
      //      - user hits reload
      // 3. Network file has either unsaved changes or commits, but fileSystem file is clear.
      //    This typically happens when we've been editing file and then realized we'd like to drop
      //    a folder and persist all the changes.
      // 4. Network file has either unsaved changes or commits, and fileSystem file has unsaved changes.
      //    We consider this to be un-realistic scenario and in this case just fail gracefully.
      //
      // To support usecase (3), we need to validate against original network content.
      if (status.fileSystem.isDirty() && (status.network.isDirty() || status.network.hasCommits())) {
        return null;
      }

      const [fileSystemContent, networkContent] = (await Promise.all([
                                                    status.fileSystem.requestContentData(),
                                                    status.network.project().requestFileContent(status.network),
                                                  ])).map(TextUtils.ContentData.ContentData.asDeferredContent);
      if (fileSystemContent.content === null || networkContent === null) {
        return null;
      }

      if (this.#sourceCodeToProcessingPromiseMap.get(networkSourceCode) !== createBindingPromise) {
        return null;
      }

      const target = Bindings.NetworkProject.NetworkProject.targetForUISourceCode(status.network);
      let isValid = false;
      const fileContent = fileSystemContent.content;
      if (target && target.type() === SDK.Target.Type.NODE) {
        if (networkContent.content) {
          const rewrappedNetworkContent =
              PersistenceImpl.rewrapNodeJSContent(status.fileSystem, fileContent, networkContent.content);
          isValid = fileContent === rewrappedNetworkContent;
        }
      } else if (networkContent.content) {
        // Trim trailing whitespaces because V8 adds trailing newline.
        isValid = fileContent.trimEnd() === networkContent.content.trimEnd();
      }
      if (!isValid) {
        this.prevalidationFailedForTest(status);
        return null;
      }
      return status;
    }

    async function onStatus(this: Automapping, status: AutomappingStatus|null): Promise<void> {
      if (this.#sourceCodeToProcessingPromiseMap.get(networkSourceCode) !== createBindingPromise) {
        return;
      }
      if (!status) {
        this.onBindingFailedForTest();
        this.#sourceCodeToProcessingPromiseMap.delete(networkSourceCode);
        return;
      }
      // TODO(lushnikov): remove this check once there's a single uiSourceCode per url. @see crbug.com/670180
      if (this.#sourceCodeToAutoMappingStatusMap.has(status.network) ||
          this.#sourceCodeToAutoMappingStatusMap.has(status.fileSystem)) {
        this.#sourceCodeToProcessingPromiseMap.delete(networkSourceCode);
        return;
      }

      this.statuses.add(status);
      this.#sourceCodeToAutoMappingStatusMap.set(status.network, status);
      this.#sourceCodeToAutoMappingStatusMap.set(status.fileSystem, status);
      if (status.exactMatch) {
        const projectFolder = this.#projectFoldersIndex.closestParentFolder(status.fileSystem.url());
        const newFolderAdded = projectFolder ? this.#activeFoldersIndex.addFolder(projectFolder) : false;
        if (newFolderAdded) {
          this.#scheduleSweep();
        }
      }
      await this.#onStatusAdded.call(null, status);
      this.#sourceCodeToProcessingPromiseMap.delete(networkSourceCode);
    }
  }

  private prevalidationFailedForTest(_binding: AutomappingStatus): void {
  }

  private onBindingFailedForTest(): void {
  }

  #clearNetworkStatus(networkSourceCode: Workspace.UISourceCode.UISourceCode): void {
    if (this.#sourceCodeToProcessingPromiseMap.has(networkSourceCode)) {
      this.#sourceCodeToProcessingPromiseMap.delete(networkSourceCode);
      return;
    }
    const status = this.#sourceCodeToAutoMappingStatusMap.get(networkSourceCode);
    if (!status) {
      return;
    }

    this.statuses.delete(status);
    this.#sourceCodeToAutoMappingStatusMap.delete(status.network);
    this.#sourceCodeToAutoMappingStatusMap.delete(status.fileSystem);
    if (status.exactMatch) {
      const projectFolder = this.#projectFoldersIndex.closestParentFolder(status.fileSystem.url());
      if (projectFolder) {
        this.#activeFoldersIndex.removeFolder(projectFolder);
      }
    }
    void this.#onStatusRemoved.call(null, status);
  }

  async #createBinding(networkSourceCode: Workspace.UISourceCode.UISourceCode): Promise<AutomappingStatus|null> {
    const url = networkSourceCode.url();
    if (Common.ParsedURL.schemeIs(url, 'file:') || Common.ParsedURL.schemeIs(url, 'snippet:')) {
      const fileSourceCode = this.#fileSystemUISourceCodes.get(url);
      const status = fileSourceCode ? new AutomappingStatus(networkSourceCode, fileSourceCode, false) : null;
      return status;
    }

    let networkPath = Common.ParsedURL.ParsedURL.extractPath(url);
    if (networkPath === null) {
      return null;
    }

    if (networkPath.endsWith('/')) {
      networkPath = Common.ParsedURL.ParsedURL.concatenate(networkPath, 'index.html');
    }

    const similarFiles =
        this.#filesIndex.similarFiles(networkPath).map(path => this.#fileSystemUISourceCodes.get(path)) as
        Workspace.UISourceCode.UISourceCode[];
    if (!similarFiles.length) {
      return null;
    }

    await Promise.all(similarFiles.concat(networkSourceCode).map(async sourceCode => {
      this.#sourceCodeToMetadataMap.set(sourceCode, await sourceCode.requestMetadata());
    }));

    const activeFiles = similarFiles.filter(file => !!this.#activeFoldersIndex.closestParentFolder(file.url()));
    const networkMetadata = this.#sourceCodeToMetadataMap.get(networkSourceCode);
    if (!networkMetadata || (!networkMetadata.modificationTime && typeof networkMetadata.contentSize !== 'number')) {
      // If networkSourceCode does not have metadata, try to match against active folders.
      if (activeFiles.length !== 1) {
        return null;
      }
      return new AutomappingStatus(networkSourceCode, activeFiles[0], false);
    }

    // Try to find exact matches, prioritizing active folders.
    let exactMatches = this.#filterWithMetadata(activeFiles, networkMetadata);
    if (!exactMatches.length) {
      exactMatches = this.#filterWithMetadata(similarFiles, networkMetadata);
    }
    if (exactMatches.length !== 1) {
      return null;
    }
    return new AutomappingStatus(networkSourceCode, exactMatches[0], true);
  }

  #filterWithMetadata(
      files: Workspace.UISourceCode.UISourceCode[],
      networkMetadata: Workspace.UISourceCode.UISourceCodeMetadata): Workspace.UISourceCode.UISourceCode[] {
    return files.filter(file => {
      const fileMetadata = this.#sourceCodeToMetadataMap.get(file);
      if (!fileMetadata) {
        return false;
      }
      // Allow a second of difference due to network timestamps lack of precision.
      const timeMatches = !networkMetadata.modificationTime || !fileMetadata.modificationTime ||
          Math.abs(networkMetadata.modificationTime.getTime() - fileMetadata.modificationTime.getTime()) < 1000;
      const contentMatches = !networkMetadata.contentSize || fileMetadata.contentSize === networkMetadata.contentSize;
      return timeMatches && contentMatches;
    });
  }
}

class FilePathIndex {
  readonly #reversedIndex = Common.Trie.Trie.newArrayTrie<string[]>();

  addPath(path: Platform.DevToolsPath.UrlString): void {
    const reversePathParts = path.split('/').reverse();
    this.#reversedIndex.add(reversePathParts);
  }

  removePath(path: Platform.DevToolsPath.UrlString): void {
    const reversePathParts = path.split('/').reverse();
    this.#reversedIndex.remove(reversePathParts);
  }

  similarFiles(networkPath: Platform.DevToolsPath.EncodedPathString): Platform.DevToolsPath.UrlString[] {
    const reversePathParts = networkPath.split('/').reverse();
    const longestCommonPrefix = this.#reversedIndex.longestPrefix(reversePathParts, false);
    if (longestCommonPrefix.length === 0) {
      return [];
    }
    return this.#reversedIndex.words(longestCommonPrefix)
               .map(reversePathParts => reversePathParts.reverse().join('/')) as Platform.DevToolsPath.UrlString[];
  }
}

class FolderIndex {
  readonly #index = Common.Trie.Trie.newArrayTrie<string[]>();
  readonly #folderCount = new Map<string, number>();

  addFolder(path: Platform.DevToolsPath.UrlString): boolean {
    const pathParts = this.#removeTrailingSlash(path).split('/');
    this.#index.add(pathParts);

    const pathForCount = pathParts.join('/');
    const count = this.#folderCount.get(pathForCount) ?? 0;
    this.#folderCount.set(pathForCount, count + 1);
    return count === 0;
  }

  removeFolder(path: Platform.DevToolsPath.UrlString): boolean {
    const pathParts = this.#removeTrailingSlash(path).split('/');
    const pathForCount = pathParts.join('/');
    const count = this.#folderCount.get(pathForCount) ?? 0;
    if (!count) {
      return false;
    }
    if (count > 1) {
      this.#folderCount.set(pathForCount, count - 1);
      return false;
    }
    this.#index.remove(pathParts);
    this.#folderCount.delete(pathForCount);
    return true;
  }

  closestParentFolder(path: Platform.DevToolsPath.UrlString): Platform.DevToolsPath.UrlString {
    const pathParts = path.split('/');
    const commonPrefix = this.#index.longestPrefix(pathParts, /* fullWordOnly */ true);
    return commonPrefix.join('/') as Platform.DevToolsPath.UrlString;
  }

  #removeTrailingSlash(path: Platform.DevToolsPath.UrlString): Platform.DevToolsPath.UrlString {
    if (path.endsWith('/')) {
      return Common.ParsedURL.ParsedURL.substring(path, 0, path.length - 1);
    }
    return path;
  }
}

class FileSystemUISourceCodes {
  readonly #sourceCodes = new Map<Platform.DevToolsPath.UrlString, Workspace.UISourceCode.UISourceCode>();

  private getPlatformCanonicalFileUrl(path: Platform.DevToolsPath.UrlString): Platform.DevToolsPath.UrlString {
    return Host.Platform.isWin() ? Common.ParsedURL.ParsedURL.toLowerCase(path) : path;
  }

  add(sourceCode: Workspace.UISourceCode.UISourceCode): void {
    const fileUrl = this.getPlatformCanonicalFileUrl(sourceCode.url());
    this.#sourceCodes.set(fileUrl, sourceCode);
  }

  get(fileUrl: Platform.DevToolsPath.UrlString): Workspace.UISourceCode.UISourceCode|undefined {
    fileUrl = this.getPlatformCanonicalFileUrl(fileUrl);
    return this.#sourceCodes.get(fileUrl);
  }

  delete(fileUrl: Platform.DevToolsPath.UrlString): void {
    fileUrl = this.getPlatformCanonicalFileUrl(fileUrl);
    this.#sourceCodes.delete(fileUrl);
  }
}

export class AutomappingStatus {
  network: Workspace.UISourceCode.UISourceCode;
  fileSystem: Workspace.UISourceCode.UISourceCode;
  exactMatch: boolean;
  constructor(
      network: Workspace.UISourceCode.UISourceCode, fileSystem: Workspace.UISourceCode.UISourceCode,
      exactMatch: boolean) {
    this.network = network;
    this.fileSystem = fileSystem;
    this.exactMatch = exactMatch;
  }
}
