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

import assert from 'node:assert';
import {afterEach, beforeEach, describe, it} from 'node:test';

import type {Frame, HTTPRequest, Target, Protocol} from 'puppeteer-core';
import sinon from 'sinon';

import type {ListenerMap} from '../src/PageCollector.js';
import {
  ConsoleCollector,
  NetworkCollector,
  PageCollector,
} from '../src/PageCollector.js';
import {DevTools} from '../src/third_party/index.js';

import {getMockRequest, getMockBrowser} from './utils.js';

describe('PageCollector', () => {
  it('works', async () => {
    const browser = getMockBrowser();
    const page = (await browser.pages())[0];
    const request = getMockRequest();
    const collector = new PageCollector(browser, collect => {
      return {
        request: req => {
          collect(req);
        },
      } as ListenerMap;
    });
    await collector.init([page]);
    page.emit('request', request);

    assert.equal(collector.getData(page)[0], request);
  });

  it('clean up after navigation', async () => {
    const browser = getMockBrowser();
    const page = (await browser.pages())[0];
    const mainFrame = page.mainFrame();
    const request = getMockRequest();
    const collector = new PageCollector(browser, collect => {
      return {
        request: req => {
          collect(req);
        },
      } as ListenerMap;
    });
    await collector.init([page]);
    page.emit('request', request);

    assert.equal(collector.getData(page)[0], request);
    page.emit('framenavigated', mainFrame);

    assert.equal(collector.getData(page).length, 0);
  });

  it('does not clean up after sub frame navigation', async () => {
    const browser = getMockBrowser();
    const page = (await browser.pages())[0];
    const request = getMockRequest();
    const collector = new PageCollector(browser, collect => {
      return {
        request: req => {
          collect(req);
        },
      } as ListenerMap;
    });
    await collector.init([page]);
    page.emit('request', request);
    page.emit('framenavigated', {} as Frame);

    assert.equal(collector.getData(page).length, 1);
  });

  it('clean up after navigation and be able to add data after', async () => {
    const browser = getMockBrowser();
    const page = (await browser.pages())[0];
    const mainFrame = page.mainFrame();
    const request = getMockRequest();
    const collector = new PageCollector(browser, collect => {
      return {
        request: req => {
          collect(req);
        },
      } as ListenerMap;
    });
    await collector.init([page]);
    page.emit('request', request);

    assert.equal(collector.getData(page)[0], request);
    page.emit('framenavigated', mainFrame);

    assert.equal(collector.getData(page).length, 0);

    page.emit('request', request);

    assert.equal(collector.getData(page).length, 1);
  });

  it('should only subscribe once', async () => {
    const browser = getMockBrowser();
    const page = (await browser.pages())[0];
    const request = getMockRequest();
    const collector = new PageCollector(browser, collect => {
      return {
        request: req => {
          collect(req);
        },
      } as ListenerMap;
    });
    await collector.init([page]);
    browser.emit('targetcreated', {
      page() {
        return Promise.resolve(page);
      },
    } as Target);

    // The page inside part is async so we need to await some time
    await new Promise<void>(res => res());

    assert.equal(collector.getData(page).length, 0);

    page.emit('request', request);

    assert.equal(collector.getData(page).length, 1);

    page.emit('request', request);

    assert.equal(collector.getData(page).length, 2);
  });

  it('should clear data on page destroy', async () => {
    const browser = getMockBrowser();
    const page = (await browser.pages())[0];
    const request = getMockRequest();
    const collector = new PageCollector(browser, collect => {
      return {
        request: req => {
          collect(req);
        },
      } as ListenerMap;
    });
    await collector.init([page]);

    page.emit('request', request);

    assert.equal(collector.getData(page).length, 1);

    browser.emit('targetdestroyed', {
      page() {
        return Promise.resolve(page);
      },
    } as Target);

    // The page inside part is async so we need to await some time
    await new Promise<void>(res => res());

    assert.equal(collector.getData(page).length, 0);
  });

  it('should assign ids to requests', async () => {
    const browser = getMockBrowser();
    const page = (await browser.pages())[0];
    const request1 = getMockRequest();
    const request2 = getMockRequest();
    const collector = new PageCollector<HTTPRequest>(browser, collect => {
      return {
        request: req => {
          collect(req);
        },
      } as ListenerMap;
    });
    await collector.init([page]);

    page.emit('request', request1);
    page.emit('request', request2);

    assert.equal(collector.getData(page).length, 2);

    assert.equal(collector.getIdForResource(request1), 1);
    assert.equal(collector.getIdForResource(request2), 2);
  });
});

describe('NetworkCollector', () => {
  it('correctly picks up navigation requests to latest navigation', async () => {
    const browser = getMockBrowser();
    const page = (await browser.pages())[0];
    const mainFrame = page.mainFrame();
    const request = getMockRequest();
    const navRequest = getMockRequest({
      navigationRequest: true,
      frame: page.mainFrame(),
    });
    const request2 = getMockRequest();
    const collector = new NetworkCollector(browser);
    await collector.init([page]);
    page.emit('request', request);
    page.emit('request', navRequest);

    assert.equal(collector.getData(page)[0], request);
    assert.equal(collector.getData(page)[1], navRequest);
    page.emit('framenavigated', mainFrame);

    assert.equal(collector.getData(page).length, 1);
    assert.equal(collector.getData(page)[0], navRequest);

    page.emit('request', request2);

    assert.equal(collector.getData(page).length, 2);
    assert.equal(collector.getData(page)[0], navRequest);
    assert.equal(collector.getData(page)[1], request2);
  });

  it('correctly picks up after multiple back to back navigations', async () => {
    const browser = getMockBrowser();
    const page = (await browser.pages())[0];
    const mainFrame = page.mainFrame();
    const navRequest = getMockRequest({
      navigationRequest: true,
      frame: page.mainFrame(),
    });
    const navRequest2 = getMockRequest({
      navigationRequest: true,
      frame: page.mainFrame(),
    });
    const request = getMockRequest();

    const collector = new NetworkCollector(browser);
    await collector.init([page]);
    page.emit('request', navRequest);
    assert.equal(collector.getData(page)[0], navRequest);

    page.emit('framenavigated', mainFrame);
    assert.equal(collector.getData(page).length, 1);
    assert.equal(collector.getData(page)[0], navRequest);

    page.emit('request', navRequest2);
    assert.equal(collector.getData(page).length, 2);
    assert.equal(collector.getData(page)[0], navRequest);
    assert.equal(collector.getData(page)[1], navRequest2);

    page.emit('framenavigated', mainFrame);
    assert.equal(collector.getData(page).length, 1);
    assert.equal(collector.getData(page)[0], navRequest2);

    page.emit('request', request);
    assert.equal(collector.getData(page).length, 2);
  });

  it('works with previous navigations', async () => {
    const browser = getMockBrowser();
    const page = (await browser.pages())[0];
    const mainFrame = page.mainFrame();
    const navRequest = getMockRequest({
      navigationRequest: true,
      frame: page.mainFrame(),
    });
    const navRequest2 = getMockRequest({
      navigationRequest: true,
      frame: page.mainFrame(),
    });
    const request = getMockRequest();

    const collector = new NetworkCollector(browser);
    await collector.init([page]);
    page.emit('request', navRequest);
    assert.equal(collector.getData(page, true).length, 1);

    page.emit('framenavigated', mainFrame);
    assert.equal(collector.getData(page, true).length, 1);

    page.emit('request', navRequest2);
    assert.equal(collector.getData(page, true).length, 2);

    page.emit('framenavigated', mainFrame);
    assert.equal(collector.getData(page, true).length, 2);

    page.emit('request', request);
    assert.equal(collector.getData(page, true).length, 3);
  });

  it('should not grow beyond maxNavigationSaved', async () => {
    const browser = getMockBrowser();
    const page = (await browser.pages())[0];
    const mainFrame = page.mainFrame();
    const collector = new NetworkCollector(browser);
    await collector.init([page]);

    // Simulate 5 navigations (maxNavigationSaved is 3)
    for (let i = 0; i < 5; i++) {
      const req = getMockRequest({
        url: `http://example.com/nav${i}`,
        navigationRequest: true,
        frame: mainFrame,
      });
      page.emit('request', req);
      page.emit('framenavigated', mainFrame);
    }

    // We expect 3 arrays in navigations (current + 2 saved)
    // Each navigation has 1 request, so total should be 3
    assert.equal(collector.getData(page, true).length, 3);
  });
});

describe('ConsoleCollector', () => {
  let issue: Protocol.Audits.InspectorIssue;

  beforeEach(() => {
    issue = {
      code: 'MixedContentIssue',
      details: {
        mixedContentIssueDetails: {
          insecureURL: 'test.url',
          resolutionStatus: 'MixedContentBlocked',
          mainResourceURL: '',
        },
      },
    };
  });

  afterEach(() => {
    sinon.restore();
  });

  it('collects issues', async () => {
    const browser = getMockBrowser();
    const page = (await browser.pages())[0];
    const collector = new ConsoleCollector(browser, collect => {
      return {
        devtoolsAggregatedIssue: issue => {
          collect(issue);
        },
      } as ListenerMap;
    });
    await collector.init([page]);

    const issue2 = {
      code: 'ElementAccessibilityIssue' as const,
      details: {
        elementAccessibilityIssueDetails: {
          nodeId: 1,
          elementAccessibilityIssueReason: 'DisallowedSelectChild',
          hasDisallowedAttributes: true,
        },
      },
    } satisfies Protocol.Audits.InspectorIssue;

    page.emit('issue', issue);
    page.emit('issue', issue2);
    const data = collector.getData(page);
    assert.equal(data.length, 2);
  });

  it('silently ignores unmapped PerformanceIssue events', async () => {
    const browser = getMockBrowser();
    const page = (await browser.pages())[0];
    const warnStub = sinon.stub(console, 'warn');

    const collector = new ConsoleCollector(browser, collect => {
      return {
        devtoolsAggregatedIssue: issue => {
          collect(issue);
        },
      } as ListenerMap;
    });
    await collector.init([page]);

    const performanceIssue = {
      code: 'PerformanceIssue',
      details: {
        performanceIssueDetails: {
          performanceIssueType: 'DocumentCookie',
        },
      },
    } as unknown as Protocol.Audits.InspectorIssue;

    page.emit('issue', performanceIssue);

    assert.equal(collector.getData(page).length, 0);
    sinon.assert.notCalled(warnStub);
  });

  it('filters duplicated issues', async () => {
    const browser = getMockBrowser();
    const page = (await browser.pages())[0];

    const collector = new ConsoleCollector(browser, collect => {
      return {
        devtoolsAggregatedIssue: issue => {
          collect(issue);
        },
      } as ListenerMap;
    });
    await collector.init([page]);

    page.emit('issue', issue);
    page.emit('issue', issue);
    const data = collector.getData(page);
    assert.equal(data.length, 1);
    const collectedIssue = data[0];
    assert(collectedIssue instanceof DevTools.AggregatedIssue);
    assert.equal(collectedIssue.code(), 'MixedContentIssue');
    assert.equal(collectedIssue.getAggregatedIssuesCount(), 1);
  });

  it('emits UncaughtErrors for Runtime.exceptionThrown CDP events', async () => {
    const browser = getMockBrowser();
    const page = (await browser.pages())[0];
    // @ts-expect-error internal API.
    const cdpSession = page._client();
    const onUncaughtErrorListener = sinon.spy();
    const collector = new ConsoleCollector(browser, () => {
      return {
        uncaughtError: onUncaughtErrorListener,
      } as ListenerMap;
    });
    await collector.init([page]);

    cdpSession.emit('Runtime.exceptionThrown', {
      exceptionDetails: {
        exception: {description: 'SyntaxError: Expected {'},
        text: 'Uncaught',
        stackTrace: {callFrames: []},
      },
    });

    sinon.assert.calledOnceWithMatch(
      onUncaughtErrorListener,
      sinon.match(e => {
        return (
          e.details.exception.description === 'SyntaxError: Expected {',
          e.details.text === 'Uncaught',
          e.details.stackTrace.callFrames.length === 0
        );
      }),
    );
  });
});
