/**
 * Copyright (c) Microsoft Corporation.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import type * as trace from '@trace/trace';
import type * as traceV3 from './versions/traceV3';
import type * as traceV4 from './versions/traceV4';
import type * as traceV5 from './versions/traceV5';
import type * as traceV6 from './versions/traceV6';
import type * as traceV7 from './versions/traceV7';
import type * as traceV8 from './versions/traceV8';
import type { ActionEntry, ContextEntry, PageEntry } from '../types/entries';
import type { SnapshotStorage } from './snapshotStorage';

export class TraceVersionError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'TraceVersionError';
  }
}

// 6 => 10/2023 ~1.40
// 7 => 05/2024 ~1.45
const latestVersion: trace.VERSION = 8;

export class TraceModernizer {
  private _contextEntry: ContextEntry;
  private _snapshotStorage: SnapshotStorage;
  private _actionMap = new Map<string, ActionEntry>();
  private _version: number | undefined;
  private _pageEntries = new Map<string, PageEntry>();
  private _jsHandles = new Map<string, { preview: string }>();
  private _consoleObjects = new Map<string, { type: string, text: string, location: { url: string, lineNumber: number, columnNumber: number }, args?: { preview: string, value: string }[] }>();

  constructor(contextEntry: ContextEntry, snapshotStorage: SnapshotStorage) {
    this._contextEntry = contextEntry;
    this._snapshotStorage = snapshotStorage;
  }

  appendTrace(trace: string) {
    for (const line of trace.split('\n'))
      this._appendEvent(line);
  }

  actions(): ActionEntry[] {
    return [...this._actionMap.values()];
  }

  private _pageEntry(pageId: string): PageEntry {
    let pageEntry = this._pageEntries.get(pageId);
    if (!pageEntry) {
      pageEntry = {
        pageId,
        screencastFrames: [],
      };
      this._pageEntries.set(pageId, pageEntry);
      this._contextEntry.pages.push(pageEntry);
    }
    return pageEntry;
  }

  private _appendEvent(line: string) {
    if (!line)
      return;
    const events = this._modernize(JSON.parse(line));
    for (const event of events)
      this._innerAppendEvent(event);
  }

  private _innerAppendEvent(event: trace.TraceEvent) {
    const contextEntry = this._contextEntry;
    switch (event.type) {
      case 'context-options': {
        if (event.version > latestVersion)
          throw new TraceVersionError('The trace was created by a newer version of Playwright and is not supported by this version of the viewer. Please use latest Playwright to open the trace.');
        this._version = event.version;
        contextEntry.origin = event.origin;
        contextEntry.browserName = event.browserName;
        contextEntry.channel = event.channel;
        contextEntry.title = event.title;
        contextEntry.platform = event.platform;
        contextEntry.playwrightVersion = event.playwrightVersion;
        contextEntry.wallTime = event.wallTime;
        contextEntry.startTime = event.monotonicTime;
        contextEntry.sdkLanguage = event.sdkLanguage;
        contextEntry.options = event.options;
        contextEntry.testIdAttributeName = event.testIdAttributeName;
        contextEntry.contextId = event.contextId ?? '';
        break;
      }
      case 'screencast-frame': {
        this._pageEntry(event.pageId).screencastFrames.push(event);
        break;
      }
      case 'before': {
        this._actionMap.set(event.callId, { ...event, type: 'action', endTime: 0, log: [] });
        break;
      }
      case 'input': {
        const existing = this._actionMap.get(event.callId);
        existing!.inputSnapshot = event.inputSnapshot;
        existing!.point = event.point;
        break;
      }
      case 'log': {
        const existing = this._actionMap.get(event.callId);
        // We have some corrupted traces out there, tolerate them.
        if (!existing)
          return;
        existing.log.push({
          time: event.time,
          message: event.message,
        });
        break;
      }
      case 'after': {
        const existing = this._actionMap.get(event.callId);
        existing!.afterSnapshot = event.afterSnapshot;
        existing!.endTime = event.endTime;
        existing!.result = event.result;
        existing!.error = event.error;
        existing!.attachments = event.attachments;
        existing!.annotations = event.annotations;
        if (event.point)
          existing!.point = event.point;
        break;
      }
      case 'action': {
        this._actionMap.set(event.callId, { ...event, log: [] });
        break;
      }
      case 'event': {
        contextEntry.events.push(event);
        break;
      }
      case 'stdout': {
        contextEntry.stdio.push(event);
        break;
      }
      case 'stderr': {
        contextEntry.stdio.push(event);
        break;
      }
      case 'error': {
        contextEntry.errors.push(event);
        break;
      }
      case 'console': {
        contextEntry.events.push(event);
        break;
      }
      case 'resource-snapshot':
        this._snapshotStorage.addResource(this._contextEntry.contextId, event.snapshot);
        contextEntry.resources.push(event.snapshot);
        break;
      case 'frame-snapshot':
        this._snapshotStorage.addFrameSnapshot(this._contextEntry.contextId, event.snapshot, this._pageEntry(event.snapshot.pageId).screencastFrames);
        break;
    }
    // Make sure there is a page entry for each page, even without screencast frames,
    // to show in the metadata view.
    if (('pageId' in event) && event.pageId)
      this._pageEntry(event.pageId);
    if (event.type === 'action' || event.type === 'before')
      contextEntry.startTime = Math.min(contextEntry.startTime, event.startTime);
    if (event.type === 'action' || event.type === 'after')
      contextEntry.endTime = Math.max(contextEntry.endTime, event.endTime);
    if (event.type === 'event') {
      contextEntry.startTime = Math.min(contextEntry.startTime, event.time);
      contextEntry.endTime = Math.max(contextEntry.endTime, event.time);
    }
    if (event.type === 'screencast-frame') {
      contextEntry.startTime = Math.min(contextEntry.startTime, event.timestamp);
      contextEntry.endTime = Math.max(contextEntry.endTime, event.timestamp);
    }
  }

  private _processedContextCreatedEvent() {
    return this._version !== undefined;
  }

  private _modernize(event: any): trace.TraceEvent[] {
    // First record does not have this._version, but should have a version in the event entry itself.
    // Test traces before 7 (including 6) did not have version in the first entry, run the modernizer for 6=>*.
    let version = this._version ?? event.version ?? 6;
    let events = [event];
    for (; version < latestVersion; ++version)
      events = (this as any)[`_modernize_${version}_to_${version + 1}`].call(this, events);
    return events;
  }

  _modernize_0_to_1(events: any[]): any[] {
    for (const event of events) {
      if (event.type !== 'action')
        continue;
      if (typeof event.metadata.error === 'string')
        event.metadata.error = { error: { name: 'Error', message: event.metadata.error } };
    }
    return events;
  }

  _modernize_1_to_2(events: any[]): any[] {
    for (const event of events) {
      if (event.type !== 'frame-snapshot' || !event.snapshot.isMainFrame)
        continue;
      // Old versions had completely wrong viewport.
      event.snapshot.viewport = this._contextEntry.options?.viewport || { width: 1280, height: 720 };
    }
    return events;
  }

  _modernize_2_to_3(events: any[]): any[] {
    for (const event of events) {
      if (event.type !== 'resource-snapshot' || event.snapshot.request)
        continue;
      // Migrate from old ResourceSnapshot to new har entry format.
      const resource = event.snapshot;
      event.snapshot = {
        _frameref: resource.frameId,
        request: {
          url: resource.url,
          method: resource.method,
          headers: resource.requestHeaders,
          postData: resource.requestSha1 ? { _sha1: resource.requestSha1 } : undefined,
        },
        response: {
          status: resource.status,
          headers: resource.responseHeaders,
          content: {
            mimeType: resource.contentType,
            _sha1: resource.responseSha1,
          },
        },
        _monotonicTime: resource.timestamp,
      };
    }
    return events;
  }

  _modernize_3_to_4(events: traceV3.TraceEvent[]): traceV4.TraceEvent[] {
    const result: traceV4.TraceEvent[] = [];
    for (const event of events) {
      const e = this._modernize_event_3_to_4(event);
      if (e)
        result.push(e);
    }
    return result;
  }

  _modernize_event_3_to_4(event: traceV3.TraceEvent): traceV4.TraceEvent | null {
    if (event.type !== 'action' && event.type !== 'event') {
      return event as traceV3.ContextCreatedTraceEvent |
        traceV3.ScreencastFrameTraceEvent |
        traceV3.ResourceSnapshotTraceEvent |
        traceV3.FrameSnapshotTraceEvent;
    }

    const metadata = event.metadata;
    if (metadata.internal || metadata.method.startsWith('tracing'))
      return null;

    if (event.type === 'event') {
      if (metadata.method === '__create__' && metadata.type === 'ConsoleMessage') {
        return {
          type: 'object',
          class: metadata.type,
          guid: metadata.params.guid,
          initializer: metadata.params.initializer,
        };
      }
      return {
        type: 'event',
        time: metadata.startTime,
        class: metadata.type,
        method: metadata.method,
        params: metadata.params,
        pageId: metadata.pageId,
      };
    }

    return {
      type: 'action',
      callId: metadata.id,
      startTime: metadata.startTime,
      endTime: metadata.endTime,
      apiName: metadata.apiName || metadata.type + '.' + metadata.method,
      class: metadata.type,
      method: metadata.method,
      params: metadata.params,
      wallTime: metadata.wallTime || Date.now(),
      log: metadata.log,
      beforeSnapshot: metadata.snapshots.find(s => s.title === 'before')?.snapshotName,
      inputSnapshot: metadata.snapshots.find(s => s.title === 'input')?.snapshotName,
      afterSnapshot: metadata.snapshots.find(s => s.title === 'after')?.snapshotName,
      error: metadata.error?.error,
      result: metadata.result,
      point: metadata.point,
      pageId: metadata.pageId,
    };
  }

  _modernize_4_to_5(events: traceV4.TraceEvent[]): traceV5.TraceEvent[] {
    const result: traceV5.TraceEvent[] = [];
    for (const event of events) {
      const e = this._modernize_event_4_to_5(event);
      if (e)
        result.push(e);
    }
    return result;
  }

  _modernize_event_4_to_5(event: traceV4.TraceEvent): traceV5.TraceEvent | null {
    if (event.type === 'event' && event.method === '__create__' && event.class === 'JSHandle')
      this._jsHandles.set(event.params.guid, event.params.initializer);
    if (event.type === 'object') {
      // We do not expect any other 'object' events.
      if (event.class !== 'ConsoleMessage')
        return null;
      // Older traces might have `args` inherited from the protocol initializer - guid of JSHandle,
      // but might also have modern `args` with preview and value.
      const args: { preview: string, value: string }[] = (event.initializer as any).args?.map((arg: any) => {
        if (arg.guid) {
          const handle = this._jsHandles.get(arg.guid);
          return { preview: handle?.preview || '', value: '' };
        }
        return { preview: arg.preview || '', value: arg.value || '' };
      });
      this._consoleObjects.set(event.guid, {
        type: event.initializer.type,
        text: event.initializer.text,
        location: event.initializer.location,
        args,
      });
      return null;
    }
    if (event.type === 'event' && event.method === 'console') {
      const consoleMessage = this._consoleObjects.get(event.params.message?.guid || '');
      if (!consoleMessage)
        return null;
      return {
        type: 'console',
        time: event.time,
        pageId: event.pageId,
        messageType: consoleMessage.type,
        text: consoleMessage.text,
        args: consoleMessage.args,
        location: consoleMessage.location,
      };
    }
    return event;
  }

  _modernize_5_to_6(events: traceV5.TraceEvent[]): traceV6.TraceEvent[] {
    const result: traceV6.TraceEvent[] = [];
    for (const event of events) {
      result.push(event);
      if (event.type !== 'after' || !event.log.length)
        continue;
      for (const log of event.log) {
        result.push({
          type: 'log',
          callId: event.callId,
          message: log,
          time: -1,
        });
      }
    }
    return result;
  }

  _modernize_6_to_7(events: traceV6.TraceEvent[]): traceV7.TraceEvent[] {
    const result: traceV7.TraceEvent[] = [];
    if (!this._processedContextCreatedEvent() && events[0].type !== 'context-options') {
      const event: traceV7.ContextCreatedTraceEvent = {
        type: 'context-options',
        origin: 'testRunner',
        version: 6,
        browserName: '',
        options: {},
        platform: 'unknown',
        wallTime: 0,
        monotonicTime: 0,
        sdkLanguage: 'javascript',
        contextId: '',
      };
      result.push(event);
    }

    for (const event of events) {
      if (event.type === 'context-options') {
        result.push({ ...event, monotonicTime: 0, origin: 'library', contextId: '' });
        continue;
      }
      if (event.type === 'before' || event.type === 'action') {
        // Take wall and monotonic time from the first event.
        if (!this._contextEntry.wallTime)
          this._contextEntry.wallTime = event.wallTime;
        const eventAsV6 = event as traceV6.BeforeActionTraceEvent;
        const eventAsV7 = event as traceV7.BeforeActionTraceEvent;
        eventAsV7.stepId = `${eventAsV6.apiName}@${eventAsV6.wallTime}`;
        result.push(eventAsV7);
      } else {
        result.push(event);
      }
    }
    return result;
  }

  _modernize_7_to_8(events: traceV7.TraceEvent[]): traceV8.TraceEvent[] {
    const result: traceV8.TraceEvent[] = [];
    for (const event of events) {
      if (event.type === 'before' || event.type === 'action') {
        const eventAsV7 = event as traceV7.BeforeActionTraceEvent;
        const eventAsV8 = event as traceV8.BeforeActionTraceEvent;
        if (eventAsV7.apiName) {
          eventAsV8.title = eventAsV7.apiName;
          delete (eventAsV8 as any).apiName;
        }
        eventAsV8.stepId = eventAsV7.stepId ?? eventAsV7.callId;
        result.push(eventAsV8);
      } else {
        result.push(event);
      }
    }
    return result;
  }
}
