// Copyright 2017 The Chromium Authors. All rights reserved.
// 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 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 {FormatterSourceMapping} from './ScriptFormatter.js';
import {format} from './ScriptFormatter.js';

const objectToFormattingResult = new WeakMap<Object, SourceFormatData>();

export class SourceFormatData {
  originalSourceCode: Workspace.UISourceCode.UISourceCode;
  formattedSourceCode: Workspace.UISourceCode.UISourceCode;
  mapping: FormatterSourceMapping;

  constructor(
      originalSourceCode: Workspace.UISourceCode.UISourceCode, formattedSourceCode: Workspace.UISourceCode.UISourceCode,
      mapping: FormatterSourceMapping) {
    this.originalSourceCode = originalSourceCode;
    this.formattedSourceCode = formattedSourceCode;
    this.mapping = mapping;
  }

  originalPath(): string {
    return this.originalSourceCode.project().id() + ':' + this.originalSourceCode.url();
  }

  static for(object: Object): SourceFormatData|null {
    return objectToFormattingResult.get(object) || null;
  }
}

let sourceFormatterInstance: SourceFormatter|null = null;

export class SourceFormatter {
  private readonly projectId: string;
  private readonly project: Bindings.ContentProviderBasedProject.ContentProviderBasedProject;
  private readonly formattedSourceCodes: Map<Workspace.UISourceCode.UISourceCode, {
    promise: Promise<SourceFormatData>,
    formatData: SourceFormatData|null,
  }>;
  private readonly scriptMapping: ScriptMapping;
  private readonly styleMapping: StyleMapping;

  constructor() {
    this.projectId = 'formatter:';
    this.project = new Bindings.ContentProviderBasedProject.ContentProviderBasedProject(
        Workspace.Workspace.WorkspaceImpl.instance(), this.projectId, Workspace.Workspace.projectTypes.Formatter,
        'formatter', true /* isServiceProject */);

    this.formattedSourceCodes = new Map();
    this.scriptMapping = new ScriptMapping();
    this.styleMapping = new StyleMapping();
    Workspace.Workspace.WorkspaceImpl.instance().addEventListener(
        Workspace.Workspace.Events.UISourceCodeRemoved, event => {
          void this.onUISourceCodeRemoved(event);
        }, this);
  }

  static instance({forceNew = false}: {forceNew?: boolean} = {}): SourceFormatter {
    if (!sourceFormatterInstance || forceNew) {
      sourceFormatterInstance = new SourceFormatter();
    }
    return sourceFormatterInstance;
  }

  private async onUISourceCodeRemoved(event: Common.EventTarget.EventTargetEvent<Workspace.UISourceCode.UISourceCode>):
      Promise<void> {
    const uiSourceCode = event.data;
    const cacheEntry = this.formattedSourceCodes.get(uiSourceCode);
    if (cacheEntry && cacheEntry.formatData) {
      await this.discardFormatData(cacheEntry.formatData);
    }
    this.formattedSourceCodes.delete(uiSourceCode);
  }

  async discardFormattedUISourceCode(formattedUISourceCode: Workspace.UISourceCode.UISourceCode):
      Promise<Workspace.UISourceCode.UISourceCode|null> {
    const formatData = SourceFormatData.for(formattedUISourceCode);
    if (!formatData) {
      return null;
    }
    await this.discardFormatData(formatData);
    this.formattedSourceCodes.delete(formatData.originalSourceCode);
    return formatData.originalSourceCode;
  }

  private async discardFormatData(formatData: SourceFormatData): Promise<void> {
    objectToFormattingResult.delete(formatData.formattedSourceCode);
    await this.scriptMapping.setSourceMappingEnabled(formatData, false);
    void this.styleMapping.setSourceMappingEnabled(formatData, false);
    this.project.removeFile(formatData.formattedSourceCode.url());
  }

  hasFormatted(uiSourceCode: Workspace.UISourceCode.UISourceCode): boolean {
    return this.formattedSourceCodes.has(uiSourceCode);
  }

  getOriginalUISourceCode(uiSourceCode: Workspace.UISourceCode.UISourceCode): Workspace.UISourceCode.UISourceCode {
    const formatData = objectToFormattingResult.get(uiSourceCode);
    if (!formatData) {
      return uiSourceCode;
    }
    return formatData.originalSourceCode;
  }

  async format(uiSourceCode: Workspace.UISourceCode.UISourceCode): Promise<SourceFormatData> {
    const cacheEntry = this.formattedSourceCodes.get(uiSourceCode);
    if (cacheEntry) {
      return cacheEntry.promise;
    }

    const resultPromise = new Promise<SourceFormatData>(async (resolve, reject) => {
      const {content} = await uiSourceCode.requestContent();

      try {
        const {formattedContent, formattedMapping} =
            await format(uiSourceCode.contentType(), uiSourceCode.mimeType(), content || '');
        const cacheEntry = this.formattedSourceCodes.get(uiSourceCode);
        if (!cacheEntry || cacheEntry.promise !== resultPromise) {
          return;
        }
        let formattedURL;
        let count = 0;
        let suffix = '';
        do {
          formattedURL = Common.ParsedURL.ParsedURL.concatenate(uiSourceCode.url(), ':formatted', suffix);
          suffix = `:${count++}`;
        } while (this.project.uiSourceCodeForURL(formattedURL));
        const contentProvider = TextUtils.StaticContentProvider.StaticContentProvider.fromString(
            formattedURL, uiSourceCode.contentType(), formattedContent);
        const formattedUISourceCode = this.project.createUISourceCode(formattedURL, contentProvider.contentType());
        const formatData = new SourceFormatData(uiSourceCode, formattedUISourceCode, formattedMapping);
        objectToFormattingResult.set(formattedUISourceCode, formatData);
        this.project.addUISourceCodeWithProvider(
            formattedUISourceCode, contentProvider, /* metadata */ null, uiSourceCode.mimeType());
        await this.scriptMapping.setSourceMappingEnabled(formatData, true);
        await this.styleMapping.setSourceMappingEnabled(formatData, true);
        cacheEntry.formatData = formatData;
        resolve(formatData);
      } catch (e) {
        reject(e);
      }
    });

    this.formattedSourceCodes.set(uiSourceCode, {promise: resultPromise, formatData: null});

    return resultPromise;
  }
}

class ScriptMapping implements Bindings.DebuggerWorkspaceBinding.DebuggerSourceMapping {
  constructor() {
    Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance().addSourceMapping(this);
  }

  rawLocationToUILocation(rawLocation: SDK.DebuggerModel.Location): Workspace.UISourceCode.UILocation|null {
    const script = rawLocation.script();
    const formatData = script && SourceFormatData.for(script);
    if (!formatData || !script) {
      return null;
    }
    const [lineNumber, columnNumber] =
        formatData.mapping.originalToFormatted(rawLocation.lineNumber, rawLocation.columnNumber || 0);
    return formatData.formattedSourceCode.uiLocation(lineNumber, columnNumber);
  }

  uiLocationToRawLocations(uiSourceCode: Workspace.UISourceCode.UISourceCode, lineNumber: number, columnNumber: number):
      SDK.DebuggerModel.Location[] {
    const formatData = SourceFormatData.for(uiSourceCode);
    if (!formatData) {
      return [];
    }
    const [originalLine, originalColumn] = formatData.mapping.formattedToOriginal(lineNumber, columnNumber);
    if (formatData.originalSourceCode.contentType().isScript()) {
      // Here we have a script that is displayed on its own (i.e. it has a dedicated uiSourceCode). This means it is
      // either a stand-alone script or an inline script with a #sourceURL= and in both cases we can just forward the
      // question to the original (unformatted) source code.
      const rawLocations = Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance()
                               .uiLocationToRawLocationsForUnformattedJavaScript(
                                   formatData.originalSourceCode, originalLine, originalColumn);
      console.assert(rawLocations.every(l => l && Boolean(l.script())));
      return rawLocations;
    }
    if (formatData.originalSourceCode.contentType() === Common.ResourceType.resourceTypes.Document) {
      const target = Bindings.NetworkProject.NetworkProject.targetForUISourceCode(formatData.originalSourceCode);
      const debuggerModel = target && target.model(SDK.DebuggerModel.DebuggerModel);
      if (debuggerModel) {
        const scripts = debuggerModel.scriptsForSourceURL(formatData.originalSourceCode.url())
                            .filter(script => script.isInlineScript() && !script.hasSourceURL);
        // Here we have an inline script, which was formatted together with the containing document, so we must not
        // translate locations as they are relative to the start of the document.
        const locations =
            (scripts.map(script => script.rawLocation(originalLine, originalColumn)).filter(l => Boolean(l)) as
             SDK.DebuggerModel.Location[]);
        console.assert(locations.every(l => l && Boolean(l.script())));
        return locations;
      }
    }
    return [];
  }

  async setSourceMappingEnabled(formatData: SourceFormatData, enabled: boolean): Promise<void> {
    const scripts = this.scriptsForUISourceCode(formatData.originalSourceCode);
    if (!scripts.length) {
      return;
    }
    if (enabled) {
      for (const script of scripts) {
        objectToFormattingResult.set(script, formatData);
      }
    } else {
      for (const script of scripts) {
        objectToFormattingResult.delete(script);
      }
    }
    const updatePromises = scripts.map(
        script => Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance().updateLocations(script));
    await Promise.all(updatePromises);
  }

  private scriptsForUISourceCode(uiSourceCode: Workspace.UISourceCode.UISourceCode): SDK.Script.Script[] {
    if (uiSourceCode.contentType() === Common.ResourceType.resourceTypes.Document) {
      const target = Bindings.NetworkProject.NetworkProject.targetForUISourceCode(uiSourceCode);
      const debuggerModel = target && target.model(SDK.DebuggerModel.DebuggerModel);
      if (debuggerModel) {
        const scripts = debuggerModel.scriptsForSourceURL(uiSourceCode.url())
                            .filter(script => script.isInlineScript() && !script.hasSourceURL);
        return scripts;
      }
    }
    if (uiSourceCode.contentType().isScript()) {
      console.assert(!objectToFormattingResult.has(uiSourceCode));
      const rawLocations = Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance()
                               .uiLocationToRawLocationsForUnformattedJavaScript(uiSourceCode, 0, 0);
      return rawLocations.map(location => location.script()).filter(script => Boolean(script)) as SDK.Script.Script[];
    }
    return [];
  }
}

const sourceCodeToHeaders =
    new WeakMap<Workspace.UISourceCode.UISourceCode, SDK.CSSStyleSheetHeader.CSSStyleSheetHeader[]>();

class StyleMapping implements Bindings.CSSWorkspaceBinding.SourceMapping {
  private readonly headersSymbol: symbol;
  constructor() {
    Bindings.CSSWorkspaceBinding.CSSWorkspaceBinding.instance().addSourceMapping(this);
    this.headersSymbol = Symbol('Formatter.SourceFormatter.StyleMapping._headersSymbol');
  }

  rawLocationToUILocation(rawLocation: SDK.CSSModel.CSSLocation): Workspace.UISourceCode.UILocation|null {
    const styleHeader = rawLocation.header();
    const formatData = styleHeader && SourceFormatData.for(styleHeader);
    if (!formatData) {
      return null;
    }
    const formattedLocation =
        formatData.mapping.originalToFormatted(rawLocation.lineNumber, rawLocation.columnNumber || 0);
    return formatData.formattedSourceCode.uiLocation(formattedLocation[0], formattedLocation[1]);
  }

  uiLocationToRawLocations(uiLocation: Workspace.UISourceCode.UILocation): SDK.CSSModel.CSSLocation[] {
    const formatData = SourceFormatData.for(uiLocation.uiSourceCode);
    if (!formatData) {
      return [];
    }
    const [originalLine, originalColumn] =
        formatData.mapping.formattedToOriginal(uiLocation.lineNumber, uiLocation.columnNumber);
    const allHeaders = sourceCodeToHeaders.get(formatData.originalSourceCode);

    if (!allHeaders) {
      return [];
    }

    const headers = allHeaders.filter(header => header.containsLocation(originalLine, originalColumn));
    return headers.map(header => new SDK.CSSModel.CSSLocation(header, originalLine, originalColumn));
  }

  async setSourceMappingEnabled(formatData: SourceFormatData, enable: boolean): Promise<void> {
    const original = formatData.originalSourceCode;
    const headers = this.headersForUISourceCode(original);
    if (enable) {
      sourceCodeToHeaders.set(original, headers);
      headers.forEach(header => {
        objectToFormattingResult.set(header, formatData);
      });
    } else {
      sourceCodeToHeaders.delete(original);
      headers.forEach(header => {
        objectToFormattingResult.delete(header);
      });
    }
    const updatePromises =
        headers.map(header => Bindings.CSSWorkspaceBinding.CSSWorkspaceBinding.instance().updateLocations(header));
    await Promise.all(updatePromises);
  }

  private headersForUISourceCode(uiSourceCode: Workspace.UISourceCode.UISourceCode):
      SDK.CSSStyleSheetHeader.CSSStyleSheetHeader[] {
    if (uiSourceCode.contentType() === Common.ResourceType.resourceTypes.Document) {
      const target = Bindings.NetworkProject.NetworkProject.targetForUISourceCode(uiSourceCode);
      const cssModel = target && target.model(SDK.CSSModel.CSSModel);
      if (cssModel) {
        return cssModel.headersForSourceURL(uiSourceCode.url())
            .filter(header => header.isInline && !header.hasSourceURL);
      }
    } else if (uiSourceCode.contentType().isStyleSheet()) {
      const rawLocations = Bindings.CSSWorkspaceBinding.CSSWorkspaceBinding.instance().uiLocationToRawLocations(
          uiSourceCode.uiLocation(0, 0));
      return rawLocations.map(rawLocation => rawLocation.header()).filter(header => Boolean(header)) as
          SDK.CSSStyleSheetHeader.CSSStyleSheetHeader[];
    }
    return [];
  }
}
