// 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 * as Platform from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as Bindings from '../bindings/bindings.js';
import * as BreakpointManager from '../breakpoints/breakpoints.js';
import * as TextUtils from '../text_utils/text_utils.js';
import * as Workspace from '../workspace/workspace.js';

import {Automapping, type AutomappingStatus} from './Automapping.js';

let persistenceInstance: PersistenceImpl;

export class PersistenceImpl extends Common.ObjectWrapper.ObjectWrapper<EventTypes> {
  readonly #workspace: Workspace.Workspace.WorkspaceImpl;
  readonly #breakpointManager: BreakpointManager.BreakpointManager.BreakpointManager;
  readonly #filePathPrefixesToBindingCount = new FilePathPrefixesBindingCounts();
  #subscribedBindingEventListeners =
      new Platform.MapUtilities.Multimap<Workspace.UISourceCode.UISourceCode, () => void>();
  readonly #mapping: Automapping;

  constructor(
      workspace: Workspace.Workspace.WorkspaceImpl,
      breakpointManager: BreakpointManager.BreakpointManager.BreakpointManager) {
    super();
    this.#workspace = workspace;
    this.#breakpointManager = breakpointManager;
    this.#breakpointManager.addUpdateBindingsCallback(this.#setupBindings.bind(this));

    this.#mapping = new Automapping(this.#workspace, this.onStatusAdded.bind(this), this.onStatusRemoved.bind(this));
  }

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

    return persistenceInstance;
  }

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

  refreshAutomapping(): void {
    this.#mapping.scheduleRemap();
  }

  async addBinding(binding: PersistenceBinding): Promise<void> {
    await this.#addBinding(binding);
  }

  async addBindingForTest(binding: PersistenceBinding): Promise<void> {
    await this.#addBinding(binding);
  }

  async removeBinding(binding: PersistenceBinding): Promise<void> {
    await this.#removeBinding(binding);
  }

  async removeBindingForTest(binding: PersistenceBinding): Promise<void> {
    await this.#removeBinding(binding);
  }

  #setupBindings(networkUISourceCode: Workspace.UISourceCode.UISourceCode): Promise<void> {
    if (networkUISourceCode.project().type() !== Workspace.Workspace.projectTypes.Network) {
      return Promise.resolve();
    }
    return this.#mapping.computeNetworkStatus(networkUISourceCode);
  }

  async #addBinding(binding: PersistenceBinding): Promise<void> {
    bindings.set(binding.network, binding);
    bindings.set(binding.fileSystem, binding);

    binding.fileSystem.forceLoadOnCheckContent();

    binding.network.addEventListener(
        Workspace.UISourceCode.Events.WorkingCopyCommitted, this.onWorkingCopyCommitted, this);
    binding.fileSystem.addEventListener(
        Workspace.UISourceCode.Events.WorkingCopyCommitted, this.onWorkingCopyCommitted, this);
    binding.network.addEventListener(Workspace.UISourceCode.Events.WorkingCopyChanged, this.onWorkingCopyChanged, this);
    binding.fileSystem.addEventListener(
        Workspace.UISourceCode.Events.WorkingCopyChanged, this.onWorkingCopyChanged, this);

    this.#filePathPrefixesToBindingCount.add(binding.fileSystem.url());

    await this.moveBreakpoints(binding.fileSystem, binding.network);

    console.assert(!binding.fileSystem.isDirty() || !binding.network.isDirty());
    if (binding.fileSystem.isDirty()) {
      this.syncWorkingCopy(binding.fileSystem);
    } else if (binding.network.isDirty()) {
      this.syncWorkingCopy(binding.network);
    } else if (binding.network.hasCommits() && binding.network.content() !== binding.fileSystem.content()) {
      binding.network.setWorkingCopy(binding.network.content());
      this.syncWorkingCopy(binding.network);
    }

    this.notifyBindingEvent(binding.network);
    this.notifyBindingEvent(binding.fileSystem);
    this.dispatchEventToListeners(Events.BindingCreated, binding);
  }

  async #removeBinding(binding: PersistenceBinding): Promise<void> {
    if (bindings.get(binding.network) !== binding) {
      return;
    }
    console.assert(
        bindings.get(binding.network) === bindings.get(binding.fileSystem),
        'ERROR: inconsistent binding for networkURL ' + binding.network.url());

    bindings.delete(binding.network);
    bindings.delete(binding.fileSystem);

    binding.network.removeEventListener(
        Workspace.UISourceCode.Events.WorkingCopyCommitted, this.onWorkingCopyCommitted, this);
    binding.fileSystem.removeEventListener(
        Workspace.UISourceCode.Events.WorkingCopyCommitted, this.onWorkingCopyCommitted, this);
    binding.network.removeEventListener(
        Workspace.UISourceCode.Events.WorkingCopyChanged, this.onWorkingCopyChanged, this);
    binding.fileSystem.removeEventListener(
        Workspace.UISourceCode.Events.WorkingCopyChanged, this.onWorkingCopyChanged, this);

    this.#filePathPrefixesToBindingCount.remove(binding.fileSystem.url());
    await this.#breakpointManager.copyBreakpoints(binding.network, binding.fileSystem);

    this.notifyBindingEvent(binding.network);
    this.notifyBindingEvent(binding.fileSystem);
    this.dispatchEventToListeners(Events.BindingRemoved, binding);
  }

  private onStatusAdded(status: AutomappingStatus): Promise<void> {
    const binding = new PersistenceBinding(status.network, status.fileSystem);
    statusBindings.set(status, binding);
    return this.#addBinding(binding);
  }

  private async onStatusRemoved(status: AutomappingStatus): Promise<void> {
    const binding = statusBindings.get(status) as PersistenceBinding;
    await this.#removeBinding(binding);
  }

  private onWorkingCopyChanged(event: Common.EventTarget.EventTargetEvent<Workspace.UISourceCode.UISourceCode>): void {
    const uiSourceCode = event.data;
    this.syncWorkingCopy(uiSourceCode);
  }

  private syncWorkingCopy(uiSourceCode: Workspace.UISourceCode.UISourceCode): void {
    const binding = bindings.get(uiSourceCode);
    if (!binding || mutedWorkingCopies.has(binding)) {
      return;
    }
    const other = binding.network === uiSourceCode ? binding.fileSystem : binding.network;
    if (!uiSourceCode.isDirty()) {
      mutedWorkingCopies.add(binding);
      other.resetWorkingCopy();
      mutedWorkingCopies.delete(binding);
      this.contentSyncedForTest();
      return;
    }

    const target = Bindings.NetworkProject.NetworkProject.targetForUISourceCode(binding.network);
    if (target && target.type() === SDK.Target.Type.NODE) {
      const newContent = uiSourceCode.workingCopy();
      void other.requestContentData().then(() => {
        const nodeJSContent = PersistenceImpl.rewrapNodeJSContent(other, other.workingCopy(), newContent);
        setWorkingCopy.call(this, () => nodeJSContent);
      });
      return;
    }

    setWorkingCopy.call(this, () => uiSourceCode.workingCopy());

    function setWorkingCopy(this: PersistenceImpl, workingCopyGetter: () => string): void {
      if (binding) {
        mutedWorkingCopies.add(binding);
      }
      other.setWorkingCopyGetter(workingCopyGetter);
      if (binding) {
        mutedWorkingCopies.delete(binding);
      }
      this.contentSyncedForTest();
    }
  }

  private onWorkingCopyCommitted(
      event: Common.EventTarget.EventTargetEvent<Workspace.UISourceCode.WorkingCopyCommittedEvent>): void {
    const uiSourceCode = event.data.uiSourceCode;
    const newContent = event.data.content;
    this.syncContent(uiSourceCode, newContent, Boolean(event.data.encoded));
  }

  syncContent(uiSourceCode: Workspace.UISourceCode.UISourceCode, newContent: string, encoded: boolean): void {
    const binding = bindings.get(uiSourceCode);
    if (!binding || mutedCommits.has(binding)) {
      return;
    }
    const other = binding.network === uiSourceCode ? binding.fileSystem : binding.network;
    const target = Bindings.NetworkProject.NetworkProject.targetForUISourceCode(binding.network);
    if (target && target.type() === SDK.Target.Type.NODE) {
      void other.requestContentData()
          .then(contentDataOrError => TextUtils.ContentData.ContentData.textOr(contentDataOrError, ''))
          .then(currentContent => {
            const nodeJSContent = PersistenceImpl.rewrapNodeJSContent(other, currentContent, newContent);
            setContent.call(this, nodeJSContent);
          });
      return;
    }
    setContent.call(this, newContent);

    function setContent(this: PersistenceImpl, newContent: string): void {
      if (binding) {
        mutedCommits.add(binding);
      }
      other.setContent(newContent, encoded);
      if (binding) {
        mutedCommits.delete(binding);
      }
      this.contentSyncedForTest();
    }
  }

  static rewrapNodeJSContent(
      uiSourceCode: Workspace.UISourceCode.UISourceCode, currentContent: string, newContent: string): string {
    if (uiSourceCode.project().type() === Workspace.Workspace.projectTypes.FileSystem) {
      if (newContent.startsWith(NodePrefix) && newContent.endsWith(NodeSuffix)) {
        newContent = newContent.substring(NodePrefix.length, newContent.length - NodeSuffix.length);
      }
      if (currentContent.startsWith(NodeShebang)) {
        newContent = NodeShebang + newContent;
      }
    } else {
      if (newContent.startsWith(NodeShebang)) {
        newContent = newContent.substring(NodeShebang.length);
      }
      if (currentContent.startsWith(NodePrefix) && currentContent.endsWith(NodeSuffix)) {
        newContent = NodePrefix + newContent + NodeSuffix;
      }
    }
    return newContent;
  }

  private contentSyncedForTest(): void {
  }

  private async moveBreakpoints(from: Workspace.UISourceCode.UISourceCode, to: Workspace.UISourceCode.UISourceCode):
      Promise<void> {
    const breakpoints = this.#breakpointManager.breakpointLocationsForUISourceCode(from).map(
        breakpointLocation => breakpointLocation.breakpoint);
    await Promise.all(breakpoints.map(async breakpoint => {
      await breakpoint.remove(false /* keepInStorage */);
      return await this.#breakpointManager.setBreakpoint(
          to, breakpoint.lineNumber(), breakpoint.columnNumber(), breakpoint.condition(), breakpoint.enabled(),
          breakpoint.isLogpoint(), BreakpointManager.BreakpointManager.BreakpointOrigin.OTHER);
    }));
  }

  hasUnsavedCommittedChanges(uiSourceCode: Workspace.UISourceCode.UISourceCode): boolean {
    if (this.#workspace.hasResourceContentTrackingExtensions()) {
      return false;
    }
    if (uiSourceCode.project().canSetFileContent()) {
      return false;
    }
    if (bindings.has(uiSourceCode)) {
      return false;
    }
    return Boolean(uiSourceCode.hasCommits());
  }

  binding(uiSourceCode: Workspace.UISourceCode.UISourceCode): PersistenceBinding|null {
    return bindings.get(uiSourceCode) || null;
  }

  /**
   * Returns whether the UISourceCode has editable content - either its project
   * supports file content changes, or it has a persistence binding to a file system.
   */
  hasEditableContent(uiSourceCode: Workspace.UISourceCode.UISourceCode): boolean {
    return uiSourceCode.project().canSetFileContent() || this.binding(uiSourceCode) !== null;
  }

  subscribeForBindingEvent(uiSourceCode: Workspace.UISourceCode.UISourceCode, listener: () => void): void {
    this.#subscribedBindingEventListeners.set(uiSourceCode, listener);
  }

  unsubscribeFromBindingEvent(uiSourceCode: Workspace.UISourceCode.UISourceCode, listener: () => void): void {
    this.#subscribedBindingEventListeners.delete(uiSourceCode, listener);
  }

  private notifyBindingEvent(uiSourceCode: Workspace.UISourceCode.UISourceCode): void {
    if (!this.#subscribedBindingEventListeners.has(uiSourceCode)) {
      return;
    }
    const listeners = Array.from(this.#subscribedBindingEventListeners.get(uiSourceCode));
    for (const listener of listeners) {
      listener.call(null);
    }
  }

  fileSystem(uiSourceCode: Workspace.UISourceCode.UISourceCode): Workspace.UISourceCode.UISourceCode|null {
    const binding = this.binding(uiSourceCode);
    return binding ? binding.fileSystem : null;
  }

  network(uiSourceCode: Workspace.UISourceCode.UISourceCode): Workspace.UISourceCode.UISourceCode|null {
    const binding = this.binding(uiSourceCode);
    return binding ? binding.network : null;
  }

  filePathHasBindings(filePath: Platform.DevToolsPath.UrlString): boolean {
    return this.#filePathPrefixesToBindingCount.hasBindingPrefix(filePath);
  }
}

class FilePathPrefixesBindingCounts {
  #prefixCounts = new Map<string, number>();

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

  add(filePath: Platform.DevToolsPath.UrlString): void {
    filePath = this.getPlatformCanonicalFilePath(filePath);
    let relative = '';
    for (const token of filePath.split('/')) {
      relative += token + '/';
      const count = this.#prefixCounts.get(relative) || 0;
      this.#prefixCounts.set(relative, count + 1);
    }
  }

  remove(filePath: Platform.DevToolsPath.UrlString): void {
    filePath = this.getPlatformCanonicalFilePath(filePath);
    let relative = '';
    for (const token of filePath.split('/')) {
      relative += token + '/';
      const count = this.#prefixCounts.get(relative);
      if (count === 1) {
        this.#prefixCounts.delete(relative);
      } else if (count !== undefined) {
        this.#prefixCounts.set(relative, count - 1);
      }
    }
  }

  hasBindingPrefix(filePath: Platform.DevToolsPath.UrlString): boolean {
    filePath = this.getPlatformCanonicalFilePath(filePath);
    if (!filePath.endsWith('/')) {
      filePath = Common.ParsedURL.ParsedURL.concatenate(filePath, '/');
    }
    return this.#prefixCounts.has(filePath);
  }
}

const bindings = new WeakMap<Workspace.UISourceCode.UISourceCode, PersistenceBinding>();
const statusBindings = new WeakMap<AutomappingStatus, PersistenceBinding>();

const mutedCommits = new WeakSet<PersistenceBinding>();

const mutedWorkingCopies = new WeakSet<PersistenceBinding>();

export const NodePrefix = '(function (exports, require, module, __filename, __dirname) { ';
export const NodeSuffix = '\n});';
export const NodeShebang = '#!/usr/bin/env node';

export enum Events {
  /* eslint-disable @typescript-eslint/naming-convention -- Used by web_tests. */
  BindingCreated = 'BindingCreated',
  BindingRemoved = 'BindingRemoved',
  /* eslint-enable @typescript-eslint/naming-convention */
}

export interface EventTypes {
  [Events.BindingCreated]: PersistenceBinding;
  [Events.BindingRemoved]: PersistenceBinding;
}

export class PersistenceBinding {
  readonly network: Workspace.UISourceCode.UISourceCode;
  readonly fileSystem: Workspace.UISourceCode.UISourceCode;
  constructor(network: Workspace.UISourceCode.UISourceCode, fileSystem: Workspace.UISourceCode.UISourceCode) {
    this.network = network;
    this.fileSystem = fileSystem;
  }
}
