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

import type * as Protocol from '../../generated/protocol.js';
import * as Common from '../common/common.js';
import * as Host from '../host/host.js';
import * as i18n from '../i18n/i18n.js';
import type * as Platform from '../platform/platform.js';

import {FrameManager} from './FrameManager.js';
import {IOModel} from './IOModel.js';
import {MultitargetNetworkManager, NetworkManager} from './NetworkManager.js';
import {
  Events as ResourceTreeModelEvents,
  PrimaryPageChangeType,
  type ResourceTreeFrame,
  ResourceTreeModel,
} from './ResourceTreeModel.js';
import type {Target} from './Target.js';
import {TargetManager} from './TargetManager.js';

const UIStrings = {
  /**
   * @description Error message for canceled source map loads
   */
  loadCanceledDueToReloadOf: 'Load canceled due to reload of inspected page',
} as const;
const str_ = i18n.i18n.registerUIStrings('core/sdk/PageResourceLoader.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);

export interface ExtensionInitiator {
  target: null;
  frameId: null;
  initiatorUrl: Platform.DevToolsPath.UrlString;
  extensionId: string;
}

export type PageResourceLoadInitiator = {
  target: null,
  frameId: Protocol.Page.FrameId,
  initiatorUrl: Platform.DevToolsPath.UrlString|null,
}|{
  target: Target,
  frameId: Protocol.Page.FrameId | null,
  initiatorUrl: Platform.DevToolsPath.UrlString | null,
}|ExtensionInitiator;

function isExtensionInitiator(initiator: PageResourceLoadInitiator): initiator is ExtensionInitiator {
  return 'extensionId' in initiator;
}

export interface PageResource {
  success: boolean|null;
  errorMessage?: string;
  initiator: PageResourceLoadInitiator;
  url: Platform.DevToolsPath.UrlString;
  size: number|null;
  duration: number|null;
}

/** Used for revealing a resource. **/
export class ResourceKey {
  readonly key: string;

  constructor(key: string) {
    this.key = key;
  }
}

let pageResourceLoader: PageResourceLoader|null = null;

interface LoadQueueEntry {
  resolve: () => void;
  reject: (arg0: Error) => void;
}

/**
 * The page resource loader is a bottleneck for all DevTools-initiated resource loads. For each such load, it keeps a
 * `PageResource` object around that holds meta information. This can be as the basis for reporting to the user which
 * resources were loaded, and whether there was a load error.
 */
export class PageResourceLoader extends Common.ObjectWrapper.ObjectWrapper<EventTypes> {
  #currentlyLoading = 0;
  #currentlyLoadingPerTarget = new Map<Protocol.Target.TargetID|'main', number>();
  readonly #maxConcurrentLoads: number;
  #pageResources = new Map<string, PageResource>();
  #queuedLoads: LoadQueueEntry[] = [];
  readonly #loadOverride: ((arg0: string) => Promise<{
                             success: boolean,
                             content: string,
                             errorDescription: Host.ResourceLoader.LoadErrorDescription,
                           }>)|null;
  constructor(
      loadOverride: ((arg0: string) => Promise<{
                       success: boolean,
                       content: string,
                       errorDescription: Host.ResourceLoader.LoadErrorDescription,
                     }>)|null,
      maxConcurrentLoads: number) {
    super();
    this.#maxConcurrentLoads = maxConcurrentLoads;
    TargetManager.instance().addModelListener(
        ResourceTreeModel, ResourceTreeModelEvents.PrimaryPageChanged, this.onPrimaryPageChanged, this);
    this.#loadOverride = loadOverride;
  }

  static instance({forceNew, loadOverride, maxConcurrentLoads}: {
    forceNew: boolean,
    loadOverride: (null|((arg0: string) => Promise<{
                           success: boolean,
                           content: string,
                           errorDescription: Host.ResourceLoader.LoadErrorDescription,
                         }>)),
    maxConcurrentLoads: number,
  } = {
    forceNew: false,
    loadOverride: null,
    maxConcurrentLoads: 500,
  }): PageResourceLoader {
    if (!pageResourceLoader || forceNew) {
      pageResourceLoader = new PageResourceLoader(loadOverride, maxConcurrentLoads);
    }

    return pageResourceLoader;
  }

  static removeInstance(): void {
    pageResourceLoader = null;
  }

  onPrimaryPageChanged(
      event: Common.EventTarget.EventTargetEvent<{frame: ResourceTreeFrame, type: PrimaryPageChangeType}>): void {
    const {frame: mainFrame, type} = event.data;
    if (!mainFrame.isOutermostFrame()) {
      return;
    }
    for (const {reject} of this.#queuedLoads) {
      reject(new Error(i18nString(UIStrings.loadCanceledDueToReloadOf)));
    }
    this.#queuedLoads = [];
    const mainFrameTarget = mainFrame.resourceTreeModel().target();
    const keptResources = new Map<string, PageResource>();
    // If the navigation is a prerender-activation, the pageResources for the destination page have
    // already been preloaded. In such cases, we therefore don't just discard all pageResources, but
    // instead make sure to keep the pageResources for the prerendered target.
    for (const [key, pageResource] of this.#pageResources.entries()) {
      if ((type === PrimaryPageChangeType.ACTIVATION) && mainFrameTarget === pageResource.initiator.target) {
        keptResources.set(key, pageResource);
      }
    }
    this.#pageResources = keptResources;
    this.dispatchEventToListeners(Events.UPDATE);
  }

  getResourcesLoaded(): Map<string, PageResource> {
    return this.#pageResources;
  }

  getScopedResourcesLoaded(): Map<string, PageResource> {
    return new Map([...this.#pageResources].filter(
        ([_, pageResource]) => TargetManager.instance().isInScope(pageResource.initiator.target) ||
            isExtensionInitiator(pageResource.initiator)));
  }

  /**
   * Loading is the number of currently loading and queued items. Resources is the total number of resources,
   * including loading and queued resources, but not including resources that are still loading but scheduled
   * for cancelation.;
   */
  getNumberOfResources(): {
    loading: number,
    queued: number,
    resources: number,
  } {
    return {loading: this.#currentlyLoading, queued: this.#queuedLoads.length, resources: this.#pageResources.size};
  }

  getScopedNumberOfResources(): {
    loading: number,
    resources: number,
  } {
    const targetManager = TargetManager.instance();
    let loadingCount = 0;
    for (const [targetId, count] of this.#currentlyLoadingPerTarget) {
      const target = targetManager.targetById(targetId);
      if (targetManager.isInScope(target)) {
        loadingCount += count;
      }
    }
    return {loading: loadingCount, resources: this.getScopedResourcesLoaded().size};
  }

  private async acquireLoadSlot(target: Target|null): Promise<void> {
    this.#currentlyLoading++;
    if (target) {
      const currentCount = this.#currentlyLoadingPerTarget.get(target.id()) || 0;
      this.#currentlyLoadingPerTarget.set(target.id(), currentCount + 1);
    }
    if (this.#currentlyLoading > this.#maxConcurrentLoads) {
      const {
        promise: waitForCapacity,
        resolve,
        reject,
      } = Promise.withResolvers<void>();
      this.#queuedLoads.push({resolve, reject});
      await waitForCapacity;
    }
  }

  private releaseLoadSlot(target: Target|null): void {
    this.#currentlyLoading--;
    if (target) {
      const currentCount = this.#currentlyLoadingPerTarget.get(target.id());
      if (currentCount) {
        this.#currentlyLoadingPerTarget.set(target.id(), currentCount - 1);
      }
    }
    const entry = this.#queuedLoads.shift();
    if (entry) {
      entry.resolve();
    }
  }

  static makeExtensionKey(url: Platform.DevToolsPath.UrlString, initiator: PageResourceLoadInitiator): string {
    if (isExtensionInitiator(initiator) && initiator.extensionId) {
      return `${url}-${initiator.extensionId}`;
    }
    throw new Error('Invalid initiator');
  }

  static makeKey(url: Platform.DevToolsPath.UrlString, initiator: PageResourceLoadInitiator): string {
    if (initiator.frameId) {
      return `${url}-${initiator.frameId}`;
    }
    if (initiator.target) {
      return `${url}-${initiator.target.id()}`;
    }
    throw new Error('Invalid initiator');
  }

  resourceLoadedThroughExtension(pageResource: PageResource): void {
    const key = PageResourceLoader.makeExtensionKey(pageResource.url, pageResource.initiator);
    this.#pageResources.set(key, pageResource);
    this.dispatchEventToListeners(Events.UPDATE);
  }

  loadResource(url: Platform.DevToolsPath.UrlString, initiator: PageResourceLoadInitiator, isBinary: true): Promise<{
    content: Uint8Array<ArrayBuffer>,
  }>;
  loadResource(url: Platform.DevToolsPath.UrlString, initiator: PageResourceLoadInitiator, isBinary?: false): Promise<{
    content: string,
  }>;
  async loadResource(url: Platform.DevToolsPath.UrlString, initiator: PageResourceLoadInitiator, isBinary = false):
      Promise<{
        content: string | Uint8Array<ArrayBuffer>,
      }> {
    if (isExtensionInitiator(initiator)) {
      throw new Error('Invalid initiator');
    }
    const key = PageResourceLoader.makeKey(url, initiator);
    const pageResource:
        PageResource = {success: null, size: null, duration: null, errorMessage: undefined, url, initiator};
    this.#pageResources.set(key, pageResource);
    this.dispatchEventToListeners(Events.UPDATE);
    const startTime = performance.now();
    try {
      await this.acquireLoadSlot(initiator.target);
      const resultPromise = this.dispatchLoad(url, initiator, isBinary);
      const result = await resultPromise;
      pageResource.errorMessage = result.errorDescription.message;
      pageResource.success = result.success;
      if (result.success) {
        pageResource.size = result.content.length;
        return {content: result.content};
      }
      throw new Error(result.errorDescription.message);
    } catch (e) {
      if (pageResource.errorMessage === undefined) {
        pageResource.errorMessage = e.message;
      }
      if (pageResource.success === null) {
        pageResource.success = false;
      }
      throw e;
    } finally {
      pageResource.duration = performance.now() - startTime;
      this.releaseLoadSlot(initiator.target);
      this.dispatchEventToListeners(Events.UPDATE);
    }
  }

  private async dispatchLoad(
      url: Platform.DevToolsPath.UrlString, initiator: PageResourceLoadInitiator, isBinary: boolean): Promise<{
    success: boolean,
    content: string|Uint8Array<ArrayBuffer>,
    errorDescription: Host.ResourceLoader.LoadErrorDescription,
  }> {
    if (isExtensionInitiator(initiator)) {
      throw new Error('Invalid initiator');
    }

    const failureReason: string|null = null;
    if (this.#loadOverride) {
      return await this.#loadOverride(url);
    }
    const parsedURL = new Common.ParsedURL.ParsedURL(url);
    const eligibleForLoadFromTarget = getLoadThroughTargetSetting().get() && parsedURL && parsedURL.scheme !== 'file' &&
        parsedURL.scheme !== 'data' && parsedURL.scheme !== 'devtools';
    Host.userMetrics.developerResourceScheme(this.getDeveloperResourceScheme(parsedURL));
    if (eligibleForLoadFromTarget) {
      try {
        if (initiator.target) {
          Host.userMetrics.developerResourceLoaded(
              Host.UserMetrics.DeveloperResourceLoaded.LOAD_THROUGH_PAGE_VIA_TARGET);
          const result = await this.loadFromTarget(initiator.target, initiator.frameId, url, isBinary);
          return result;
        }
        const frame = FrameManager.instance().getFrame(initiator.frameId);
        if (frame) {
          Host.userMetrics.developerResourceLoaded(
              Host.UserMetrics.DeveloperResourceLoaded.LOAD_THROUGH_PAGE_VIA_FRAME);
          const result =
              await this.loadFromTarget(frame.resourceTreeModel().target(), initiator.frameId, url, isBinary);
          return result;
        }
      } catch (e) {
        if (e instanceof Error) {
          Host.userMetrics.developerResourceLoaded(Host.UserMetrics.DeveloperResourceLoaded.LOAD_THROUGH_PAGE_FAILURE);
          if (e.message.includes('CSP violation')) {
            return {
              success: false,
              content: '',
              errorDescription:
                  {statusCode: 0, netError: undefined, netErrorName: undefined, message: e.message, urlValid: undefined}
            };
          }
        }
      }
      Host.userMetrics.developerResourceLoaded(Host.UserMetrics.DeveloperResourceLoaded.LOAD_THROUGH_PAGE_FALLBACK);
    } else {
      const code = getLoadThroughTargetSetting().get() ?
          Host.UserMetrics.DeveloperResourceLoaded.FALLBACK_PER_PROTOCOL :
          Host.UserMetrics.DeveloperResourceLoaded.FALLBACK_PER_OVERRIDE;
      Host.userMetrics.developerResourceLoaded(code);
    }

    const result = await MultitargetNetworkManager.instance().loadResource(url);
    if (eligibleForLoadFromTarget && !result.success) {
      Host.userMetrics.developerResourceLoaded(Host.UserMetrics.DeveloperResourceLoaded.FALLBACK_FAILURE);
    }
    if (failureReason) {
      // In case we have a success, add a note about why the load through the target failed.
      result.errorDescription.message =
          `Fetch through target failed: ${failureReason}; Fallback: ${result.errorDescription.message}`;
    }
    return result;
  }

  private getDeveloperResourceScheme(parsedURL: Common.ParsedURL.ParsedURL|null):
      Host.UserMetrics.DeveloperResourceScheme {
    if (!parsedURL || parsedURL.scheme === '') {
      return Host.UserMetrics.DeveloperResourceScheme.UKNOWN;
    }
    const isLocalhost = parsedURL.host === 'localhost' || parsedURL.host.endsWith('.localhost');
    switch (parsedURL.scheme) {
      case 'file':
        return Host.UserMetrics.DeveloperResourceScheme.FILE;
      case 'data':
        return Host.UserMetrics.DeveloperResourceScheme.DATA;
      case 'blob':
        return Host.UserMetrics.DeveloperResourceScheme.BLOB;
      case 'http':
        return isLocalhost ? Host.UserMetrics.DeveloperResourceScheme.HTTP_LOCALHOST :
                             Host.UserMetrics.DeveloperResourceScheme.HTTP;
      case 'https':
        return isLocalhost ? Host.UserMetrics.DeveloperResourceScheme.HTTPS_LOCALHOST :
                             Host.UserMetrics.DeveloperResourceScheme.HTTPS;
    }
    return Host.UserMetrics.DeveloperResourceScheme.OTHER;
  }

  private async loadFromTarget(
      target: Target, frameId: Protocol.Page.FrameId|null, url: Platform.DevToolsPath.UrlString,
      isBinary: boolean): Promise<{
    success: boolean,
    content: string|Uint8Array<ArrayBuffer>,
    errorDescription: {
      statusCode: number,
      netError: number|undefined,
      netErrorName: string|undefined,
      message: string,
      urlValid: undefined,
    },
  }> {
    const networkManager = (target.model(NetworkManager) as NetworkManager);
    const ioModel = (target.model(IOModel) as IOModel);
    const disableCache = Common.Settings.Settings.instance().moduleSetting('cache-disabled').get();
    const resource = await networkManager.loadNetworkResource(frameId, url, {disableCache, includeCredentials: true});
    try {
      const content = resource.stream ?
          (isBinary ? await ioModel.readToBuffer(resource.stream) : await ioModel.readToString(resource.stream)) :
          '';
      return {
        success: resource.success,
        content,
        errorDescription: {
          statusCode: resource.httpStatusCode || 0,
          netError: resource.netError,
          netErrorName: resource.netErrorName,
          message: Host.ResourceLoader.netErrorToMessage(
                       resource.netError, resource.httpStatusCode, resource.netErrorName) ||
              '',
          urlValid: undefined,
        },
      };
    } finally {
      if (resource.stream) {
        void ioModel.close(resource.stream);
      }
    }
  }
}

export function getLoadThroughTargetSetting(): Common.Settings.Setting<boolean> {
  return Common.Settings.Settings.instance().createSetting('load-through-target', true);
}

export const enum Events {
  UPDATE = 'Update',
}

export interface EventTypes {
  [Events.UPDATE]: void;
}
