import { z } from 'zod';
import { compareCodeUnits } from '../../utils.js';
import { classifyEntityEvent } from './change-class.js';
import { countEntityAmbiguityEvidence, summarizeEntityEvidenceGaps } from './evidence.js';
import { OccEntityQuerySchema } from './api-types.js';
import { resolveEntity } from './resolve.js';
import { TRUNCATION_CODES } from './store.js';
export const ENTITY_COCHANGE_DEFAULT_LIMIT = 20;
export const ENTITY_COCHANGE_DEFAULT_MIN_COUNT = 3;
export const ENTITY_COCHANGE_DEFAULT_MAX_COMMIT_ENTITIES = 500;
export const EntityCochangeOptionsSchema = z.object({
    limit: z.number().int().positive().max(500).optional(),
    minCount: z.number().int().positive().optional(),
    minConfidence: z.number().min(0).max(1).optional(),
    /** Skip broad sweeps rather than turning them into an all-to-all clique. */
    maxCommitEntities: z.number().int().positive().max(5_000).optional(),
});
function pairKey(a, b) {
    return a < b ? `${a}\0${b}` : `${b}\0${a}`;
}
function refOrder(a, b) {
    return (compareCodeUnits(a.relativePath, b.relativePath) ||
        a.startLine - b.startLine ||
        compareCodeUnits(a.qualifiedName ?? a.name, b.qualifiedName ?? b.name) ||
        compareCodeUnits(a.lineageId, b.lineageId));
}
function laterFirst(a, b) {
    const aInstant = Date.parse(a.lastCoChange);
    const bInstant = Date.parse(b.lastCoChange);
    if (Number.isFinite(aInstant) && Number.isFinite(bInstant) && aInstant !== bInstant)
        return bInstant - aInstant;
    return compareCodeUnits(b.lastCoChange, a.lastCoChange);
}
/**
 * Aggregate temporal coupling from persisted entity events.
 *
 * Each commit contributes a set of lineage ids, so multiple events for one
 * lineage cannot inflate a pair. Only proved logic changes enter that set.
 * Current refs are joined after aggregation, which is what lets a pair survive
 * file and symbol renames while keeping the result directly addressable.
 */
export function entityCochange(query, options, context) {
    const parsedOptions = EntityCochangeOptionsSchema.safeParse(options);
    if (!parsedOptions.success) {
        return {
            status: 'error',
            code: 'invalid-options',
            message: parsedOptions.error.issues[0]?.message ?? 'Invalid entity co-change options.',
        };
    }
    let target;
    let parsedQuery;
    if (query !== undefined) {
        const checked = OccEntityQuerySchema.safeParse(query);
        if (!checked.success) {
            return {
                status: 'error',
                code: 'invalid-query',
                message: checked.error.issues[0]?.message ?? 'Invalid entity query.',
            };
        }
        parsedQuery = checked.data;
        if (parsedQuery.ref) {
            return {
                status: 'error',
                query: parsedQuery,
                code: 'invalid-ref',
                message: 'co-change is projected onto current entities and cannot be asked about a past ref.',
            };
        }
        const resolved = resolveEntity(parsedQuery, context.live);
        if (resolved.status !== 'resolved')
            return { ...resolved, query: parsedQuery };
        target = resolved.entity.ref;
    }
    const limit = parsedOptions.data.limit ?? ENTITY_COCHANGE_DEFAULT_LIMIT;
    const minCount = parsedOptions.data.minCount ?? ENTITY_COCHANGE_DEFAULT_MIN_COUNT;
    const minConfidence = parsedOptions.data.minConfidence ?? 0;
    const maxCommitEntities = parsedOptions.data.maxCommitEntities ?? ENTITY_COCHANGE_DEFAULT_MAX_COMMIT_ENTITIES;
    const current = new Map([...context.live.byLineageId.entries()].filter(([, entity]) => entity.ref.nodeId && !entity.ref.provisional && !entity.ref.indeterminate));
    const byCommit = new Map();
    let eventsScanned = 0;
    let logicEvents = 0;
    let cosmeticEventsFiltered = 0;
    let neutralEventsFiltered = 0;
    let unknownEventsFiltered = 0;
    let historicalLineagesExcluded = 0;
    for (const event of context.entityIndex.events) {
        eventsScanned++;
        if (!current.has(event.lineageId)) {
            historicalLineagesExcluded++;
            continue;
        }
        switch (classifyEntityEvent(event)) {
            case 'logic': {
                logicEvents++;
                const lineages = byCommit.get(event.commit);
                if (lineages)
                    lineages.add(event.lineageId);
                else
                    byCommit.set(event.commit, new Set([event.lineageId]));
                break;
            }
            case 'cosmetic':
                cosmeticEventsFiltered++;
                break;
            case 'neutral':
                neutralEventsFiltered++;
                break;
            case 'unknown':
                unknownEventsFiltered++;
                break;
        }
    }
    const changeCounts = new Map();
    const pairCounts = new Map();
    let commitsConsidered = 0;
    let commitsSkipped = 0;
    for (const commit of context.entityIndex.commits) {
        const lineages = [...(byCommit.get(commit.sha) ?? [])].sort(compareCodeUnits);
        if (lineages.length === 0)
            continue;
        if (lineages.length > maxCommitEntities) {
            commitsSkipped++;
            continue;
        }
        commitsConsidered++;
        for (const lineageId of lineages) {
            changeCounts.set(lineageId, (changeCounts.get(lineageId) ?? 0) + 1);
        }
        for (let i = 0; i < lineages.length; i++) {
            const lineageA = lineages[i];
            if (lineageA === undefined)
                continue;
            for (let j = i + 1; j < lineages.length; j++) {
                const lineageB = lineages[j];
                if (lineageB === undefined)
                    continue;
                if (target && lineageA !== target.lineageId && lineageB !== target.lineageId)
                    continue;
                const key = pairKey(lineageA, lineageB);
                const previous = pairCounts.get(key);
                const instant = Date.parse(commit.committedAt);
                if (previous) {
                    previous.count++;
                    if (Number.isFinite(instant) && instant > previous.lastInstant) {
                        previous.lastInstant = instant;
                        previous.lastCoChange = commit.committedAt;
                    }
                }
                else {
                    pairCounts.set(key, {
                        lineageA,
                        lineageB,
                        count: 1,
                        lastCoChange: commit.committedAt,
                        lastInstant: Number.isFinite(instant) ? instant : Number.NEGATIVE_INFINITY,
                    });
                }
            }
        }
    }
    const pairs = [];
    for (const pair of pairCounts.values()) {
        if (pair.count < minCount)
            continue;
        const liveA = current.get(pair.lineageA)?.ref;
        const liveB = current.get(pair.lineageB)?.ref;
        if (!liveA || !liveB)
            continue;
        const countA = changeCounts.get(pair.lineageA) ?? 0;
        const countB = changeCounts.get(pair.lineageB) ?? 0;
        let a = liveA;
        let b = liveB;
        let aChangeCount = countA;
        if (target) {
            if (liveB.lineageId === target.lineageId) {
                a = liveB;
                b = liveA;
                aChangeCount = countB;
            }
        }
        else if (countB < countA || (countA === countB && refOrder(liveB, liveA) < 0)) {
            // Bare leaderboards use the rarer entity as the symmetric denominator:
            // "when A changed, how often did B also change?"
            a = liveB;
            b = liveA;
            aChangeCount = countB;
        }
        const confidence = aChangeCount === 0 ? 0 : pair.count / aChangeCount;
        if (confidence < minConfidence)
            continue;
        pairs.push({
            a,
            b,
            cochangeCount: pair.count,
            aChangeCount,
            confidence,
            lastCoChange: pair.lastCoChange,
        });
    }
    pairs.sort((a, b) => b.confidence - a.confidence ||
        b.cochangeCount - a.cochangeCount ||
        laterFirst(a, b) ||
        refOrder(a.a, b.a) ||
        refOrder(a.b, b.b));
    const selected = pairs.slice(0, limit);
    const omitted = Math.max(0, pairs.length - selected.length);
    const diagnostics = context.entityIndex.status.diagnostics;
    const payload = {
        pairs: selected,
        windowCommits: context.entityIndex.commits.length,
        minCount,
        minConfidence,
        honesty: {
            freshness: {
                fresh: context.freshness.fresh,
                reasons: context.freshness.reasons,
                workingTreeMeasured: context.freshness.workingTree !== undefined,
            },
            truncation: {
                truncated: context.entityIndex.status.truncated || commitsSkipped > 0 || omitted > 0,
                reasons: diagnostics.filter(diagnostic => TRUNCATION_CODES.has(diagnostic.code)),
                commitsSkipped,
                maxCommitEntities,
                omitted,
            },
            coverage: {
                commitsIndexed: context.entityIndex.status.commitsIndexed,
                commitsConsidered,
                eventsScanned,
                logicEvents,
                cosmeticEventsFiltered,
                neutralEventsFiltered,
                unknownEventsFiltered,
                historicalLineagesExcluded,
                currentCodeEntities: current.size,
            },
            ambiguityCount: countEntityAmbiguityEvidence(context.entityIndex.counters.ambiguitiesRefused, diagnostics),
            evidenceGaps: summarizeEntityEvidenceGaps(diagnostics),
        },
    };
    return target
        ? { status: 'resolved', query: parsedQuery, entity: target, ...payload }
        : { status: 'resolved', ...payload };
}
//# sourceMappingURL=cochange.js.map