// 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 * as Root from '../root/root.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;
  }
}

export type UserAgentProvider = Pick<MultitargetNetworkManager, 'currentUserAgent'>;

/**
 * The PageResourceLoader has two responsibilities: loading resources and tracking statistics scoped to targets
 * for the DeveloperResourcesPanel. Many places only require the former, so we expose that functionality via small
 * sub-interface. This makes it easier to test classes that require resource loading.
 */
export type ResourceLoader = Pick<PageResourceLoader, 'loadResource'>;

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> implements ResourceLoader {
  readonly #targetManager: TargetManager;
  readonly #settings: Common.Settings.Settings;
  readonly #userAgentProvider: UserAgentProvider;
  #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|Uint8Array<ArrayBuffer>,
                             errorDescription: Host.ResourceLoader.LoadErrorDescription,
                           }>)|null;
  constructor(
      targetManager: TargetManager, settings: Common.Settings.Settings, userAgentProvider: UserAgentProvider,
      loadOverride: ((arg0: string) => Promise<{
                       success: boolean,
                       content: string|Uint8Array<ArrayBuffer>,
                       errorDescription: Host.ResourceLoader.LoadErrorDescription,
                     }>)|null,
      maxConcurrentLoads = 500) {
    super();
    this.#targetManager = targetManager;
    this.#settings = settings;
    this.#userAgentProvider = userAgentProvider;
    this.#maxConcurrentLoads = maxConcurrentLoads;
    this.#targetManager.addModelListener(
        ResourceTreeModel, ResourceTreeModelEvents.PrimaryPageChanged, this.onPrimaryPageChanged, this);
    this.#loadOverride = loadOverride;
  }

  static instance({forceNew, targetManager, settings, userAgentProvider, loadOverride, maxConcurrentLoads}: {
    forceNew: boolean,
    loadOverride: (null|((arg0: string) => Promise<{
                           success: boolean,
                           content: string|Uint8Array<ArrayBuffer>,
                           errorDescription: Host.ResourceLoader.LoadErrorDescription,
                         }>)),
    targetManager?: TargetManager,
    settings?: Common.Settings.Settings,
    userAgentProvider?: UserAgentProvider,
    maxConcurrentLoads?: number,
  } = {
    forceNew: false,
    loadOverride: null,
  }): PageResourceLoader {
    if (forceNew) {
      Root.DevToolsContext.globalInstance().set(
          PageResourceLoader,
          new PageResourceLoader(
              targetManager ?? TargetManager.instance(), settings ?? Common.Settings.Settings.instance(),
              userAgentProvider ?? MultitargetNetworkManager.instance(), loadOverride, maxConcurrentLoads));
    }

    return Root.DevToolsContext.globalInstance().get(PageResourceLoader);
  }

  static removeInstance(): void {
    Root.DevToolsContext.globalInstance().delete(PageResourceLoader);
  }

  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]) => this.#targetManager.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,
  } {
    let loadingCount = 0;
    for (const [targetId, count] of this.#currentlyLoadingPerTarget) {
      const target = this.#targetManager.targetById(targetId);
      if (this.#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,
      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 = this.getLoadThroughTargetSetting().get() && parsedURL &&
        parsedURL.scheme !== 'file' && parsedURL.scheme !== 'data' && parsedURL.scheme !== 'devtools' &&
        initiator.target;
    Host.userMetrics.developerResourceScheme(this.getDeveloperResourceScheme(parsedURL));
    if (eligibleForLoadFromTarget) {
      let mustEnforceCSP = false;
      const isHttp = parsedURL.scheme === 'http' || parsedURL.scheme === 'https';
      if (isHttp && initiator.target) {
        const networkManager = initiator.target.model(NetworkManager);
        if (networkManager) {
          let status = await networkManager.getSecurityIsolationStatus(initiator.frameId);
          if (!status && initiator.frameId) {
            status = await networkManager.getSecurityIsolationStatus(null);
          }
          if (status?.csp) {
            for (const csp of status.csp) {
              const directives = csp.effectiveDirectives;
              if (directives.includes('connect-src') || directives.includes('default-src')) {
                mustEnforceCSP = true;
                break;
              }
            }
          }
        }
      }

      try {
        Host.userMetrics.developerResourceLoaded(Host.UserMetrics.DeveloperResourceLoaded.LOAD_THROUGH_PAGE_VIA_TARGET);
        const result = await this.loadFromTarget(initiator.target, initiator.frameId, url, isBinary);
        return result;
      } catch (e) {
        if (e instanceof Error) {
          Host.userMetrics.developerResourceLoaded(Host.UserMetrics.DeveloperResourceLoaded.LOAD_THROUGH_PAGE_FAILURE);
          if (mustEnforceCSP || e.message.includes('CSP violation')) {
            return {
              success: false,
              content: '',
              errorDescription: {
                statusCode: 0,
                message: e.message,
              }
            };
          }
        }
      }
      Host.userMetrics.developerResourceLoaded(Host.UserMetrics.DeveloperResourceLoaded.LOAD_THROUGH_PAGE_FALLBACK);
    } else {
      const code = this.getLoadThroughTargetSetting().get() ?
          Host.UserMetrics.DeveloperResourceLoaded.FALLBACK_PER_PROTOCOL :
          Host.UserMetrics.DeveloperResourceLoaded.FALLBACK_PER_OVERRIDE;
      Host.userMetrics.developerResourceLoaded(code);
    }

    const result = await this.loadFromHostBindings(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,
    },
  }> {
    const networkManager = (target.model(NetworkManager) as NetworkManager);
    const ioModel = (target.model(IOModel) as IOModel);
    const disableCache = this.#settings.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) ||
              '',
        },
      };
    } finally {
      if (resource.stream) {
        void ioModel.close(resource.stream);
      }
    }
  }

  private async loadFromHostBindings(url: Platform.DevToolsPath.UrlString): Promise<{
    success: boolean,
    content: string,
    errorDescription: Host.ResourceLoader.LoadErrorDescription,
  }> {
    const headers: Record<string, string> = {};

    const currentUserAgent = this.#userAgentProvider.currentUserAgent();
    if (currentUserAgent) {
      headers['User-Agent'] = currentUserAgent;
    }

    if (this.#settings.moduleSetting('cache-disabled').get()) {
      headers['Cache-Control'] = 'no-cache';
    }

    const allowRemoteFilePaths = this.#settings.moduleSetting('network.enable-remote-file-loading').get();

    return await new Promise(
        resolve => Host.ResourceLoader.load(url, headers, (success, _responseHeaders, content, errorDescription) => {
          resolve({success, content, errorDescription});
        }, allowRemoteFilePaths));
  }

  getLoadThroughTargetSetting(): Common.Settings.Setting<boolean> {
    return this.#settings.createSetting('load-through-target', true);
  }
}

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

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