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

import type * as Common from '../../../core/common/common.js';
import * as Host from '../../../core/host/host.js';
import * as Platform from '../../../core/platform/platform.js';
import * as TimelineUtils from '../../../panels/timeline/utils/utils.js';
import {mockAidaClient} from '../../../testing/AiAssistanceHelpers.js';
import {describeWithEnvironment} from '../../../testing/EnvironmentHelpers.js';
import {getInsightOrError} from '../../../testing/InsightHelpers.js';
import {TraceLoader} from '../../../testing/TraceLoader.js';
import * as Trace from '../../trace/trace.js';
import {
  type ActionResponse,
  InsightContext,
  PerformanceInsightFormatter,
  PerformanceInsightsAgent,
  ResponseType,
  TraceEventFormatter,
} from '../ai_assistance.js';

const FAKE_LCP_MODEL = {
  insightKey: Trace.Insights.Types.InsightKeys.LCP_PHASES,
  strings: {},
  title: 'LCP by phase' as Common.UIString.LocalizedString,
  description: 'some description' as Common.UIString.LocalizedString,
  category: Trace.Insights.Types.InsightCategory.ALL,
  state: 'fail',
  frameId: '123',
} as const;
const FAKE_INP_MODEL = {
  insightKey: Trace.Insights.Types.InsightKeys.INTERACTION_TO_NEXT_PAINT,
  strings: {},
  title: 'INP by phase' as Common.UIString.LocalizedString,
  description: 'some description' as Common.UIString.LocalizedString,
  category: Trace.Insights.Types.InsightCategory.ALL,
  state: 'fail',
  frameId: '123',
} as const;
const FAKE_PARSED_TRACE = {
  Meta: {traceBounds: {min: 0, max: 10}},
} as unknown as Trace.Handlers.Types.ParsedTrace;

describeWithEnvironment('PerformanceInsightsAgent', () => {
  it('uses the min and max bounds of the trace as the origin', async function() {
    const {parsedTrace, insights} = await TraceLoader.traceEngine(this, 'lcp-images.json.gz');
    assert.isOk(insights);
    const [firstNav] = parsedTrace.Meta.mainFrameNavigations;
    const lcpPhases = getInsightOrError('LCPPhases', insights, firstNav);
    const activeInsight = new TimelineUtils.InsightAIContext.ActiveInsight(lcpPhases, parsedTrace);
    const context = new InsightContext(activeInsight);
    assert.strictEqual(context.getOrigin(), 'trace-658799706428-658804825864');
  });

  it('outputs the right title for the selected insight', async () => {
    const mockInsight = new TimelineUtils.InsightAIContext.ActiveInsight(FAKE_LCP_MODEL, FAKE_PARSED_TRACE);
    const context = new InsightContext(mockInsight);
    assert.strictEqual(context.getTitle(), 'Insight: LCP by phase');
  });

  // See b/405054694 for context on why we do this.
  describe('parsing text responses', () => {
    it('strips out 5 backticks if the response has them', async () => {
      const agent = new PerformanceInsightsAgent({aidaClient: mockAidaClient()});
      const response = agent.parseTextResponse('`````hello world`````');
      assert.deepEqual(response, {answer: 'hello world'});
    });

    it('strips any newlines before the backticks', async () => {
      const agent = new PerformanceInsightsAgent({aidaClient: mockAidaClient()});
      const response = agent.parseTextResponse('\n\n`````hello world`````');
      assert.deepEqual(response, {answer: 'hello world'});
    });

    it('does not strip the backticks if the response does not fully start and end with them', async () => {
      const agent = new PerformanceInsightsAgent({aidaClient: mockAidaClient()});
      const response = agent.parseTextResponse('answer: `````hello world`````');
      assert.deepEqual(response, {answer: 'answer: `````hello world`````'});
    });

    it('does not strip the backticks in the middle of the response even if the response is also wrapped', async () => {
      const agent = new PerformanceInsightsAgent({aidaClient: mockAidaClient()});
      const response = agent.parseTextResponse('`````hello ````` world`````');
      assert.deepEqual(response, {answer: 'hello ````` world'});
    });

    it('does not strip out inline code backticks', async () => {
      const agent = new PerformanceInsightsAgent({aidaClient: mockAidaClient()});
      const response = agent.parseTextResponse('This is code `console.log("hello")`');
      assert.deepEqual(response, {answer: 'This is code `console.log("hello")`'});
    });

    it('does not strip out code block 3 backticks', async () => {
      const agent = new PerformanceInsightsAgent({aidaClient: mockAidaClient()});
      const response = agent.parseTextResponse(`\`\`\`
code
\`\`\``);
      assert.deepEqual(response, {
        answer: `\`\`\`
code
\`\`\``
      });
    });
  });

  describe('handleContextDetails', () => {
    it('outputs the right context for the initial query from the user', async () => {
      const mockInsight = new TimelineUtils.InsightAIContext.ActiveInsight(FAKE_LCP_MODEL, FAKE_PARSED_TRACE);
      const context = new InsightContext(mockInsight);
      const agent = new PerformanceInsightsAgent({
        aidaClient: mockAidaClient([[{
          explanation: 'This is the answer',
          metadata: {
            rpcGlobalId: 123,
          }
        }]])
      });

      const expectedDetailText = new PerformanceInsightFormatter(mockInsight).formatInsight();

      const responses = await Array.fromAsync(agent.run('test', {selected: context}));
      assert.deepEqual(responses, [
        {
          type: ResponseType.USER_QUERY,
          query: 'test',
          imageInput: undefined,
          imageId: undefined,
        },
        {
          type: ResponseType.CONTEXT,
          title: 'Analyzing insight: LCP by phase',
          details: [
            {title: 'LCP by phase', text: expectedDetailText},
          ],
        },
        {
          type: ResponseType.QUERYING,
        },
        {
          type: ResponseType.ANSWER,
          text: 'This is the answer',
          complete: true,
          suggestions: undefined,
          rpcId: 123,
        },
      ]);
    });
  });

  describe('enhanceQuery', () => {
    it('adds the context to the query from the user', async () => {
      const agent = new PerformanceInsightsAgent({
        aidaClient: {} as Host.AidaClient.AidaClient,
      });

      const mockInsight = new TimelineUtils.InsightAIContext.ActiveInsight(FAKE_LCP_MODEL, FAKE_PARSED_TRACE);
      const context = new InsightContext(mockInsight);
      const extraContext = new PerformanceInsightFormatter(mockInsight).formatInsight();

      const finalQuery = await agent.enhanceQuery('What is this?', context);
      const expected = `${extraContext}

# User question for you to answer:
What is this?`;

      assert.strictEqual(finalQuery, expected);
    });

    it('does not add the context for follow-up queries with the same context', async () => {
      const agent = new PerformanceInsightsAgent({
        aidaClient: {} as Host.AidaClient.AidaClient,
      });

      const mockInsight = new TimelineUtils.InsightAIContext.ActiveInsight(FAKE_LCP_MODEL, FAKE_PARSED_TRACE);
      const context = new InsightContext(mockInsight);

      await agent.enhanceQuery('What is this?', context);
      const finalQuery = await agent.enhanceQuery('Help me understand?', context);
      const expected = `# User question for you to answer:
Help me understand?`;

      assert.strictEqual(finalQuery, expected);
    });

    it('does add context to queries if the insight context changes', async () => {
      const agent = new PerformanceInsightsAgent({
        aidaClient: {} as Host.AidaClient.AidaClient,
      });
      const mockInsight1 = new TimelineUtils.InsightAIContext.ActiveInsight(FAKE_LCP_MODEL, FAKE_PARSED_TRACE);
      const mockInsight2 = new TimelineUtils.InsightAIContext.ActiveInsight(FAKE_INP_MODEL, FAKE_PARSED_TRACE);
      const context1 = new InsightContext(mockInsight1);
      const context2 = new InsightContext(mockInsight2);
      const firstQuery = await agent.enhanceQuery('Q1', context1);
      const secondQuery = await agent.enhanceQuery('Q2', context1);
      const thirdQuery = await agent.enhanceQuery('Q3', context2);
      assert.include(firstQuery, '## Insight Title: LCP by phase');
      assert.notInclude(secondQuery, '## Insight Title');
      assert.include(thirdQuery, '## Insight Title: INP by phase');
    });
  });

  describe('function calls', () => {
    it('calls getNetworkActivitySummary and logs the response bytes size', async function() {
      const metricsSpy = sinon.spy(Host.userMetrics, 'performanceAINetworkSummaryResponseSize');
      const {parsedTrace, insights} = await TraceLoader.traceEngine(this, 'lcp-images.json.gz');
      assert.isOk(insights);
      const [firstNav] = parsedTrace.Meta.mainFrameNavigations;
      const lcpPhases = getInsightOrError('LCPPhases', insights, firstNav);
      const agent = new PerformanceInsightsAgent({
        aidaClient: mockAidaClient([
          [{explanation: '', functionCalls: [{name: 'getNetworkActivitySummary', args: {}}]}], [{explanation: 'done'}]
        ])
      });
      const activeInsight = new TimelineUtils.InsightAIContext.ActiveInsight(lcpPhases, parsedTrace);
      const context = new InsightContext(activeInsight);

      const responses = await Array.fromAsync(agent.run('test', {selected: context}));
      const action = responses.find(response => response.type === ResponseType.ACTION);

      // Find the requests we expect the handler to have returned.
      const expectedRequestUrls = [
        'https://chromedevtools.github.io/performance-stories/lcp-large-image/index.html',
        'https://fonts.googleapis.com/css2?family=Poppins:ital,wght@1,800',
        'https://chromedevtools.github.io/performance-stories/lcp-large-image/app.css',
        'https://via.placeholder.com/50.jpg', 'https://via.placeholder.com/2000.jpg'
      ];

      const requests = expectedRequestUrls.map(url => {
        const match = parsedTrace.NetworkRequests.byTime.find(r => r.args.data.url === url);
        assert.isOk(match, `no request found for ${url}`);
        return match;
      });

      const expectedRequestsOutput =
          requests.map(r => TraceEventFormatter.networkRequest(r, parsedTrace, {verbose: false}));

      const expectedBytesSize = Platform.StringUtilities.countWtf8Bytes(expectedRequestsOutput.join('\n'));
      sinon.assert.calledWith(metricsSpy, expectedBytesSize);

      const expectedOutput = JSON.stringify({requests: expectedRequestsOutput});
      const titleResponse = responses.find(response => response.type === ResponseType.TITLE);
      assert.exists(titleResponse);
      assert.strictEqual(titleResponse.title, 'Investigating network activity…');

      assert.exists(action);
      assert.deepEqual(action, {
        type: 'action' as ActionResponse['type'],
        output: expectedOutput,
        code: 'getNetworkActivitySummary()',
        canceled: false
      });
    });

    it('can call getNetworkRequestDetail to get detail about a single request', async function() {
      const {parsedTrace, insights} = await TraceLoader.traceEngine(this, 'lcp-images.json.gz');
      assert.isOk(insights);
      const [firstNav] = parsedTrace.Meta.mainFrameNavigations;
      const lcpPhases = getInsightOrError('LCPPhases', insights, firstNav);
      const requestUrl = 'https://chromedevtools.github.io/performance-stories/lcp-large-image/app.css';
      const agent = new PerformanceInsightsAgent({
        aidaClient: mockAidaClient([
          [{explanation: '', functionCalls: [{name: 'getNetworkRequestDetail', args: {url: requestUrl}}]}],
          [{explanation: 'done'}]
        ])
      });
      const activeInsight = new TimelineUtils.InsightAIContext.ActiveInsight(lcpPhases, parsedTrace);
      const context = new InsightContext(activeInsight);

      const responses = await Array.fromAsync(agent.run('test', {selected: context}));
      const titleResponse = responses.find(response => response.type === ResponseType.TITLE);
      assert.exists(titleResponse);
      assert.strictEqual(titleResponse.title, `Investigating network request ${requestUrl}…`);
      const action = responses.find(response => response.type === ResponseType.ACTION);
      const request = parsedTrace.NetworkRequests.byTime.find(r => r.args.data.url === requestUrl);
      assert.isOk(request);

      const expectedRequestOutput = TraceEventFormatter.networkRequest(request, parsedTrace, {verbose: true});
      const expectedOutput = JSON.stringify({request: expectedRequestOutput});

      assert.exists(action);
      assert.deepEqual(action, {
        type: 'action' as ActionResponse['type'],
        output: expectedOutput,
        code: `getNetworkRequestDetail('${requestUrl}')`,
        canceled: false
      });
    });

    it('calls getMainThreadActivity', async function() {
      const {parsedTrace, insights} = await TraceLoader.traceEngine(this, 'lcp-discovery-delay.json.gz');
      assert.isOk(insights);
      const [firstNav] = parsedTrace.Meta.mainFrameNavigations;
      const lcpPhases = getInsightOrError('LCPPhases', insights, firstNav);
      const agent = new PerformanceInsightsAgent({
        aidaClient: mockAidaClient(
            [[{explanation: '', functionCalls: [{name: 'getMainThreadActivity', args: {}}]}], [{explanation: 'done'}]])
      });
      const activeInsight = new TimelineUtils.InsightAIContext.ActiveInsight(lcpPhases, parsedTrace);
      const context = new InsightContext(activeInsight);

      const responses = await Array.fromAsync(agent.run('test', {selected: context}));
      const titleResponse = responses.find(response => response.type === ResponseType.TITLE);
      assert.exists(titleResponse);
      assert.strictEqual(titleResponse.title, 'Investigating main thread activity…');

      const action = responses.find(response => response.type === ResponseType.ACTION);
      assert.exists(action);

      const expectedTree = TimelineUtils.InsightAIContext.AIQueries.mainThreadActivity(lcpPhases, parsedTrace);
      assert.isOk(expectedTree);
      const expectedOutput = JSON.stringify({activity: expectedTree.serialize()});

      assert.deepEqual(action, {
        type: 'action' as ActionResponse['type'],
        output: expectedOutput,
        code: 'getMainThreadActivity()',
        canceled: false
      });
    });

    it('caches getNetworkActivitySummary calls and passes them to future requests as facts', async function() {
      const {parsedTrace, insights} = await TraceLoader.traceEngine(this, 'lcp-images.json.gz');
      assert.isOk(insights);
      const [firstNav] = parsedTrace.Meta.mainFrameNavigations;
      const lcpPhases = getInsightOrError('LCPPhases', insights, firstNav);
      const agent = new PerformanceInsightsAgent({
        aidaClient: mockAidaClient([
          [{explanation: '', functionCalls: [{name: 'getNetworkActivitySummary', args: {}}]}], [{explanation: 'done'}]
        ])
      });
      const activeInsight = new TimelineUtils.InsightAIContext.ActiveInsight(lcpPhases, parsedTrace);
      const context = new InsightContext(activeInsight);

      // Make the first query to trigger the getNetworkActivitySummary function
      const responses = await Array.fromAsync(agent.run('test', {selected: context}));
      const action = responses.find(response => response.type === ResponseType.ACTION);
      assert.exists(action);
      assert.strictEqual(action.code, 'getNetworkActivitySummary()');

      // Trigger another request so that the agent populates the facts.
      await Array.fromAsync(agent.run('test 2', {selected: context}));

      assert.strictEqual(agent.currentFacts().size, 1);
      const networkSummaryFact = Array.from(agent.currentFacts()).at(0);
      assert.exists(networkSummaryFact);

      const expectedRequestUrls = [
        'https://chromedevtools.github.io/performance-stories/lcp-large-image/index.html',
        'https://fonts.googleapis.com/css2?family=Poppins:ital,wght@1,800',
        'https://chromedevtools.github.io/performance-stories/lcp-large-image/app.css',
        'https://via.placeholder.com/50.jpg', 'https://via.placeholder.com/2000.jpg'
      ];
      // Ensure that each URL was in the fact as a way to validate the fact is accurate.
      assert.isTrue(expectedRequestUrls.every(url => {
        return networkSummaryFact.text.includes(url);
      }));

      // Now we make one more request; we do this to ensure that we don't add the same fact again.
      await Array.fromAsync(agent.run('test 3', {selected: context}));

      assert.strictEqual(agent.currentFacts().size, 1);
    });

    it('caches getMainThreadActivity calls and passes them to future requests as facts', async function() {
      const {parsedTrace, insights} = await TraceLoader.traceEngine(this, 'lcp-discovery-delay.json.gz');
      assert.isOk(insights);
      const [firstNav] = parsedTrace.Meta.mainFrameNavigations;
      const lcpPhases = getInsightOrError('LCPPhases', insights, firstNav);
      const agent = new PerformanceInsightsAgent({
        aidaClient: mockAidaClient(
            [[{explanation: '', functionCalls: [{name: 'getMainThreadActivity', args: {}}]}], [{explanation: 'done'}]])
      });
      const activeInsight = new TimelineUtils.InsightAIContext.ActiveInsight(lcpPhases, parsedTrace);
      const context = new InsightContext(activeInsight);

      // Make the first query to trigger the getMainThreadActivity function
      const responses = await Array.fromAsync(agent.run('test', {selected: context}));
      const action = responses.find(response => response.type === ResponseType.ACTION);
      assert.exists(action);
      assert.strictEqual(action.code, 'getMainThreadActivity()');

      // Trigger another request so that the agent populates the facts.
      await Array.fromAsync(agent.run('test 2', {selected: context}));

      assert.strictEqual(agent.currentFacts().size, 1);
      const mainThreadActivityFact = Array.from(agent.currentFacts()).at(0);
      assert.exists(mainThreadActivityFact);

      const expectedTree = TimelineUtils.InsightAIContext.AIQueries.mainThreadActivity(lcpPhases, parsedTrace);
      assert.isOk(expectedTree);
      assert.include(mainThreadActivityFact.text, expectedTree.serialize());

      // Now we make one more request; we do this to ensure that we don't add the same fact again.
      await Array.fromAsync(agent.run('test 3', {selected: context}));

      assert.strictEqual(agent.currentFacts().size, 1);
    });

    it('will not send facts from a previous insight if the context changes', async function() {
      const {parsedTrace, insights} = await TraceLoader.traceEngine(this, 'lcp-discovery-delay.json.gz');
      assert.isOk(insights);
      const [firstNav] = parsedTrace.Meta.mainFrameNavigations;
      const lcpPhases = getInsightOrError('LCPPhases', insights, firstNav);
      const renderBlocking = getInsightOrError('RenderBlocking', insights, firstNav);
      const agent = new PerformanceInsightsAgent({
        aidaClient: mockAidaClient([
          [{explanation: '', functionCalls: [{name: 'getMainThreadActivity', args: {}}]}],
        ])
      });
      const lcpPhasesActiveInsight = new TimelineUtils.InsightAIContext.ActiveInsight(lcpPhases, parsedTrace);
      const lcpContext = new InsightContext(lcpPhasesActiveInsight);
      const renderBlockingActiveInsight = new TimelineUtils.InsightAIContext.ActiveInsight(renderBlocking, parsedTrace);
      const renderBlockingContext = new InsightContext(renderBlockingActiveInsight);

      // Populate the function calls for the LCP Context
      await Array.fromAsync(agent.run('test 1 LCP', {selected: lcpContext}));
      await Array.fromAsync(agent.run('test 2 LCP', {selected: lcpContext}));
      assert.strictEqual(agent.currentFacts().size, 1);
      // Now change the context and send a request.
      await Array.fromAsync(agent.run('test 1 RenderBlocking', {selected: renderBlockingContext}));
      // Because the context changed, we should now not have any facts.
      assert.strictEqual(agent.currentFacts().size, 0);
    });

    it('will send multiple facts', async function() {
      const {parsedTrace, insights} = await TraceLoader.traceEngine(this, 'lcp-discovery-delay.json.gz');
      assert.isOk(insights);
      const [firstNav] = parsedTrace.Meta.mainFrameNavigations;
      const lcpPhases = getInsightOrError('LCPPhases', insights, firstNav);
      const agent = new PerformanceInsightsAgent({
        aidaClient: mockAidaClient([
          [{explanation: '', functionCalls: [{name: 'getMainThreadActivity', args: {}}]}],
          [{explanation: '', functionCalls: [{name: 'getNetworkActivitySummary', args: {}}]}], [{explanation: 'done'}]
        ])
      });
      const activeInsight = new TimelineUtils.InsightAIContext.ActiveInsight(lcpPhases, parsedTrace);
      const context = new InsightContext(activeInsight);
      // First query to populate the function calls
      await Array.fromAsync(agent.run('test 1', {selected: context}));
      // Second query should have two facts
      await Array.fromAsync(agent.run('test 2', {selected: context}));
      assert.deepEqual(Array.from(agent.currentFacts(), fact => {
        return fact.metadata.source;
      }), ['getMainThreadActivity()', 'getNetworkActivitySummary()']);
    });
  });
});
