/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import assert from 'node:assert';
import {mkdtemp, readFile, rm, writeFile} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {afterEach, beforeEach, describe, it} from 'node:test';

import {NetworkFormatter} from '../../src/formatters/NetworkFormatter.js';
import type {HTTPRequest} from '../../src/third_party/index.js';
import {getMockRequest, getMockResponse} from '../utils.js';

describe('NetworkFormatter', () => {
  let tmpDir: string;

  beforeEach(async () => {
    tmpDir = await mkdtemp(join(tmpdir(), 'network-formatter-test-'));
  });

  afterEach(async () => {
    await rm(tmpDir, {recursive: true, force: true});
  });

  describe('toString', () => {
    it('works', async () => {
      const request = getMockRequest();
      const formatter = await NetworkFormatter.from(request, {
        requestId: 1,
        saveFile: async () => ({filename: ''}),
        redactNetworkHeaders: false,
      });

      assert.equal(
        formatter.toString(),
        'reqid=1 GET http://example.com [pending]',
      );
    });
    it('shows correct method', async () => {
      const request = getMockRequest({method: 'POST'});
      const formatter = await NetworkFormatter.from(request, {
        requestId: 1,
        saveFile: async () => ({filename: ''}),
        redactNetworkHeaders: false,
      });

      assert.equal(
        formatter.toString(),
        'reqid=1 POST http://example.com [pending]',
      );
    });
    it('shows correct status for request with response code in 200', async () => {
      const response = getMockResponse();
      const request = getMockRequest({response});
      const formatter = await NetworkFormatter.from(request, {
        requestId: 1,
        saveFile: async () => ({filename: ''}),
        redactNetworkHeaders: false,
      });

      assert.equal(
        formatter.toString(),
        'reqid=1 GET http://example.com [200]',
      );
    });
    it('shows correct status for request with response code in 100', async () => {
      const response = getMockResponse({
        status: 199,
      });
      const request = getMockRequest({response});
      const formatter = await NetworkFormatter.from(request, {
        requestId: 1,
        saveFile: async () => ({filename: ''}),
        redactNetworkHeaders: false,
      });

      assert.equal(
        formatter.toString(),
        'reqid=1 GET http://example.com [199]',
      );
    });
    it('shows correct status for request with response code above 200', async () => {
      const response = getMockResponse({
        status: 300,
      });
      const request = getMockRequest({response});
      const formatter = await NetworkFormatter.from(request, {
        requestId: 1,
        saveFile: async () => ({filename: ''}),
        redactNetworkHeaders: false,
      });

      assert.equal(
        formatter.toString(),
        'reqid=1 GET http://example.com [300]',
      );
    });
    it('shows correct status for request that failed', async () => {
      const request = getMockRequest({
        failure() {
          return {
            errorText: 'Error in Network',
          };
        },
      });
      const formatter = await NetworkFormatter.from(request, {
        requestId: 1,
        saveFile: async () => ({filename: ''}),
        redactNetworkHeaders: false,
      });

      assert.equal(
        formatter.toString(),
        'reqid=1 GET http://example.com [Error in Network]',
      );
    });

    it('marks requests selected in DevTools UI', async () => {
      const request = getMockRequest();
      const formatter = await NetworkFormatter.from(request, {
        requestId: 1,
        selectedInDevToolsUI: true,
        saveFile: async () => ({filename: ''}),
        redactNetworkHeaders: false,
      });

      assert.equal(
        formatter.toString(),
        'reqid=1 GET http://example.com [pending] [selected in the DevTools Network panel]',
      );
    });
  });

  describe('toStringDetailed', () => {
    it('works with request body from fetchPostData', async () => {
      const request = getMockRequest({
        hasPostData: true,
        postData: undefined,
        fetchPostData: Promise.resolve('test'),
      });
      const formatter = await NetworkFormatter.from(request, {
        requestId: 200,
        fetchData: true,
        saveFile: async () => ({filename: ''}),
        redactNetworkHeaders: false,
      });
      const result = formatter.toStringDetailed();
      assert.match(result, /test/);
    });

    it('works with request body from postData', async () => {
      const request = getMockRequest({
        postData: JSON.stringify({
          request: 'body',
        }),
        hasPostData: true,
      });
      const formatter = await NetworkFormatter.from(request, {
        requestId: 200,
        fetchData: true,
        saveFile: async () => ({filename: ''}),
        redactNetworkHeaders: false,
      });
      const result = formatter.toStringDetailed();

      assert.match(
        result,
        new RegExp(
          JSON.stringify({
            request: 'body',
          }),
        ),
      );
    });

    it('truncates request body', async () => {
      const request = getMockRequest({
        postData: 'some text that is longer than expected',
        hasPostData: true,
      });
      const formatter = await NetworkFormatter.from(request, {
        requestId: 20,
        fetchData: true,
        saveFile: async () => ({filename: ''}),
        redactNetworkHeaders: false,
      });
      const result = formatter.toStringDetailed();
      assert.match(result, /some text/);
    });

    it('should save bodies to file when file paths are provided', async () => {
      const request = {
        method: () => 'POST',
        url: () => 'http://example.com',
        headers: () => ({}),
        hasPostData: () => true,
        postData: () => 'request body',
        response: () => ({
          status: () => 200,
          headers: () => ({}),
          buffer: async () => Buffer.from('response body'),
        }),
        failure: () => null,
        redirectChain: () => [],
        fetchPostData: async () => undefined,
      } as unknown as HTTPRequest;

      const reqPath = join(tmpDir, 'test_req_' + Date.now());
      const resPath = join(tmpDir, 'test_res_' + Date.now());

      const formatter = await NetworkFormatter.from(request, {
        fetchData: true,
        requestFilePath: reqPath,
        responseFilePath: resPath,
        saveFile: async (data, filename) => {
          await writeFile(filename, data);
          return {filename};
        },
        redactNetworkHeaders: false,
      });

      const json = formatter.toJSONDetailed() as {
        requestBody: string;
        responseBody: string;
        requestBodyFilePath: string;
        responseBodyFilePath: string;
      };
      assert.strictEqual(json.requestBodyFilePath, reqPath);
      assert.strictEqual(json.responseBodyFilePath, resPath);
      assert.strictEqual(json.requestBody, undefined);
      assert.strictEqual(json.responseBody, undefined);
    });

    it('should not truncate large bodies when saving to file', async () => {
      const largeBody = 'a'.repeat(10005);
      const request = {
        method: () => 'POST',
        url: () => 'http://example.com',
        headers: () => ({}),
        hasPostData: () => true,
        postData: () => largeBody,
        response: () => ({
          status: () => 200,
          headers: () => ({}),
          buffer: async () => Buffer.from(largeBody),
        }),
        failure: () => null,
        redirectChain: () => [],
        fetchPostData: async () => undefined,
      } as unknown as HTTPRequest;

      const reqPath = join(tmpDir, 'test_req_large_' + Date.now());
      const resPath = join(tmpDir, 'test_res_large_' + Date.now());

      await NetworkFormatter.from(request, {
        fetchData: true,
        requestFilePath: reqPath,
        responseFilePath: resPath,
        saveFile: async (data, filename) => {
          await writeFile(filename, data);
          return {filename};
        },
        redactNetworkHeaders: false,
      });

      const reqContent = await readFile(reqPath, 'utf8');
      const resContent = await readFile(resPath, 'utf8');

      assert.strictEqual(reqContent, largeBody);
      assert.strictEqual(resContent, largeBody);
    });

    it('handles response body', async () => {
      const response = getMockResponse();
      response.buffer = () => {
        return Promise.resolve(Buffer.from(JSON.stringify({response: 'body'})));
      };
      const request = getMockRequest({response});

      const formatter = await NetworkFormatter.from(request, {
        requestId: 200,
        fetchData: true,
        saveFile: async () => ({filename: ''}),
        redactNetworkHeaders: false,
      });
      const result = formatter.toStringDetailed();

      assert.match(result, /"response":"body"/);
    });

    it('handles redirect chain', async t => {
      const redirectRequest = getMockRequest({
        url: 'http://example.com/redirect',
      });
      const request = getMockRequest({
        redirectChain: [redirectRequest],
      });
      const formatter = await NetworkFormatter.from(request, {
        requestId: 1,
        requestIdResolver: () => 2,
        saveFile: async () => ({filename: ''}),
        redactNetworkHeaders: false,
      });
      const result = formatter.toStringDetailed();
      t.assert.snapshot(result);
    });
    it('renders the redirect chain in the same order in text and JSON', async () => {
      // A >=2 element chain makes ordering observable (a single redirect hides
      // the bug). toStringDetailed() and toJSONDetailed() are emitted from the
      // same get_network_request call, so they must agree on the order.
      const first = getMockRequest({url: 'http://example.com/first'});
      const second = getMockRequest({url: 'http://example.com/second'});
      const request = getMockRequest({
        url: 'http://example.com/final',
        redirectChain: [first, second],
      });
      const formatter = await NetworkFormatter.from(request, {
        requestId: 1,
        requestIdResolver: () => 2,
        saveFile: async () => ({filename: ''}),
        redactNetworkHeaders: false,
      });

      const text = formatter.toStringDetailed();
      const json = formatter.toJSONDetailed();

      const textOrder = [
        ...text.matchAll(/http:\/\/example\.com\/(first|second)/g),
      ].map(m => m[0]);
      const jsonOrder = (json.redirectChain ?? []).map(entry => entry.url);

      assert.deepStrictEqual(
        textOrder,
        jsonOrder,
        `redirect chain order differs between text (${JSON.stringify(textOrder)}) ` +
          `and JSON (${JSON.stringify(jsonOrder)})`,
      );
    });
    it('shows saved to file message in toStringDetailed', async () => {
      const request = {
        method: () => 'POST',
        url: () => 'http://example.com',
        headers: () => ({}),
        hasPostData: () => true,
        postData: () => 'request body',
        response: () => ({
          status: () => 200,
          headers: () => ({}),
          buffer: async () => Buffer.from('response body'),
        }),
        failure: () => null,
        redirectChain: () => [],
        fetchPostData: async () => undefined,
      } as unknown as HTTPRequest;

      const reqPath = join(tmpDir, 'req.txt');
      const resPath = join(tmpDir, 'res.txt');

      const formatter = await NetworkFormatter.from(request, {
        fetchData: true,
        requestFilePath: reqPath,
        responseFilePath: resPath,
        saveFile: async (data, filename) => {
          await writeFile(filename, data);
          return {filename};
        },
        redactNetworkHeaders: false,
      });

      const result = formatter.toStringDetailed();
      assert.ok(result.includes(`Saved to ${reqPath}.`));
      assert.ok(result.includes(`Saved to ${resPath}.`));
    });

    it('handles missing bodies with filepath', async () => {
      const request = {
        method: () => 'POST',
        url: () => 'http://example.com',
        headers: () => ({}),
        hasPostData: () => true, // Claim we have data
        postData: () => null, // But returns null
        response: () => ({
          status: () => 200,
          headers: () => ({}),
          buffer: async () => {
            throw new Error('Body not available');
          },
        }),
        failure: () => null,
        redirectChain: () => [],
        fetchPostData: async () => {
          throw new Error('Body not available');
        },
      } as unknown as HTTPRequest;

      const reqPath = join(tmpDir, 'req_missing.txt');
      const resPath = join(tmpDir, 'res_missing.txt');

      const formatter = await NetworkFormatter.from(request, {
        fetchData: true,
        requestFilePath: reqPath,
        responseFilePath: resPath,
        saveFile: async (data, filename) => {
          await writeFile(filename, data);
          return {filename};
        },
        redactNetworkHeaders: false,
      });

      const result = formatter.toStringDetailed();
      assert.ok(
        result.includes(
          `### Response Body\n<Response body not available anymore>`,
        ),
      );
    });
  });

  describe('toJSON', () => {
    it('returns structured data', async () => {
      const request = getMockRequest();
      const formatter = await NetworkFormatter.from(request, {
        requestId: 1,
        selectedInDevToolsUI: true,
        saveFile: async () => ({filename: ''}),
        redactNetworkHeaders: false,
      });
      const result = formatter.toJSON();
      assert.deepEqual(result, {
        requestId: 1,
        method: 'GET',
        url: 'http://example.com',
        status: 'pending',
        selectedInDevToolsUI: true,
      });
    });
  });

  describe('toJSONDetailed', () => {
    it('returns structured detailed data', async () => {
      const response = getMockResponse();
      response.buffer = () => Promise.resolve(Buffer.from('response'));
      const request = getMockRequest({
        response,
        postData: 'request',
        hasPostData: true,
      });
      const formatter = await NetworkFormatter.from(request, {
        requestId: 1,
        fetchData: true,
        saveFile: async () => ({filename: ''}),
        redactNetworkHeaders: false,
      });
      const result = formatter.toJSONDetailed();
      assert.deepEqual(result, {
        requestId: 1,
        method: 'GET',
        url: 'http://example.com',
        status: '200',
        selectedInDevToolsUI: undefined,
        requestHeaders: {
          'content-size': '10',
        },
        requestBody: 'request',
        requestBodyFilePath: undefined,
        responseHeaders: {},
        responseBody: 'response',
        responseBodyFilePath: undefined,
        failure: undefined,
        redirectChain: undefined,
      });
    });

    it('redacts headers', async () => {
      const response = getMockResponse({
        headers: {
          'set-cookie': 'secret=123',
          'content-type': 'text/plain',
        },
      });
      response.buffer = () => Promise.resolve(Buffer.from('response'));
      const request = getMockRequest({
        response,
        headers: {
          cookie: 'secret=123',
          'user-agent': 'test',
        },
      });
      const formatter = await NetworkFormatter.from(request, {
        requestId: 1,
        fetchData: true,
        saveFile: async () => ({filename: ''}),
        redactNetworkHeaders: true,
      });
      const result = formatter.toJSONDetailed();
      assert.deepEqual(result.requestHeaders, {
        cookie: '<redacted>',
        'user-agent': 'test',
      });
      assert.deepEqual(result.responseHeaders, {
        'set-cookie': '<redacted>',
        'content-type': 'text/plain',
      });
    });

    it('returns file paths in structured detailed data', async () => {
      const request = {
        method: () => 'POST',
        url: () => 'http://example.com',
        headers: () => ({}),
        hasPostData: () => true,
        postData: () => 'request body',
        response: () => ({
          status: () => 200,
          headers: () => ({}),
          buffer: async () => Buffer.from('response body'),
        }),
        failure: () => null,
        redirectChain: () => [],
        fetchPostData: async () => undefined,
      } as unknown as HTTPRequest;

      const reqPath = join(tmpDir, 'req_json.txt');
      const resPath = join(tmpDir, 'res_json.txt');

      const formatter = await NetworkFormatter.from(request, {
        fetchData: true,
        requestFilePath: reqPath,
        responseFilePath: resPath,
        saveFile: async (data, filename) => {
          await writeFile(filename, data);
          return {filename};
        },
        redactNetworkHeaders: false,
      });

      const result = formatter.toJSONDetailed() as {
        requestBodyFilePath: string;
        responseBodyFilePath: string;
        requestBody?: string;
        responseBody?: string;
      };

      assert.strictEqual(result.requestBodyFilePath, reqPath);
      assert.strictEqual(result.responseBodyFilePath, resPath);
      assert.strictEqual(result.requestBody, undefined);
      assert.strictEqual(result.responseBody, undefined);
    });
  });
});
