// Copyright (c) 2023 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import * as Common from '../../core/common/common.js';
import * as Platform from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as Protocol from '../../generated/protocol.js';
import {
  createConsoleViewMessageWithStubDeps,
  createStackTrace,
} from '../../testing/ConsoleHelpers.js';
import {renderElementIntoDOM} from '../../testing/DOMHelpers.js';
import {createTarget} from '../../testing/EnvironmentHelpers.js';
import {describeWithMockConnection} from '../../testing/MockConnection.js';
import * as Components from '../../ui/legacy/components/utils/utils.js';
import * as UI from '../../ui/legacy/legacy.js';

import * as Console from './console.js';
// The css files aren't exported by the bundle, so we need to import it directly.
// eslint-disable-next-line rulesdir/es-modules-import
import consoleViewStyles from './consoleView.css.js';

const {urlString} = Platform.DevToolsPath;

describe('ConsoleViewMessage', () => {
  describe('concatErrorDescriptionAndIssueSummary', () => {
    const {concatErrorDescriptionAndIssueSummary} = Console.ConsoleViewMessage;

    it('correctly appends the issue summary in case of single line error descriptions', () => {
      assert.strictEqual(
          concatErrorDescriptionAndIssueSummary(
              'TypeError: Failed to fetch',
              'Access blocked by CORS policy: Cross origin requests are not allowed by request mode.'),
          'TypeError: Failed to fetch. Access blocked by CORS policy: Cross origin requests are not allowed by request mode.',
      );
    });

    it('correctly inserts the issue summary in case of multi-line error descriptions', () => {
      assert.strictEqual(
          concatErrorDescriptionAndIssueSummary(
              'TypeError: Failed to fetch\n  at (index):25:5',
              'Access blocked by CORS policy: Cross origin requests are not allowed by request mode.'),
          'TypeError: Failed to fetch. Access blocked by CORS policy: Cross origin requests are not allowed by request mode.\n  at (index):25:5',
      );
    });
  });
});

describeWithMockConnection('ConsoleViewMessage', () => {
  describe('anchor rendering', () => {
    it('links to the top frame for normal console message', () => {
      const target = createTarget();
      const runtimeModel = target.model(SDK.RuntimeModel.RuntimeModel);
      const stackTrace = createStackTrace([
        'USER_ID::userNestedFunction::http://example.com/script.js::40::15',
        'USER_ID::userFunction::http://example.com/script.js::10::2',
        'APP_ID::entry::http://example.com/app.js::25::10',
      ]);
      const messageDetails = {
        type: Protocol.Runtime.ConsoleAPICalledEventType.Log,
        stackTrace,
      };
      const rawMessage = new SDK.ConsoleModel.ConsoleMessage(
          runtimeModel, Common.Console.FrontendMessageSource.ConsoleAPI, /* level */ null, 'got here', messageDetails);
      const {message, linkifier} = createConsoleViewMessageWithStubDeps(rawMessage);

      message.toMessageElement();  // Trigger rendering.

      sinon.assert.calledOnceWithExactly(linkifier.linkifyStackTraceTopFrame, target, stackTrace);
    });

    it('links to the frame with the logpoint/breakpoint if the stack trace contains the "marker sourceURL"', () => {
      const target = createTarget();
      const runtimeModel = target.model(SDK.RuntimeModel.RuntimeModel);
      const stackTrace = createStackTrace([
        `LOG_ID::eval::${SDK.DebuggerModel.LOGPOINT_SOURCE_URL}::0::15`,
        'USER_ID::userFunction::http://example.com/script.js::10::2',
        'APP_ID::entry::http://example.com/app.js::25::10',
      ]);
      const messageDetails = {
        type: Protocol.Runtime.ConsoleAPICalledEventType.Log,
        stackTrace,
      };
      const rawMessage = new SDK.ConsoleModel.ConsoleMessage(
          runtimeModel, Common.Console.FrontendMessageSource.ConsoleAPI, /* level */ null, 'value of x is 42',
          messageDetails);
      const {message, linkifier} = createConsoleViewMessageWithStubDeps(rawMessage);

      message.toMessageElement();  // Trigger rendering.

      const expectedCallFrame = stackTrace.callFrames[1];  // userFunction.
      sinon.assert.calledOnceWithExactly(
          linkifier.maybeLinkifyConsoleCallFrame, target, expectedCallFrame,
          {inlineFrameIndex: 0, revealBreakpoint: true, userMetric: undefined});
    });

    it('uses the last "marker sourceURL" frame when searching for the breakpoint/logpoint frame', () => {
      const target = createTarget();
      const runtimeModel = target.model(SDK.RuntimeModel.RuntimeModel);
      const stackTrace = createStackTrace([
        `LOG_ID::leakedClosure::${SDK.DebuggerModel.LOGPOINT_SOURCE_URL}::2::3`,
        'USER_ID::callback::http://example.com/script.js::5::42',
        `LOG_ID::eval::${SDK.DebuggerModel.LOGPOINT_SOURCE_URL}::0::15`,
        'USER_ID::userFunction::http://example.com/script.js::10::2',
        'APP_ID::entry::http://example.com/app.js::25::10',
      ]);
      const messageDetails = {
        type: Protocol.Runtime.ConsoleAPICalledEventType.Log,
        stackTrace,
      };
      const rawMessage = new SDK.ConsoleModel.ConsoleMessage(
          runtimeModel, Common.Console.FrontendMessageSource.ConsoleAPI, /* level */ null, 'value of x is 42',
          messageDetails);
      const {message, linkifier} = createConsoleViewMessageWithStubDeps(rawMessage);

      message.toMessageElement();  // Trigger rendering.

      const expectedCallFrame = stackTrace.callFrames[3];  // userFunction.
      sinon.assert.calledOnceWithExactly(
          linkifier.maybeLinkifyConsoleCallFrame, target, expectedCallFrame,
          {inlineFrameIndex: 0, revealBreakpoint: true, userMetric: undefined});
    });
  });

  describe('console insights', () => {
    it('shows a hover button', () => {
      sinon.stub(UI.ActionRegistry.ActionRegistry.instance(), 'hasAction')
          .withArgs('explain.console-message.hover')
          .returns(true);
      const target = createTarget();
      const runtimeModel = target.model(SDK.RuntimeModel.RuntimeModel);
      const rawMessage = new SDK.ConsoleModel.ConsoleMessage(
          runtimeModel, Common.Console.FrontendMessageSource.ConsoleAPI, Protocol.Log.LogEntryLevel.Error, 'got here');
      const {message} = createConsoleViewMessageWithStubDeps(rawMessage);
      const messageElement = message.toMessageElement();  // Trigger rendering.
      const button = messageElement.querySelector('[aria-label=\'Understand this error. Powered by AI.\']');
      assert.strictEqual(button?.textContent, 'Understand this errorAI');
    });

    it('does not show a hover button if the console message text is empty', () => {
      sinon.stub(UI.ActionRegistry.ActionRegistry.instance(), 'hasAction')
          .withArgs('explain.console-message.hover')
          .returns(true);
      const target = createTarget();
      const runtimeModel = target.model(SDK.RuntimeModel.RuntimeModel);
      const rawMessage = new SDK.ConsoleModel.ConsoleMessage(
          runtimeModel, Common.Console.FrontendMessageSource.ConsoleAPI, Protocol.Log.LogEntryLevel.Error, '');
      const {message} = createConsoleViewMessageWithStubDeps(rawMessage);
      const messageElement = message.toMessageElement();  // Trigger rendering.
      const button = messageElement.querySelector('[aria-label=\'Understand this error. Powered by AI.\']');
      assert.isNull(button);
    });

    it('does not show a hover button for the self-XSS warning message', () => {
      sinon.stub(UI.ActionRegistry.ActionRegistry.instance(), 'hasAction')
          .withArgs('explain.console-message.hover')
          .returns(true);
      const target = createTarget();
      const runtimeModel = target.model(SDK.RuntimeModel.RuntimeModel);
      const rawMessage = new SDK.ConsoleModel.ConsoleMessage(
          runtimeModel, Common.Console.FrontendMessageSource.SELF_XSS, Protocol.Log.LogEntryLevel.Warning,
          'Don’t paste code...');
      const {message} = createConsoleViewMessageWithStubDeps(rawMessage);
      const messageElement = message.toMessageElement();  // Trigger rendering.
      const button = messageElement.querySelector('[aria-label=\'Understand this warning. Powered by AI.\']');
      assert.isNull(button);
    });
  });

  describe('with ignore listing', () => {
    const IGNORE_LIST_LINK = 'ignore-list-link';

    function findStackPreviewContainer(element: HTMLElement) {
      const outer = element.querySelector('span.stack-preview-container');
      assert.isNotNull(outer);
      const inner = outer.shadowRoot;
      assert.isNotNull(inner);
      return inner;
    }

    function findLinks(element: HTMLElement) {
      const root = findStackPreviewContainer(element);
      const showAll = root.querySelector('.show-all-link');
      assert.isNotNull(showAll);
      const showLess = root.querySelector('.show-less-link');
      assert.isNotNull(showLess);
      return {showAll, showLess};
    }

    function assertNoLinks(element: HTMLElement) {
      const {showAll, showLess} = findLinks(element);
      assert.isFalse(showAll.checkVisibility());
      assert.isFalse(showLess.checkVisibility());
    }

    function assertShowAllLink(element: HTMLElement) {
      const {showAll, showLess} = findLinks(element);
      assert.isTrue(showAll.checkVisibility());
      assert.isFalse(showLess.checkVisibility());
    }

    function assertShowLessLink(element: HTMLElement) {
      const {showAll, showLess} = findLinks(element);
      assert.isFalse(showAll.checkVisibility());
      assert.isTrue(showLess.checkVisibility());
    }

    function errorMessageForStack(stack: Protocol.Runtime.StackTrace, withBuiltinFrames?: boolean) {
      const lines = [
        'Error:',
        ...(stack.callFrames.flatMap(frame => {
          const line = `    at ${frame.functionName} (${frame.url}:${frame.lineNumber}:${frame.columnNumber})`;
          if (withBuiltinFrames) {
            return [line, '    at JSON.parse (<anonymous>)'];
          }
          return [line];
        })),
      ];
      return lines.join('\n');
    }

    function getCallFrames(element: HTMLElement): string[] {
      const results = [];
      for (const line of element.querySelectorAll('.formatted-stack-frame,.formatted-builtin-stack-frame')) {
        if (line.checkVisibility()) {
          results.push(line.textContent ?? 'Error: line was null or undefined');
        }
      }
      return results;
    }

    function getStructuredCallFrames(element: HTMLElement): string[] {
      const results = [];
      for (const line of findStackPreviewContainer(element).querySelectorAll('tbody tr')) {
        if (line.checkVisibility()) {
          results.push(line.textContent ?? 'Error: line was null or undefined');
        }
      }
      return results;
    }

    function expandStructuredTrace(element: HTMLElement) {
      (element.querySelector('.console-message-stack-trace-wrapper > div') as HTMLElement).click();
    }

    function expandIgnored(element: HTMLElement) {
      const {showAll} = findLinks(element);
      (showAll.querySelector('.link') as HTMLElement).click();
    }

    function collapseIgnored(element: HTMLElement) {
      const {showLess} = findLinks(element);
      (showLess.querySelector('.link') as HTMLElement).click();
    }

    async function createConsoleMessageWithIgnoreListing(
        ignoreListFn: (url: string) => Boolean, withBuiltinFrames?: boolean): Promise<HTMLElement> {
      const target = createTarget();
      const runtimeModel = target.model(SDK.RuntimeModel.RuntimeModel);
      const stackTrace = createStackTrace([
        'USER_ID::userNestedFunction::http://example.com/script.js::40::15',
        'USER_ID::userFunction::http://example.com/script.js::10::2',
        'APP_ID::entry::http://example.com/app.js::25::10',
      ]);
      const stackTraceMessage = errorMessageForStack(stackTrace, withBuiltinFrames);
      const messageDetails = {
        type: Protocol.Runtime.ConsoleAPICalledEventType.Error,
        stackTrace,
        parameters: [{
          type: 'object',
          subtype: 'error',
          className: 'Error',
          description: stackTraceMessage,
        } as Protocol.Runtime.RemoteObject],
      };
      const rawMessage = new SDK.ConsoleModel.ConsoleMessage(
          runtimeModel, Common.Console.FrontendMessageSource.ConsoleAPI, Protocol.Log.LogEntryLevel.Error,
          stackTraceMessage, messageDetails);
      const {message, linkifier} = createConsoleViewMessageWithStubDeps(rawMessage);

      linkifier.linkifyScriptLocation.callsFake((target, scriptId, sourceURL, lineNumber, options) => {
        const link = Components.Linkifier.Linkifier.linkifyURL(sourceURL, {lineNumber, ...options});
        if (ignoreListFn(sourceURL)) {
          link.classList.add(IGNORE_LIST_LINK);
        }
        return link;
      });
      linkifier.maybeLinkifyConsoleCallFrame.callsFake((target, callFrame, options) => {
        const link = Components.Linkifier.Linkifier.linkifyURL(
            urlString`${callFrame.url}`, {lineNumber: callFrame.lineNumber, ...options});
        if (ignoreListFn(callFrame.url)) {
          link.classList.add(IGNORE_LIST_LINK);
        }
        return link;
      });
      const element = message.toMessageElement();  // Trigger rendering.
      await message.formatErrorStackPromiseForTest();

      const wrapperElement = document.createElement('div');
      const shadowElement = UI.UIUtils.createShadowRootWithCoreStyles(wrapperElement, {cssFile: consoleViewStyles});
      shadowElement.appendChild(element);
      renderElementIntoDOM(wrapperElement);
      assert.isTrue(element.checkVisibility());
      return element;
    }

    const EXPANDED_UNSTRUCTURED = [
      '    at userNestedFunction (/script.js:40:15)\n',
      '    at userFunction (/script.js:10:2)\n',
      '    at entry (/app.js:25:10)',
    ];
    const COLLAPSED_UNSTRUCTURED = [
      '    at userNestedFunction (/script.js:40:15)\n',
      '    at userFunction (/script.js:10:2)\n',
    ];
    const EXPANDED_UNSTRUCTURED_WITH_BUILTIN = [
      '    at userNestedFunction (/script.js:40:15)\n',
      '    at JSON.parse (<anonymous>)\n',
      '    at userFunction (/script.js:10:2)\n',
      '    at JSON.parse (<anonymous>)\n',
      '    at entry (/app.js:25:10)\n',
      '    at JSON.parse (<anonymous>)',
    ];
    const COLLAPSED_UNSTRUCTURED_WITH_BUILTIN = [
      '    at userNestedFunction (/script.js:40:15)\n',
      '    at JSON.parse (<anonymous>)\n',
      '    at userFunction (/script.js:10:2)\n',
      '    at JSON.parse (<anonymous>)\n',
    ];
    const EXPANDED_STRUCTURED = [
      '\nuserNestedFunction @ example.com/script.js:41',
      '\nuserFunction @ example.com/script.js:11',
      '\nentry @ example.com/app.js:26',
    ];
    const COLLAPSED_STRUCTURED = [
      '\nuserNestedFunction @ example.com/script.js:41',
      '\nuserFunction @ example.com/script.js:11',
    ];

    it('shows everything with no links when nothing is ignore listed', async () => {
      const element = await createConsoleMessageWithIgnoreListing(_ => false);
      assertNoLinks(element);
      assert.deepEqual(getCallFrames(element), EXPANDED_UNSTRUCTURED);
      assert.deepEqual(getStructuredCallFrames(element), []);
      expandStructuredTrace(element);
      assertNoLinks(element);
      assert.deepEqual(getStructuredCallFrames(element), EXPANDED_STRUCTURED);
    });

    it('shows everything with no links when everything is ignore listed', async () => {
      const element = await createConsoleMessageWithIgnoreListing(_ => true);
      assertNoLinks(element);
      assert.deepEqual(getCallFrames(element), EXPANDED_UNSTRUCTURED);
      assert.deepEqual(getStructuredCallFrames(element), []);
      expandStructuredTrace(element);
      assertNoLinks(element);
      assert.deepEqual(getStructuredCallFrames(element), EXPANDED_STRUCTURED);
    });

    it('shows expandable list when something is ignore listed', async () => {
      const element = await createConsoleMessageWithIgnoreListing(url => url.includes('/app.js'));
      assertShowAllLink(element);
      assert.deepEqual(getStructuredCallFrames(element), []);
      assert.deepEqual(getCallFrames(element), COLLAPSED_UNSTRUCTURED);
      expandIgnored(element);
      assertShowLessLink(element);
      assert.deepEqual(getCallFrames(element), EXPANDED_UNSTRUCTURED);
      collapseIgnored(element);
      assertShowAllLink(element);

      expandStructuredTrace(element);

      assertShowAllLink(element);
      assert.deepEqual(getStructuredCallFrames(element), COLLAPSED_STRUCTURED);
      assert.deepEqual(getCallFrames(element), COLLAPSED_UNSTRUCTURED);
      expandIgnored(element);
      assertShowLessLink(element);
      assert.deepEqual(getCallFrames(element), EXPANDED_UNSTRUCTURED);
      assert.deepEqual(getStructuredCallFrames(element), EXPANDED_STRUCTURED);
      collapseIgnored(element);
      assertShowAllLink(element);
      assert.deepEqual(getStructuredCallFrames(element), COLLAPSED_STRUCTURED);
      assert.deepEqual(getCallFrames(element), COLLAPSED_UNSTRUCTURED);
    });

    it('shows everything with no links when nothing is ignore listed, including builtin frames', async () => {
      const element = await createConsoleMessageWithIgnoreListing(_ => false, true);
      assertNoLinks(element);
      assert.deepEqual(getCallFrames(element), EXPANDED_UNSTRUCTURED_WITH_BUILTIN);
      assert.deepEqual(getStructuredCallFrames(element), []);
      expandStructuredTrace(element);
      assertNoLinks(element);
      assert.deepEqual(getStructuredCallFrames(element), EXPANDED_STRUCTURED);
    });

    it('shows everything with no links when everything is ignore listed, including builtin frames', async () => {
      const element = await createConsoleMessageWithIgnoreListing(_ => true, true);
      assertNoLinks(element);
      assert.deepEqual(getCallFrames(element), EXPANDED_UNSTRUCTURED_WITH_BUILTIN);
      assert.deepEqual(getStructuredCallFrames(element), []);
      expandStructuredTrace(element);
      assertNoLinks(element);
      assert.deepEqual(getStructuredCallFrames(element), EXPANDED_STRUCTURED);
    });

    it('shows expandable list when something is ignore listed, collapsing builtin frames', async () => {
      const element = await createConsoleMessageWithIgnoreListing(url => url.includes('/app.js'), true);
      assertShowAllLink(element);
      assert.deepEqual(getStructuredCallFrames(element), []);
      assert.deepEqual(getCallFrames(element), COLLAPSED_UNSTRUCTURED_WITH_BUILTIN);
      expandIgnored(element);
      assertShowLessLink(element);
      assert.deepEqual(getCallFrames(element), EXPANDED_UNSTRUCTURED_WITH_BUILTIN);
      collapseIgnored(element);
      assertShowAllLink(element);

      expandStructuredTrace(element);

      assertShowAllLink(element);
      assert.deepEqual(getStructuredCallFrames(element), COLLAPSED_STRUCTURED);
      assert.deepEqual(getCallFrames(element), COLLAPSED_UNSTRUCTURED_WITH_BUILTIN);
      expandIgnored(element);
      assertShowLessLink(element);
      assert.deepEqual(getCallFrames(element), EXPANDED_UNSTRUCTURED_WITH_BUILTIN);
      assert.deepEqual(getStructuredCallFrames(element), EXPANDED_STRUCTURED);
      collapseIgnored(element);
      assertShowAllLink(element);
      assert.deepEqual(getStructuredCallFrames(element), COLLAPSED_STRUCTURED);
      assert.deepEqual(getCallFrames(element), COLLAPSED_UNSTRUCTURED_WITH_BUILTIN);
    });
  });
});
