// Flags: --inspect=0 --experimental-network-inspection
'use strict';
const common = require('../common');
if (!common.hasCrypto)
  common.skip('missing crypto');
common.skipIfInspectorDisabled();

const assert = require('node:assert');
const fixtures = require('../common/fixtures');
const { on, once } = require('node:events');
const http2 = require('node:http2');
const inspector = require('node:inspector/promises');

const session = new inspector.Session();
session.connect();

const requestBody = { 'hello': 'world' };

const requestHeaders = {
  'x-header1': ['value1', 'value2'],
  [http2.constants.HTTP2_HEADER_ACCEPT_LANGUAGE]: 'en-US',
  [http2.constants.HTTP2_HEADER_AGE]: 1000,
  [http2.constants.HTTP2_HEADER_CONTENT_TYPE]: 'application/json; charset=utf-8',
  [http2.constants.HTTP2_HEADER_COOKIE]: ['k1=v1', 'k2=v2'],
  [http2.constants.HTTP2_HEADER_METHOD]: 'POST',
  [http2.constants.HTTP2_HEADER_PATH]: '/hello-world',
};

const requestErrorHeaders = {
  'x-header1': ['value1', 'value2'],
  [http2.constants.HTTP2_HEADER_ACCEPT_LANGUAGE]: 'en-US',
  [http2.constants.HTTP2_HEADER_AGE]: 1000,
  [http2.constants.HTTP2_HEADER_COOKIE]: ['k1=v1', 'k2=v2'],
  [http2.constants.HTTP2_HEADER_METHOD]: 'GET',
  [http2.constants.HTTP2_HEADER_PATH]: '/trigger-error',
};

const responseHeaders = {
  'x-header2': ['value1', 'value2'],
  [http2.constants.HTTP2_HEADER_CONTENT_TYPE]: 'text/plain; charset=utf-8',
  [http2.constants.HTTP2_HEADER_ETAG]: 12345,
  [http2.constants.HTTP2_HEADER_SERVER]: 'node',
  [http2.constants.HTTP2_HEADER_SET_COOKIE]: ['key1=value1', 'key2=value2'],
  [http2.constants.HTTP2_HEADER_STATUS]: 200,
};

const pushRequestHeaders = {
  'x-header3': ['value1', 'value2'],
  'x-push': 'true',
  [http2.constants.HTTP2_HEADER_PATH]: '/style.css',
};

const pushResponseHeaders = {
  'x-header4': ['value1', 'value2'],
  'x-push': 'true',
  [http2.constants.HTTP2_HEADER_CONTENT_TYPE]: 'text/css',
  [http2.constants.HTTP2_HEADER_STATUS]: 200,
};

const styleCss = 'body { color: red; }\n';
const serverResponse = 'hello world\n';

const kTimeout = 1000;
const kDelta = 200;

const handleStream = common.mustCallAtLeast((stream, headers) => {
  const path = headers[http2.constants.HTTP2_HEADER_PATH];
  let body = '';
  switch (path) {
    case '/hello-world':
      stream.on('data', (chunk) => {
        body += chunk;
      });

      stream.on('end', common.mustCall(() => {
        assert.strictEqual(body, JSON.stringify(requestBody));

        stream.pushStream(pushRequestHeaders, common.mustSucceed((pushStream) => {
          pushStream.respond(pushResponseHeaders);
          pushStream.end(styleCss);
        }));

        stream.respond(responseHeaders);

        setTimeout(() => {
          stream.end(serverResponse);
        }, kTimeout);
      }));
      break;
    case '/trigger-error':
      stream.close(http2.constants.NGHTTP2_STREAM_CLOSED);
      stream.on('error', common.expectsError({
        code: 'ERR_HTTP2_STREAM_ERROR',
        name: 'Error',
        message: 'Stream closed with error code NGHTTP2_STREAM_CLOSED'
      }));
      break;
    default:
      assert.fail(`Unexpected path: ${path}`);
  }
});

const http2Server = http2.createServer();

const http2SecureServer = http2.createSecureServer({
  key: fixtures.readKey('agent1-key.pem'),
  cert: fixtures.readKey('agent1-cert.pem'),
});

http2Server.on('stream', handleStream);
http2SecureServer.on('stream', handleStream);

const terminate = () => {
  session.disconnect();
  http2Server.close();
  http2SecureServer.close();
  inspector.close();
};

function findFrameInInitiator(scriptName, initiator) {
  const frame = initiator.stack.callFrames.find((it) => {
    return it.url === scriptName;
  });
  return frame;
}

function verifyRequestWillBeSent({ method, params }, expectedUrl) {
  assert.strictEqual(method, 'Network.requestWillBeSent');

  assert.ok(params.requestId.startsWith('node-network-event-'));
  assert.strictEqual(params.request.url, expectedUrl);
  assert.strictEqual(typeof params.request.headers, 'object');

  if (expectedUrl.endsWith('/hello-world')) {
    assert.strictEqual(params.request.headers['accept-language'], 'en-US');
    assert.strictEqual(params.request.headers.cookie, 'k1=v1; k2=v2');
    assert.strictEqual(params.request.headers.age, '1000');
    assert.strictEqual(params.request.headers['x-header1'], 'value1, value2');
    assert.ok(findFrameInInitiator(__filename, params.initiator));
    assert.strictEqual(params.request.hasPostData, true);
    assert.strictEqual(params.request.method, 'POST');
  } else if (expectedUrl.endsWith('/style.css')) {
    assert.strictEqual(params.request.headers['x-header3'], 'value1, value2');
    assert.strictEqual(params.request.headers['x-push'], 'true');
    assert.ok(!findFrameInInitiator(__filename, params.initiator));
    assert.strictEqual(params.request.hasPostData, true);
    assert.strictEqual(params.request.method, 'GET');
  } else {
    assert.strictEqual(params.request.hasPostData, false);
    assert.strictEqual(params.request.method, 'GET');
  }

  assert.strictEqual(typeof params.timestamp, 'number');
  assert.strictEqual(typeof params.wallTime, 'number');

  assert.strictEqual(typeof params.initiator, 'object');
  assert.strictEqual(params.initiator.type, 'script');

  return params;
}

function verifyResponseReceived({ method, params }, expectedUrl) {
  assert.strictEqual(method, 'Network.responseReceived');

  assert.ok(params.requestId.startsWith('node-network-event-'));
  assert.strictEqual(typeof params.timestamp, 'number');
  assert.strictEqual(params.type, 'Other');
  assert.strictEqual(params.response.status, 200);
  assert.strictEqual(params.response.statusText, '');
  assert.strictEqual(params.response.url, expectedUrl);
  assert.strictEqual(typeof params.response.headers, 'object');
  if (expectedUrl.endsWith('/hello-world')) {
    assert.strictEqual(params.response.headers.server, 'node');
    assert.strictEqual(params.response.headers.etag, '12345');
    assert.strictEqual(params.response.headers['set-cookie'], 'key1=value1\nkey2=value2');
    assert.strictEqual(params.response.headers['x-header2'], 'value1, value2');
    assert.strictEqual(params.response.mimeType, 'text/plain');
    assert.strictEqual(params.response.charset, 'utf-8');
  } else if (expectedUrl.endsWith('/style.css')) {
    assert.strictEqual(params.response.headers['x-header4'], 'value1, value2');
    assert.strictEqual(params.response.headers['x-push'], 'true');
    assert.strictEqual(params.response.mimeType, 'text/css');
    assert.strictEqual(params.response.charset, '');
  }

  return params;
}

function verifyLoadingFinished({ method, params }) {
  assert.strictEqual(method, 'Network.loadingFinished');

  assert.ok(params.requestId.startsWith('node-network-event-'));
  assert.strictEqual(typeof params.timestamp, 'number');
  return params;
}

function verifyLoadingFailed({ method, params }) {
  assert.strictEqual(method, 'Network.loadingFailed');

  assert.ok(params.requestId.startsWith('node-network-event-'));
  assert.strictEqual(typeof params.timestamp, 'number');
  assert.strictEqual(params.type, 'Other');
  assert.strictEqual(typeof params.errorText, 'string');
}

async function testHttp2(secure = false) {
  const port = (secure ? http2SecureServer : http2Server).address().port;
  const origin = (secure ? 'https' : 'http') + `://localhost:${port}`;
  const url = `${origin}/hello-world`;
  const pushedUrl = `${origin}/style.css`;

  const requestWillBeSent = on(session, 'Network.requestWillBeSent');
  const responseReceived = on(session, 'Network.responseReceived');
  const loadingFinished = on(session, 'Network.loadingFinished');

  session.on('Network.loadingFailed', common.mustNotCall());

  const client = http2.connect(origin, {
    rejectUnauthorized: false,
  });
  const request = client.request(requestHeaders);
  request.write(JSON.stringify(requestBody));
  request.end();

  // Dump the responses.
  request.on('data', () => {});
  client.on('stream', (pushStream) => {
    pushStream.on('data', () => {});
  });
  request.on('end', () => {
    client.close();
  });
  request.end();

  const [
    { value: [ mainRequest ] },
    { value: [ pushRequest ] },
  ] = await Promise.all([requestWillBeSent.next(), requestWillBeSent.next()]);
  verifyRequestWillBeSent(mainRequest, url);
  verifyRequestWillBeSent(pushRequest, pushedUrl);

  const { postData } = await session.post('Network.getRequestPostData', {
    requestId: mainRequest.params.requestId
  });
  assert.strictEqual(postData, JSON.stringify(requestBody));

  const [
    { value: [ mainResponse ] },
    { value: [ pushResponse ] },
  ] = await Promise.all([responseReceived.next(), responseReceived.next()]);
  verifyResponseReceived(mainResponse, url);
  verifyResponseReceived(pushResponse, pushedUrl);

  const [
    { value: [ event1 ] },
    { value: [ event2 ] },
  ] = await Promise.all([loadingFinished.next(), loadingFinished.next()]);
  verifyLoadingFinished(event1);
  verifyLoadingFinished(event2);

  const responseBody = await session.post('Network.getResponseBody', {
    requestId: mainRequest.params.requestId,
  });
  assert.strictEqual(responseBody.base64Encoded, false);
  assert.strictEqual(responseBody.body, serverResponse);

  const pushResponseBody = await session.post('Network.getResponseBody', {
    requestId: pushRequest.params.requestId,
  });
  assert.strictEqual(pushResponseBody.base64Encoded, true);
  assert.strictEqual(Buffer.from(pushResponseBody.body, 'base64').toString(), styleCss);

  const mainFinished = [event1, event2]
    .find((event) => event.params.requestId === mainResponse.params.requestId);
  const pushFinished = [event1, event2]
    .find((event) => event.params.requestId === pushResponse.params.requestId);

  assert.ok(mainFinished.params.timestamp >= mainResponse.params.timestamp);
  assert.ok(pushFinished.params.timestamp >= pushResponse.params.timestamp);

  const delta =
    (mainFinished.params.timestamp - mainResponse.params.timestamp) * 1000;
  assert.ok(delta > kDelta);
}

async function testHttp2Error(secure = false) {
  const port = (secure ? http2SecureServer : http2Server).address().port;
  const origin = (secure ? 'https' : 'http') + `://localhost:${port}`;
  const errorUrl = `${origin}/trigger-error`;

  const requestWillBeSent = once(session, 'Network.requestWillBeSent');
  session.on('Network.responseReceived', common.mustNotCall());
  session.on('Network.loadingFinished', common.mustNotCall());
  const loadingFailed = once(session, 'Network.loadingFailed');

  const client = http2.connect(origin, {
    rejectUnauthorized: false,
  });
  const request = client.request(requestErrorHeaders);

  request.on('close', common.mustCall(() => {
    assert.strictEqual(request.rstCode, http2.constants.NGHTTP2_STREAM_CLOSED);
    client.close();
  }));
  request.on('error', common.expectsError({
    code: 'ERR_HTTP2_STREAM_ERROR',
    name: 'Error',
    message: 'Stream closed with error code NGHTTP2_STREAM_CLOSED'
  }));
  request.end();

  const [ requestEvent ] = await requestWillBeSent;
  verifyRequestWillBeSent(requestEvent, errorUrl);

  const [ failed ] = await loadingFailed;
  verifyLoadingFailed(failed);
}

const testNetworkInspection = async () => {
  await testHttp2();
  session.removeAllListeners();
  await testHttp2(true);
  session.removeAllListeners();
  await testHttp2Error();
  session.removeAllListeners();
  await testHttp2Error(true);
  session.removeAllListeners();
};

http2Server.listen(0, async () => {
  http2SecureServer.listen(0, async () => {
    try {
      await session.post('Network.enable');
      await testNetworkInspection();
      await session.post('Network.disable');
    } catch (e) {
      assert.fail(e);
    } finally {
      terminate();
    }
  });
});
