// @vitest-environment node
import { describe, it, expect } from 'vitest';
import { A2aLimiter, DEFAULT_A2A_LIMITS, resolveA2aLimits } from './limiter.js';

function clock(start: number) {
  let t = start;
  return { now: () => t, advance: (ms: number) => { t += ms; } };
}

// ---------------------------------------------------------------------------
// resolveA2aLimits
// ---------------------------------------------------------------------------
describe('resolveA2aLimits (strict integer≥1)', () => {
  it('fills defaults for missing/invalid (brief canonical test)', () => {
    const r = resolveA2aLimits({
      ratePerMinute: 7,
      maxPayloadBytes: 0,
      skillBudgetPerHour: 0.5,
      maxConcurrentTasks: '3' as any,
      maxStreamSeconds: NaN,
    });
    expect(r.ratePerMinute).toBe(7);                                              // valid integer≥1 → accepted
    expect(r.maxPayloadBytes).toBe(DEFAULT_A2A_LIMITS.maxPayloadBytes);           // 0 rejected
    expect(r.skillBudgetPerHour).toBe(DEFAULT_A2A_LIMITS.skillBudgetPerHour);    // 0.5 rejected
    expect(r.maxConcurrentTasks).toBe(DEFAULT_A2A_LIMITS.maxConcurrentTasks);    // "3" rejected
    expect(r.maxStreamSeconds).toBe(DEFAULT_A2A_LIMITS.maxStreamSeconds);        // NaN rejected
  });

  it('rejects 0 → default', () => {
    const r = resolveA2aLimits({ ratePerMinute: 0 });
    expect(r.ratePerMinute).toBe(DEFAULT_A2A_LIMITS.ratePerMinute);
  });

  it('rejects negative → default', () => {
    const r = resolveA2aLimits({ ratePerMinute: -5 });
    expect(r.ratePerMinute).toBe(DEFAULT_A2A_LIMITS.ratePerMinute);
  });

  it('rejects NaN → default', () => {
    const r = resolveA2aLimits({ maxStreamSeconds: NaN });
    expect(r.maxStreamSeconds).toBe(DEFAULT_A2A_LIMITS.maxStreamSeconds);
  });

  it('rejects non-integer 0.5 → default (no starvation)', () => {
    const r = resolveA2aLimits({ ratePerMinute: 0.5 });
    expect(r.ratePerMinute).toBe(DEFAULT_A2A_LIMITS.ratePerMinute);
  });

  it('rejects string "3" → default', () => {
    const r = resolveA2aLimits({ maxConcurrentTasks: '3' as any });
    expect(r.maxConcurrentTasks).toBe(DEFAULT_A2A_LIMITS.maxConcurrentTasks);
  });

  it('accepts all valid integer≥1 values', () => {
    const r = resolveA2aLimits({
      ratePerMinute: 10,
      maxConcurrentTasks: 3,
      maxPayloadBytes: 1024,
      maxStreamSeconds: 60,
      skillBudgetPerHour: 50,
      maxConcurrentResubscribe: 2,
    });
    expect(r.ratePerMinute).toBe(10);
    expect(r.maxConcurrentTasks).toBe(3);
    expect(r.maxPayloadBytes).toBe(1024);
    expect(r.maxStreamSeconds).toBe(60);
    expect(r.skillBudgetPerHour).toBe(50);
    expect(r.maxConcurrentResubscribe).toBe(2);
  });

  it('returns defaults for undefined input', () => {
    const r = resolveA2aLimits();
    expect(r).toEqual(DEFAULT_A2A_LIMITS);
  });

  it('returns defaults for empty object', () => {
    const r = resolveA2aLimits({});
    expect(r).toEqual(DEFAULT_A2A_LIMITS);
  });

  it('DEFAULT_A2A_LIMITS has expected values', () => {
    expect(DEFAULT_A2A_LIMITS.ratePerMinute).toBe(30);
    expect(DEFAULT_A2A_LIMITS.maxConcurrentTasks).toBe(5);
    expect(DEFAULT_A2A_LIMITS.maxPayloadBytes).toBe(65536);
    expect(DEFAULT_A2A_LIMITS.maxStreamSeconds).toBe(600);
    expect(DEFAULT_A2A_LIMITS.skillBudgetPerHour).toBe(100);
    expect(DEFAULT_A2A_LIMITS.maxConcurrentResubscribe).toBe(5);
  });
});

// ---------------------------------------------------------------------------
// tryConsumeRate (token bucket)
// ---------------------------------------------------------------------------
describe('tryConsumeRate', () => {
  it('allows burst up to ratePerMinute then returns false', () => {
    const c = clock(0);
    const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, ratePerMinute: 3 }, c.now);
    expect(lim.tryConsumeRate('g')).toBe(true);
    expect(lim.tryConsumeRate('g')).toBe(true);
    expect(lim.tryConsumeRate('g')).toBe(true);
    expect(lim.tryConsumeRate('g')).toBe(false);
  });

  it('refills over time at ratePerMinute per minute', () => {
    const c = clock(0);
    const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, ratePerMinute: 2 }, c.now);
    expect(lim.tryConsumeRate('g')).toBe(true);
    expect(lim.tryConsumeRate('g')).toBe(true);
    expect(lim.tryConsumeRate('g')).toBe(false); // exhausted

    c.advance(30_000); // +30s → +1 token
    expect(lim.tryConsumeRate('g')).toBe(true);
    expect(lim.tryConsumeRate('g')).toBe(false); // exhausted again

    c.advance(60_000); // +60s → +2 tokens, capped at 2
    expect(lim.tryConsumeRate('g')).toBe(true);
    expect(lim.tryConsumeRate('g')).toBe(true);
    expect(lim.tryConsumeRate('g')).toBe(false);
  });

  it('guards negative elapsed (no backward-clock token loss)', () => {
    const c = clock(1000);
    const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, ratePerMinute: 1 }, c.now);
    lim.tryConsumeRate('g'); // consume 1 → 0 tokens
    // simulate slight clock jitter (elapsed < 0 clamped to 0)
    c.advance(-500);
    expect(lim.tryConsumeRate('g')).toBe(false); // no artificial tokens added
  });

  it('grants are isolated: separate buckets per grantId', () => {
    const c = clock(0);
    const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, ratePerMinute: 1 }, c.now);
    expect(lim.tryConsumeRate('g1')).toBe(true);
    expect(lim.tryConsumeRate('g1')).toBe(false);
    expect(lim.tryConsumeRate('g2')).toBe(true);  // separate bucket
    expect(lim.tryConsumeRate('g2')).toBe(false);
  });
});

// ---------------------------------------------------------------------------
// tryConsumeSkillBudget (rolling-hour window)
// ---------------------------------------------------------------------------
describe('tryConsumeSkillBudget', () => {
  it('allows up to skillBudgetPerHour then returns false', () => {
    const c = clock(0);
    const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, skillBudgetPerHour: 3 }, c.now);
    expect(lim.tryConsumeSkillBudget('g')).toBe(true);
    expect(lim.tryConsumeSkillBudget('g')).toBe(true);
    expect(lim.tryConsumeSkillBudget('g')).toBe(true);
    expect(lim.tryConsumeSkillBudget('g')).toBe(false);
  });

  it('frees budget after 1h rolling window', () => {
    const c = clock(0);
    const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, skillBudgetPerHour: 2 }, c.now);
    expect(lim.tryConsumeSkillBudget('g')).toBe(true);
    expect(lim.tryConsumeSkillBudget('g')).toBe(true);
    expect(lim.tryConsumeSkillBudget('g')).toBe(false); // exhausted

    c.advance(3_600_001); // 1h+1ms: all timestamps expire
    expect(lim.tryConsumeSkillBudget('g')).toBe(true);
    expect(lim.tryConsumeSkillBudget('g')).toBe(true);
    expect(lim.tryConsumeSkillBudget('g')).toBe(false);
  });

  it('partial window-free: only expired entries pruned', () => {
    const c = clock(0);
    const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, skillBudgetPerHour: 3 }, c.now);
    expect(lim.tryConsumeSkillBudget('g')).toBe(true); // t=0
    expect(lim.tryConsumeSkillBudget('g')).toBe(true); // t=0
    c.advance(3_600_001);
    // The two t=0 timestamps are now expired; can consume 3 more
    expect(lim.tryConsumeSkillBudget('g')).toBe(true);
    expect(lim.tryConsumeSkillBudget('g')).toBe(true);
    expect(lim.tryConsumeSkillBudget('g')).toBe(true);
    expect(lim.tryConsumeSkillBudget('g')).toBe(false);
  });

  it('grants are isolated', () => {
    const c = clock(0);
    const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, skillBudgetPerHour: 1 }, c.now);
    expect(lim.tryConsumeSkillBudget('g1')).toBe(true);
    expect(lim.tryConsumeSkillBudget('g1')).toBe(false);
    expect(lim.tryConsumeSkillBudget('g2')).toBe(true);
  });
});

// ---------------------------------------------------------------------------
// tryReserveConcurrency / releaseConcurrency
// ---------------------------------------------------------------------------
describe('tryReserveConcurrency', () => {
  it('caps synchronously and respects the DB backstop (brief canonical test)', () => {
    const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, maxConcurrentTasks: 2 }, () => 0);
    expect(lim.tryReserveConcurrency('g', 0)).toBe(true);
    expect(lim.tryReserveConcurrency('g', 0)).toBe(true);
    expect(lim.tryReserveConcurrency('g', 0)).toBe(false);   // 2 reserved
    lim.releaseConcurrency('g');
    expect(lim.tryReserveConcurrency('g', 0)).toBe(true);    // freed one

    const lim2 = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, maxConcurrentTasks: 2 }, () => 0);
    expect(lim2.tryReserveConcurrency('g', 2)).toBe(false);  // DB already at cap
  });

  it('max(dbCount, reserved) semantics: dbCount=1 + reserved=1 → effective 1, second reserve succeeds', () => {
    const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, maxConcurrentTasks: 2 }, () => 0);
    expect(lim.tryReserveConcurrency('g', 1)).toBe(true);  // max(1,0)=1 < 2 → ok, reserved=1
    expect(lim.tryReserveConcurrency('g', 1)).toBe(true);  // max(1,1)=1 < 2 → ok, reserved=2
    expect(lim.tryReserveConcurrency('g', 1)).toBe(false); // max(1,2)=2 >= 2 → fail
  });

  it('dbCount alone triggers the cap (DB backstop without in-memory)', () => {
    const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, maxConcurrentTasks: 3 }, () => 0);
    expect(lim.tryReserveConcurrency('g', 3)).toBe(false); // max(3,0)=3 >= 3
    expect(lim.tryReserveConcurrency('g', 2)).toBe(true);  // max(2,0)=2 < 3 → ok
  });

  it('releaseConcurrency decrements and floors at 0', () => {
    const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, maxConcurrentTasks: 3 }, () => 0);
    lim.tryReserveConcurrency('g', 0); // reserved=1
    lim.tryReserveConcurrency('g', 0); // reserved=2
    lim.releaseConcurrency('g');       // reserved=1
    lim.releaseConcurrency('g');       // reserved=0 → delete
    lim.releaseConcurrency('g');       // noop (already 0)
    // After over-release, should still be able to reserve normally
    expect(lim.tryReserveConcurrency('g', 0)).toBe(true);
    expect(lim.tryReserveConcurrency('g', 0)).toBe(true);
    expect(lim.tryReserveConcurrency('g', 0)).toBe(true);
    expect(lim.tryReserveConcurrency('g', 0)).toBe(false);
  });

  it('per-grant isolation for concurrency', () => {
    const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, maxConcurrentTasks: 1 }, () => 0);
    expect(lim.tryReserveConcurrency('g1', 0)).toBe(true);
    expect(lim.tryReserveConcurrency('g1', 0)).toBe(false);
    expect(lim.tryReserveConcurrency('g2', 0)).toBe(true); // separate counter
  });
});

// ---------------------------------------------------------------------------
// tryAcquireResubscribe / releaseResubscribe
// ---------------------------------------------------------------------------
describe('tryAcquireResubscribe / releaseResubscribe', () => {
  it('caps at maxConcurrentResubscribe then returns false', () => {
    const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, maxConcurrentResubscribe: 2 }, () => 0);
    expect(lim.tryAcquireResubscribe('g')).toBe(true);
    expect(lim.tryAcquireResubscribe('g')).toBe(true);
    expect(lim.tryAcquireResubscribe('g')).toBe(false);
  });

  it('releaseResubscribe frees a slot', () => {
    const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, maxConcurrentResubscribe: 1 }, () => 0);
    expect(lim.tryAcquireResubscribe('g')).toBe(true);
    expect(lim.tryAcquireResubscribe('g')).toBe(false);
    lim.releaseResubscribe('g');
    expect(lim.tryAcquireResubscribe('g')).toBe(true);
  });

  it('per-grant isolation', () => {
    const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, maxConcurrentResubscribe: 1 }, () => 0);
    expect(lim.tryAcquireResubscribe('g1')).toBe(true);
    expect(lim.tryAcquireResubscribe('g1')).toBe(false);
    expect(lim.tryAcquireResubscribe('g2')).toBe(true); // separate counter
  });

  it('over-release floors at 0 and does not corrupt subsequent acquires', () => {
    const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, maxConcurrentResubscribe: 1 }, () => 0);
    lim.tryAcquireResubscribe('g');
    lim.releaseResubscribe('g');
    lim.releaseResubscribe('g'); // noop
    expect(lim.tryAcquireResubscribe('g')).toBe(true);
    expect(lim.tryAcquireResubscribe('g')).toBe(false);
  });
});

// ---------------------------------------------------------------------------
// Opportunistic eviction
// ---------------------------------------------------------------------------
describe('eviction', () => {
  it('evicts a stale full rate bucket after >1h idle (Map size shrinks)', () => {
    const c = clock(0);
    const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, ratePerMinute: 2 }, c.now);

    // Populate two grant buckets
    lim.tryConsumeRate('g1');
    lim.tryConsumeRate('g2');
    expect(lim.rateBucketSize()).toBe(2);

    // Advance >1h so both buckets become full and stale
    c.advance(3_600_001);

    // Accessing g1 triggers eviction of its stale entry and re-creates it
    const result = lim.tryConsumeRate('g1');
    expect(result).toBe(true); // fresh bucket → 1 token consumed
    // g1 was evicted then re-created (size stays 2: g1 re-created, g2 still there)
    // The key assertion: g1's bucket was deleted then re-added; g2 is still stale
    expect(lim.rateBucketSize()).toBe(2);
  });

  it('evicted stale bucket starts with fresh capacity', () => {
    const c = clock(0);
    const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, ratePerMinute: 2 }, c.now);
    // Exhaust bucket
    lim.tryConsumeRate('g');
    lim.tryConsumeRate('g');
    expect(lim.tryConsumeRate('g')).toBe(false);

    // Advance >1h → bucket refills to capacity (2) and becomes stale → eviction
    c.advance(3_600_001);
    // Both tokens should be available (fresh bucket)
    expect(lim.tryConsumeRate('g')).toBe(true);
    expect(lim.tryConsumeRate('g')).toBe(true);
    expect(lim.tryConsumeRate('g')).toBe(false);
  });

  it('evicts empty skillHits arrays (pruned to empty on successful consume)', () => {
    const c = clock(0);
    const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, skillBudgetPerHour: 2 }, c.now);
    lim.tryConsumeSkillBudget('g');
    expect(lim.skillHitsSize()).toBe(1);

    // Advance >1h so the hit expires; new consume should work
    c.advance(3_600_001);
    const result = lim.tryConsumeSkillBudget('g');
    expect(result).toBe(true);
    // Array had 0 elements after prune + 1 new added → size=1
    expect(lim.skillHitsSize()).toBe(1);
  });
});
