/**
 * scoring.test.js — multi-signal ranking helpers.
 *
 * Pins (against the production-tested values from Mem0 / ChatGPT):
 *   - Identity scorer when no signals enabled (backwards compat)
 *   - Sigmoid recency: maxFactor at age=0, midpoint at age=halfLife,
 *     minFactor at age >> halfLife
 *   - Access boost saturates (log curve, clamped at maxFactor)
 *   - Missing metadata → identity (no boost, no crash)
 *   - Composition is multiplicative, clamped to factor range
 *   - Tunable via opts (halfLifeDays, weight, etc.)
 *   - Deterministic via `now` override
 */

import { describe, test, expect } from '@jest/globals';
import { makeScoringFn, sigmoidRecencyFactor } from '../scoring.js';

const NOW = Date.parse('2026-05-26T12:00:00Z');
const DAY = 24 * 60 * 60 * 1000;

function daysAgo(n) {
  return new Date(NOW - n * DAY).toISOString();
}

describe('makeScoringFn — identity / no-op cases', () => {
  test('no opts → returns base score unchanged', () => {
    const fn = makeScoringFn();
    expect(fn(0.42, {})).toBe(0.42);
    expect(fn(0.99, { createdAt: daysAgo(1) })).toBe(0.99);
  });

  test('missing metadata → returns base score (no crash)', () => {
    const fn = makeScoringFn({ recency: true, access: true, now: NOW });
    expect(fn(0.5, null)).toBe(0.5);
    expect(fn(0.5, undefined)).toBe(0.5);
  });

  test('metadata without timestamps → no recency boost applied', () => {
    const fn = makeScoringFn({ recency: true, now: NOW });
    expect(fn(0.5, { agentId: 'a' })).toBe(0.5);
  });

  test('metadata.accessCount === 0 → no access boost applied', () => {
    const fn = makeScoringFn({ access: true });
    expect(fn(0.5, { accessCount: 0 })).toBe(0.5);
    expect(fn(0.5, {})).toBe(0.5);
  });
});

describe('sigmoidRecencyFactor — shape', () => {
  const opts = { halfLifeDays: 7, minFactor: 0.3, maxFactor: 1.5 };

  test('age=0 → near maxFactor', () => {
    expect(sigmoidRecencyFactor(0, opts)).toBeGreaterThan(1.45);
    expect(sigmoidRecencyFactor(0, opts)).toBeLessThanOrEqual(1.5);
  });

  test('age=halfLife → midpoint (≈0.9)', () => {
    const mid = (opts.minFactor + opts.maxFactor) / 2;
    expect(sigmoidRecencyFactor(7, opts)).toBeCloseTo(mid, 5);
  });

  test('age=2×halfLife → near minFactor (≈0.3)', () => {
    expect(sigmoidRecencyFactor(14, opts)).toBeLessThan(0.35);
    expect(sigmoidRecencyFactor(14, opts)).toBeGreaterThanOrEqual(0.3);
  });

  test('age=very large → approaches minFactor asymptote', () => {
    expect(sigmoidRecencyFactor(365, opts)).toBeCloseTo(0.3, 4);
  });

  test('monotonically decreasing with age', () => {
    let prev = Infinity;
    for (const age of [0, 1, 3, 7, 14, 30, 90]) {
      const f = sigmoidRecencyFactor(age, opts);
      expect(f).toBeLessThan(prev);
      prev = f;
    }
  });

  test('honours custom halfLifeDays', () => {
    const fast = { ...opts, halfLifeDays: 1 };
    const slow = { ...opts, halfLifeDays: 30 };
    // At age=7, fast decay is deep into damped territory; slow decay near max.
    expect(sigmoidRecencyFactor(7, fast)).toBeLessThan(0.4);
    expect(sigmoidRecencyFactor(7, slow)).toBeGreaterThan(1.4);
  });

  test('honours custom min/max factor range', () => {
    const custom = { halfLifeDays: 7, minFactor: 0.5, maxFactor: 2.0 };
    expect(sigmoidRecencyFactor(0, custom)).toBeGreaterThan(1.9);
    expect(sigmoidRecencyFactor(7, custom)).toBeCloseTo(1.25, 5);   // midpoint
    expect(sigmoidRecencyFactor(100, custom)).toBeCloseTo(0.5, 4);
  });
});

describe('makeScoringFn — recency end-to-end', () => {
  test('today vs. month-ago: today scores higher', () => {
    const fn = makeScoringFn({ recency: true, now: NOW });
    const base = 0.7;
    const today = fn(base, { createdAt: daysAgo(0) });
    const monthAgo = fn(base, { createdAt: daysAgo(30) });
    expect(today).toBeGreaterThan(monthAgo);
  });

  test('a very-fresh weak match can beat a stale strong match', () => {
    const fn = makeScoringFn({ recency: true, now: NOW });
    // Old item with strong cosine (0.85)
    const oldStrong = fn(0.85, { createdAt: daysAgo(60) });
    // Fresh item with weaker cosine (0.65)
    const freshWeak = fn(0.65, { createdAt: daysAgo(0) });
    // The recency boost matters but shouldn't fully invert massive cosine gaps.
    // Range factor in [0.3, 1.5] means strongest possible boost is 1.5× max.
    // 0.85 * 0.3 (oldest) = 0.255; 0.65 * 1.5 = 0.975 → fresh-weak wins big.
    expect(freshWeak).toBeGreaterThan(oldStrong);
  });

  test('prefers updatedAt over createdAt when both present', () => {
    const fn = makeScoringFn({ recency: true, now: NOW });
    const ancient = fn(0.5, { createdAt: daysAgo(365), updatedAt: daysAgo(1) });
    const stale   = fn(0.5, { createdAt: daysAgo(1), updatedAt: daysAgo(365) });
    expect(ancient).toBeGreaterThan(stale);   // updatedAt=1 day ago wins
  });

  test('falls back to indexedAt when createdAt/updatedAt missing', () => {
    const fn = makeScoringFn({ recency: true, now: NOW });
    const recent = fn(0.5, { indexedAt: daysAgo(1) });
    const old    = fn(0.5, { indexedAt: daysAgo(60) });
    expect(recent).toBeGreaterThan(old);
  });
});

describe('makeScoringFn — access boost', () => {
  test('higher access count → higher score, monotonic', () => {
    const fn = makeScoringFn({ access: true });
    const s0 = fn(0.5, { accessCount: 0 });
    const s1 = fn(0.5, { accessCount: 1 });
    const s10 = fn(0.5, { accessCount: 10 });
    const s100 = fn(0.5, { accessCount: 100 });
    expect(s1).toBeGreaterThan(s0);
    expect(s10).toBeGreaterThan(s1);
    expect(s100).toBeGreaterThan(s10);
  });

  test('saturation comes from the maxFactor clamp (default 1.5)', () => {
    const fn = makeScoringFn({ access: true });
    const s100  = fn(1.0, { accessCount: 100 });    // unclamped boost ~1.46
    const s10k  = fn(1.0, { accessCount: 10_000 }); // unclamped boost ~1.92 → clamped
    const s10M  = fn(1.0, { accessCount: 10_000_000 }); // also clamped
    expect(s100).toBeLessThan(s10k);
    expect(s10k).toBeCloseTo(s10M, 6);   // both clamped to maxFactor
    expect(s10k).toBeCloseTo(1.5, 6);
  });

  test('clamped at maxFactor — pathological access counts cannot blow up', () => {
    const fn = makeScoringFn({ access: { weight: 1.0, maxFactor: 1.5 } });
    // ln(1 + huge) is large; without clamping, factor would explode.
    expect(fn(1.0, { accessCount: 1e9 })).toBeLessThanOrEqual(1.5);
  });

  test('custom weight tunes the curve', () => {
    const gentle = makeScoringFn({ access: { weight: 0.01 } });
    const strong = makeScoringFn({ access: { weight: 1.0 } });
    expect(strong(0.5, { accessCount: 10 })).toBeGreaterThan(gentle(0.5, { accessCount: 10 }));
  });
});

describe('makeScoringFn — composition (recency × access)', () => {
  test('combines multiplicatively', () => {
    const fn = makeScoringFn({ recency: true, access: true, now: NOW });
    const both    = fn(0.5, { createdAt: daysAgo(0), accessCount: 5 });
    const onlyRec = fn(0.5, { createdAt: daysAgo(0), accessCount: 0 });
    expect(both).toBeGreaterThan(onlyRec);
  });

  test('clamping range: composed factor cannot drive score above maxFactor × maxFactor', () => {
    const fn = makeScoringFn({ recency: true, access: true, now: NOW });
    // Best-case row: brand new + heavily accessed.
    const best = fn(1.0, { createdAt: daysAgo(0), accessCount: 1e6 });
    // Recency caps at ~1.5, access caps at 1.5 → 2.25 ceiling.
    expect(best).toBeLessThanOrEqual(2.26);
  });

  test('deterministic via `now` override', () => {
    const a = makeScoringFn({ recency: true, now: NOW });
    const b = makeScoringFn({ recency: true, now: NOW });
    expect(a(0.5, { createdAt: daysAgo(3) })).toBe(b(0.5, { createdAt: daysAgo(3) }));
  });
});
