/**
 * sf-learning: outcome-recorder + outcome-aggregator tests
 *
 * Uses Vitest with a minimal in-memory fake `db` that mimics the
 * node:sqlite DatabaseSync surface (`prepare(sql).run/get/all`, `exec`,
 * `transaction`). The fake parses just enough SQL to verify the
 * insert and aggregate semantics without spinning up real SQLite.
 */

import assert from "node:assert/strict";
import { test } from "vitest";
import { computeObservedScore } from "./bayesian-blender.mjs";
import {
	aggregateAllForUnitType,
	aggregateOutcomes,
	recentOutcomes,
	totalSamples,
} from "./outcome-aggregator.mjs";
import {
	recordOutcome,
	recordOutcomeBatch,
	validateOutcome,
} from "./outcome-recorder.mjs";

// ---------------------------------------------------------------------------
// Minimal in-memory fake of the SQLite surface consumed by sf-learning.
// ---------------------------------------------------------------------------

const INSERT_COLUMNS = [
	"model_id",
	"provider",
	"unit_type",
	"unit_id",
	"succeeded",
	"retries",
	"escalated",
	"verification_passed",
	"blocker_discovered",
	"duration_ms",
	"tokens_total",
	"cost_usd",
	"recorded_at",
];

function createFakeDb({
	includeTransaction = true,
	throwOnPrepare = false,
} = {}) {
	const rows = [];
	const execSql = [];
	let nextId = 1;

	function prepare(sql) {
		if (throwOnPrepare) {
			throw new Error("simulated db.prepare failure");
		}
		const normalized = sql.replace(/\s+/g, " ").trim().toLowerCase();

		if (normalized.startsWith("insert into llm_task_outcomes")) {
			return {
				run(...params) {
					const row = { id: nextId++ };
					INSERT_COLUMNS.forEach((col, i) => {
						row[col] = params[i];
					});
					rows.push(row);
					return { changes: 1, lastInsertRowid: row.id };
				},
			};
		}

		if (
			normalized.startsWith("select count(*) as sample_count") ||
			normalized.startsWith("select count(*) as total")
		) {
			return {
				get(...params) {
					return runAggregate(normalized, params, rows);
				},
			};
		}

		if (normalized.startsWith("select model_id, count(*) as sample_count")) {
			return {
				all(...params) {
					return runGroupedAggregate(normalized, params, rows);
				},
			};
		}

		if (normalized.startsWith("select id, model_id")) {
			return {
				all(...params) {
					return runRecentSelect(normalized, params, rows);
				},
			};
		}

		if (
			normalized.startsWith("create table") ||
			normalized.startsWith("create index")
		) {
			return {
				run() {
					return { changes: 0 };
				},
			};
		}

		throw new Error(`fake db: unsupported sql: ${normalized.slice(0, 80)}`);
	}

	function exec(sql) {
		execSql.push(sql);
		// no-op — schema bootstrap success path
	}

	function transaction(fn) {
		return function wrapped(...args) {
			return fn(...args);
		};
	}

	const db = {
		prepare,
		exec,
		_rows: rows,
		_execSql: execSql,
	};
	if (includeTransaction) {
		db.transaction = transaction;
	}
	return db;
}

function runAggregate(sql, params, rows) {
	if (sql.startsWith("select count(*) as total")) {
		const since = params[0];
		const filtered = rows.filter((r) => r.recorded_at > since);
		return { total: filtered.length };
	}

	// single-pair aggregate: model_id, unit_type, since
	const [modelId, unitType, since] = params;
	const filtered = rows.filter(
		(r) =>
			r.model_id === modelId &&
			r.unit_type === unitType &&
			r.recorded_at > since,
	);
	return summarize(filtered);
}

function runGroupedAggregate(_sql, params, rows) {
	const [unitType, since] = params;
	const filtered = rows.filter(
		(r) => r.unit_type === unitType && r.recorded_at > since,
	);
	const byModel = new Map();
	for (const row of filtered) {
		if (!byModel.has(row.model_id)) byModel.set(row.model_id, []);
		byModel.get(row.model_id).push(row);
	}
	const out = [];
	for (const [modelId, modelRows] of byModel) {
		out.push({ model_id: modelId, ...summarize(modelRows) });
	}
	return out;
}

function effectiveWeight(row) {
	if (row.succeeded === 1) return 1.0;
	const fm = row.failure_mode ?? null;
	if (fm === "rate_limit") return 0.7;
	if (fm === "quota_exhausted") return 0.2;
	if (fm === "auth_error") return 0.0;
	return 0.5;
}

function summarize(rows) {
	if (rows.length === 0) {
		return {
			sample_count: 0,
			success_rate: null,
			effective_success_rate: null,
			hard_failure_count: 0,
			avg_retries: null,
			verification_pass_rate: null,
			blocker_rate: null,
			escalation_rate: null,
			avg_duration_ms: null,
			avg_tokens: null,
			avg_cost_usd: null,
		};
	}
	const avg = (key, filterFn = null) => {
		const vals = rows
			.map((r) => r[key])
			.filter(
				(v) => v !== null && v !== undefined && (filterFn ? filterFn(v) : true),
			);
		if (vals.length === 0) return null;
		return vals.reduce((a, b) => a + b, 0) / vals.length;
	};
	const verificationVals = rows
		.map((r) => r.verification_passed)
		.filter((v) => v !== null && v !== undefined);
	const verification_pass_rate =
		verificationVals.length === 0
			? null
			: verificationVals.reduce((a, b) => a + b, 0) / verificationVals.length;

	const effective_success_rate =
		rows.reduce((sum, r) => sum + effectiveWeight(r), 0) / rows.length;

	const hard_failure_count = rows.filter(
		(r) =>
			r.failure_mode === "quota_exhausted" || r.failure_mode === "auth_error",
	).length;

	return {
		sample_count: rows.length,
		success_rate: avg("succeeded"),
		effective_success_rate,
		hard_failure_count,
		avg_retries: avg("retries"),
		verification_pass_rate,
		blocker_rate: avg("blocker_discovered"),
		escalation_rate: avg("escalated"),
		avg_duration_ms: avg("duration_ms"),
		avg_tokens: avg("tokens_total"),
		avg_cost_usd: avg("cost_usd"),
	};
}

function runRecentSelect(sql, params, rows) {
	const limit = params[params.length - 1];
	let filtered = [...rows];

	// crude WHERE parser — match against remaining params in order of "?"
	const filterParams = params.slice(0, -1);
	let pi = 0;
	if (sql.includes("unit_type = ?")) {
		const unitType = filterParams[pi++];
		filtered = filtered.filter((r) => r.unit_type === unitType);
	}
	if (sql.includes("model_id = ?")) {
		const modelId = filterParams[pi++];
		filtered = filtered.filter((r) => r.model_id === modelId);
	}
	filtered.sort((a, b) => b.recorded_at - a.recorded_at);
	return filtered.slice(0, limit);
}

// ---------------------------------------------------------------------------
// Fixture
// ---------------------------------------------------------------------------

function minimalOutcome(overrides = {}) {
	return {
		modelId: "kimi-coding/kimi-for-coding",
		provider: "kimi-coding",
		unitType: "execute-task",
		unitId: "M001/S01/T01",
		succeeded: true,
		...overrides,
	};
}

// ---------------------------------------------------------------------------
// validateOutcome
// ---------------------------------------------------------------------------

test("validateOutcome rejects missing required fields", () => {
	const result = validateOutcome({ modelId: "x", provider: "y" });
	assert.equal(result.valid, false);
	assert.ok(result.errors.some((e) => e.includes("unitType")));
	assert.ok(result.errors.some((e) => e.includes("unitId")));
	assert.ok(result.errors.some((e) => e.includes("succeeded")));
});

test("validateOutcome accepts minimal valid outcome", () => {
	const result = validateOutcome(minimalOutcome());
	assert.equal(result.valid, true);
	assert.deepEqual(result.errors, []);
});

test("validateOutcome rejects non-object input", () => {
	assert.equal(validateOutcome(null).valid, false);
	assert.equal(validateOutcome("nope").valid, false);
});

test("validateOutcome rejects negative retries", () => {
	const result = validateOutcome(minimalOutcome({ retries: -1 }));
	assert.equal(result.valid, false);
});

// ---------------------------------------------------------------------------
// recordOutcome
// ---------------------------------------------------------------------------

test("recordOutcome returns true on valid outcome", () => {
	const db = createFakeDb();
	const ok = recordOutcome(db, minimalOutcome());
	assert.equal(ok, true);
	assert.equal(db._rows.length, 1);
});

test("recordOutcome returns false on invalid outcome", () => {
	const db = createFakeDb();
	const ok = recordOutcome(db, { modelId: "x" });
	assert.equal(ok, false);
	assert.equal(db._rows.length, 0);
});

test("recordOutcome returns false when db.prepare throws", () => {
	const db = createFakeDb({ throwOnPrepare: true });
	const ok = recordOutcome(db, minimalOutcome());
	assert.equal(ok, false);
});

test("recordOutcome coerces booleans to 0/1", () => {
	const db = createFakeDb();
	recordOutcome(
		db,
		minimalOutcome({
			succeeded: true,
			escalated: false,
			verification_passed: true,
			blocker_discovered: false,
		}),
	);
	const row = db._rows[0];
	assert.equal(row.succeeded, 1);
	assert.equal(row.escalated, 0);
	assert.equal(row.verification_passed, 1);
	assert.equal(row.blocker_discovered, 0);
});

test("recordOutcome preserves null verification_passed", () => {
	const db = createFakeDb();
	recordOutcome(db, minimalOutcome({ verification_passed: null }));
	assert.equal(db._rows[0].verification_passed, null);
});

test("recordOutcome defaults recorded_at to Date.now()", () => {
	const db = createFakeDb();
	const before = Date.now();
	recordOutcome(db, minimalOutcome());
	const after = Date.now();
	const ts = db._rows[0].recorded_at;
	assert.ok(
		ts >= before && ts <= after,
		`timestamp ${ts} outside [${before}, ${after}]`,
	);
});

test("recordOutcome respects supplied recorded_at", () => {
	const db = createFakeDb();
	recordOutcome(db, minimalOutcome({ recorded_at: 12345 }));
	assert.equal(db._rows[0].recorded_at, 12345);
});

// ---------------------------------------------------------------------------
// recordOutcomeBatch
// ---------------------------------------------------------------------------

test("recordOutcomeBatch inserts multiple outcomes in one transaction", () => {
	const db = createFakeDb();
	const result = recordOutcomeBatch(db, [
		minimalOutcome({ unitId: "T01" }),
		minimalOutcome({ unitId: "T02" }),
		minimalOutcome({ unitId: "T03" }),
	]);
	assert.deepEqual(result, { inserted: 3, skipped: 0 });
	assert.equal(db._rows.length, 3);
});

test("recordOutcomeBatch skips invalid rows but inserts valid ones", () => {
	const db = createFakeDb();
	const result = recordOutcomeBatch(db, [
		minimalOutcome({ unitId: "T01" }),
		{ modelId: "broken" }, // missing required fields
		minimalOutcome({ unitId: "T02" }),
	]);
	assert.deepEqual(result, { inserted: 2, skipped: 1 });
});

test("recordOutcomeBatch handles empty array", () => {
	const db = createFakeDb();
	const result = recordOutcomeBatch(db, []);
	assert.deepEqual(result, { inserted: 0, skipped: 0 });
});

test("recordOutcomeBatch_when_no_transaction_helper_wraps_batch_with_sql_transaction", () => {
	const db = createFakeDb({ includeTransaction: false });
	const result = recordOutcomeBatch(db, [
		minimalOutcome({ unitId: "T01" }),
		minimalOutcome({ unitId: "T02" }),
	]);
	assert.deepEqual(result, { inserted: 2, skipped: 0 });
	assert.deepEqual(db._execSql, ["BEGIN IMMEDIATE", "COMMIT"]);
});

// ---------------------------------------------------------------------------
// aggregateOutcomes
// ---------------------------------------------------------------------------

test("aggregateOutcomes returns zeros when no samples", () => {
	const db = createFakeDb();
	const stats = aggregateOutcomes(db, "ghost-model", "execute-task");
	assert.equal(stats.sample_count, 0);
	assert.equal(stats.success_rate, 0);
	assert.equal(stats.verification_pass_rate, null);
	assert.equal(stats.window_days, 30);
});

test("aggregateOutcomes computes success_rate correctly from multiple rows", () => {
	const db = createFakeDb();
	const now = Date.now();
	// 3 successes, 1 failure → 0.75
	recordOutcome(
		db,
		minimalOutcome({ succeeded: true, recorded_at: now - 1000 }),
	);
	recordOutcome(
		db,
		minimalOutcome({ succeeded: true, recorded_at: now - 1000 }),
	);
	recordOutcome(
		db,
		minimalOutcome({ succeeded: true, recorded_at: now - 1000 }),
	);
	recordOutcome(
		db,
		minimalOutcome({ succeeded: false, recorded_at: now - 1000 }),
	);

	const stats = aggregateOutcomes(
		db,
		"kimi-coding/kimi-for-coding",
		"execute-task",
		{
			now,
		},
	);
	assert.equal(stats.sample_count, 4);
	assert.equal(stats.success_rate, 0.75);
});

test("aggregateOutcomes excludes rows outside the rolling window", () => {
	const db = createFakeDb();
	const now = Date.now();
	const oneDayMs = 24 * 60 * 60 * 1000;
	// inside window
	recordOutcome(
		db,
		minimalOutcome({ succeeded: true, recorded_at: now - oneDayMs }),
	);
	// outside 30-day window
	recordOutcome(
		db,
		minimalOutcome({ succeeded: false, recorded_at: now - 60 * oneDayMs }),
	);

	const stats = aggregateOutcomes(
		db,
		"kimi-coding/kimi-for-coding",
		"execute-task",
		{
			now,
			rollingDays: 30,
		},
	);
	assert.equal(stats.sample_count, 1);
	assert.equal(stats.success_rate, 1);
});

test("aggregateOutcomes verification_pass_rate is null when no verification data", () => {
	const db = createFakeDb();
	const now = Date.now();
	recordOutcome(
		db,
		minimalOutcome({ verification_passed: null, recorded_at: now - 1000 }),
	);
	const stats = aggregateOutcomes(
		db,
		"kimi-coding/kimi-for-coding",
		"execute-task",
		{
			now,
		},
	);
	assert.equal(stats.verification_pass_rate, null);
});

// ---------------------------------------------------------------------------
// aggregateAllForUnitType
// ---------------------------------------------------------------------------

test("aggregateAllForUnitType returns a Map keyed by modelId", () => {
	const db = createFakeDb();
	const now = Date.now();
	recordOutcome(
		db,
		minimalOutcome({
			modelId: "model-a",
			succeeded: true,
			recorded_at: now - 1000,
		}),
	);
	recordOutcome(
		db,
		minimalOutcome({
			modelId: "model-a",
			succeeded: false,
			recorded_at: now - 1000,
		}),
	);
	recordOutcome(
		db,
		minimalOutcome({
			modelId: "model-b",
			succeeded: true,
			recorded_at: now - 1000,
		}),
	);

	const ranking = aggregateAllForUnitType(db, "execute-task", { now });
	assert.ok(ranking instanceof Map);
	assert.equal(ranking.size, 2);
	assert.equal(ranking.get("model-a").sample_count, 2);
	assert.equal(ranking.get("model-a").success_rate, 0.5);
	assert.equal(ranking.get("model-b").sample_count, 1);
	assert.equal(ranking.get("model-b").success_rate, 1);
});

test("aggregateAllForUnitType returns empty Map when no rows", () => {
	const db = createFakeDb();
	const ranking = aggregateAllForUnitType(db, "ghost-unit");
	assert.equal(ranking.size, 0);
});

// ---------------------------------------------------------------------------
// totalSamples
// ---------------------------------------------------------------------------

test("totalSamples counts correctly across the rolling window", () => {
	const db = createFakeDb();
	const now = Date.now();
	const oneDayMs = 24 * 60 * 60 * 1000;
	recordOutcome(db, minimalOutcome({ recorded_at: now - 1000 }));
	recordOutcome(db, minimalOutcome({ recorded_at: now - 5 * oneDayMs }));
	recordOutcome(db, minimalOutcome({ recorded_at: now - 60 * oneDayMs })); // outside

	assert.equal(totalSamples(db, { now, rollingDays: 30 }), 2);
});

// ---------------------------------------------------------------------------
// recentOutcomes
// ---------------------------------------------------------------------------

test("recentOutcomes returns rows ordered by recorded_at DESC", () => {
	const db = createFakeDb();
	recordOutcome(db, minimalOutcome({ unitId: "T01", recorded_at: 1000 }));
	recordOutcome(db, minimalOutcome({ unitId: "T02", recorded_at: 3000 }));
	recordOutcome(db, minimalOutcome({ unitId: "T03", recorded_at: 2000 }));

	const recent = recentOutcomes(db, { limit: 10 });
	assert.equal(recent.length, 3);
	assert.equal(recent[0].unit_id, "T02");
	assert.equal(recent[1].unit_id, "T03");
	assert.equal(recent[2].unit_id, "T01");
});

test("recentOutcomes respects limit and filters", () => {
	const db = createFakeDb();
	recordOutcome(
		db,
		minimalOutcome({
			modelId: "a",
			unitType: "execute-task",
			recorded_at: 1000,
		}),
	);
	recordOutcome(
		db,
		minimalOutcome({
			modelId: "b",
			unitType: "execute-task",
			recorded_at: 2000,
		}),
	);
	recordOutcome(
		db,
		minimalOutcome({ modelId: "a", unitType: "plan-slice", recorded_at: 3000 }),
	);

	const filtered = recentOutcomes(db, {
		limit: 10,
		unitType: "execute-task",
		modelId: "a",
	});
	assert.equal(filtered.length, 1);
	assert.equal(filtered[0].model_id, "a");
	assert.equal(filtered[0].unit_type, "execute-task");
});

// ---------------------------------------------------------------------------
// effective_success_rate and hard_failure_count
// ---------------------------------------------------------------------------

test("aggregateOutcomes_rate_limit_failures_rank_higher_than_quota_exhausted", () => {
	const now = Date.now();
	const dbRateLimit = createFakeDb();
	const dbQuota = createFakeDb();

	// model with only rate_limit failures (weight 0.7)
	dbRateLimit._rows.push(
		{
			id: 1,
			model_id: "model-x",
			provider: "p",
			unit_type: "execute-task",
			unit_id: "T01",
			succeeded: 0,
			failure_mode: "rate_limit",
			retries: 0,
			escalated: 0,
			verification_passed: null,
			blocker_discovered: 0,
			duration_ms: 100,
			tokens_total: 10,
			cost_usd: 0,
			recorded_at: now - 1000,
		},
		{
			id: 2,
			model_id: "model-x",
			provider: "p",
			unit_type: "execute-task",
			unit_id: "T02",
			succeeded: 0,
			failure_mode: "rate_limit",
			retries: 0,
			escalated: 0,
			verification_passed: null,
			blocker_discovered: 0,
			duration_ms: 100,
			tokens_total: 10,
			cost_usd: 0,
			recorded_at: now - 2000,
		},
	);
	// model with only quota_exhausted failures (weight 0.2)
	dbQuota._rows.push(
		{
			id: 1,
			model_id: "model-x",
			provider: "p",
			unit_type: "execute-task",
			unit_id: "T01",
			succeeded: 0,
			failure_mode: "quota_exhausted",
			retries: 0,
			escalated: 0,
			verification_passed: null,
			blocker_discovered: 0,
			duration_ms: 100,
			tokens_total: 10,
			cost_usd: 0,
			recorded_at: now - 1000,
		},
		{
			id: 2,
			model_id: "model-x",
			provider: "p",
			unit_type: "execute-task",
			unit_id: "T02",
			succeeded: 0,
			failure_mode: "quota_exhausted",
			retries: 0,
			escalated: 0,
			verification_passed: null,
			blocker_discovered: 0,
			duration_ms: 100,
			tokens_total: 10,
			cost_usd: 0,
			recorded_at: now - 2000,
		},
	);

	const statsRateLimit = aggregateOutcomes(
		dbRateLimit,
		"model-x",
		"execute-task",
		{ now },
	);
	const statsQuota = aggregateOutcomes(dbQuota, "model-x", "execute-task", {
		now,
	});

	assert.ok(
		statsRateLimit.effective_success_rate > statsQuota.effective_success_rate,
		`rate_limit (${statsRateLimit.effective_success_rate}) should exceed quota_exhausted (${statsQuota.effective_success_rate})`,
	);
	assert.ok(Math.abs(statsRateLimit.effective_success_rate - 0.7) < 1e-9);
	assert.ok(Math.abs(statsQuota.effective_success_rate - 0.2) < 1e-9);
});

test("computeObservedScore_uses_effective_success_rate_when_available", () => {
	// Stats with low success_rate but high effective_success_rate (rate_limit failures)
	const statsWithEffective = {
		sample_count: 10,
		success_rate: 0.0,
		effective_success_rate: 0.7,
		hard_failure_count: 0,
		avg_retries: 0,
		verification_pass_rate: null,
		blocker_rate: 0,
	};
	// Stats with same success_rate but no effective_success_rate
	const statsWithoutEffective = {
		sample_count: 10,
		success_rate: 0.0,
		avg_retries: 0,
		verification_pass_rate: null,
		blocker_rate: 0,
	};

	const scoreWith = computeObservedScore(statsWithEffective);
	const scoreWithout = computeObservedScore(statsWithoutEffective);

	assert.ok(
		scoreWith > scoreWithout,
		`score with effective_success_rate (${scoreWith}) should exceed score without (${scoreWithout})`,
	);
});

test("computeObservedScore_applies_0_5x_penalty_when_hard_failure_rate_exceeds_50_percent", () => {
	const baseStats = {
		sample_count: 10,
		success_rate: 0.3,
		effective_success_rate: 0.3,
		hard_failure_count: 0,
		avg_retries: 0,
		verification_pass_rate: null,
		blocker_rate: 0,
	};
	const hardFailureStats = {
		...baseStats,
		hard_failure_count: 6, // 6/10 = 60% > 50%
	};
	const borderlineStats = {
		...baseStats,
		hard_failure_count: 5, // 5/10 = 50%, not > 50%, no penalty
	};

	const baseScore = computeObservedScore(baseStats);
	const penalizedScore = computeObservedScore(hardFailureStats);
	const borderlineScore = computeObservedScore(borderlineStats);

	assert.ok(
		penalizedScore < baseScore,
		`penalized score (${penalizedScore}) should be less than base score (${baseScore})`,
	);
	assert.ok(
		Math.abs(penalizedScore - baseScore * 0.5) < 1e-9,
		`penalized score (${penalizedScore}) should be exactly half of base score (${baseScore})`,
	);
	// Exactly 50% is NOT > 50%, so no penalty
	assert.ok(
		Math.abs(borderlineScore - baseScore) < 1e-9,
		`borderline (50%) should not be penalized: ${borderlineScore} vs ${baseScore}`,
	);
});
