// Copyright 2025 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 SDK from '../../../core/sdk/sdk.js';
import type * as Protocol from '../../../generated/protocol.js';
import * as Annotations from '../../annotations/annotations.js';
import * as Logs from '../../logs/logs.js';
import * as NetworkTimeCalculator from '../../network_time_calculator/network_time_calculator.js';
import * as TextUtils from '../../text_utils/text_utils.js';

import {seconds} from './UnitFormatters.js';

const MAX_HEADERS_SIZE = 1000;
const MAX_BODY_SIZE = 10000;

/**
 * Sanitizes the set of headers, removing values that are not on the allow-list and replacing them with '<redacted>'.
 */
export function sanitizeHeaders(headers: Array<{name: string, value: string}>): Array<{name: string, value: string}> {
  return headers.map(header => {
    if (NetworkRequestFormatter.allowHeader(header.name)) {
      return header;
    }
    return {name: header.name, value: '<redacted>'};
  });
}

export class NetworkRequestFormatter {
  #calculator: NetworkTimeCalculator.NetworkTransferTimeCalculator;
  #request: SDK.NetworkRequest.NetworkRequest;

  static allowHeader(headerName: string): boolean {
    return allowedHeaders.has(headerName.toLowerCase().trim());
  }
  static formatHeaders(title: string, headers: Array<{name: string, value: string}>, addListPrefixToEachLine?: boolean):
      string {
    return formatLines(
        title, sanitizeHeaders(headers).map(header => {
          const prefix = addListPrefixToEachLine ? '- ' : '';
          return prefix + header.name + ': ' + header.value + '\n';
        }),
        MAX_HEADERS_SIZE);
  }

  static async formatBody(title: string, request: SDK.NetworkRequest.NetworkRequest, maxBodySize: number):
      Promise<string> {
    const data = await request.requestContentData();

    if (TextUtils.ContentData.ContentData.isError(data)) {
      return '';
    }

    if (data.isEmpty) {
      return `${title}\n<empty response>`;
    }

    if (data.isTextContent) {
      const dataAsText = data.text;

      if (dataAsText.length > maxBodySize) {
        return `${title}\n${dataAsText.substring(0, maxBodySize) + '... <truncated>'}`;
      }

      return `${title}\n${dataAsText}`;
    }

    return `${title}\n<binary data>`;
  }

  static formatInitiatorUrl(initiatorUrl: string, allowedOrigin: string): string {
    try {
      // Some scheme or URLs might cause errors depending on the runtime environment.
      const initiatorOrigin = new URL(initiatorUrl).origin;
      if (initiatorOrigin === allowedOrigin) {
        return initiatorUrl;
      }
      return '<redacted cross-origin initiator URL>';
    } catch {
      return '<redacted cross-origin initiator URL>';
    }
  }

  static formatStatus(status: {
    statusCode: number,
    statusText: string,
    failed: boolean,
    canceled: boolean,
    preserved: boolean,
    finished: boolean,
  }): string {
    let responseStatus = '';
    if (status.statusCode) {
      responseStatus = `Response status: ${status.statusCode} ${status.statusText}\n`;
    }
    const flags = [];
    flags.push(status.finished ? 'finished' : 'pending');
    if (status.failed) {
      flags.push('failed');
    }
    if (status.canceled) {
      flags.push('canceled');
    }
    if (status.preserved) {
      flags.push('preserved');
    }
    const requestStatus = flags.length > 0 ? `Network request status: ${flags.join(', ')}\n` : '';
    return `${responseStatus}${requestStatus}`;
  }

  static formatFailureReasons(reasons: {
    blockedReason?: Protocol.Network.BlockedReason,
    corsErrorStatus?: Protocol.Network.CorsErrorStatus,
    localizedFailDescription?: string|null,
  }): string {
    const lines = [];
    if (reasons.blockedReason) {
      lines.push(`Blocked reason: ${reasons.blockedReason}`);
    }
    if (reasons.corsErrorStatus) {
      lines.push(`CORS error: ${reasons.corsErrorStatus.corsError} ${reasons.corsErrorStatus.failedParameter}`);
    }
    if (reasons.localizedFailDescription) {
      lines.push(`Fail description: ${reasons.localizedFailDescription}`);
    }
    return lines.length > 0 ? `${lines.join('\n')}\n` : '';
  }

  constructor(
      request: SDK.NetworkRequest.NetworkRequest, calculator: NetworkTimeCalculator.NetworkTransferTimeCalculator) {
    this.#request = request;
    this.#calculator = calculator;
  }

  formatRequestHeaders(): string {
    return NetworkRequestFormatter.formatHeaders('Request headers:', this.#request.requestHeaders());
  }

  formatResponseHeaders(): string {
    return NetworkRequestFormatter.formatHeaders('Response headers:', this.#request.responseHeaders);
  }

  async formatResponseBody(): Promise<string> {
    return await NetworkRequestFormatter.formatBody('Response body:', this.#request, MAX_BODY_SIZE);
  }

  /**
   * Note: nothing here should include information from origins other than
   * the request's origin.
   */
  async formatNetworkRequest(): Promise<string> {
    let responseBody = await this.formatResponseBody();

    if (responseBody) {
      // if we have a response then we add 2 new line to follow same structure of the context
      responseBody = `\n\n${responseBody}`;
    }

    return `Request: ${this.#request.url()}
${Annotations.AnnotationRepository.annotationsEnabled() ? `\nRequest ID: ${this.#request.requestId()}\n` : ''}
${this.formatRequestHeaders()}

${this.formatResponseHeaders()}${responseBody}

${this.formatStatus()}${this.formatFailureReasons()}
Request timing:\n${this.formatNetworkRequestTiming()}

Request initiator chain:\n${this.formatRequestInitiatorChain()}`;
  }

  formatStatus(): string {
    return NetworkRequestFormatter.formatStatus({
      statusCode: this.#request.statusCode,
      statusText: this.#request.statusText,
      failed: this.#request.failed,
      canceled: this.#request.canceled,
      preserved: this.#request.preserved,
      finished: this.#request.finished,
    });
  }

  formatFailureReasons(): string {
    return NetworkRequestFormatter.formatFailureReasons({
      blockedReason: this.#request.blockedReason(),
      corsErrorStatus: this.#request.corsErrorStatus(),
      localizedFailDescription: this.#request.localizedFailDescription,
    });
  }

  /**
   * Note: nothing here should include information from origins other than
   * the request's origin.
   */
  formatRequestInitiatorChain(): string {
    const allowedOrigin = new URL(this.#request.url()).origin;
    let initiatorChain = '';
    let lineStart = '- URL: ';
    const graph = Logs.NetworkLog.NetworkLog.instance().initiatorGraphForRequest(this.#request);

    for (const initiator of Array.from(graph.initiators).reverse()) {
      initiatorChain = initiatorChain + lineStart +
          NetworkRequestFormatter.formatInitiatorUrl(initiator.url(), allowedOrigin) + '\n';
      lineStart = '\t' + lineStart;
      if (initiator === this.#request) {
        initiatorChain =
            this.#formatRequestInitiated(graph.initiated, this.#request, initiatorChain, lineStart, allowedOrigin);
      }
    }

    return initiatorChain.trim();
  }

  formatNetworkRequestTiming(): string {
    const results = NetworkTimeCalculator.calculateRequestTimeRanges(this.#request, this.#calculator.minimumBoundary());

    const getDuration = (name: string): string|undefined => {
      const result = results.find(r => r.name === name);
      if (!result) {
        return;
      }
      return seconds(result.end - result.start);
    };

    const labels = [
      {
        label: 'Queued at (timestamp)',
        value: seconds(this.#request.issueTime() - this.#calculator.zeroTime()),
      },
      {
        label: 'Started at (timestamp)',
        value: seconds(this.#request.startTime - this.#calculator.zeroTime()),
      },
      {
        label: 'Queueing (duration)',
        value: getDuration('queueing'),
      },
      {
        label: 'Connection start (stalled) (duration)',
        value: getDuration('blocking'),
      },
      {
        label: 'Request sent (duration)',
        value: getDuration('sending'),
      },
      {
        label: 'Waiting for server response (duration)',
        value: getDuration('waiting'),
      },
      {
        label: 'Content download (duration)',
        value: getDuration('receiving'),
      },
      {
        label: 'Duration (duration)',
        value: getDuration('total'),
      },
    ];

    return labels.filter(label => !!label.value).map(label => `${label.label}: ${label.value}`).join('\n');
  }

  #formatRequestInitiated(
      initiated: Map<SDK.NetworkRequest.NetworkRequest, SDK.NetworkRequest.NetworkRequest>,
      parentRequest: SDK.NetworkRequest.NetworkRequest,
      initiatorChain: string,
      lineStart: string,
      allowedOrigin: string,
      ): string {
    const visited = new Set<SDK.NetworkRequest.NetworkRequest>();

    // this.request should be already in the tree when build initiator part
    visited.add(this.#request);
    for (const [keyRequest, initiatedRequest] of initiated.entries()) {
      if (initiatedRequest === parentRequest) {
        if (!visited.has(keyRequest)) {
          visited.add(keyRequest);
          initiatorChain = initiatorChain + lineStart +
              NetworkRequestFormatter.formatInitiatorUrl(keyRequest.url(), allowedOrigin) + '\n';
          initiatorChain =
              this.#formatRequestInitiated(initiated, keyRequest, initiatorChain, '\t' + lineStart, allowedOrigin);
        }
      }
    }

    return initiatorChain;
  }
}

// Header names that could be included in the prompt, lowercase.
const allowedHeaders = new Set([
  ':authority',
  ':method',
  ':path',
  ':scheme',
  'a-im',
  'accept-ch',
  'accept-charset',
  'accept-datetime',
  'accept-encoding',
  'accept-language',
  'accept-patch',
  'accept-ranges',
  'accept',
  'access-control-allow-credentials',
  'access-control-allow-headers',
  'access-control-allow-methods',
  'access-control-allow-origin',
  'access-control-expose-headers',
  'access-control-max-age',
  'access-control-request-headers',
  'access-control-request-method',
  'age',
  'allow',
  'alt-svc',
  'cache-control',
  'connection',
  'content-disposition',
  'content-encoding',
  'content-language',
  'content-location',
  'content-range',
  'content-security-policy',
  'content-type',
  'correlation-id',
  'date',
  'delta-base',
  'dnt',
  'expect-ct',
  'expect',
  'expires',
  'forwarded',
  'front-end-https',
  'host',
  'http2-settings',
  'if-modified-since',
  'if-range',
  'if-unmodified-source',
  'im',
  'last-modified',
  'link',
  'location',
  'max-forwards',
  'nel',
  'origin',
  'permissions-policy',
  'pragma',
  'preference-applied',
  'proxy-connection',
  'public-key-pins',
  'range',
  'referer',
  'refresh',
  'report-to',
  'retry-after',
  'save-data',
  'sec-gpc',
  'server',
  'status',
  'strict-transport-security',
  'te',
  'timing-allow-origin',
  'tk',
  'trailer',
  'transfer-encoding',
  'upgrade-insecure-requests',
  'upgrade',
  'user-agent',
  'vary',
  'via',
  'warning',
  'www-authenticate',
  'x-att-deviceid',
  'x-content-duration',
  'x-content-security-policy',
  'x-content-type-options',
  'x-correlation-id',
  'x-forwarded-for',
  'x-forwarded-host',
  'x-forwarded-proto',
  'x-frame-options',
  'x-http-method-override',
  'x-powered-by',
  'x-redirected-by',
  'x-request-id',
  'x-requested-with',
  'x-ua-compatible',
  'x-wap-profile',
  'x-webkit-csp',
  'x-xss-protection',
]);

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;
}
