import { normalizeLabel } from '../util/text.js';
import { cosineSimilarity } from '../util/vector.js';
const COSINE_NOOP_THRESHOLD = 0.92;
const LABEL_NOOP_THRESHOLD = 0.7;
const COSINE_NEAR_THRESHOLD = 0.8;
const LABEL_NEAR_THRESHOLD = 0.5;
export async function computeWriteGate(input) {
    const now = input.now ?? new Date();
    if (input.candidates.length === 0) {
        return {
            decision: 'ADD',
            scores: { cosine: 0, labelOverlap: 0, referenceOverlap: 0, recencyDays: Number.POSITIVE_INFINITY },
            evidenceIds: [],
            reason: 'No prior memories matched the draft topic.',
        };
    }
    const draftEmbedding = input.models
        ? await safeEmbed(input.models, `${input.draft.summary}\n${input.draft.content}`)
        : undefined;
    // Audit P1 fix: if embeddings were requested (models supplied) but the draft
    // embedding came back empty, the lexical rawScore proxy is the only signal
    // available — that proxy is not strong enough to auto-NOOP/UPDATE/DELETE a
    // memory that could contradict the draft, so we force the decision back to
    // ADD downstream. `embeddingDegraded` records that condition.
    const embeddingRequested = input.models !== undefined;
    const embeddingAvailable = draftEmbedding !== undefined && draftEmbedding.length > 0;
    const embeddingDegraded = embeddingRequested && !embeddingAvailable;
    const cosineFn = await buildCosineFn(input.models, draftEmbedding);
    let best;
    for (const candidate of input.candidates) {
        const cosine = await cosineFn(candidate);
        const labelOverlap = jaccardLabelOverlap(input.draft.labels, candidate.labels ?? []);
        const referenceOverlap = jaccardReferenceOverlap(input.draft.references, candidate.references ?? []);
        const recencyDays = daysBetween(candidate.freshnessAt ?? candidate.createdAt, now);
        const contradicts = detectContradiction(input.draft, candidate);
        const addsNovelFacts = draftAddsNovelFacts(input.draft, candidate);
        if (!best || cosine > best.scores.cosine) {
            best = {
                candidate,
                scores: { cosine, labelOverlap, referenceOverlap, recencyDays },
                contradicts,
                addsNovelFacts,
            };
        }
    }
    // Safe: candidates is non-empty here so the loop ran at least once.
    const { candidate, scores, contradicts, addsNovelFacts } = best;
    if (embeddingDegraded) {
        return {
            decision: 'ADD',
            scores,
            evidenceIds: [],
            closestKnowledgeId: candidate.knowledgeId,
            reason: 'Embedding unavailable; defaulting to ADD to avoid auto-deciding on a lexical proxy alone.',
        };
    }
    if (scores.cosine >= COSINE_NOOP_THRESHOLD && scores.labelOverlap >= LABEL_NOOP_THRESHOLD) {
        return {
            decision: 'NOOP',
            scores,
            evidenceIds: [candidate.knowledgeId],
            closestKnowledgeId: candidate.knowledgeId,
            reason: `Existing memory ${truncateTitle(candidate.title)} covers this lesson (cosine ${scores.cosine.toFixed(2)}, label overlap ${scores.labelOverlap.toFixed(2)}).`,
        };
    }
    if (scores.cosine >= COSINE_NEAR_THRESHOLD && contradicts) {
        return {
            decision: 'DELETE',
            scores,
            evidenceIds: [candidate.knowledgeId],
            closestKnowledgeId: candidate.knowledgeId,
            reason: `Draft references conflicting evidence vs ${truncateTitle(candidate.title)}; propose superseding the old memory.`,
        };
    }
    if (scores.cosine >= COSINE_NEAR_THRESHOLD && scores.labelOverlap >= LABEL_NEAR_THRESHOLD && addsNovelFacts) {
        return {
            decision: 'UPDATE',
            scores,
            evidenceIds: [candidate.knowledgeId],
            closestKnowledgeId: candidate.knowledgeId,
            reason: `Draft adds new facts to existing memory ${truncateTitle(candidate.title)}; propose merging.`,
        };
    }
    return {
        decision: 'ADD',
        scores,
        evidenceIds: [],
        closestKnowledgeId: candidate.knowledgeId,
        reason: 'Closest existing memory diverges enough that a new entry is the right move.',
    };
}
async function buildCosineFn(models, draftEmbedding) {
    if (models && draftEmbedding) {
        return async (candidate) => {
            const text = candidate.contextualContent ?? candidate.content ?? `${candidate.title ?? ''}\n${candidate.summary ?? ''}`;
            if (!text.trim()) {
                return clampCosine(candidate.rawScore);
            }
            const candidateEmbedding = await safeEmbed(models, text);
            if (!candidateEmbedding || candidateEmbedding.length === 0) {
                return clampCosine(candidate.rawScore);
            }
            return cosineSimilarity(draftEmbedding, candidateEmbedding);
        };
    }
    return async (candidate) => clampCosine(candidate.rawScore);
}
async function safeEmbed(models, text) {
    try {
        return await models.embed(text);
    }
    catch {
        return undefined;
    }
}
function clampCosine(rawScore) {
    if (typeof rawScore !== 'number' || Number.isNaN(rawScore))
        return 0;
    if (rawScore < 0)
        return 0;
    if (rawScore > 1)
        return 1;
    return rawScore;
}
function jaccardLabelOverlap(left, right) {
    const leftKeys = new Set(left
        .filter((label) => label.type === 'file' || label.type === 'symbol' || label.type === 'error')
        .map(labelKey));
    const rightKeys = new Set(right
        .filter((label) => label.type === 'file' || label.type === 'symbol' || label.type === 'error')
        .map(labelKey));
    return jaccard(leftKeys, rightKeys);
}
function jaccardReferenceOverlap(left, right) {
    const leftKeys = new Set(left.map(referenceKey));
    const rightKeys = new Set(right.map(referenceKey));
    return jaccard(leftKeys, rightKeys);
}
function jaccard(left, right) {
    if (left.size === 0 && right.size === 0)
        return 0;
    let intersection = 0;
    for (const key of left) {
        if (right.has(key))
            intersection += 1;
    }
    const union = left.size + right.size - intersection;
    return union === 0 ? 0 : intersection / union;
}
function labelKey(label) {
    return `${label.type}:${normalizeLabel(label.value)}`;
}
function referenceKey(reference) {
    return `${reference.type}:${normalizeLabel(reference.uri)}`;
}
function daysBetween(at, now) {
    if (!at)
        return Number.POSITIVE_INFINITY;
    const past = Date.parse(at);
    if (Number.isNaN(past))
        return Number.POSITIVE_INFINITY;
    const deltaMs = Math.max(0, now.getTime() - past);
    return deltaMs / (1000 * 60 * 60 * 24);
}
/**
 * Phase 6b heuristic: draft contradicts candidate when they share at least one
 * file or symbol label but the file-URI references disagree on the *same
 * basename*. e.g. draft says `src/email/sender.ts` for symbol X while the
 * existing memory references `src/legacy/email/sender.ts` for X — same role,
 * different path.
 */
function detectContradiction(draft, candidate) {
    const sharedLabel = sharesAnyLabel(draft.labels, candidate.labels ?? []);
    if (!sharedLabel)
        return false;
    const draftFiles = draft.references.filter((ref) => ref.type === 'file').map((ref) => ref.uri);
    const candidateFiles = (candidate.references ?? []).filter((ref) => ref.type === 'file').map((ref) => ref.uri);
    if (draftFiles.length === 0 || candidateFiles.length === 0)
        return false;
    for (const draftFile of draftFiles) {
        const draftBase = basename(draftFile);
        if (!draftBase)
            continue;
        for (const candidateFile of candidateFiles) {
            if (basename(candidateFile) === draftBase && draftFile !== candidateFile) {
                return true;
            }
        }
    }
    return false;
}
function sharesAnyLabel(left, right) {
    const leftKeys = new Set(left
        .filter((label) => label.type === 'file' || label.type === 'symbol')
        .map(labelKey));
    for (const label of right) {
        if (label.type !== 'file' && label.type !== 'symbol')
            continue;
        if (leftKeys.has(labelKey(label)))
            return true;
    }
    return false;
}
function basename(path) {
    const slash = path.lastIndexOf('/');
    return slash >= 0 ? path.slice(slash + 1) : path;
}
/**
 * Phase 6b heuristic: a draft adds novel facts when it contains lexical
 * tokens of meaningful length that are absent from every candidate's body.
 * A small ratio threshold (≥ 0.2 of draft tokens unique) keeps the signal
 * resistant to incidental wording overlap.
 */
function draftAddsNovelFacts(draft, candidate) {
    const draftTokens = significantTokens(`${draft.title}\n${draft.summary}\n${draft.content}`);
    if (draftTokens.size === 0)
        return false;
    const candidateText = `${candidate.title ?? ''}\n${candidate.summary ?? ''}\n${candidate.content ?? ''}\n${candidate.contextualContent ?? ''}`;
    const candidateTokens = significantTokens(candidateText);
    let novel = 0;
    for (const token of draftTokens) {
        if (!candidateTokens.has(token))
            novel += 1;
    }
    return novel / draftTokens.size >= 0.2;
}
function significantTokens(text) {
    const tokens = text.toLowerCase().match(/[a-z0-9_./:-]{4,}/g) ?? [];
    return new Set(tokens);
}
function truncateTitle(title) {
    const value = title?.trim() ?? '';
    if (value.length <= 60)
        return value;
    return `${value.slice(0, 57)}…`;
}
