import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { MemoryCache } from '../src/cache.js';
import { generateSandboxFixture } from '../eval/sandbox/generator.js';
import { buildSandboxPrompts } from '../eval/sandbox/prompts.js';
import { IngestionService } from '../src/ingest/service.js';
import { HashModelProvider } from '../src/model/provider.js';
import { DEFAULT_POLICY, resetRetrievalPolicyCache, setRetrievalPolicy } from '../src/retrieval/policy.js';
import { RetrievalService } from '../src/retrieval/service.js';
import { MemoryKnowledgeStore } from '../src/storage/memory-store.js';
/**
 * Phase 7 — RRF k candidates for grid search. Range chosen to cover both ends
 * of the practical curve:
 *   - 30 (sharper / top-rank dominates) for exact-match tasks like debugging.
 *   - 60 (default, balanced).
 *   - 120 (smoothest) where multi-source aggregation should outweigh rank.
 */
const RRF_K_CANDIDATES = [30, 45, 60, 80, 120];
async function main() {
    const options = parseArgs(process.argv.slice(2));
    if (options.help) {
        console.log(usage());
        return;
    }
    const fixture = generateSandboxFixture(options.seed);
    const prompts = buildSandboxPrompts(fixture).prompts;
    const full = await runCalibration(fixture, prompts);
    const calibration = buildCalibrationOutput(full.contributions, full.rrf, options.seed);
    if (options.dryRun) {
        console.log(JSON.stringify(calibration, null, 2));
        return;
    }
    await writeCalibration(options.output, calibration);
    printSummary(calibration);
}
async function runCalibration(fixture, prompts) {
    const { ingestion, retrieval, store, cache } = createServices();
    try {
        const idMap = await ingestFixture(fixture, ingestion);
        const contributions = await aggregateContributions(prompts, idMap, fixture, retrieval);
        // Phase 7 — RRF k grid search. Runs after weight aggregation so the
        // contribution pass uses the default global k (no per-task overrides),
        // then the k pass sweeps the candidate set with empty kByTaskType.
        const rrf = await calibrateRrfK(prompts, idMap, retrieval);
        return { contributions, rrf };
    }
    finally {
        resetRetrievalPolicyCache();
        await Promise.allSettled([store.close(), cache.close()]);
    }
}
async function calibrateRrfK(prompts, idMap, retrieval) {
    const globalHits = new Map();
    const perTaskHits = new Map();
    for (const k of RRF_K_CANDIDATES) {
        const policy = JSON.parse(JSON.stringify(DEFAULT_POLICY));
        policy.rrf.k = k;
        policy.rrf.kByTaskType = {};
        setRetrievalPolicy(policy);
        let totalHits = 0;
        for (const prompt of prompts) {
            const expectedIds = new Set([...prompt.expectedSelectedSandboxIds]
                .map((sandboxId) => idMap.get(sandboxId))
                .filter((id) => Boolean(id)));
            if (expectedIds.size === 0)
                continue;
            const pack = await retrieval.searchContext({
                prompt: prompt.prompt,
                project: prompt.project,
                files: prompt.files,
                symbols: prompt.symbols,
                errors: prompt.errors,
                taskType: prompt.taskType,
                noiseTolerance: 'strict',
                contextMode: 'compact',
                bypassCache: true,
            });
            const surfacedIds = pack.sections
                .flatMap((section) => section.items)
                .slice(0, 5)
                .map((item) => item.knowledgeId);
            const hit = surfacedIds.some((id) => expectedIds.has(id));
            if (hit) {
                totalHits += 1;
                const taskMap = perTaskHits.get(prompt.taskType) ?? new Map();
                taskMap.set(k, (taskMap.get(k) ?? 0) + 1);
                perTaskHits.set(prompt.taskType, taskMap);
            }
            else {
                // Make sure the task map for this task type exists with an entry for `k`
                // even on miss, so all (taskType, k) cells are comparable.
                const taskMap = perTaskHits.get(prompt.taskType) ?? new Map();
                taskMap.set(k, taskMap.get(k) ?? 0);
                perTaskHits.set(prompt.taskType, taskMap);
            }
        }
        globalHits.set(k, totalHits);
    }
    resetRetrievalPolicyCache();
    // Pick the k with the highest hit count; ties go to the default k=60 to keep
    // shipped behavior stable when calibration is indeterminate.
    const selectedGlobalK = selectBestK(globalHits);
    const selectedPerTaskK = new Map();
    for (const [taskType, hitsMap] of perTaskHits) {
        const best = selectBestK(hitsMap);
        // Only emit a per-task override if it differs from the global default —
        // shipping unnecessary overrides bloats the patch and obscures real signal.
        if (best !== selectedGlobalK) {
            selectedPerTaskK.set(taskType, best);
        }
    }
    return { globalHits, perTaskHits, selectedGlobalK, selectedPerTaskK };
}
function selectBestK(hits) {
    let bestK = DEFAULT_POLICY.rrf.k;
    let bestHits = hits.get(bestK) ?? -1;
    for (const [k, count] of hits) {
        if (count > bestHits) {
            bestK = k;
            bestHits = count;
        }
    }
    return bestK;
}
function createServices() {
    const store = new MemoryKnowledgeStore();
    const cache = new MemoryCache();
    const models = new HashModelProvider(CALIBRATION_CONFIG.model.embeddingDimensions);
    const ingestion = new IngestionService(store, models);
    const retrieval = new RetrievalService(store, cache, models, CALIBRATION_CONFIG);
    return { ingestion, retrieval, store, cache };
}
async function ingestFixture(fixture, ingestion) {
    const idMap = new Map();
    for (const knowledge of fixture.knowledge) {
        try {
            const stored = await ingestion.ingestKnowledge({
                project: knowledge.project,
                sourceType: knowledge.sourceType,
                sourceUri: knowledge.sourceUri,
                sourceTitle: knowledge.sourceTitle,
                itemType: knowledge.itemType,
                title: knowledge.title,
                summary: knowledge.summary,
                content: knowledge.content,
                trustLevel: knowledge.trustLevel,
                labels: knowledge.labels,
                references: knowledge.references,
                freshnessAt: knowledge.freshnessAt,
                metadata: knowledge.metadata,
            });
            idMap.set(knowledge.sandboxId, stored.id);
        }
        catch {
            // Adversarial / duplicate ingest blocks are expected and not relevant to calibration.
        }
    }
    return idMap;
}
async function aggregateContributions(prompts, idMap, fixture, retrieval) {
    const global = newSourceMap();
    const perTask = new Map();
    const reverse = new Map();
    for (const knowledge of fixture.knowledge) {
        const storedId = idMap.get(knowledge.sandboxId);
        if (storedId)
            reverse.set(storedId, knowledge);
    }
    for (const prompt of prompts) {
        const pack = await retrieval.searchContext({
            prompt: prompt.prompt,
            project: prompt.project,
            files: prompt.files,
            symbols: prompt.symbols,
            errors: prompt.errors,
            taskType: prompt.taskType,
            noiseTolerance: 'strict',
            contextMode: 'compact',
            debug: true,
        });
        const trace = pack.debug;
        if (!trace?.fusionBreakdown)
            continue;
        const breakdownById = new Map();
        for (const entry of trace.fusionBreakdown)
            breakdownById.set(entry.knowledgeId, entry);
        const expectedSandboxIds = new Set(prompt.expectedSelectedSandboxIds);
        const expectedIds = [...expectedSandboxIds]
            .map((sandboxId) => idMap.get(sandboxId))
            .filter((id) => Boolean(id));
        const promptTaskMap = perTask.get(prompt.taskType) ?? newSourceMap();
        for (const knowledgeId of expectedIds) {
            const entry = breakdownById.get(knowledgeId);
            if (!entry)
                continue;
            for (const contribution of entry.contributions) {
                global[contribution.source] += contribution.contribution;
                promptTaskMap[contribution.source] += contribution.contribution;
            }
        }
        perTask.set(prompt.taskType, promptTaskMap);
    }
    return { global, perTask };
}
function buildCalibrationOutput(aggregation, rrf, seed) {
    const globalWeights = toBoundedWeights(aggregation.global, DEFAULT_POLICY.sourceWeights);
    const taskProfiles = {};
    for (const [taskType, contributions] of aggregation.perTask) {
        const weights = toBoundedWeights(contributions, globalWeights);
        const deltas = {};
        for (const source of Object.keys(weights)) {
            const delta = weights[source] - globalWeights[source];
            if (Math.abs(delta) >= 0.01) {
                deltas[source] = Math.round(delta * 1000) / 1000;
            }
        }
        if (Object.keys(deltas).length > 0) {
            taskProfiles[taskType] = { sourceWeights: deltas };
        }
    }
    const perTaskOutput = {};
    for (const [taskType, contributions] of aggregation.perTask) {
        perTaskOutput[taskType] = roundEntries(contributions);
    }
    const rrfConfig = {
        k: rrf.selectedGlobalK,
        kByTaskType: Object.fromEntries(rrf.selectedPerTaskK),
    };
    const globalHitsObject = {};
    for (const [k, hits] of rrf.globalHits)
        globalHitsObject[k] = hits;
    const perTaskHitsObject = {};
    for (const [taskType, hitsMap] of rrf.perTaskHits) {
        const entry = {};
        for (const [k, hits] of hitsMap)
            entry[k] = hits;
        perTaskHitsObject[taskType] = entry;
    }
    return {
        calibratedAt: new Date().toISOString(),
        seed,
        globalContribution: roundEntries(aggregation.global),
        perTaskContribution: perTaskOutput,
        rrfCalibration: {
            candidateKs: [...RRF_K_CANDIDATES],
            globalHits: globalHitsObject,
            perTaskHits: perTaskHitsObject,
            selectedGlobalK: rrf.selectedGlobalK,
            selectedPerTaskK: Object.fromEntries(rrf.selectedPerTaskK),
        },
        patch: {
            sourceWeights: globalWeights,
            taskProfiles,
            rrf: rrfConfig,
            calibration: {
                calibratedAt: new Date().toISOString(),
                seed,
                notes: 'Generated by scripts/calibrate-fusion.ts',
            },
        },
    };
}
function newSourceMap() {
    return { metadata: 0, lexical: 0, vector: 0, memory: 0, graph: 0, worktree: 0, atoms: 0, userStyle: 0, convention: 0 };
}
function roundEntries(map) {
    const out = { ...map };
    for (const key of Object.keys(out)) {
        out[key] = Math.round(out[key] * 10000) / 10000;
    }
    return out;
}
/**
 * Convert raw per-source contribution totals into bounded weights.
 *
 * - Normalize to the mean so a source that contributes 1.5× the average gets a 1.5× weight.
 * - Clamp into [0.7, 1.4] to keep calibration from producing extreme values.
 * - Round to 3 decimals.
 */
function toBoundedWeights(contributions, baseline) {
    const sources = ['metadata', 'lexical', 'vector', 'memory', 'graph'];
    const values = sources.map((source) => contributions[source]);
    const total = values.reduce((sum, value) => sum + value, 0);
    if (total <= 0) {
        return { ...baseline };
    }
    const mean = total / sources.length;
    const weights = { ...baseline };
    for (const source of sources) {
        if (mean === 0) {
            weights[source] = baseline[source];
            continue;
        }
        const ratio = contributions[source] / mean;
        const blended = baseline[source] * (0.6 + 0.4 * ratio);
        weights[source] = Math.max(0.7, Math.min(1.4, Math.round(blended * 1000) / 1000));
    }
    return weights;
}
async function writeCalibration(outputPath, calibration) {
    const absolute = resolve(outputPath);
    await mkdir(dirname(absolute), { recursive: true });
    let existing = {};
    try {
        const raw = await readFile(absolute, 'utf8');
        const trimmed = raw.trim();
        if (trimmed.length > 0 && !trimmed.startsWith('//')) {
            existing = JSON.parse(trimmed);
        }
    }
    catch {
        existing = {};
    }
    const next = {
        ...existing,
        ...calibration.patch,
        calibration: calibration.patch.calibration,
    };
    const serialized = JSON.stringify(next, null, 2) + '\n';
    await writeFile(absolute, serialized, 'utf8');
}
function printSummary(calibration) {
    console.log(`Calibration written. seed=${calibration.seed}`);
    console.log('Global per-source contribution toward expected items:');
    for (const [source, value] of Object.entries(calibration.globalContribution)) {
        console.log(`  ${source.padEnd(10)} ${value.toFixed(4)}`);
    }
    const taskCount = Object.keys(calibration.perTaskContribution).length;
    console.log(`Per-task profiles emitted: ${taskCount}`);
    console.log('RRF k grid search:');
    for (const [k, hits] of Object.entries(calibration.rrfCalibration.globalHits)) {
        console.log(`  k=${k.padStart(3)}  hits=${hits}`);
    }
    console.log(`Selected global k: ${calibration.rrfCalibration.selectedGlobalK}`);
    const perTaskKeys = Object.keys(calibration.rrfCalibration.selectedPerTaskK);
    if (perTaskKeys.length === 0) {
        console.log('Per-task k overrides: none (global k wins for every task type)');
    }
    else {
        console.log('Per-task k overrides:');
        for (const [taskType, k] of Object.entries(calibration.rrfCalibration.selectedPerTaskK)) {
            console.log(`  ${taskType.padEnd(14)} k=${k}`);
        }
    }
}
function parseArgs(args) {
    const options = {
        seed: 0xC0FFEE,
        output: 'config/retrieval-policy.json',
        dryRun: false,
        help: false,
    };
    for (let index = 0; index < args.length; index += 1) {
        const arg = args[index];
        if (arg === '--help' || arg === '-h')
            options.help = true;
        else if (arg === '--dry-run')
            options.dryRun = true;
        else if (arg === '--seed') {
            options.seed = Number(args[index + 1]);
            index += 1;
            if (!Number.isFinite(options.seed))
                throw new Error('--seed must be a number');
        }
        else if (arg === '--output') {
            options.output = args[index + 1];
            index += 1;
        }
        else if (arg === '--')
            continue;
        else
            throw new Error(`Unknown option: ${arg}\n${usage()}`);
    }
    return options;
}
function usage() {
    return [
        'Usage: pnpm run calibrate-fusion -- [options]',
        '',
        'Options:',
        '  --seed <number>          Sandbox fixture seed (default 12648430)',
        '  --output <path>          Path to write the calibrated policy patch (default config/retrieval-policy.json)',
        '  --dry-run                Print the patch without writing the file',
        '  -h, --help               Show this help',
    ].join('\n');
}
const CALIBRATION_CONFIG = {
    env: 'calibration',
    http: {
        port: 3027,
        host: '127.0.0.1',
        requireApiKeyForNonLoopback: false,
        maxRequestBytes: 10 * 1024 * 1024,
    },
    storage: {
        databaseUrl: '',
        redisUrl: '',
        store: 'memory',
        cache: 'memory',
        autoMigrate: false,
    },
    model: {
        provider: 'hash',
        openAiTimeoutMs: 30_000,
        embeddingDimensions: 1536,
        openAiEmbeddingModel: 'text-embedding-3-small',
        llmCriticEnabled: false,
    },
    context: {
        cacheTtlSeconds: 0,
    },
    ingest: {
        maxContentBytes: 2 * 1024 * 1024,
    },
    backup: {
        dir: '.tuberosa/test-backups',
        exportBaseDir: '.tuberosa/test-exports',
        importBaseDir: '.tuberosa/test-imports',
        intervalSeconds: 0,
        startupDelaySeconds: 0,
        retentionCount: 24,
        retentionMaxAgeDays: 30,
        writeThrough: false,
        writeThroughThrottleSeconds: 600,
    },
    mirror: {
        debounceMs: 500,
    },
    errorLog: {
        dir: '.tuberosa/test-error-logs',
        maxBytes: 256 * 1024,
        autoCapture: true,
        captureClientErrors: false,
    },
    worktree: {
        enabled: true,
        maxFiles: 50,
        maxMtimeAgeHours: 72,
    },
    archival: {
        enabled: false,
        intervalHours: 24,
    },
    graphInference: {
        enabled: false,
    },
    atlas: {},
    userStyle: {},
    persistReplay: false,
};
main().catch((error) => {
    console.error(error instanceof Error ? error.stack ?? error.message : error);
    process.exitCode = 1;
});
