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

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AgentHistoryProvider } from './agentHistoryProvider.js';
import { estimateTokenCountSync } from '../utils/tokenCalculation.js';

vi.mock('../utils/tokenCalculation.js', () => ({
  estimateTokenCountSync: vi.fn(),
  ASCII_TOKENS_PER_CHAR: 0.25,
  NON_ASCII_TOKENS_PER_CHAR: 1.3,
}));

import type { Content, GenerateContentResponse, Part } from '@google/genai';
import type { Config } from '../config/config.js';
import type { BaseLlmClient } from '../core/baseLlmClient.js';
import type {
  AgentHistoryProviderConfig,
  ContextManagementConfig,
} from './types.js';
import {
  TEXT_TRUNCATION_PREFIX,
  TOOL_TRUNCATION_PREFIX,
  truncateProportionally,
} from './truncation.js';

describe('AgentHistoryProvider', () => {
  let config: Config;
  let provider: AgentHistoryProvider;
  let providerConfig: AgentHistoryProviderConfig;
  let generateContentMock: ReturnType<typeof vi.fn>;

  beforeEach(() => {
    config = {
      isExperimentalAgentHistoryTruncationEnabled: vi
        .fn()
        .mockReturnValue(false),
      getContextManagementConfig: vi.fn().mockReturnValue(false),
      getBaseLlmClient: vi.fn(),
    } as unknown as Config;

    // By default, messages are small
    vi.mocked(estimateTokenCountSync).mockImplementation(
      (parts: Part[]) => parts.length * 100,
    );
    generateContentMock = vi.fn().mockResolvedValue({
      candidates: [{ content: { parts: [{ text: 'Mock intent summary' }] } }],
    } as unknown as GenerateContentResponse);

    config.getBaseLlmClient = vi.fn().mockReturnValue({
      generateContent: generateContentMock,
    } as unknown as BaseLlmClient);
    providerConfig = {
      maxTokens: 60000,
      retainedTokens: 40000,
      normalMessageTokens: 2500,
      maximumMessageTokens: 10000,
      normalizationHeadRatio: 0.2,
    };
    provider = new AgentHistoryProvider(providerConfig, config);
  });

  const createMockHistory = (count: number): Content[] =>
    Array.from({ length: count }).map((_, i) => ({
      role: i % 2 === 0 ? 'user' : 'model',
      parts: [{ text: `Message ${i}` }],
    }));

  it('should return history unchanged if length is under threshold', async () => {
    const history = createMockHistory(20); // Threshold is 30
    const result = await provider.manageHistory(history);

    expect(result).toBe(history);
    expect(result.length).toBe(20);
  });

  it('should truncate when total tokens exceed budget, preserving structural integrity', async () => {
    providerConfig.maxTokens = 60000;
    providerConfig.retainedTokens = 60000;
    vi.spyOn(config, 'getContextManagementConfig').mockReturnValue({
      enabled: false,
    } as unknown as ContextManagementConfig);

    // Make each message cost 4000 tokens
    vi.mocked(estimateTokenCountSync).mockImplementation(
      (parts: Part[]) => parts.length * 4000,
    );
    const history = createMockHistory(35); // 35 * 4000 = 140,000 total tokens > maxTokens
    const result = await provider.manageHistory(history);
    expect(result.length).toBe(15); // Budget = 60000. Each message costs 4000. 60000 / 4000 = 15.
  });

  it('should call summarizer and prepend summary', async () => {
    providerConfig.maxTokens = 60000;
    providerConfig.retainedTokens = 60000;
    vi.spyOn(config, 'getContextManagementConfig').mockReturnValue({
      enabled: true,
    } as unknown as ContextManagementConfig);

    vi.mocked(estimateTokenCountSync).mockImplementation(
      (parts: Part[]) => parts.length * 4000,
    );
    const history = createMockHistory(35);
    const result = await provider.manageHistory(history);

    expect(generateContentMock).toHaveBeenCalled();
    expect(result.length).toBe(15);
    expect(result[0].role).toBe('user');
    expect(result[0].parts![0].text).toContain('<intent_summary>');
    expect(result[0].parts![0].text).toContain('Mock intent summary');
  });

  it('should handle summarizer failures gracefully', async () => {
    providerConfig.maxTokens = 60000;
    providerConfig.retainedTokens = 60000;
    vi.spyOn(config, 'getContextManagementConfig').mockReturnValue({
      enabled: true,
    } as unknown as ContextManagementConfig);
    vi.mocked(estimateTokenCountSync).mockImplementation(
      (parts: Part[]) => parts.length * 4000,
    );
    generateContentMock.mockRejectedValue(new Error('API Error'));

    const history = createMockHistory(35);
    const result = await provider.manageHistory(history);

    expect(generateContentMock).toHaveBeenCalled();
    expect(result.length).toBe(15);
    // Should fallback to fallback text
    expect(result[0].parts![0].text).toContain(
      '[System Note: Conversation History Truncated]',
    );
  });

  it('should use unambiguous label in fallback summary to avoid LLM confusion', async () => {
    providerConfig.maxTokens = 60000;
    providerConfig.retainedTokens = 60000;
    vi.spyOn(config, 'getContextManagementConfig').mockReturnValue({
      enabled: true,
    } as unknown as ContextManagementConfig);
    vi.mocked(estimateTokenCountSync).mockImplementation(
      (parts: Part[]) => parts.length * 4000,
    );
    generateContentMock.mockRejectedValue(new Error('API Error'));

    const history = createMockHistory(35);
    const result = await provider.manageHistory(history);

    expect(generateContentMock).toHaveBeenCalled();
    expect(result.length).toBe(15);
    // The fallback summary should use clear and unambiguous phrasing
    expect(result[0].parts![0].text).toContain(
      'Previous User Intent (Truncated):',
    );
    expect(result[0].parts![0].text).not.toContain('Last User Intent:');
  });

  it('should pass the contextual bridge to the summarizer', async () => {
    vi.spyOn(config, 'getContextManagementConfig').mockReturnValue({
      enabled: true,
    } as unknown as ContextManagementConfig);

    // Max tokens 30 means if total tokens > 30, it WILL truncate.
    providerConfig.maxTokens = 30;
    // budget 20 tokens means it will keep 2 messages if they are 10 each.
    providerConfig.retainedTokens = 20;

    vi.mocked(estimateTokenCountSync).mockImplementation(
      (parts: Part[]) => parts.length * 10,
    );
    const history: Content[] = [
      { role: 'user', parts: [{ text: 'Old Message' }] },
      { role: 'model', parts: [{ text: 'Old Response' }] },
      { role: 'user', parts: [{ text: 'Keep 1' }] },
      { role: 'user', parts: [{ text: 'Keep 2' }] },
    ];

    await provider.manageHistory(history);

    expect(generateContentMock).toHaveBeenCalled();
    const callArgs = generateContentMock.mock.calls[0][0];
    const prompt = callArgs.contents[0].parts[0].text;

    expect(prompt).toContain('ACTIVE BRIDGE (LOOKAHEAD):');
    expect(prompt).toContain('Keep 1');
    expect(prompt).toContain('Keep 2');
  });

  it('should detect a previous summary in the truncated head', async () => {
    vi.spyOn(config, 'getContextManagementConfig').mockReturnValue({
      enabled: true,
    } as unknown as ContextManagementConfig);

    providerConfig.maxTokens = 20;
    providerConfig.retainedTokens = 10;

    vi.mocked(estimateTokenCountSync).mockImplementation(
      (parts: Part[]) => parts.length * 10,
    );
    const history: Content[] = [
      {
        role: 'user',
        parts: [{ text: '<intent_summary>Previous Mandate</intent_summary>' }],
      },
      { role: 'model', parts: [{ text: 'Work' }] },
      { role: 'user', parts: [{ text: 'New Work' }] },
    ];

    await provider.manageHistory(history);

    expect(generateContentMock).toHaveBeenCalled();
    const callArgs = generateContentMock.mock.calls[0][0];
    const prompt = callArgs.contents[0].parts[0].text;

    expect(prompt).toContain('1. **Previous Summary:**');
    expect(prompt).toContain('PREVIOUS SUMMARY AND TRUNCATED HISTORY:');
  });

  it('should include the Action Path (necklace of function names) in the prompt', async () => {
    vi.spyOn(config, 'getContextManagementConfig').mockReturnValue({
      enabled: true,
    } as unknown as ContextManagementConfig);

    providerConfig.maxTokens = 20;
    providerConfig.retainedTokens = 10;

    vi.mocked(estimateTokenCountSync).mockImplementation(
      (parts: Part[]) => parts.length * 10,
    );
    const history: Content[] = [
      {
        role: 'model',
        parts: [
          { functionCall: { name: 'tool_a', args: {} } },
          { functionCall: { name: 'tool_b', args: {} } },
        ],
      },
      { role: 'user', parts: [{ text: 'Keep' }] },
    ];

    await provider.manageHistory(history);

    expect(generateContentMock).toHaveBeenCalled();
    const callArgs = generateContentMock.mock.calls[0][0];
    const prompt = callArgs.contents[0].parts[0].text;

    expect(prompt).toContain('The Action Path:');
    expect(prompt).toContain('tool_a → tool_b');
  });

  describe('Tiered Normalization Logic', () => {
    it('normalizes large messages incrementally: newest and exit-grace', async () => {
      providerConfig.retainedTokens = 30000;
      providerConfig.maximumMessageTokens = 10000;
      providerConfig.normalMessageTokens = 2500; // History of 35 messages.
      // Index 34: Newest (Grace Zone) -> Target 10000 tokens (~40000 chars)
      // Index 19: Exit Grace (35-1-15=19) -> Target 2500 tokens (~10000 chars)
      // Index 10: Archived -> Should NOT be normalized in this turn (Incremental optimization)
      const history = createMockHistory(35);
      const hugeText = 'H'.repeat(100000);

      history[34] = { role: 'user', parts: [{ text: hugeText }] };
      history[19] = { role: 'model', parts: [{ text: hugeText }] };
      history[10] = { role: 'user', parts: [{ text: hugeText }] };

      // Mock token count to trigger normalization (100k chars = 25k tokens @ 4 chars/token)
      vi.mocked(estimateTokenCountSync).mockImplementation((parts: Part[]) => {
        if (!parts?.[0]) return 10;
        const text = parts[0].text || '';
        if (text.startsWith('H')) return 25000;
        return 10;
      });

      const result = await provider.manageHistory(history);

      // 1. Newest message (index 34) normalized to ~40000 chars
      const normalizedLast = result[34].parts![0].text!;
      expect(normalizedLast).toContain(TEXT_TRUNCATION_PREFIX);
      expect(normalizedLast.length).toBeLessThan(50000);
      expect(normalizedLast.length).toBeGreaterThan(30000);

      // 2. Exit grace message (index 19) normalized to ~10000 chars
      const normalizedArchived = result[19].parts![0].text!;
      expect(normalizedArchived).toContain(TEXT_TRUNCATION_PREFIX);
      expect(normalizedArchived.length).toBeLessThan(15000);
      expect(normalizedArchived.length).toBeGreaterThan(8000);

      // 3. Archived message (index 10) IS touched and normalized to ~10000 chars
      const normalizedPastArchived = result[10].parts![0].text!;
      expect(normalizedPastArchived).toContain(TEXT_TRUNCATION_PREFIX);
      expect(normalizedPastArchived.length).toBeLessThan(15000);
      expect(normalizedPastArchived.length).toBeGreaterThan(8000);
    });

    it('normalize function responses correctly by targeting large string values', async () => {
      providerConfig.maximumMessageTokens = 1000;

      const hugeValue = 'O'.repeat(5000);
      const history: Content[] = [
        {
          role: 'user',
          parts: [
            {
              functionResponse: {
                name: 'test_tool',
                id: '1',
                response: {
                  stdout: hugeValue,
                  stderr: 'small error',
                  exitCode: 0,
                },
              },
            },
          ],
        },
      ];

      vi.mocked(estimateTokenCountSync).mockImplementation(
        (parts: readonly Part[]) => {
          if (parts?.[0]?.functionResponse) return 5000;
          return 10;
        },
      );

      const result = await provider.manageHistory(history);

      const fr = result[0].parts![0].functionResponse!;
      const resp = fr.response as Record<string, unknown>;

      // stdout should be truncated
      expect(resp['stdout']).toContain(TOOL_TRUNCATION_PREFIX);
      expect((resp['stdout'] as string).length).toBeLessThan(hugeValue.length);

      // stderr and exitCode should be PRESERVED (JSON integrity)
      expect(resp['stderr']).toBe('small error');
      expect(resp['exitCode']).toBe(0);

      // Schema should be intact
      expect(fr.name).toBe('test_tool');
      expect(fr.id).toBe('1');
    });
  });

  describe('truncateProportionally', () => {
    it('returns original string if under target chars', () => {
      const str = 'A'.repeat(50);
      expect(truncateProportionally(str, 100, TEXT_TRUNCATION_PREFIX)).toBe(
        str,
      );
    });

    it('truncates proportionally with prefix and ellipsis', () => {
      const str = 'A'.repeat(500) + 'B'.repeat(500); // 1000 chars
      const target = 100;
      const result = truncateProportionally(
        str,
        target,
        TEXT_TRUNCATION_PREFIX,
      );

      expect(result.startsWith(TEXT_TRUNCATION_PREFIX)).toBe(true);
      expect(result).toContain('\n...\n');

      // The prefix and ellipsis take up some space
      // It should keep ~20% head and ~80% tail of the *available* space
      const ellipsis = '\n...\n';
      const overhead = TEXT_TRUNCATION_PREFIX.length + ellipsis.length + 1; // +1 for the newline after prefix
      const availableChars = Math.max(0, target - overhead);
      const expectedHeadChars = Math.floor(availableChars * 0.2);
      const expectedTailChars = availableChars - expectedHeadChars;

      // Extract parts around the ellipsis
      const parts = result.split(ellipsis);
      expect(parts.length).toBe(2);

      // Remove prefix + newline from the first part to check head length
      const actualHead = parts[0].replace(TEXT_TRUNCATION_PREFIX + '\n', '');
      const actualTail = parts[1];

      expect(actualHead.length).toBe(expectedHeadChars);
      expect(actualTail.length).toBe(expectedTailChars);
    });

    it('handles very small targets gracefully by just returning prefix', () => {
      const str = 'A'.repeat(100);
      const result = truncateProportionally(str, 10, TEXT_TRUNCATION_PREFIX);
      expect(result).toBe(TEXT_TRUNCATION_PREFIX);
    });
  });

  describe('Multi-part Proportional Normalization', () => {
    it('distributes token budget proportionally across multiple large parts', async () => {
      providerConfig.maximumMessageTokens = 2500; // Small limit to trigger normalization on last msg

      const history = createMockHistory(35);

      // Make newest message (index 34) have two large parts
      // Part 1: 10000 chars (~2500 tokens at 4 chars/token)
      // Part 2: 30000 chars (~7500 tokens at 4 chars/token)
      // Total tokens = 10000. Target = 2500. Ratio = 0.25.
      const part1Text = 'A'.repeat(10000);
      const part2Text = 'B'.repeat(30000);

      history[34] = {
        role: 'user',
        parts: [{ text: part1Text }, { text: part2Text }],
      };

      vi.mocked(estimateTokenCountSync).mockImplementation(
        (parts: readonly Part[]) => {
          if (!parts || parts.length === 0) return 0;
          let tokens = 0;
          for (const p of parts) {
            if (p.text?.startsWith('A')) tokens += 2500;
            else if (p.text?.startsWith('B')) tokens += 7500;
            else tokens += 10;
          }
          return tokens;
        },
      );

      const result = await provider.manageHistory(history);

      const normalizedMsg = result[34];
      expect(normalizedMsg.parts!.length).toBe(2);

      const p1 = normalizedMsg.parts![0].text!;
      const p2 = normalizedMsg.parts![1].text!;

      expect(p1).toContain(TEXT_TRUNCATION_PREFIX);
      expect(p2).toContain(TEXT_TRUNCATION_PREFIX);

      // Part 1: Target chars ~ 2500 * 0.25 * 4 = 2500
      // Part 2: Target chars ~ 7500 * 0.25 * 4 = 7500
      expect(p1.length).toBeLessThan(3500);
      expect(p2.length).toBeLessThan(9000);
      expect(p1.length).toBeLessThan(p2.length);
    });

    it('preserves small parts while truncating large parts in the same message', async () => {
      providerConfig.maximumMessageTokens = 2500;

      const history = createMockHistory(35);

      const smallText = 'Hello I am small';
      const hugeText = 'B'.repeat(40000); // 10000 tokens

      history[34] = {
        role: 'user',
        parts: [{ text: smallText }, { text: hugeText }],
      };

      vi.mocked(estimateTokenCountSync).mockImplementation(
        (parts: readonly Part[]) => {
          if (!parts || parts.length === 0) return 0;
          let tokens = 0;
          for (const p of parts) {
            if (p.text === smallText) tokens += 10;
            else if (p.text?.startsWith('B')) tokens += 10000;
            else tokens += 10;
          }
          return tokens;
        },
      );

      const result = await provider.manageHistory(history);

      const normalizedMsg = result[34];
      expect(normalizedMsg.parts!.length).toBe(2);

      const p1 = normalizedMsg.parts![0].text!;
      const p2 = normalizedMsg.parts![1].text!;

      // Small part should be preserved
      expect(p1).toBe(smallText);

      // Huge part should be truncated
      expect(p2).toContain(TEXT_TRUNCATION_PREFIX);
      // Target tokens for huge part = ~2500 * (10000/10010) = ~2500
      // Target chars = ~10000
      expect(p2.length).toBeLessThan(12000);
    });
  });
});
