/**
 * VF — Transaction-anomaly detection, P1 engine core.
 * #50 (greenfield build; the fabricated zeroDay engine was deleted in 40abe2d).
 *
 * In-process, pure-function harness (no DB, no route, no client). Proves the engine
 * is honest-by-construction before it touches anything:
 *   Leg 1  deterministic        — same inputs → identical score (anti-Math.random)
 *   Leg 2  planted-outlier-high — 5σ amount scores high, amount is the dominant contribution
 *   Leg 3  normal-low           — a baseline-typical txn scores low
 *   Leg 4  decomposes+reconciles— contributions are named and sum to the composite
 *   Leg 5  insufficient-data    — under-sampled baseline → insufficientData, NOT a score
 *   Leg 6  velocity both-dir    — burst fires velocity; lone txn does not
 *   Leg 7  channel both-dir     — unseen channel fires; usual channel does not
 *   Leg 8  null-partition       — null-tenant baseline never crosses into a real tenant
 *   Guard  non-positive amount  — refund/reversal is excluded, never NaN
 *
 * Exit 0 iff all pass; non-zero on any fail (the P1 gate).
 */

import { computeBaseline } from "../server/lib/transaction-anomaly/baseline";
import { scoreTransaction } from "../server/lib/transaction-anomaly/detector";
import { TxnRow, DEFAULT_BASELINE_CONFIG } from "../server/lib/transaction-anomaly/types";

const DAY = 24 * 60 * 60 * 1000;
const MIN = 60 * 1000;
const NOW = new Date("2026-07-14T12:00:00.000Z");

let txnSeq = 0;
function mkTxn(p: {
  userId: string;
  amount: number;
  channel?: string;
  recipientType?: string;
  tenantId: string | null;
  scoredAt: Date;
}): TxnRow {
  return {
    id: `txn-${++txnSeq}`,
    userId: p.userId,
    amount: p.amount,
    channel: p.channel ?? "MOBILE",
    recipientType: p.recipientType ?? "KNOWN",
    tenantId: p.tenantId,
    scoredAt: p.scoredAt,
  };
}

// ── Baseline set: 30 daily txns for u1/t1, deterministic amount spread (70k..130k UGX).
const baselineTxns: TxnRow[] = [];
for (let i = 1; i <= 30; i++) {
  baselineTxns.push(
    mkTxn({
      userId: "u1",
      amount: 100_000 * (0.7 + (i % 7) * 0.1),
      channel: "MOBILE",
      recipientType: "KNOWN",
      tenantId: "t1",
      scoredAt: new Date(NOW.getTime() - i * DAY),
    }),
  );
}
const baseT1 = computeBaseline(baselineTxns, "u1", "t1", NOW);

type Leg = { name: string; pass: boolean; detail: string };
const legs: Leg[] = [];
function record(name: string, pass: boolean, detail: string) {
  legs.push({ name, pass, detail });
}

// ── Leg 1 — deterministic
{
  const t = mkTxn({ userId: "u1", amount: Math.exp(baseT1.lnAmountMean + 5 * baseT1.lnAmountStd), tenantId: "t1", scoredAt: NOW });
  const a = scoreTransaction(t, baseT1, baselineTxns);
  const b = scoreTransaction(t, baseT1, baselineTxns);
  const equal = JSON.stringify(a) === JSON.stringify(b);
  record("Leg 1 deterministic", equal, equal ? `identical score=${a.score} across two calls` : "scores differed across identical calls");
}

// ── Leg 2 + Leg 4 — rich planted outlier (5σ amount + unseen channel + FLAGGED recipient)
const richOutlier = mkTxn({
  userId: "u1",
  amount: Math.exp(baseT1.lnAmountMean + 5 * baseT1.lnAmountStd),
  channel: "API",
  recipientType: "FLAGGED",
  tenantId: "t1",
  scoredAt: NOW,
});
const richScore = scoreTransaction(richOutlier, baseT1, baselineTxns);
{
  const dominant = [...richScore.contributions].sort((x, y) => y.contribution - x.contribution)[0];
  const high = richScore.score >= 50 && dominant?.feature === "amount";
  record("Leg 2 planted-outlier-high", high, `score=${richScore.score}, dominant=${dominant?.feature}(${dominant?.contribution.toFixed(1)})`);
}
{
  const named = richScore.contributions.every((c) => !!c.feature && !!c.detail);
  const sum = richScore.contributions.reduce((s, c) => s + c.contribution, 0);
  const reconciles = Math.min(100, Math.round(sum)) === richScore.score;
  const dominant = [...richScore.contributions].sort((x, y) => y.contribution - x.contribution)[0];
  const multi = richScore.contributions.length >= 2;
  record(
    "Leg 4 decomposes+reconciles",
    named && reconciles && multi && dominant.feature === "amount",
    `${richScore.contributions.length} contributions, Σ=${sum.toFixed(1)}→score ${richScore.score}, amount dominant`,
  );
}

// ── Leg 3 — normal-low
{
  const t = mkTxn({ userId: "u1", amount: Math.exp(baseT1.lnAmountMean), channel: "MOBILE", recipientType: "KNOWN", tenantId: "t1", scoredAt: NOW });
  const s = scoreTransaction(t, baseT1, baselineTxns);
  const low = !s.insufficientData && s.score <= 15;
  record("Leg 3 normal-low", low, `score=${s.score} (contributions=${s.contributions.length})`);
}

// ── Leg 5 — insufficient-data (u2 with 5 txns)
{
  const fewTxns: TxnRow[] = [];
  for (let i = 1; i <= 5; i++) fewTxns.push(mkTxn({ userId: "u2", amount: 100_000, tenantId: "t1", scoredAt: new Date(NOW.getTime() - i * DAY) }));
  const baseU2 = computeBaseline(fewTxns, "u2", "t1", NOW);
  const t = mkTxn({ userId: "u2", amount: 5_000_000, tenantId: "t1", scoredAt: NOW });
  const s = scoreTransaction(t, baseU2, fewTxns);
  const honest = s.insufficientData === true && s.score === 0 && s.contributions.length === 0;
  record("Leg 5 insufficient-data", honest, honest ? `insufficientData (samples=${baseU2.sampleCount}<${DEFAULT_BASELINE_CONFIG.minSamples}), score=0` : `expected insufficientData, got score=${s.score}`);
}

// ── Leg 6 — velocity both-directions
{
  const burst: TxnRow[] = [];
  for (let i = 0; i < 6; i++) burst.push(mkTxn({ userId: "u1", amount: Math.exp(baseT1.lnAmountMean), tenantId: "t1", scoredAt: new Date(NOW.getTime() - i * MIN) }));
  const posScore = scoreTransaction(burst[0], baseT1, burst);
  const posHasVel = posScore.contributions.some((c) => c.feature === "velocity");
  const lone = mkTxn({ userId: "u1", amount: Math.exp(baseT1.lnAmountMean), tenantId: "t1", scoredAt: NOW });
  const negScore = scoreTransaction(lone, baseT1, [lone]);
  const negNoVel = !negScore.contributions.some((c) => c.feature === "velocity");
  record("Leg 6 velocity both-dir", posHasVel && negNoVel, `burst(6/1h)→velocity present (score=${posScore.score}); lone→velocity absent (score=${negScore.score})`);
}

// ── Leg 7 — channel both-directions
{
  const pos = mkTxn({ userId: "u1", amount: Math.exp(baseT1.lnAmountMean), channel: "API", tenantId: "t1", scoredAt: NOW });
  const posScore = scoreTransaction(pos, baseT1, baselineTxns);
  const posHasChan = posScore.contributions.some((c) => c.feature === "channel");
  const neg = mkTxn({ userId: "u1", amount: Math.exp(baseT1.lnAmountMean), channel: "MOBILE", tenantId: "t1", scoredAt: NOW });
  const negScore = scoreTransaction(neg, baseT1, baselineTxns);
  const negNoChan = !negScore.contributions.some((c) => c.feature === "channel");
  record("Leg 7 channel both-dir", posHasChan && negNoChan, `unseen(API)→channel present (score=${posScore.score}); usual(MOBILE)→channel absent (score=${negScore.score})`);
}

// ── Leg 8 — null-partition isolation (u1 under tenantId=null must NOT mix with u1/t1)
{
  const nullTxns: TxnRow[] = [];
  for (let i = 1; i <= 25; i++) nullTxns.push(mkTxn({ userId: "u1", amount: 500_000 * (0.9 + (i % 5) * 0.05), channel: "WEB", tenantId: null, scoredAt: new Date(NOW.getTime() - i * DAY) }));
  const all = [...baselineTxns, ...nullTxns];
  const baseNull = computeBaseline(all, "u1", null, NOW);
  const baseT1Again = computeBaseline(all, "u1", "t1", NOW);
  const nullIsolated = baseNull.sampleCount === 25; // only null-partition rows
  const t1Unpolluted = baseT1Again.sampleCount === 30; // real tenant unaffected by null rows
  const meansDistinct = Math.abs(baseNull.lnAmountMean - baseT1Again.lnAmountMean) > 1.0; // ln(500k) vs ln(100k) ≈ 1.6
  record(
    "Leg 8 null-partition isolation",
    nullIsolated && t1Unpolluted && meansDistinct,
    `null baseline n=${baseNull.sampleCount} (mean≈${baseNull.lnAmountMean.toFixed(2)}); t1 baseline n=${baseT1Again.sampleCount} (mean≈${baseT1Again.lnAmountMean.toFixed(2)}) — partitions distinct`,
  );
}

// ── Guard — non-positive amount (refund/reversal) never NaN-poisons the score
{
  const refund = mkTxn({ userId: "u1", amount: -5_000, tenantId: "t1", scoredAt: NOW });
  const s = scoreTransaction(refund, baseT1, baselineTxns);
  const amountC = s.contributions.find((c) => c.feature === "amount");
  const safe = Number.isFinite(s.score) && !Number.isNaN(s.score) && !!amountC && /non-positive/.test(amountC.detail);
  record("Guard non-positive amount", safe, safe ? `score=${s.score} finite; amount excluded ("${amountC?.detail}")` : "non-positive amount produced NaN or was not excluded");
}

// ── Report
console.log("\n=== VF transaction-anomaly P1 ===\n");
let failed = 0;
for (const l of legs) {
  console.log(`${l.pass ? "PASS" : "FAIL"}  ${l.name} — ${l.detail}`);
  if (!l.pass) failed++;
}
console.log("\n--- planted-outlier decomposition (leg 2/4 concretely) ---");
console.log(`composite score = ${richScore.score}`);
for (const c of [...richScore.contributions].sort((a, b) => b.contribution - a.contribution)) {
  console.log(`  ${c.feature.padEnd(14)} +${c.contribution.toFixed(1).padStart(5)}  ${c.detail}`);
}
console.log(`\n${legs.length - failed}/${legs.length} legs passed.`);
if (failed > 0) {
  console.log(`\nP1 GATE: FAIL (${failed} leg(s) failed)`);
  process.exit(1);
}
console.log("\nP1 GATE: GREEN — engine deterministic, explainable, honest empty-state, tenant-isolated.");
