import { describe, it, expect, beforeEach } from 'vitest';
import {
  selectPruneTargets,
  PROTECTED_TOKENS,
  PRUNE_TRIGGER_TOKENS,
  type PartForPrune,
} from '../inference/prune.js';

// Test fixture: build a tool_result part whose payload size yields a known
// token estimate (chars/4). The decision logic only cares about
// JSON.stringify(payload).length, so a string payload of `4n` chars
// produces exactly `n` tokens.
let seq = 0;
function part(tokens: number, createdAt: Date): PartForPrune {
  seq += 1;
  // JSON.stringify("xxx...") wraps in quotes (adds 2 chars), so subtract 2
  // before multiplying. Math.ceil((len+2)/4) needs len ≈ 4*tokens - 2 so the
  // total stringified length is 4*tokens. Approximate by padding 4 chars per
  // token; the off-by-one from quotes is small and tests check totals, not
  // exact per-part counts.
  const text = 'x'.repeat(tokens * 4 - 2);
  return { id: `p${seq}`, payload: text, created_at: createdAt };
}

const T_NOW = new Date('2026-05-22T12:00:00Z');
function ago(secondsBack: number): Date {
  return new Date(T_NOW.getTime() - secondsBack * 1000);
}

describe('selectPruneTargets', () => {
  beforeEach(() => {
    seq = 0;
  });

  it('returns nothing when there are no parts', () => {
    expect(selectPruneTargets([], null)).toEqual({ ids: [], freedTokens: 0 });
  });

  it('returns nothing when total tokens are under the protection window', () => {
    const parts: PartForPrune[] = [
      part(10_000, ago(10)),
      part(10_000, ago(20)),
    ]; // 20k total, all protected
    expect(selectPruneTargets(parts, null)).toEqual({ ids: [], freedTokens: 0 });
  });

  it('returns nothing when candidate total is below the prune trigger', () => {
    // Protection fills with ~40k newest, candidates only ~5k. Below 20k trigger.
    const parts: PartForPrune[] = [
      part(20_000, ago(10)),
      part(20_000, ago(20)),
      // Past protection; total ~5k won't trigger.
      part(5_000, ago(30)),
    ];
    const result = selectPruneTargets(parts, null);
    expect(result.ids).toEqual([]);
    expect(result.freedTokens).toBe(0);
  });

  it('hides candidates past protection when their total clears the trigger', () => {
    // Newest 40k protected; older 30k cleanly above the 20k trigger.
    const parts: PartForPrune[] = [
      part(20_000, ago(10)),
      part(20_000, ago(20)),
      // Past protection, total ~30k freed.
      part(15_000, ago(30)),
      part(15_000, ago(40)),
    ];
    const result = selectPruneTargets(parts, null);
    expect(result.ids).toEqual(['p3', 'p4']);
    expect(result.freedTokens).toBeGreaterThanOrEqual(PRUNE_TRIGGER_TOKENS);
  });

  it('stops at the compaction summary boundary', () => {
    // Newest 30k protected (just under PROTECTED_TOKENS=40k); then 30k of
    // older parts. Boundary sits at ago(35), so the ago(40) part is
    // beyond it and gets skipped.
    const parts: PartForPrune[] = [
      part(15_000, ago(10)),
      part(15_000, ago(20)),
      part(15_000, ago(30)), // crosses protection threshold; candidate
      part(15_000, ago(40)), // beyond summary boundary; skipped
    ];
    const tailStart = ago(35);
    const result = selectPruneTargets(parts, tailStart);
    // ago(30) is the only candidate inside the window; 15k is below the
    // 20k trigger so we expect no hides.
    expect(result.ids).toEqual([]);
  });

  it('does not prune when only protected parts exist (no candidates)', () => {
    // Exactly PROTECTED_TOKENS of newest parts; no older candidates.
    const parts: PartForPrune[] = [part(PROTECTED_TOKENS, ago(10))];
    expect(selectPruneTargets(parts, null)).toEqual({ ids: [], freedTokens: 0 });
  });
});
