import { createHash } from 'node:crypto';
import { ModelProviderError } from '../errors.js';
import { clamp, truncate } from '../util/text.js';
import { ATOM_EXTRACTION_SYSTEM_PROMPT, ATOM_UTILITY_SYSTEM_PROMPT, atomExtractionSchema, atomUtilitySchema, parseAtomUtilityVerdict, parseExtractedAtoms, } from './atom-extraction.js';
export const OPENAI_RERANK_SYSTEM_PROMPT = [
    'Rerank candidate knowledge for a coding agent.',
    'Prefer concrete evidence coverage over generic semantic similarity.',
    'Strong evidence includes exact project, file, symbol, error, task, and required evidence type matches.',
    'Also value bounded graph relations, current or fresh references, high-trust reviewed knowledge, and positive feedback signals.',
    'Penalize stale, superseded, rejected, irrelevant, cross-project, weakly related, or generic background candidates.',
    'Do not reward repeated prompt wording unless the candidate also contains concrete task evidence.',
    'Score only useful candidates from 0 to 1 and explain each score with the specific evidence or risk that drove it.',
    'Return JSON only.',
].join(' ');
export class HashModelProvider {
    dimensions;
    // Test seam — the hash provider has no real LLM. Tests inject deterministic
    // atom candidates via setFixtureAtoms when they need extractor coverage.
    fixtureAtoms = [];
    constructor(dimensions = 1536) {
        this.dimensions = dimensions;
    }
    setFixtureAtoms(atoms) {
        this.fixtureAtoms = atoms;
    }
    async extractAtoms() {
        return this.fixtureAtoms;
    }
    async embed(text) {
        const vector = new Array(this.dimensions).fill(0);
        const tokens = text.toLowerCase().match(/[a-z0-9_./:-]+/g) ?? [];
        for (const token of tokens) {
            const digest = createHash('sha256').update(token).digest();
            const index = digest.readUInt32BE(0) % this.dimensions;
            const sign = digest[4] % 2 === 0 ? 1 : -1;
            vector[index] += sign;
        }
        const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0)) || 1;
        return vector.map((value) => value / norm);
    }
    async rewriteQuery(_input) {
        return undefined;
    }
    async rerank(input) {
        const promptTerms = new Set(input.prompt.toLowerCase().match(/[a-z0-9_./:-]+/g) ?? []);
        const candidates = input.candidates
            .map((candidate) => {
            const candidateTerms = new Set(`${candidate.title} ${candidate.summary} ${candidate.contextualContent}`
                .toLowerCase()
                .match(/[a-z0-9_./:-]+/g) ?? []);
            const overlap = [...promptTerms].filter((term) => candidateTerms.has(term)).length;
            const overlapScore = promptTerms.size ? overlap / promptTerms.size : 0;
            const trustScore = clamp(candidate.trustLevel / 100, 0, 1);
            const rerankScore = clamp(candidate.fusedScore * 0.62 + overlapScore * 0.28 + trustScore * 0.1, 0, 1);
            return {
                ...candidate,
                rerankScore,
                finalScore: rerankScore,
            };
        })
            .sort((left, right) => right.finalScore - left.finalScore);
        return { candidates };
    }
}
export class OpenAiModelProvider {
    config;
    fallback;
    constructor(config) {
        this.config = config;
        this.fallback = new HashModelProvider(config.model.embeddingDimensions);
    }
    async embed(text) {
        const response = await fetchOpenAiEmbedding(this.config, text);
        if (!response.ok) {
            const detail = await response.text();
            throw new ModelProviderError(`OpenAI embedding request failed: ${response.status} ${detail}`);
        }
        const body = (await response.json());
        const embedding = body.data?.[0]?.embedding;
        if (!embedding) {
            throw new ModelProviderError('OpenAI embedding response did not include an embedding.');
        }
        return embedding;
    }
    async rewriteQuery(input) {
        if (!this.config.model.openAiRewriteModel) {
            return undefined;
        }
        // Phase 7 — when `mode === 'diverse_angle'`, ask the rewriter for multiple
        // task-perspective variants (how/where/what depends/what changes) populating
        // exactTerms for OR-style FTS expansion. Otherwise use the legacy paraphrase
        // prompt for backwards compatibility.
        const useDiverseAngle = input.mode === 'diverse_angle';
        const systemPrompt = useDiverseAngle
            ? [
                'Rewrite retrieval queries for a local project knowledge broker as 3-5 diverse-angle search variants.',
                'Each variant frames the same topic from a different task perspective:',
                '  - how does X work (mechanism / call graph),',
                '  - where is X used (call sites / consumers),',
                '  - what depends on X (downstream impact),',
                '  - what changes X (writers / mutators),',
                '  - when does X run (lifecycle / triggers).',
                'Return compact JSON only.',
                'Populate `exactTerms` with the per-angle variant phrases (one per angle, concrete and unambiguous).',
                'Populate `lexicalQuery` with the union of concrete signal terms (file paths, symbols, errors, technologies) — comma- or space-separated.',
                'Preserve exact file paths, symbols, errors, technologies, and domain terms verbatim — never paraphrase them.',
                'Do not add facts that are not supported by the prompt or existing classification.',
            ].join(' ')
            : [
                'Rewrite retrieval queries for a local project knowledge broker.',
                'Return compact JSON only.',
                'Preserve exact file paths, symbols, errors, technologies, and domain terms.',
                'Do not add facts that are not supported by the prompt or existing classification.',
            ].join(' ');
        const response = await fetchOpenAiJson(this.config, this.config.model.openAiRewriteModel, systemPrompt, 'query_rewrite', queryRewriteSchema(), {
            prompt: input.prompt,
            mode: input.mode ?? 'paraphrase',
            classified: {
                taskType: input.classified.taskType,
                files: input.classified.files,
                symbols: input.classified.symbols,
                errors: input.classified.errors,
                technologies: input.classified.technologies,
                businessAreas: input.classified.businessAreas,
                exactTerms: input.classified.exactTerms,
                lexicalQuery: input.classified.lexicalQuery,
            },
        });
        if (!response.ok) {
            const detail = await response.text();
            throw new ModelProviderError(`OpenAI query rewrite request failed: ${response.status} ${detail}`);
        }
        return parseQueryRewriteResponse(await response.json(), this.config.model.openAiRewriteModel);
    }
    async rerank(input) {
        if (!this.config.model.openAiRerankModel || input.candidates.length === 0) {
            return this.fallback.rerank(input);
        }
        const response = await fetchOpenAiJson(this.config, this.config.model.openAiRerankModel, OPENAI_RERANK_SYSTEM_PROMPT, 'candidate_rerank', rerankSchema(input.candidates.length), buildOpenAiRerankPayload(input));
        if (!response.ok) {
            const detail = await response.text();
            throw new ModelProviderError(`OpenAI rerank request failed: ${response.status} ${detail}`);
        }
        const decisions = parseRerankResponse(await response.json());
        return applyProviderRerank(input, decisions, this.config.model.openAiRerankModel, this.fallback);
    }
    async judgeAtomUtility(input) {
        // Reuse the structured-output rerank model for judgments. With no model
        // configured we cannot judge, so we keep the candidate (fail-open) rather
        // than silently dropping borderline atoms.
        const model = this.config.model.openAiRerankModel;
        if (!model) {
            return { generalizable: true, reason: 'no judgment model configured', confidence: 0 };
        }
        const response = await fetchOpenAiJson(this.config, model, ATOM_UTILITY_SYSTEM_PROMPT, 'atom_utility_judgement', atomUtilitySchema(), { claim: input.claim, type: input.type, trigger: input.trigger });
        if (!response.ok) {
            const detail = await response.text();
            throw new ModelProviderError(`OpenAI atom-utility request failed: ${response.status} ${detail}`);
        }
        const outputText = extractOutputText(await response.json());
        if (!outputText) {
            throw new ModelProviderError('OpenAI atom-utility response did not include output text.');
        }
        return parseAtomUtilityVerdict(outputText);
    }
    async extractAtoms(input) {
        // SP2 — reuse the structured-output rerank model slot, same as
        // judgeAtomUtility. No model configured -> no atoms (fail-open).
        const model = this.config.model.openAiRerankModel;
        if (!model) {
            return [];
        }
        const response = await fetchOpenAiJson(this.config, model, ATOM_EXTRACTION_SYSTEM_PROMPT, 'atom_extraction', atomExtractionSchema(), input);
        if (!response.ok) {
            const detail = await response.text();
            throw new ModelProviderError(`OpenAI atom-extraction request failed: ${response.status} ${detail}`);
        }
        const outputText = extractOutputText(await response.json());
        if (!outputText) {
            throw new ModelProviderError('OpenAI atom-extraction response did not include output text.');
        }
        return parseExtractedAtoms(outputText);
    }
    async extractPromptIntent(input) {
        const model = this.config.model.openAiRewriteModel;
        if (!model) {
            // Fail-open: with no model configured the preprocessor will fall back to
            // the anchor-window slice. Throwing here would force a hard error on the
            // long-prompt path; instead we signal "no verdict" upstream.
            throw new ModelProviderError('OpenAI prompt-intent extraction requires openAiRewriteModel.');
        }
        const response = await fetchOpenAiJson(this.config, model, PROMPT_INTENT_SYSTEM_PROMPT, 'prompt_intent', promptIntentSchema(), {
            prompt: input.prompt,
            cwd: input.cwd,
            knownFiles: input.files ?? [],
            knownSymbols: input.symbols ?? [],
        });
        if (!response.ok) {
            const detail = await response.text();
            throw new ModelProviderError(`OpenAI prompt-intent request failed: ${response.status} ${detail}`);
        }
        const outputText = extractOutputText(await response.json());
        if (!outputText) {
            throw new ModelProviderError('OpenAI prompt-intent response did not include output text.');
        }
        const parsed = parseJsonObject(outputText, 'OpenAI prompt-intent response');
        const subTasks = Array.isArray(parsed.subTasks)
            ? parsed.subTasks.filter((entry) => typeof entry === 'string').map((entry) => truncate(entry, 300))
            : [];
        const detectedTechnologies = Array.isArray(parsed.detectedTechnologies)
            ? parsed.detectedTechnologies.filter((entry) => typeof entry === 'string')
            : undefined;
        const validTaskTypes = ['debugging', 'implementation', 'refactor', 'review', 'planning', 'exploration', 'testing', 'unknown'];
        const detectedTaskType = typeof parsed.detectedTaskType === 'string'
            && validTaskTypes.includes(parsed.detectedTaskType)
            ? parsed.detectedTaskType
            : undefined;
        return {
            primary: typeof parsed.primary === 'string' ? truncate(parsed.primary, 600) : '',
            subTasks,
            detectedTaskType,
            detectedTechnologies,
            confidence: typeof parsed.confidence === 'number' ? clamp(parsed.confidence, 0, 1) : 0,
        };
    }
}
const PROMPT_INTENT_SYSTEM_PROMPT = [
    'You distill a long agent prompt into its primary intent and discrete sub-tasks.',
    '`primary` is the single most important goal — what the agent should embed for retrieval.',
    '`subTasks` lists the remaining discrete follow-up actions in execution order (max 8).',
    'Detect the dominant task type and any concrete technology mentions when present.',
    'Preserve exact file paths, symbols, and error tokens verbatim in primary/subTasks.',
    'Return JSON only.',
].join(' ');
function promptIntentSchema() {
    return {
        type: 'object',
        additionalProperties: false,
        properties: {
            primary: { type: 'string', minLength: 10, maxLength: 600 },
            subTasks: {
                type: 'array',
                items: { type: 'string', minLength: 5, maxLength: 300 },
                maxItems: 8,
            },
            detectedTaskType: {
                type: 'string',
                enum: ['debugging', 'implementation', 'refactor', 'review', 'planning', 'exploration', 'testing', 'unknown'],
            },
            detectedTechnologies: { type: 'array', items: { type: 'string' }, maxItems: 10 },
            confidence: { type: 'number', minimum: 0, maximum: 1 },
        },
        required: ['primary', 'subTasks', 'confidence'],
    };
}
async function fetchOpenAiEmbedding(config, text) {
    try {
        return await fetch('https://api.openai.com/v1/embeddings', {
            method: 'POST',
            headers: {
                Authorization: `Bearer ${config.model.openAiApiKey}`,
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({
                model: config.model.openAiEmbeddingModel,
                input: text,
                dimensions: config.model.embeddingDimensions,
            }),
            signal: AbortSignal.timeout(config.model.openAiTimeoutMs),
        });
    }
    catch (error) {
        throw new ModelProviderError('OpenAI embedding request failed.', error);
    }
}
async function fetchOpenAiJson(config, model, systemPrompt, schemaName, schema, input) {
    try {
        return await fetch('https://api.openai.com/v1/responses', {
            method: 'POST',
            headers: {
                Authorization: `Bearer ${config.model.openAiApiKey}`,
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({
                model,
                input: [
                    {
                        role: 'system',
                        content: systemPrompt,
                    },
                    {
                        role: 'user',
                        content: JSON.stringify(input),
                    },
                ],
                text: {
                    format: {
                        type: 'json_schema',
                        name: schemaName,
                        strict: true,
                        schema,
                    },
                },
            }),
            signal: AbortSignal.timeout(config.model.openAiTimeoutMs),
        });
    }
    catch (error) {
        throw new ModelProviderError('OpenAI Responses request failed.', error);
    }
}
function queryRewriteSchema() {
    return {
        type: 'object',
        additionalProperties: false,
        properties: {
            lexicalQuery: { type: 'string' },
            exactTerms: {
                type: 'array',
                items: { type: 'string' },
                maxItems: 16,
            },
            reasons: {
                type: 'array',
                items: { type: 'string' },
                maxItems: 6,
            },
        },
        required: ['lexicalQuery', 'exactTerms', 'reasons'],
    };
}
function rerankSchema(candidateCount) {
    return {
        type: 'object',
        additionalProperties: false,
        properties: {
            rankings: {
                type: 'array',
                maxItems: Math.min(candidateCount, 24),
                items: {
                    type: 'object',
                    additionalProperties: false,
                    properties: {
                        knowledgeId: { type: 'string' },
                        score: { type: 'number', minimum: 0, maximum: 1 },
                        reason: { type: 'string' },
                    },
                    required: ['knowledgeId', 'score', 'reason'],
                },
            },
        },
        required: ['rankings'],
    };
}
function parseQueryRewriteResponse(body, model) {
    const outputText = extractOutputText(body);
    if (!outputText) {
        throw new ModelProviderError('OpenAI query rewrite response did not include output text.');
    }
    const parsed = parseJsonObject(outputText, 'OpenAI query rewrite response');
    const lexicalQuery = typeof parsed.lexicalQuery === 'string' ? truncate(parsed.lexicalQuery, 600) : '';
    if (!lexicalQuery.trim()) {
        return undefined;
    }
    return {
        lexicalQuery,
        exactTerms: stringArray(parsed.exactTerms, 16),
        reasons: stringArray(parsed.reasons, 6),
        model,
    };
}
function parseRerankResponse(body) {
    const outputText = extractOutputText(body);
    if (!outputText) {
        throw new ModelProviderError('OpenAI rerank response did not include output text.');
    }
    const parsed = parseJsonObject(outputText, 'OpenAI rerank response');
    const rankings = Array.isArray(parsed.rankings) ? parsed.rankings : [];
    return rankings
        .filter(isRecord)
        .map((item) => ({
        knowledgeId: typeof item.knowledgeId === 'string' ? item.knowledgeId : '',
        score: typeof item.score === 'number' ? clamp(item.score, 0, 1) : Number.NaN,
        reason: typeof item.reason === 'string' ? truncate(item.reason, 160).trim() : undefined,
    }))
        .filter((item) => item.knowledgeId && Number.isFinite(item.score));
}
async function applyProviderRerank(input, decisions, model, fallback) {
    if (decisions.length === 0) {
        return fallback.rerank(input);
    }
    const fallbackResult = await fallback.rerank(input);
    const candidateById = new Map(input.candidates.map((candidate) => [candidate.knowledgeId, candidate]));
    const usedIds = new Set();
    const providerRanked = decisions.flatMap((decision) => {
        const candidate = candidateById.get(decision.knowledgeId);
        if (!candidate || usedIds.has(candidate.knowledgeId)) {
            return [];
        }
        usedIds.add(candidate.knowledgeId);
        const trustScore = clamp(candidate.trustLevel / 100, 0, 1);
        const rerankScore = clamp(decision.score * 0.74 + candidate.fusedScore * 0.18 + trustScore * 0.08, 0, 1);
        const reason = decision.reason ? `provider rerank: ${decision.reason}` : 'provider rerank';
        return [{
                ...candidate,
                rerankScore,
                finalScore: rerankScore,
                matchReasons: [...candidate.matchReasons, reason],
                metadata: {
                    ...(candidate.metadata ?? {}),
                    providerRerank: {
                        model,
                        score: decision.score,
                        reason: decision.reason,
                    },
                },
            }];
    });
    const fallbackOnly = fallbackResult.candidates
        .filter((candidate) => !usedIds.has(candidate.knowledgeId))
        .map((candidate) => ({
        ...candidate,
        rerankScore: clamp(candidate.rerankScore * 0.78, 0, 1),
        finalScore: clamp(candidate.finalScore * 0.78, 0, 1),
    }));
    const candidates = [...providerRanked, ...fallbackOnly]
        .sort((left, right) => right.finalScore - left.finalScore || left.rank - right.rank)
        .map((candidate, index) => ({ ...candidate, rank: index + 1 }));
    return {
        candidates,
        decisions: decisions.filter((decision) => candidateById.has(decision.knowledgeId)),
        model,
    };
}
export function buildOpenAiRerankPayload(input) {
    return {
        prompt: input.prompt,
        classified: {
            taskType: input.classified.taskType,
            project: input.classified.project,
            files: input.classified.files,
            symbols: input.classified.symbols,
            errors: input.classified.errors,
            technologies: input.classified.technologies,
            businessAreas: input.classified.businessAreas,
            exactTerms: input.classified.exactTerms,
            intent: {
                taskGoal: input.classified.intent.taskGoal,
                workflowStage: input.classified.intent.workflowStage,
                requiredEvidenceTypes: input.classified.intent.requiredEvidenceTypes,
                uncertaintyReasons: input.classified.intent.uncertaintyReasons,
            },
        },
        candidates: input.candidates.map((candidate) => toRerankPayload(candidate, input.classified)),
    };
}
function toRerankPayload(candidate, classified) {
    return {
        knowledgeId: candidate.knowledgeId,
        title: truncate(candidate.title, 160),
        summary: truncate(candidate.summary, 320),
        itemType: candidate.itemType,
        project: candidate.project,
        source: candidate.source,
        fusedScore: roundScore(candidate.fusedScore),
        trustLevel: candidate.trustLevel,
        matchReasons: candidate.matchReasons.slice(0, 10),
        labels: candidate.labels.slice(0, 16).map((label) => `${label.type}:${label.value}`),
        references: candidate.references.slice(0, 10).map((reference) => reference.uri),
        freshnessAt: candidate.freshnessAt,
        evidence: buildCandidateEvidence(candidate, classified),
        content: truncate(candidate.contextualContent || candidate.content, 900),
    };
}
function buildCandidateEvidence(candidate, classified) {
    return {
        exactFiles: matchedSignals(candidate, classified.files),
        exactSymbols: matchedSignals(candidate, classified.symbols),
        exactErrors: matchedSignals(candidate, classified.errors),
        exactTechnologies: matchedLabelSignals(candidate, 'technology', classified.technologies),
        exactBusinessAreas: matchedLabelSignals(candidate, 'business_area', classified.businessAreas),
        taskMatch: candidate.labels.some((label) => label.type === 'task_type' && sameSignal(label.value, classified.taskType)),
        projectMatch: sameSignal(candidate.project, classified.project),
        requiredEvidenceTypes: matchedEvidenceTypes(candidate, classified.intent.requiredEvidenceTypes),
        graphPaths: metadataRecordArray(candidate.metadata?.graphPaths).slice(0, 3),
        feedback: metadataRecord(candidate.metadata?.feedback),
        freshness: {
            freshnessAt: candidate.freshnessAt,
            staleMetadata: candidate.metadata?.stale === true,
            staleLanguage: staleLanguage(candidate),
        },
        riskSignals: candidate.matchReasons
            .filter((reason) => /stale|superseded|rejected|irrelevant|suppression/i.test(reason))
            .slice(0, 8),
    };
}
function matchedSignals(candidate, signals) {
    const text = candidateEvidenceText(candidate);
    return signals.filter((signal) => text.includes(signal.toLowerCase()));
}
function matchedLabelSignals(candidate, labelType, signals) {
    return signals.filter((signal) => candidate.labels.some((label) => (label.type === labelType && sameSignal(label.value, signal))));
}
function matchedEvidenceTypes(candidate, evidenceTypes) {
    const text = candidateEvidenceText(candidate);
    return evidenceTypes.filter((evidenceType) => {
        if (evidenceType === 'code_reference') {
            return candidate.itemType === 'code_ref' || candidate.references.some((reference) => reference.type === 'file');
        }
        if (evidenceType === 'bugfix') {
            return candidate.itemType === 'bugfix';
        }
        if (evidenceType === 'workflow') {
            return candidate.itemType === 'workflow' || candidate.labels.some((label) => label.type === 'workflow_stage');
        }
        if (evidenceType === 'docs' || evidenceType === 'spec') {
            return candidate.itemType === 'wiki' || candidate.references.some((reference) => reference.type === 'file');
        }
        if (evidenceType === 'reflection_memory' || evidenceType === 'incident_lesson') {
            return candidate.itemType === 'memory' || candidate.itemType === 'bugfix';
        }
        return text.includes(evidenceType.replaceAll('_', ' ')) || text.includes(evidenceType);
    });
}
function candidateEvidenceText(candidate) {
    return [
        candidate.project,
        candidate.title,
        candidate.summary,
        candidate.content,
        candidate.contextualContent,
        candidate.itemType,
        candidate.source,
        candidate.matchReasons.join(' '),
        candidate.labels.map((label) => `${label.type}:${label.value}`).join(' '),
        candidate.references.map((reference) => `${reference.type}:${reference.uri}`).join(' '),
    ].join(' ').toLowerCase();
}
function staleLanguage(candidate) {
    return /\b(stale|superseded|deprecated|legacy|obsolete|outdated)\b/i.test(`${candidate.title} ${candidate.summary} ${candidate.content}`);
}
function metadataRecord(value) {
    return isRecord(value) ? value : undefined;
}
function metadataRecordArray(value) {
    return Array.isArray(value) ? value.filter(isRecord) : [];
}
function sameSignal(left, right) {
    return Boolean(left && right && left.toLowerCase() === right.toLowerCase());
}
function roundScore(value) {
    return Math.round(value * 10000) / 10000;
}
function extractOutputText(body) {
    if (!isRecord(body)) {
        return undefined;
    }
    if (typeof body.output_text === 'string') {
        return body.output_text;
    }
    const output = Array.isArray(body.output) ? body.output : [];
    for (const item of output) {
        if (!isRecord(item) || !Array.isArray(item.content)) {
            continue;
        }
        for (const content of item.content) {
            if (isRecord(content) && typeof content.text === 'string') {
                return content.text;
            }
        }
    }
    return undefined;
}
function parseJsonObject(value, description) {
    try {
        const parsed = JSON.parse(value);
        if (isRecord(parsed)) {
            return parsed;
        }
    }
    catch {
        // Fall through to the typed provider error below.
    }
    throw new ModelProviderError(`${description} was not a JSON object.`);
}
function stringArray(value, maxItems) {
    if (!Array.isArray(value)) {
        return [];
    }
    return value
        .filter((item) => typeof item === 'string')
        .map((item) => truncate(item, 120).trim())
        .filter(Boolean)
        .slice(0, maxItems);
}
function isRecord(value) {
    return typeof value === 'object' && value !== null;
}
