import { describe, it, expect } from 'vitest';
import { fitTeamDc, teamDcLambdas, poolMatrices, type DcMatch } from './teamDc';
import { scoreMatrix } from './poisson';

function synth(): DcMatch[] {
  // Strong beats Weak repeatedly; Mid splits with both.
  const ms: DcMatch[] = [];
  for (let i = 0; i < 30; i++) {
    ms.push({ daysAgo: i * 10, home: 'Strong', away: 'Weak', homeGoals: 3, awayGoals: 0, neutral: true });
    ms.push({ daysAgo: i * 10, home: 'Mid', away: 'Strong', homeGoals: 0, awayGoals: 2, neutral: true });
    ms.push({ daysAgo: i * 10, home: 'Weak', away: 'Mid', homeGoals: 0, awayGoals: 2, neutral: true });
  }
  return ms;
}

describe('fitTeamDc', () => {
  const p = fitTeamDc(synth(), 1, 5);

  it('ranks attack and defence sensibly', () => {
    expect(p.attack['Strong']!).toBeGreaterThan(p.attack['Mid']!);
    expect(p.attack['Mid']!).toBeGreaterThan(p.attack['Weak']!);
    expect(p.defence['Strong']!).toBeLessThan(p.defence['Weak']!); // lower = concedes less
  });

  it('keeps strengths normalized around 1', () => {
    const atts = Object.values(p.attack);
    const mean = atts.reduce((s, x) => s + x, 0) / atts.length;
    expect(mean).toBeCloseTo(1, 6);
    expect(p.mu).toBeGreaterThan(0.5);
    expect(p.mu).toBeLessThan(3);
  });

  it('predicts higher λ for the stronger side and falls back for unknowns', () => {
    const { lambdaHome, lambdaAway } = teamDcLambdas(p, 'Strong', 'Weak', true);
    expect(lambdaHome).toBeGreaterThan(lambdaAway);
    const fb = teamDcLambdas(p, 'Nowhere FC', 'Strong', true);
    expect(fb.lambdaHome).toBeGreaterThan(0); // unseen team gets average strengths
  });

  it('shrinkage pulls thin histories toward average', () => {
    const thin: DcMatch[] = [{ daysAgo: 1, home: 'A', away: 'B', homeGoals: 5, awayGoals: 0, neutral: true }];
    const loose = fitTeamDc(thin, 0, 1);
    const tight = fitTeamDc(thin, 0, 50);
    expect(Math.abs(tight.attack['A']! - 1)).toBeLessThan(Math.abs(loose.attack['A']! - 1));
  });

  it('home multiplier learned from non-neutral games', () => {
    const ms: DcMatch[] = [];
    for (let i = 0; i < 60; i++) {
      ms.push({ daysAgo: i, home: 'X', away: 'Y', homeGoals: 2, awayGoals: 1, neutral: false });
      ms.push({ daysAgo: i, home: 'Y', away: 'X', homeGoals: 2, awayGoals: 1, neutral: false });
    }
    const hp = fitTeamDc(ms, 0, 5);
    expect(hp.homeMult).toBeGreaterThan(1.05); // symmetric fixtures, home side scores more
  });
});

describe('ensemble predictMatch', () => {
  it('pools Elo-DC and Team-DC and stays a valid distribution', async () => {
    const { predictMatch } = await import('./predict');
    const p = fitTeamDc(synth(), 1, 5);
    const model = {
      asOf: '2026-06-10',
      params: { goalsPerElo: 0.0057, avgGoals: 2.73, rho: -0.05, homeAdvElo: 70 },
      ratings: { Strong: 2000, Weak: 1700 },
      teamDc: p,
      ensembleW: 0.75,
    };
    const pred = predictMatch('Strong', 'Weak', model);
    expect(pred.probs.home + pred.probs.draw + pred.probs.away).toBeCloseTo(1, 6);
    expect(pred.probs.home).toBeGreaterThan(0.5);
    expect(pred.lambdaHome).toBeGreaterThan(pred.lambdaAway);
    // pure-Elo path still works when ensemble params are absent
    const pure = predictMatch('Strong', 'Weak', { ...model, teamDc: undefined as never, ensembleW: undefined as never });
    expect(pure.probs.home + pure.probs.draw + pure.probs.away).toBeCloseTo(1, 6);
  });
});

describe('poolMatrices', () => {
  it('renormalizes and interpolates between the two models', () => {
    const a = scoreMatrix(2.0, 0.8, -0.05);
    const b = scoreMatrix(1.0, 1.4, -0.05);
    const pooled = poolMatrices(a, b, 0.5);
    let sum = 0;
    for (const row of pooled) for (const v of row) sum += v;
    expect(sum).toBeCloseTo(1, 9);
    // w=1 returns A, w=0 returns B (up to renormalization)
    const pa = poolMatrices(a, b, 1);
    expect(pa[2]![0]!).toBeCloseTo(a[2]![0]!, 9);
  });
});
