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

import * as SDK from '../../core/sdk/sdk.js';
import * as AiAssistanceModel from '../../models/ai_assistance/ai_assistance.js';
import * as Bindings from '../../models/bindings/bindings.js';
import * as Formatter from '../../models/formatter/formatter.js';
import * as Logs from '../../models/logs/logs.js';
import * as TextUtils from '../../models/text_utils/text_utils.js';
import * as Components from '../../ui/legacy/components/utils/utils.js';
import * as UI from '../../ui/legacy/legacy.js';

import type {ConsoleViewMessage} from './ConsoleViewMessage.js';

const MAX_MESSAGE_SIZE = 1000;
const MAX_STACK_TRACE_SIZE = 1000;
const MAX_CODE_SIZE = 1000;

export enum SourceType {
  MESSAGE = 'message',
  STACKTRACE = 'stacktrace',
  NETWORK_REQUEST = 'networkRequest',
  RELATED_CODE = 'relatedCode',
}

export interface Source {
  type: SourceType;
  value: string;
}

export class PromptBuilder {
  #consoleMessage: ConsoleViewMessage;

  constructor(consoleMessage: ConsoleViewMessage) {
    this.#consoleMessage = consoleMessage;
  }

  async getNetworkRequest(): Promise<SDK.NetworkRequest.NetworkRequest|undefined> {
    const requestId = this.#consoleMessage.consoleMessage().getAffectedResources()?.requestId;
    if (!requestId) {
      return;
    }
    const log = Logs.NetworkLog.NetworkLog.instance();
    // TODO: we might try handling redirect requests too later.
    return log.requestsForId(requestId)[0];
  }

  /**
   * Gets the source file associated with the top of the message's stacktrace.
   * Returns an empty string if the source is not available for any reasons.
   */
  async getMessageSourceCode(): Promise<{text: string, columnNumber: number, lineNumber: number}> {
    const callframe = this.#consoleMessage.consoleMessage().stackTrace?.callFrames[0];
    const runtimeModel = this.#consoleMessage.consoleMessage().runtimeModel();
    const debuggerModel = runtimeModel?.debuggerModel();
    if (!debuggerModel || !runtimeModel || !callframe) {
      return {text: '', columnNumber: 0, lineNumber: 0};
    }
    const rawLocation =
        new SDK.DebuggerModel.Location(debuggerModel, callframe.scriptId, callframe.lineNumber, callframe.columnNumber);
    const mappedLocation =
        await Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance().rawLocationToUILocation(
            rawLocation);
    const content = await mappedLocation?.uiSourceCode.requestContentData().then(
        contentDataOrError => TextUtils.ContentData.ContentData.asDeferredContent(contentDataOrError));
    const text = !content?.isEncoded && content?.content ? content.content : '';
    const firstNewline = text.indexOf('\n');
    if (text.length > MAX_CODE_SIZE && (firstNewline < 0 || firstNewline > MAX_CODE_SIZE)) {
      // Use formatter
      const {formattedContent, formattedMapping} = await Formatter.ScriptFormatter.formatScriptContent(
          mappedLocation?.uiSourceCode.mimeType() ?? 'text/javascript', text);
      const [lineNumber, columnNumber] =
          formattedMapping.originalToFormatted(mappedLocation?.lineNumber ?? 0, mappedLocation?.columnNumber ?? 0);
      return {text: formattedContent, columnNumber, lineNumber};
    }
    return {text, columnNumber: mappedLocation?.columnNumber ?? 0, lineNumber: mappedLocation?.lineNumber ?? 0};
  }

  async buildPrompt(sourcesTypes: SourceType[] = Object.values(SourceType)):
      Promise<{prompt: string, sources: Source[], isPageReloadRecommended: boolean}> {
    const [sourceCode, request] = await Promise.all([
      sourcesTypes.includes(SourceType.RELATED_CODE) ? this.getMessageSourceCode() : undefined,
      sourcesTypes.includes(SourceType.NETWORK_REQUEST) ? this.getNetworkRequest() : undefined,
    ]);

    const relatedCode = sourceCode?.text ? formatRelatedCode(sourceCode) : '';
    const relatedRequest = request ? formatNetworkRequest(request) : '';
    const stacktrace = sourcesTypes.includes(SourceType.STACKTRACE) ? await formatStackTrace(this.#consoleMessage) : '';

    const message = formatConsoleMessage(this.#consoleMessage);

    const prompt = this.formatPrompt({
      message: [message, stacktrace].join('\n').trim(),
      relatedCode,
      relatedRequest,
    });

    const sources = [
      {
        type: SourceType.MESSAGE,
        value: message,
      },
    ];

    if (stacktrace) {
      sources.push({
        type: SourceType.STACKTRACE,
        value: stacktrace,
      });
    }

    if (relatedCode) {
      sources.push({
        type: SourceType.RELATED_CODE,
        value: relatedCode,
      });
    }

    if (relatedRequest) {
      sources.push({
        type: SourceType.NETWORK_REQUEST,
        value: relatedRequest,
      });
    }

    return {
      prompt,
      sources,
      isPageReloadRecommended: sourcesTypes.includes(SourceType.NETWORK_REQUEST) &&
          Boolean(this.#consoleMessage.consoleMessage().getAffectedResources()?.requestId) && !relatedRequest,
    };
  }

  formatPrompt({message, relatedCode, relatedRequest}: {message: string, relatedCode: string, relatedRequest: string}):
      string {
    let prompt = `Please explain the following console error or warning:

\`\`\`
${message}
\`\`\``;

    if (relatedCode) {
      prompt += `
For the following code:

\`\`\`
${relatedCode}
\`\`\``;
    }

    if (relatedRequest) {
      prompt += `
For the following network request:

\`\`\`
${relatedRequest}
\`\`\``;
    }
    return prompt;
  }

  getSearchQuery(): string {
    let message = this.#consoleMessage.toMessageTextString();
    if (message) {
      // If there are multiple lines, it is likely the rest of the message
      // is a stack trace, which we don't want.
      message = message.split('\n')[0];
    }
    return message;
  }
}

export function allowHeader(header: SDK.NetworkRequest.NameValue): boolean {
  const normalizedName = header.name.toLowerCase().trim();
  // Skip custom headers.
  if (normalizedName.startsWith('x-')) {
    return false;
  }
  // Skip cookies as they might contain auth.
  if (normalizedName === 'cookie' || normalizedName === 'set-cookie') {
    return false;
  }
  if (normalizedName === 'authorization') {
    return false;
  }
  return true;
}

export function lineWhitespace(line: string): string|null {
  const matches = /^\s*/.exec(line);
  if (!matches?.length) {
    // This should not happen
    return null;
  }
  const whitespace = matches[0];
  if (whitespace === line) {
    return null;
  }
  return whitespace;
}

export function formatRelatedCode(
    {text, columnNumber, lineNumber}: {text: string, columnNumber: number, lineNumber: number},
    maxCodeSize = MAX_CODE_SIZE): string {
  const lines = text.split('\n');
  if (lines[lineNumber].length >= maxCodeSize / 2) {
    const start = Math.max(columnNumber - maxCodeSize / 2, 0);
    const end = Math.min(columnNumber + maxCodeSize / 2, lines[lineNumber].length);
    return lines[lineNumber].substring(start, end);
  }
  let relatedCodeSize = 0;
  let currentLineNumber = lineNumber;
  let currentWhitespace = lineWhitespace(lines[lineNumber]);
  const startByPrefix = new Map<string, number>();
  while (lines[currentLineNumber] !== undefined &&
         (relatedCodeSize + lines[currentLineNumber].length <= maxCodeSize / 2)) {
    const whitespace = lineWhitespace(lines[currentLineNumber]);
    if (whitespace !== null && currentWhitespace !== null &&
        (whitespace === currentWhitespace || !whitespace.startsWith(currentWhitespace))) {
      // Don't start on a line that begins with a closing character
      if (!/^\s*[\}\)\]]/.exec(lines[currentLineNumber])) {
        // Update map of where code should start based on its indentation
        startByPrefix.set(whitespace, currentLineNumber);
      }
      currentWhitespace = whitespace;
    }
    relatedCodeSize += lines[currentLineNumber].length + 1;
    currentLineNumber--;
  }
  currentLineNumber = lineNumber + 1;
  let startLine = lineNumber;
  let endLine = lineNumber;
  currentWhitespace = lineWhitespace(lines[lineNumber]);
  while (lines[currentLineNumber] !== undefined && (relatedCodeSize + lines[currentLineNumber].length <= maxCodeSize)) {
    relatedCodeSize += lines[currentLineNumber].length;
    const whitespace = lineWhitespace(lines[currentLineNumber]);
    if (whitespace !== null && currentWhitespace !== null &&
        (whitespace === currentWhitespace || !whitespace.startsWith(currentWhitespace))) {
      // We shouldn't end on a line if it is followed by an indented line
      const nextLine = lines[currentLineNumber + 1];
      const nextWhitespace = nextLine ? lineWhitespace(nextLine) : null;
      if (!nextWhitespace || nextWhitespace === whitespace || !nextWhitespace.startsWith(whitespace)) {
        // Look up where code should start based on its indentation
        if (startByPrefix.has(whitespace)) {
          startLine = startByPrefix.get(whitespace) ?? 0;
          endLine = currentLineNumber;
        }
      }
      currentWhitespace = whitespace;
    }
    currentLineNumber++;
  }
  return lines.slice(startLine, endLine + 1).join('\n');
}

function formatLines(title: string, lines: string[], maxLength: number): string {
  let result = '';
  for (const line of lines) {
    if (result.length + line.length > maxLength) {
      break;
    }
    result += line;
  }
  result = result.trim();
  return result && title ? title + '\n' + result : result;
}

export function formatNetworkRequest(
    request:
        Pick<SDK.NetworkRequest.NetworkRequest, 'url'|'requestHeaders'|'responseHeaders'|'statusCode'|'statusText'>):
    string {
  return `Request: ${request.url()}

${
      AiAssistanceModel.NetworkRequestFormatter.NetworkRequestFormatter.formatHeaders(
          'Request headers:', request.requestHeaders())}

${
      AiAssistanceModel.NetworkRequestFormatter.NetworkRequestFormatter.formatHeaders(
          'Response headers:', request.responseHeaders)}

Response status: ${request.statusCode} ${request.statusText}`;
}

export function formatConsoleMessage(message: ConsoleViewMessage): string {
  return message.toMessageTextString().substr(0, MAX_MESSAGE_SIZE);
}

/**
 * This formats the stacktrace from the console message which might or might not
 * match the content of stacktrace(s) in the console message arguments.
 */
async function formatStackTrace(message: ConsoleViewMessage): Promise<string> {
  const previewContainer = message.contentElement().querySelector('.stack-preview-container');

  if (!previewContainer) {
    return '';
  }

  const widget =
      UI.Widget.Widget.get(previewContainer) as Components.JSPresentationUtils.StackTracePreviewContent | undefined;
  if (!widget) {
    return '';
  }

  await widget.updateComplete;

  // TODO(crbug.com/433162438): Get the `StackTrace` from the widget and render that instead
  //    of crawling the DOM. `StackTrace` is source mapped and has ignore listing.

  const preview = widget.contentElement.querySelector('.stack-preview-container') as HTMLElement;

  const nodes = preview.childTextNodes();
  // Gets application-level source mapped stack trace taking the ignore list
  // into account.
  const messageContent = nodes
                             .filter(n => {
                               return !n.parentElement?.closest('.show-all-link,.show-less-link,.hidden-row');
                             })
                             .map(Components.Linkifier.Linkifier.untruncatedNodeText);
  return formatLines('', messageContent, MAX_STACK_TRACE_SIZE);
}
