// Copyright 2024 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 type * as Platform from '../platform/platform.js';
import {UserVisibleError} from '../platform/platform.js';

import type {
  HydratingDataPerTarget, RehydratingExecutionContext, RehydratingResource, RehydratingScript, RehydratingTarget} from
  './RehydratingObject.js';
import type {SourceMapV3} from './SourceMap.js';
import type {TraceObject} from './TraceObject.js';

interface EventBase {
  cat: string;
  pid: number;
  args: {data: object};
  name: string;
}

/**
 * While called 'TargetRundown', this event is emitted for each script that is compiled or evaluated.
 * Within EnhancedTraceParser, this event is used to construct targets and execution contexts (and to associate scripts to frames).
 *
 * See `inspector_target_rundown_event::Data` https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/inspector/inspector_trace_events.cc;l=1189-1232;drc=48d6f7175422b2c969c14258f9f8d5b196c28d18
 */
export interface RundownScriptCompiled extends EventBase {
  cat: 'disabled-by-default-devtools.target-rundown';
  name: 'ScriptCompiled'|'ModuleEvaluated';
  args: {
    data: {
      frame: Protocol.Page.FrameId,
      frameType: 'page'|'iframe',
      url: string,
      /**
       * Older traces were a number, but this is an unsigned 64 bit value, so that was a bug.
       * New traces use string instead. See https://crbug.com/447654178.
       */
      isolate: string|number,
      /** AKA V8ContextToken. https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/inspector/inspector_trace_events.cc;l=1229;drc=3c88f61e18b043e70c225d8d57c77832a85e7f58 */
      v8context: string,
      origin: string,
      scriptId: number,
      /** script->World().isMainWorld() */
      isDefault?: boolean,
      contextType?: 'default'|'isolated'|'worker',
    },
  };
}
/**
 * When profiling starts, all currently loaded scripts are emitted via this event.
 *
 * See `Script::TraceScriptRundown()` https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/script.cc;l=184-220;drc=328f6c467b940f322544567740c9c871064d045c
 */
export interface RundownScript extends EventBase {
  cat: 'disabled-by-default-devtools.v8-source-rundown';
  name: 'ScriptCatchup';
  args: {
    data: {
      /**
       * Older traces were a number, but this is an unsigned 64 bit value, so that was a bug.
       * New traces use string instead. See https://crbug.com/447654178.
       */
      isolate: string|number,
      executionContextId: Protocol.Runtime.ExecutionContextId,
      scriptId: number,
      isModule: boolean,
      /** aka HasSourceURLComment */
      hasSourceUrl: boolean,
      // These don't actually get set in v8.
      url?: string,
      hash?: string,
      /** value of the sourceURL comment. */
      sourceUrl?: string,
      /* value of the sourceMappingURL comment */
      sourceMapUrl?: string,
      /** If true, the source map url was a data URL, so the `sourceMapUrl` was removed. */
      sourceMapUrlElided?: boolean,
      startLine?: number,
      startColumn?: number,
      endLine?: number,
      endColumn?: number,
    },
  };
}

export interface RundownScriptSource extends EventBase {
  cat: 'disabled-by-default-devtools.v8-source-rundown-sources';
  name: 'ScriptCatchup'|'LargeScriptCatchup'|'TooLargeScriptCatchup';
  args: {
    data: {
      isolate: number,
      scriptId: number,
      length?: number,
      sourceText?: string,
    },
  };
}

interface TracingStartedInBrowser extends EventBase {
  cat: 'disabled-by-default-devtools.timeline';
  args: {
    data: {
      frames: [{
        frame: Protocol.Page.FrameId,
        isInPrimaryMainFrame: boolean,
        isOutermostMainFrame: boolean,
        parent: Protocol.Page.FrameId,
        processId: number,
        url: string,
        pid: number,
      }],
    },
  };
}

interface FunctionCall extends EventBase {
  cat: 'devtools.timeline';
  args: {
    data: {
      frame: Protocol.Page.FrameId,
      scriptId: Protocol.Runtime.ScriptId,
      /**
       * Older traces were a number, but this is an unsigned 64 bit value, so that was a bug.
       * New traces use string instead. See https://crbug.com/447654178.
       */
      isolate?: string|number,
    },
  };
}

export class EnhancedTracesParser {
  #trace: TraceObject;
  #scriptRundownEvents: RundownScript[] = [];
  #scriptToV8Context: Map<string, string> = new Map<string, string>();
  #scriptToFrame: Map<string, Protocol.Page.FrameId> = new Map<string, Protocol.Page.FrameId>();
  #scriptToScriptSource: Map<string, string> = new Map<string, string>();
  #largeScriptToScriptSource: Map<string, string[]> = new Map<string, string[]>();
  #scriptToSourceLength: Map<string, number> = new Map<string, number>();
  #targets: RehydratingTarget[] = [];
  #executionContexts: RehydratingExecutionContext[] = [];
  #scripts: RehydratingScript[] = [];
  #resources: RehydratingResource[] = [];
  static readonly enhancedTraceVersion: number = 1;

  constructor(trace: TraceObject) {
    this.#trace = trace;

    // Initialize with the trace provided.
    try {
      this.parseEnhancedTrace();
    } catch (e) {
      throw new UserVisibleError.UserVisibleError(e);
    }
  }

  parseEnhancedTrace(): void {
    for (const event of this.#trace.traceEvents) {
      if (this.isTracingStartedInBrowser(event)) {
        // constructs all targets by devtools.timeline TracingStartedInBrowser
        const data = event.args?.data;
        for (const frame of data.frames) {
          if (frame.url === 'about:blank') {
            continue;
          }
          if (!frame.isInPrimaryMainFrame) {
            continue;
          }

          const frameId = frame.frame as string as Protocol.Target.TargetID;
          if (!this.#targets.find(target => target.targetId === frameId)) {
            const frameType = frame.isOutermostMainFrame ? 'page' : 'iframe';
            this.#targets.push({
              targetId: frameId,
              type: frameType,
              pid: frame.processId,
              url: frame.url,
            });
          }
        }
      } else if (this.isFunctionCallEvent(event)) {
        // constructs all script to frame mapping with devtools.timeline FunctionCall
        const data = event.args?.data;
        if (data.isolate) {
          this.#scriptToFrame.set(this.getScriptIsolateId(data.isolate, data.scriptId), data.frame);
        }
      } else if (this.isRundownScriptCompiled(event)) {
        // Set up script to v8 context mapping
        const data = event.args?.data;
        this.#scriptToV8Context.set(this.getScriptIsolateId(data.isolate, data.scriptId), data.v8context);
        this.#scriptToFrame.set(this.getScriptIsolateId(data.isolate, data.scriptId), data.frame);
        // All the targets should've been added by the TracingStartedInBrowser event, but just in case we're missing some there
        const frameId = data.frame as string as Protocol.Target.TargetID;
        if (!this.#targets.find(target => target.targetId === frameId)) {
          this.#targets.push({
            targetId: frameId,
            type: data.frameType,
            isolate: String(data.isolate),
            pid: event.pid,
            url: data.url,
          });
        }
        // Add execution context, need to put back execution context id with info from other traces
        if (!this.#executionContexts.find(executionContext => executionContext.v8Context === data.v8context)) {
          this.#executionContexts.push({
            id: -1 as Protocol.Runtime.ExecutionContextId,
            origin: data.origin,
            v8Context: data.v8context,
            auxData: {
              frameId: data.frame,
              isDefault: data.isDefault,
              type: data.contextType,
            },
            isolate: String(data.isolate),
            name: data.origin,
            uniqueId: `${data.v8context}-${data.isolate}`,
          });
        }
      } else if (this.isRundownScript(event)) {
        this.#scriptRundownEvents.push(event);
        const data = event.args.data;
        // Add script
        if (!this.#scripts.find(
                script => script.scriptId === String(data.scriptId) && script.isolate === String(data.isolate))) {
          this.#scripts.push({
            scriptId: String(data.scriptId) as Protocol.Runtime.ScriptId,
            isolate: String(data.isolate),
            buildId: '',
            executionContextId: data.executionContextId,
            startLine: data.startLine ?? 0,
            startColumn: data.startColumn ?? 0,
            endLine: data.endLine ?? 0,
            endColumn: data.endColumn ?? 0,
            hash: data.hash ?? '',
            isModule: data.isModule,
            url: data.url ?? '',
            hasSourceURL: data.hasSourceUrl,
            sourceURL: data.sourceUrl ?? '',
            sourceMapURL: data.sourceMapUrl,
            pid: event.pid,
          });
        }
      } else if (this.isRundownScriptSource(event)) {
        // Set up script to source text and length mapping
        const data = event.args.data;
        const scriptIsolateId = this.getScriptIsolateId(data.isolate, data.scriptId);
        if ('splitIndex' in data && 'splitCount' in data) {
          if (!this.#largeScriptToScriptSource.has(scriptIsolateId)) {
            this.#largeScriptToScriptSource.set(scriptIsolateId, new Array(data.splitCount).fill('') as string[]);
          }
          const splittedSource = this.#largeScriptToScriptSource.get(scriptIsolateId);
          if (splittedSource && data.sourceText) {
            splittedSource[data.splitIndex as number] = data.sourceText;
          }
        } else {
          if (data.sourceText) {
            this.#scriptToScriptSource.set(scriptIsolateId, data.sourceText);
          }
          if (data.length) {
            this.#scriptToSourceLength.set(scriptIsolateId, data.length);
          }
        }
      }
    }
  }

  data(): HydratingDataPerTarget[] {
    // Put back execution context id
    const v8ContextToExecutionContextId: Map<string, Protocol.Runtime.ExecutionContextId> =
        new Map<string, Protocol.Runtime.ExecutionContextId>();
    this.#scriptRundownEvents.forEach(scriptRundownEvent => {
      const data = scriptRundownEvent.args.data;
      const v8Context = this.#scriptToV8Context.get(this.getScriptIsolateId(data.isolate, data.scriptId));
      if (v8Context) {
        v8ContextToExecutionContextId.set(v8Context, data.executionContextId);
      }
    });
    this.#executionContexts.forEach(executionContext => {
      if (executionContext.v8Context) {
        const id = v8ContextToExecutionContextId.get(executionContext.v8Context);
        if (id) {
          executionContext.id = id;
        }
      }
    });

    // Put back script source text and length
    this.#scripts.forEach(script => {
      const scriptIsolateId = this.getScriptIsolateId(script.isolate, script.scriptId);
      if (this.#scriptToScriptSource.has(scriptIsolateId)) {
        script.sourceText = this.#scriptToScriptSource.get(scriptIsolateId);
        script.length = this.#scriptToSourceLength.get(scriptIsolateId);
      } else if (this.#largeScriptToScriptSource.has(scriptIsolateId)) {
        const splittedSources = this.#largeScriptToScriptSource.get(scriptIsolateId);
        if (splittedSources) {
          script.sourceText = splittedSources.join('');
          script.length = script.sourceText.length;
        }
      }
      // put in the aux data
      const linkedExecutionContext = this.#executionContexts.find(
          context => context.id === script.executionContextId && context.isolate === script.isolate);
      if (linkedExecutionContext) {
        script.executionContextAuxData = linkedExecutionContext.auxData;
        // If a script successfully mapped to an execution context and aux data, link script to frame
        if (script.executionContextAuxData?.frameId) {
          this.#scriptToFrame.set(scriptIsolateId, script.executionContextAuxData?.frameId);
        }
      }
    });

    for (const script of this.#scripts) {
      // Resolve the source map from the provided metadata.
      // If no map is found for a given source map url, no source map is passed to the debugger model.
      // Encoded as a data url so that the debugger model makes no network request.
      // NOTE: consider passing directly as object and hacking `parsedScriptSource` in DebuggerModel.ts to handle
      // this fake event. Would avoid a lot of wasteful (de)serialization. Maybe add SDK.Script.hydratedSourceMap.
      this.resolveSourceMap(script);
    }

    this.#resources = this.#trace.metadata.resources ?? [];

    return this.groupContextsAndScriptsUnderTarget(
        this.#targets, this.#executionContexts, this.#scripts, this.#resources);
  }

  private resolveSourceMap(script: RehydratingScript): void {
    if (script.sourceMapURL?.startsWith('data:')) {
      return;
    }

    const sourceMap = this.getSourceMapFromMetadata(script);
    if (!sourceMap) {
      return;
    }

    // Note: this encoding + re-parsing overhead cost ~10ms per 1MB of JSON on my
    // Mac M1 Pro.
    // See https://crrev.com/c/6490409/comments/f294c12a_69781e24
    const payload = encodeURIComponent(JSON.stringify(sourceMap));
    script.sourceMapURL = `data:application/json;charset=utf-8,${payload}`;
  }

  private getSourceMapFromMetadata(script: RehydratingScript): SourceMapV3|undefined {
    const {hasSourceURL, sourceURL, url, sourceMapURL, isolate, scriptId} = script;

    if (!sourceMapURL || !this.#trace.metadata.sourceMaps) {
      return;
    }

    const frame =
        this.#scriptToFrame.get(this.getScriptIsolateId(isolate, scriptId)) as string as Protocol.Target.TargetID;
    if (!frame) {
      return;
    }

    const target = this.#targets.find(t => t.targetId === frame);
    if (!target) {
      return;
    }

    let resolvedSourceUrl = url;
    if (hasSourceURL && sourceURL) {
      const targetUrl = target.url as Platform.DevToolsPath.UrlString;
      resolvedSourceUrl = Common.ParsedURL.ParsedURL.completeURL(targetUrl, sourceURL) ?? sourceURL;
    }

    // Resolve the source map url. The value given by v8 may be relative, so resolve it here.
    // This process should match the one in `SourceMapManager.attachSourceMap`.
    const resolvedSourceMapUrl =
        Common.ParsedURL.ParsedURL.completeURL(resolvedSourceUrl as Platform.DevToolsPath.UrlString, sourceMapURL);
    if (!resolvedSourceMapUrl) {
      return;
    }

    const {sourceMap} = this.#trace.metadata.sourceMaps.find(m => m.sourceMapUrl === resolvedSourceMapUrl) ?? {};
    return sourceMap;
  }

  private getScriptIsolateId(isolate: number|string, scriptId: Protocol.Runtime.ScriptId|number): string {
    return `${scriptId}@${isolate}`;
  }

  private getExecutionContextIsolateId(isolate: number|string, executionContextId: Protocol.Runtime.ExecutionContextId):
      string {
    return `${executionContextId}@${isolate}`;
  }

  private isTraceEvent(event: unknown): event is EventBase {
    return 'cat' in (event as EventBase) && 'pid' in (event as EventBase) && 'args' in (event as EventBase) &&
        'data' in (event as EventBase).args;
  }

  private isRundownScriptCompiled(event: unknown): event is RundownScriptCompiled {
    return this.isTraceEvent(event) && event.cat === 'disabled-by-default-devtools.target-rundown';
  }

  private isRundownScript(event: unknown): event is RundownScript {
    return this.isTraceEvent(event) && event.cat === 'disabled-by-default-devtools.v8-source-rundown';
  }

  private isRundownScriptSource(event: unknown): event is RundownScriptSource {
    return this.isTraceEvent(event) && event.cat === 'disabled-by-default-devtools.v8-source-rundown-sources';
  }

  private isTracingStartedInBrowser(event: unknown): event is TracingStartedInBrowser {
    return this.isTraceEvent(event) && event.cat === 'disabled-by-default-devtools.timeline' &&
        event.name === 'TracingStartedInBrowser';
  }

  private isFunctionCallEvent(event: unknown): event is FunctionCall {
    return this.isTraceEvent(event) && event.cat === 'devtools.timeline' && event.name === 'FunctionCall';
  }

  private groupContextsAndScriptsUnderTarget(
      targets: RehydratingTarget[], executionContexts: RehydratingExecutionContext[], scripts: RehydratingScript[],
      resources: RehydratingResource[]): HydratingDataPerTarget[] {
    const data: HydratingDataPerTarget[] = [];
    const targetIds = new Set<Protocol.Target.TargetID>();
    const targetToExecutionContexts: Map<string, RehydratingExecutionContext[]> =
        new Map<Protocol.Target.TargetID, RehydratingExecutionContext[]>();
    // We want to keep track of how each execution context is linked to targets so we may use this
    // information to link scripts with no target to a target
    const executionContextIsolateToTarget: Map<string, Protocol.Target.TargetID> =
        new Map<string, Protocol.Target.TargetID>();
    const targetToScripts: Map<Protocol.Target.TargetID, RehydratingScript[]> =
        new Map<Protocol.Target.TargetID, RehydratingScript[]>();
    const orphanScripts: RehydratingScript[] = [];
    const targetToResources: Map<Protocol.Target.TargetID, RehydratingResource[]> =
        new Map<Protocol.Target.TargetID, RehydratingResource[]>();

    // Initialize all the mapping needed
    for (const target of targets) {
      targetIds.add(target.targetId);
      targetToExecutionContexts.set(target.targetId, []);
      targetToScripts.set(target.targetId, []);
      targetToResources.set(target.targetId, []);
    }

    // Put all of the known execution contexts under respective targets
    for (const executionContext of executionContexts) {
      const frameId = executionContext.auxData?.frameId as string as Protocol.Target.TargetID;
      if (frameId && targetIds.has(frameId)) {
        targetToExecutionContexts.get(frameId)?.push(executionContext);
        executionContextIsolateToTarget.set(
            this.getExecutionContextIsolateId(executionContext.isolate, executionContext.id), frameId);
      } else {
        console.error('Execution context can\'t be linked to a target', executionContext);
      }
    }

    // Put all of the scripts under respective targets with collected information
    for (const script of scripts) {
      const scriptExecutionContextIsolateId =
          this.getExecutionContextIsolateId(script.isolate, script.executionContextId);
      const scriptFrameId = script.executionContextAuxData?.frameId as string as Protocol.Target.TargetID;
      if (script.executionContextAuxData?.frameId && targetIds.has(scriptFrameId)) {
        targetToScripts.get(scriptFrameId)?.push(script);
        executionContextIsolateToTarget.set(scriptExecutionContextIsolateId, scriptFrameId);
      } else if (this.#scriptToFrame.has(this.getScriptIsolateId(script.isolate, script.scriptId))) {
        const targetId = this.#scriptToFrame.get(this.getScriptIsolateId(script.isolate, script.scriptId)) as string as
            Protocol.Target.TargetID;
        if (targetId) {
          targetToScripts.get(targetId)?.push(script);
          executionContextIsolateToTarget.set(scriptExecutionContextIsolateId, targetId);
        }
      } else {
        // These scripts are not linked to any target
        orphanScripts.push(script);
      }
    }

    // If a script is not linked to a target, use executionContext@isolate to link to a target
    // Using PID is the last resort
    for (const orphanScript of orphanScripts) {
      const orphanScriptExecutionContextIsolateId =
          this.getExecutionContextIsolateId(orphanScript.isolate, orphanScript.executionContextId);
      const frameId = executionContextIsolateToTarget.get(orphanScriptExecutionContextIsolateId);

      if (frameId) {
        // Found a link via execution context, use it.
        targetToScripts.get(frameId)?.push(orphanScript);
      } else if (orphanScript.pid) {
        const target = targets.find(target => target.pid === orphanScript.pid);
        if (target) {
          targetToScripts.get(target.targetId)?.push(orphanScript);
        }
      } else {
        console.error('Script can\'t be linked to any target', orphanScript);
      }
    }

    for (const resource of resources) {
      const frameId = resource.frame as Protocol.Target.TargetID;
      if (targetIds.has(frameId)) {
        targetToResources.get(frameId)?.push(resource);
      }
    }

    // Now all the scripts are linked to a target, we want to make sure all the scripts are pointing to a valid
    // execution context. If not, we will create an artificial execution context for the script
    for (const target of targets) {
      const targetId = target.targetId;
      const executionContexts = targetToExecutionContexts.get(targetId) || [];
      const scripts = targetToScripts.get(targetId) || [];
      const resources = targetToResources.get(targetId) || [];
      for (const script of scripts) {
        if (!executionContexts.find(context => context.id === script.executionContextId)) {
          const artificialContext: RehydratingExecutionContext = {
            id: script.executionContextId,
            origin: '',
            v8Context: '',
            name: '',
            auxData: {
              frameId: targetId as string as Protocol.Page.FrameId,
              isDefault: false,
              type: 'type',
            },
            isolate: script.isolate,
            uniqueId: `${targetId}-${script.isolate}`,
          };
          executionContexts.push(artificialContext);
        }
      }

      // Finally, we put all the information into the data structure we want to return as.
      data.push({target, executionContexts, scripts, resources});
    }

    return data;
  }
}
