/**
 * Tests für die Multi-Touch-Attribution-Engine.
 * Aufruf: `npx tsx scripts/test-multi-touch.ts`
 */
import { attributeConversion, aggregateAttribution, type AttributionTouch } from '../src/modules/stats/lib/attribution-index';

let failed = 0;
let passed = 0;
function check(name: string, cond: boolean, detail?: string) {
  if (cond) { passed++; console.log(`  ✓ ${name}`); }
  else { failed++; console.log(`  ✗ ${name}${detail ? ` — ${detail}` : ''}`); }
}
function section(t: string) { console.log(`\n${t}`); }
function nearEq(a: number, b: number, eps = 1e-6) { return Math.abs(a - b) < eps; }

// Reuse-able Touch-Fixtures
const day = 24 * 60 * 60 * 1000;
const T0 = new Date('2026-05-01T09:00:00Z'); // Fr
const conversionAt = new Date(T0.getTime() + 5 * day); // 6.5. Mi

function makeTouch(opts: { campaignId: string; variantId?: string | null; eventType?: 'click' | 'open'; ts: Date }): AttributionTouch {
  return {
    campaignId: opts.campaignId,
    variantId: opts.variantId ?? null,
    eventType: opts.eventType ?? 'click',
    createdAt: opts.ts,
  };
}

const touchA = makeTouch({ campaignId: 'cmp-A', ts: new Date(T0.getTime() + 0 * day) }); // Mo (eigentlich Fr)
const touchB = makeTouch({ campaignId: 'cmp-B', ts: new Date(T0.getTime() + 1 * day) });
const touchC = makeTouch({ campaignId: 'cmp-C', ts: new Date(T0.getTime() + 2 * day) });

// ─── last-click ─────────────────────────────────────────────────────────────
section('last-click');
{
  const r = attributeConversion({
    touches: [touchA, touchB, touchC],
    conversionAt,
    model: 'last-click',
    lookbackDays: 30,
  });
  check('1 attribuierter Touch', r.length === 1);
  check('Letzter Click = C', r[0]?.campaignId === 'cmp-C');
  check('Weight = 1.0', nearEq(r[0]?.weight ?? 0, 1.0));
}

// ─── first-click ────────────────────────────────────────────────────────────
section('first-click');
{
  const r = attributeConversion({
    touches: [touchA, touchB, touchC],
    conversionAt,
    model: 'first-click',
    lookbackDays: 30,
  });
  check('Erster Click = A', r[0]?.campaignId === 'cmp-A');
  check('Weight = 1.0', nearEq(r[0]?.weight ?? 0, 1.0));
}

// ─── linear ─────────────────────────────────────────────────────────────────
section('linear');
{
  const r = attributeConversion({
    touches: [touchA, touchB, touchC],
    conversionAt,
    model: 'linear',
    lookbackDays: 30,
  });
  check('3 Touches', r.length === 3);
  check('Jeder Weight ≈ 1/3', r.every((t) => nearEq(t.weight, 1 / 3)));
  check('Summe = 1.0', nearEq(r.reduce((s, t) => s + t.weight, 0), 1.0));
}

// ─── time-decay ─────────────────────────────────────────────────────────────
section('time-decay');
{
  const r = attributeConversion({
    touches: [touchA, touchB, touchC],
    conversionAt,
    model: 'time-decay',
    lookbackDays: 30,
  });
  check('3 Touches', r.length === 3);
  check('Summe = 1.0', nearEq(r.reduce((s, t) => s + t.weight, 0), 1.0));
  // C ist näher zur Conversion → höchstes Weight
  check('C > B > A', (r[2]?.weight ?? 0) > (r[1]?.weight ?? 0) && (r[1]?.weight ?? 0) > (r[0]?.weight ?? 0));
}

// ─── Lookback-Filter ────────────────────────────────────────────────────────
section('Lookback ignoriert alte Touches');
{
  // touchA: 5 Tage alt, touchB: 4 Tage, touchC: 3 Tage. Lookback 3.5 Tage → nur C
  const r = attributeConversion({
    touches: [touchA, touchB, touchC],
    conversionAt,
    model: 'linear',
    lookbackDays: 3.5,
  });
  check('Nur 1 Touch (innerhalb 3.5 Tage)', r.length === 1, `got ${r.length}`);
  check('Übriger Touch = C', r[0]?.campaignId === 'cmp-C');
}

// ─── Click bevorzugt vor Open ───────────────────────────────────────────────
section('Open wird ignoriert wenn Clicks vorhanden');
{
  const open = makeTouch({ campaignId: 'cmp-D', eventType: 'open', ts: new Date(T0.getTime() + 3 * day) });
  const r = attributeConversion({
    touches: [touchA, touchB, open, touchC],
    conversionAt,
    model: 'last-click',
    lookbackDays: 30,
  });
  check('Letzter Click = C (Open D wird übersprungen)', r[0]?.campaignId === 'cmp-C');
}

// ─── Open-Fallback wenn keine Clicks ────────────────────────────────────────
section('Open-Fallback wenn keine Clicks vorhanden');
{
  const open1 = makeTouch({ campaignId: 'cmp-E', eventType: 'open', ts: new Date(T0.getTime() + 0 * day) });
  const open2 = makeTouch({ campaignId: 'cmp-F', eventType: 'open', ts: new Date(T0.getTime() + 1 * day) });
  const r = attributeConversion({
    touches: [open1, open2],
    conversionAt,
    model: 'last-click',
    lookbackDays: 30,
  });
  check('Letzter Open = F', r[0]?.campaignId === 'cmp-F');
  check('Weight = 1.0 (Open-Fallback)', nearEq(r[0]?.weight ?? 0, 1.0));
}

// ─── Leere Touches → leeres Ergebnis ────────────────────────────────────────
section('Edge case: keine Touches');
{
  const r = attributeConversion({
    touches: [],
    conversionAt,
    model: 'linear',
    lookbackDays: 30,
  });
  check('Leeres Ergebnis', r.length === 0);
}

// ─── Aggregate ──────────────────────────────────────────────────────────────
section('aggregateAttribution: merge per campaignId');
{
  // Zwei touches auf gleicher Campaign + Variant
  const tA1 = makeTouch({ campaignId: 'cmp-A', variantId: 'v1', ts: new Date(T0.getTime() + 0 * day) });
  const tA2 = makeTouch({ campaignId: 'cmp-A', variantId: 'v1', ts: new Date(T0.getTime() + 1 * day) });
  const tB = makeTouch({ campaignId: 'cmp-B', variantId: 'v1', ts: new Date(T0.getTime() + 2 * day) });
  const r = attributeConversion({
    touches: [tA1, tA2, tB],
    conversionAt,
    model: 'linear',
    lookbackDays: 30,
  });
  const agg = aggregateAttribution(r);
  check('2 Gruppen (A, B)', agg.length === 2);
  const groupA = agg.find((g) => g.campaignId === 'cmp-A');
  check('A-Weight = 2/3 (2 von 3 Touches)', nearEq(groupA?.weight ?? 0, 2 / 3));
}

// ─── Tie-breaker deterministic ──────────────────────────────────────────────
section('Tie-breaker bei gleichem createdAt');
{
  const ts = new Date(T0.getTime() + 1 * day);
  const t1 = makeTouch({ campaignId: 'cmp-zzz', ts });
  const t2 = makeTouch({ campaignId: 'cmp-aaa', ts });
  const r = attributeConversion({
    touches: [t1, t2],
    conversionAt,
    model: 'last-click',
    lookbackDays: 30,
  });
  // Bei gleichem Timestamp und sort asc nach campaignId: aaa, zzz → letzter ist zzz
  check('Tie-Break: campaignId asc → letzter = zzz', r[0]?.campaignId === 'cmp-zzz');
}

console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
