// 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 '../common/common.js';
import * as Platform from '../platform/platform.js';

import type {FrameAssociated} from './FrameAssociated.js';
import {PageResourceLoader, type PageResourceLoadInitiator, type ResourceLoader} from './PageResourceLoader.js';
import {type DebugId, parseSourceMap, SourceMap, type SourceMapV3} from './SourceMap.js';
import {SourceMapCache} from './SourceMapCache.js';
import {type Target, Type} from './Target.js';

export type SourceMapFactory<T> =
    (compiledURL: Platform.DevToolsPath.UrlString, sourceMappingURL: Platform.DevToolsPath.UrlString,
     payload: SourceMapV3, client: T) => SourceMap;

export const lazyLoadingSettingDescriptor: Common.Settings.SettingDescriptor<boolean> = {
  name: 'source-maps-lazy-loading',
  type: Common.Settings.SettingType.BOOLEAN,
  defaultValue: false,
  storageType: Common.Settings.SettingStorageType.LOCAL,
};

export class SourceMapManager<T extends FrameAssociated> extends Common.ObjectWrapper.ObjectWrapper<EventTypes<T>> {
  readonly #target: Target;
  readonly #factory: SourceMapFactory<T>;
  readonly #lazyLoadingSetting: Common.Settings.Setting<boolean>;
  #isEnabled = true;
  readonly #clientData = new Map<T, ClientData>();
  readonly #sourceMaps = new Map<SourceMap, T>();
  #attachingClient: T|null = null;
  readonly #sourceMapCache = SourceMapCache.create();

  constructor(target: Target, factory?: SourceMapFactory<T>) {
    super();

    this.#target = target;
    this.#factory = factory ??
        ((compiledURL, sourceMappingURL, payload) =>
             new SourceMap(compiledURL, sourceMappingURL, payload, this.#target.targetManager().getConsole()));
    const settings = target.targetManager().settings;
    this.#lazyLoadingSetting = settings.resolve(lazyLoadingSettingDescriptor);
  }

  isLazyLoadEnabled(): boolean {
    return this.#lazyLoadingSetting.get();
  }

  setEnabled(isEnabled: boolean): void {
    if (isEnabled === this.#isEnabled) {
      return;
    }

    // We need this copy, because `this.#clientData` is getting modified
    // in the loop body and trying to iterate over it at the same time
    // leads to an infinite loop.
    const clientData = [...this.#clientData.entries()];
    for (const [client] of clientData) {
      this.detachSourceMap(client);
    }
    this.#isEnabled = isEnabled;
    for (const [client, {relativeSourceURL, relativeSourceMapURL}] of clientData) {
      this.attachSourceMap(client, relativeSourceURL, relativeSourceMapURL);
    }
  }

  private static getBaseUrl(target: Target|null): Platform.DevToolsPath.UrlString {
    while (target && target.type() !== Type.FRAME) {
      target = target.parentTarget();
    }
    return target?.inspectedURL() ?? Platform.DevToolsPath.EmptyUrlString;
  }

  static resolveRelativeSourceURL(target: Target|null, url: Platform.DevToolsPath.UrlString):
      Platform.DevToolsPath.UrlString {
    url = Common.ParsedURL.ParsedURL.completeURL(SourceMapManager.getBaseUrl(target), url) ?? url;
    return url;
  }

  sourceMapForClient(client: T): SourceMap|undefined {
    return this.#clientData.get(client)?.sourceMap;
  }

  // This method actively awaits the source map, if still loading.
  sourceMapForClientPromise(client: T): Promise<SourceMap|undefined> {
    const clientData = this.#clientData.get(client);
    if (!clientData) {
      return Promise.resolve(undefined);
    }

    return clientData.getSourceMap();
  }

  clientForSourceMap(sourceMap: SourceMap): T|undefined {
    return this.#sourceMaps.get(sourceMap);
  }

  // TODO(bmeurer): We are lying about the type of |relativeSourceURL| here.
  attachSourceMap(
      client: T, relativeSourceURL: Platform.DevToolsPath.UrlString, relativeSourceMapURL: string|undefined): void {
    if (this.#clientData.has(client)) {
      throw new Error('SourceMap is already attached or being attached to client');
    }
    if (!relativeSourceMapURL) {
      return;
    }

    const clientData: ClientData = {
      relativeSourceURL,
      relativeSourceMapURL,
      getSourceMap: () => Promise.resolve(undefined),
    };
    this.#clientData.set(client, clientData);

    if (this.#isEnabled) {
      // The `// #sourceURL=foo` can be a random string, but is generally an absolute path.
      // Complete it to inspected page url for relative links.
      const sourceURL = SourceMapManager.resolveRelativeSourceURL(this.#target, relativeSourceURL);
      const sourceMapURL = Common.ParsedURL.ParsedURL.completeURL(sourceURL, relativeSourceMapURL);
      if (sourceMapURL) {
        let sourceMapPromise: Promise<SourceMap|undefined>|undefined;
        const doLoad = (): Promise<SourceMap|undefined> => {
          if (!sourceMapPromise) {
            if (this.#attachingClient) {
              // This should not happen
              console.error('Attaching source map may cancel previously attaching source map');
            }
            this.#attachingClient = client;
            this.dispatchEventToListeners(Events.SourceMapWillAttach, {client});

            if (this.#attachingClient === client) {
              this.#attachingClient = null;
              const initiator = client.createPageResourceLoadInitiator();
              // TODO(crbug.com/458180550): Pass PageResourceLoader via constructor.
              //     The reason we grab it here lazily from the context is that otherwise every
              //     unit test using `createTarget` would need to set up a `PageResourceLoader`, as
              //     CSSModel and DebuggerModel are autostarted by default, and they create a
              //     SourceMapManager in their respective constructors.
              const resourceLoader = this.#target.targetManager().context.get(PageResourceLoader);
              sourceMapPromise =
                  loadSourceMap(resourceLoader, this.#sourceMapCache, sourceMapURL, client.debugId(), initiator)
                      .then(
                          payload => {
                            const sourceMap = this.#factory(sourceURL, sourceMapURL, payload, client);
                            if (this.#clientData.get(client) === clientData) {
                              clientData.sourceMap = sourceMap;
                              this.#sourceMaps.set(sourceMap, client);
                              this.dispatchEventToListeners(Events.SourceMapAttached, {client, sourceMap});
                            }
                            return sourceMap;
                          },
                          () => {
                            if (this.#clientData.get(client) === clientData) {
                              this.dispatchEventToListeners(Events.SourceMapFailedToAttach, {client});
                            }
                            return undefined;
                          });
            } else {
              // Assume cancelAttachSourceMap was called.
              if (this.#attachingClient) {
                // This should not happen
                console.error('Cancelling source map attach because another source map is attaching');
              }
              this.#clientData.delete(client);
              this.dispatchEventToListeners(Events.SourceMapFailedToAttach, {client});
              sourceMapPromise = Promise.resolve(undefined);
            }
          }
          return sourceMapPromise;
        };

        if (this.isLazyLoadEnabled()) {
          clientData.getSourceMap = doLoad;
        } else {
          const promise = doLoad();
          clientData.getSourceMap = () => promise;
        }
      }
    }
  }

  cancelAttachSourceMap(client: T): void {
    if (client === this.#attachingClient) {
      this.#attachingClient = null;
      // This should not happen.
    } else if (this.#attachingClient) {
      console.error('cancel attach source map requested but a different source map was being attached');
    } else {
      console.error('cancel attach source map requested but no source map was being attached');
    }
  }

  detachSourceMap(client: T): void {
    const clientData = this.#clientData.get(client);
    if (!clientData) {
      return;
    }
    this.#clientData.delete(client);
    if (!this.#isEnabled) {
      return;
    }
    const {sourceMap} = clientData;
    if (sourceMap) {
      this.#sourceMaps.delete(sourceMap);
      this.dispatchEventToListeners(Events.SourceMapDetached, {client, sourceMap});
    } else {
      this.dispatchEventToListeners(Events.SourceMapFailedToAttach, {client});
    }
  }

  waitForSourceMapsProcessedForTest(): Promise<unknown> {
    return Promise.all(this.#sourceMaps.keys().map(sourceMap => sourceMap.waitForScopeInfo()));
  }
}

async function loadSourceMap(resourceLoader: ResourceLoader, sourceMapCache: SourceMapCache,
                             url: Platform.DevToolsPath.UrlString, debugId: DebugId|null,
                             initiator: PageResourceLoadInitiator): Promise<SourceMapV3> {
  try {
    if (debugId) {
      const securityOrigin = initiator.initiatorUrl ? Common.ParsedURL.ParsedURL.extractOrigin(initiator.initiatorUrl) :
                                                      Platform.DevToolsPath.EmptyUrlString;
      const cachedSourceMap = await sourceMapCache.get(debugId, securityOrigin);
      if (cachedSourceMap) {
        return cachedSourceMap;
      }
    }

    const {content} = await resourceLoader.loadResource(url, initiator);
    const sourceMap = parseSourceMap(content);
    if (debugId && 'debugId' in sourceMap && sourceMap.debugId === debugId) {
      // In case something goes wrong with updating the cache, we still want to use the source map.
      const securityOrigin = initiator.initiatorUrl ? Common.ParsedURL.ParsedURL.extractOrigin(initiator.initiatorUrl) :
                                                      Platform.DevToolsPath.EmptyUrlString;
      await sourceMapCache.set(sourceMap.debugId as DebugId, securityOrigin, sourceMap).catch();
    }
    return sourceMap;
  } catch (cause) {
    throw new Error(`Could not load content for ${url}: ${cause.message}`, {cause});
  }
}

export async function tryLoadSourceMap(
    resourceLoader: ResourceLoader, url: Platform.DevToolsPath.UrlString,
    initiator: PageResourceLoadInitiator): Promise<SourceMapV3|null> {
  try {
    return await loadSourceMap(resourceLoader, SourceMapCache.create(), url, null, initiator);
  } catch (cause) {
    console.error(cause);
    return null;
  }
}

interface ClientData {
  relativeSourceURL: Platform.DevToolsPath.UrlString;
  // Stores the raw sourceMappingURL as provided by V8. These are not guaranteed to
  // be valid URLs and will be checked and resolved once `attachSourceMap` is called.
  relativeSourceMapURL: string;
  sourceMap?: SourceMap;
  getSourceMap: () => Promise<SourceMap|undefined>;
}

export enum Events {
  /* eslint-disable @typescript-eslint/naming-convention -- Used by web_tests. */
  SourceMapWillAttach = 'SourceMapWillAttach',
  SourceMapFailedToAttach = 'SourceMapFailedToAttach',
  SourceMapAttached = 'SourceMapAttached',
  SourceMapDetached = 'SourceMapDetached',
  /* eslint-enable @typescript-eslint/naming-convention */
}

export interface EventTypes<T extends FrameAssociated> {
  [Events.SourceMapWillAttach]: {client: T};
  [Events.SourceMapFailedToAttach]: {client: T};
  [Events.SourceMapAttached]: {client: T, sourceMap: SourceMap};
  [Events.SourceMapDetached]: {client: T, sourceMap: SourceMap};
}
