import { describe, it, expect } from 'vitest';
import {
  selectIdleEvictionTargets,
  selectLruEvictionTargets,
  decideRestart,
  selectOrphanWorktreeTargets,
  DEFAULT_IDLE_TTL_MS,
  DEFAULT_MAX_LIVE_BACKENDS,
  type PoolEntrySnapshot,
} from '../lifecycle-decisions.js';

/**
 * v2.6 Phase 3 — pure lifecycle decisions. No DB, no children, no timers; `now`
 * is injected. Models prune.ts:selectPruneTargets — the caller acts on the keys.
 */

const NOW = 1_000_000_000_000;

function entry(key: string, ageMs: number, busy = false): PoolEntrySnapshot {
  return { key, lastActiveAt: NOW - ageMs, busy };
}

describe('selectIdleEvictionTargets (3.1)', () => {
  it('evicts entries idle past the TTL', () => {
    const entries = [
      entry('a:opencode', DEFAULT_IDLE_TTL_MS + 1),
      entry('b:goose', DEFAULT_IDLE_TTL_MS - 1),
    ];
    expect(selectIdleEvictionTargets(entries, NOW)).toEqual(['a:opencode']);
  });

  it('never evicts a busy entry even when idle past the TTL', () => {
    const entries = [entry('a:opencode', DEFAULT_IDLE_TTL_MS * 10, /* busy */ true)];
    expect(selectIdleEvictionTargets(entries, NOW)).toEqual([]);
  });

  it('respects a custom TTL', () => {
    const entries = [entry('a:goose', 5_000), entry('b:qwen', 500)];
    expect(selectIdleEvictionTargets(entries, NOW, 1_000)).toEqual(['a:goose']);
  });

  it('treats exactly-at-TTL as evictable (>=)', () => {
    expect(selectIdleEvictionTargets([entry('a:x', 1_000)], NOW, 1_000)).toEqual(['a:x']);
  });

  it('returns empty for an empty pool', () => {
    expect(selectIdleEvictionTargets([], NOW)).toEqual([]);
  });
});

describe('selectLruEvictionTargets (3.4)', () => {
  it('returns nothing when at or under the cap', () => {
    const entries = [entry('a:x', 10), entry('b:y', 20)];
    expect(selectLruEvictionTargets(entries, 2)).toEqual([]);
    expect(selectLruEvictionTargets(entries, 5)).toEqual([]);
  });

  it('evicts the least-recently-used beyond the cap', () => {
    // oldest first: c (300ms ago) is LRU, then a (100ms), then b (10ms).
    const entries = [entry('a:x', 100), entry('b:y', 10), entry('c:z', 300)];
    expect(selectLruEvictionTargets(entries, 2)).toEqual(['c:z']);
  });

  it('evicts multiple LRU entries to reach the cap', () => {
    const entries = [
      entry('a:x', 100),
      entry('b:y', 10),
      entry('c:z', 300),
      entry('d:w', 200),
    ];
    // cap 1: must remove 3, oldest-first c(300), d(200), a(100).
    expect(selectLruEvictionTargets(entries, 1)).toEqual(['c:z', 'd:w', 'a:x']);
  });

  it('never evicts a busy entry even if it is the LRU', () => {
    // c is LRU but busy → it cannot be evicted; fall to the next-oldest (a).
    const entries = [entry('a:x', 100), entry('b:y', 10), entry('c:z', 300, true)];
    expect(selectLruEvictionTargets(entries, 2)).toEqual(['a:x']);
  });

  it('can transiently exceed the cap when too many are busy', () => {
    // cap 1, but both old entries busy → only the single idle one is evictable.
    const entries = [entry('a:x', 100, true), entry('c:z', 300, true), entry('b:y', 10)];
    expect(selectLruEvictionTargets(entries, 1)).toEqual(['b:y']);
  });

  it('uses the default cap when omitted', () => {
    const entries = Array.from({ length: DEFAULT_MAX_LIVE_BACKENDS + 1 }, (_, i) =>
      entry(`k${String(i).padStart(2, '0')}:a`, (i + 1) * 1000),
    );
    const evicted = selectLruEvictionTargets(entries);
    // exactly one over the default cap → evict the single LRU (largest age).
    expect(evicted).toHaveLength(1);
    expect(evicted[0]).toBe(`k${String(DEFAULT_MAX_LIVE_BACKENDS).padStart(2, '0')}:a`);
  });
});

describe('decideRestart (3.2, busy-aware)', () => {
  const base = {
    consecutiveFailures: 0,
    busy: false,
    unhealthyBusySince: 0,
    now: NOW,
    failureThreshold: 3,
    staleBusyGraceMs: 120_000,
  };

  it('does nothing when healthy', () => {
    expect(decideRestart({ ...base, processExited: false, healthy: true }))
      .toEqual({ action: 'none', reason: 'healthy' });
  });

  it('restarts immediately when the process exited', () => {
    expect(decideRestart({ ...base, processExited: true, busy: true }))
      .toEqual({ action: 'restart', reason: 'process-exited' });
  });

  it('waits below the failure threshold', () => {
    expect(decideRestart({ ...base, processExited: false, consecutiveFailures: 2 }))
      .toEqual({ action: 'wait', reason: 'below-threshold' });
  });

  it('restarts at the threshold when idle', () => {
    expect(decideRestart({ ...base, processExited: false, consecutiveFailures: 3 }))
      .toEqual({ action: 'restart', reason: 'threshold' });
  });

  it('defers a restart while busy within the grace window', () => {
    expect(decideRestart({
      ...base, processExited: false, consecutiveFailures: 5, busy: true,
      unhealthyBusySince: NOW - 1_000,
    })).toEqual({ action: 'wait', reason: 'busy-grace' });
  });

  it('force-restarts a busy backend after the stale-busy grace', () => {
    expect(decideRestart({
      ...base, processExited: false, consecutiveFailures: 5, busy: true,
      unhealthyBusySince: NOW - 120_001,
    })).toEqual({ action: 'restart', reason: 'stale-busy-grace' });
  });

  it('waits (busy-grace) when busy + threshold but the window just started', () => {
    // unhealthyBusySince === 0 means the caller is about to stamp it this cycle.
    expect(decideRestart({
      ...base, processExited: false, consecutiveFailures: 5, busy: true,
      unhealthyBusySince: 0,
    })).toEqual({ action: 'wait', reason: 'busy-grace' });
  });
});

describe('selectOrphanWorktreeTargets (3.4)', () => {
  it('skips dirs tracked by a live worktrees row', () => {
    const onDisk = [{ path: '/wt/sess-a', mtimeMs: NOW - 10_000_000 }];
    expect(selectOrphanWorktreeTargets(onDisk, new Set(['/wt/sess-a']), NOW, 1000)).toEqual([]);
  });

  it('reaps an untracked dir older than the grace', () => {
    const onDisk = [{ path: '/wt/sess-orphan', mtimeMs: NOW - 5000 }];
    expect(selectOrphanWorktreeTargets(onDisk, new Set(), NOW, 1000)).toEqual(['/wt/sess-orphan']);
  });

  it('never reaps a dir younger than the grace (mid-create race)', () => {
    const onDisk = [{ path: '/wt/sess-fresh', mtimeMs: NOW - 500 }];
    expect(selectOrphanWorktreeTargets(onDisk, new Set(), NOW, 1000)).toEqual([]);
  });

  it('mixes tracked, fresh, and orphaned correctly', () => {
    const onDisk = [
      { path: '/wt/sess-live', mtimeMs: NOW - 10_000 },
      { path: '/wt/sess-fresh', mtimeMs: NOW - 100 },
      { path: '/wt/sess-orphan', mtimeMs: NOW - 10_000 },
    ];
    expect(selectOrphanWorktreeTargets(onDisk, new Set(['/wt/sess-live']), NOW, 1000))
      .toEqual(['/wt/sess-orphan']);
  });
});
