import path from 'node:path';
import { compareCodeUnits } from '../../utils.js';
import { getLanguageSpec } from '../languages.js';
import { CODE_PARSER_VERSION, parseCodeContent } from '../parsers.js';
import { toNormalizedSymbolKind } from '../types.js';
import { computeEntityHashes, ENTITY_HASH_VERSION } from './hash.js';
import { extractEntityEvidenceV2 } from './syntax-evidence.js';
import { extractFormatEntities, formatForPath } from '../formats/index.js';
import { bumpCounter } from './types.js';
/** NUL within this prefix means the blob is not text OCC can reason about. */
const BINARY_PROBE_BYTES = 8192;
export const ENTITY_PARSER_CONTRACT = Object.freeze({
    parserName: 'occ.code.parsers',
    parserVersion: CODE_PARSER_VERSION,
});
export const ENTITY_PARSER_CONTRACT_ID = `occ.code.parsers@${CODE_PARSER_VERSION}`;
function snapshotProvenance(provenance) {
    return {
        parserName: provenance.parserName,
        parserVersion: provenance.parserVersion,
        parserTier: provenance.tier,
        ...(provenance.grammar ? { grammar: provenance.grammar } : {}),
        syntaxStatus: provenance.syntax.status,
        syntaxErrorCount: provenance.syntax.errorCount,
        capabilities: { ...provenance.capabilities },
        diagnostics: [...provenance.syntax.diagnostics, ...(provenance.resolver.diagnostics ?? [])].slice(0, 100),
    };
}
export function looksBinary(buffer) {
    const end = Math.min(buffer.length, BINARY_PROBE_BYTES);
    for (let index = 0; index < end; index++) {
        if (buffer[index] === 0)
            return true;
    }
    return false;
}
function stripBom(text) {
    return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
}
function qualify(name, containerName) {
    return containerName ? `${containerName}.${name}` : name;
}
/**
 * Turn one blob's text into entity records.
 *
 * Pure with respect to git and to the working tree: everything it needs
 * arrives as arguments, which is what makes the result cacheable by blob id.
 * Import resolution is disabled because it would consult today's filesystem
 * for a historical revision, and nothing about entity identity reads it.
 */
export async function extractEntitiesFromBlob(input) {
    const spec = getLanguageSpec(input.relativePath);
    const base = {
        blobSha: input.blobSha,
        relativePath: input.relativePath,
        language: spec?.name ?? '',
        hashVersion: ENTITY_HASH_VERSION,
    };
    if (!spec) {
        // Data formats are not languages, so they have no spec — but they do have
        // entities. Routing them through the same blob path is what gives them
        // history, diff, and blame with no separate machinery.
        const format = formatForPath(input.relativePath);
        if (format) {
            const maxFormatEntities = input.bounds?.maxEntitiesPerFile ?? Number.POSITIVE_INFINITY;
            const extracted = entitiesFromFormat(stripBom(input.content), input.relativePath, maxFormatEntities, input.formats !== false);
            bumpCounter(input.counters, extracted.records.length > 0 ? 'blobsParsed' : 'blobsSkipped');
            bumpCounter(input.counters, 'entitiesExtracted', extracted.records.length);
            const diagnostic = extracted.parseFailed
                ? {
                    message: `The ${format} extractor could not parse this document; an empty result is not proof of absence.`,
                    severity: 'error',
                    source: `occ-format-${format}`,
                    reason: 'parse-failed',
                }
                : undefined;
            return {
                ...base,
                language: format,
                entities: extracted.records,
                truncated: extracted.truncated,
                provenance: {
                    parserName: 'occ.code.parsers',
                    parserVersion: CODE_PARSER_VERSION,
                    parserTier: 'format',
                    syntaxStatus: extracted.parseFailed ? 'recovered' : 'valid',
                    syntaxErrorCount: extracted.parseFailed ? 1 : 0,
                    capabilities: {
                        definitions: extracted.parseFailed ? 'partial' : 'full',
                        imports: extracted.parseFailed ? 'unavailable' : 'full',
                        calls: extracted.parseFailed ? 'unavailable' : 'full',
                        inheritance: extracted.parseFailed ? 'unavailable' : 'full',
                        exports: extracted.parseFailed ? 'unavailable' : 'full',
                        exactSpans: 'unavailable',
                    },
                    diagnostics: diagnostic ? [diagnostic] : [],
                },
            };
        }
        bumpCounter(input.counters, 'blobsSkipped');
        return { ...base, entities: [], truncated: false, skippedReason: 'No language spec for this extension' };
    }
    const absolutePath = path.resolve(input.repoRoot, input.relativePath);
    const content = stripBom(input.content);
    const leadingUtf8Bytes = content === input.content ? 0 : Buffer.byteLength(input.content.slice(0, 1), 'utf8');
    bumpCounter(input.counters, 'blobsParsed');
    const parsed = await parseCodeContent(absolutePath, content, { repoRoot: input.repoRoot, resolveImports: false }, 
    // git already reported the exact byte length in the batch header, so the
    // size guardrail uses it rather than re-measuring the decoded string.
    { ...input.guardrails, byteSize: input.byteLength });
    if (!parsed) {
        bumpCounter(input.counters, 'blobsSkipped');
        return { ...base, entities: [], truncated: false, skippedReason: 'Parser produced no result' };
    }
    if (parsed.kind === 'skipped') {
        bumpCounter(input.counters, 'blobsSkipped');
        return { ...base, entities: [], truncated: false, skippedReason: parsed.reason };
    }
    const maxEntities = input.bounds?.maxEntitiesPerFile ?? Number.POSITIVE_INFINITY;
    const evidence = await extractEntityEvidenceV2(parsed.file, input.relativePath, { leadingUtf8Bytes });
    const entities = entitiesFromParsedFile(parsed.file, input.relativePath, maxEntities, evidence);
    bumpCounter(input.counters, 'entitiesExtracted', entities.records.length);
    return {
        ...base,
        entities: entities.records,
        truncated: entities.truncated,
        ...(parsed.file.provenance ? { provenance: snapshotProvenance(parsed.file.provenance) } : {}),
    };
}
export function entitiesFromParsedFile(file, relativePath, maxEntities = Number.POSITIVE_INFINITY, evidence = new Map()) {
    const lines = file.lines ?? file.content?.split(/\r?\n/) ?? [];
    const ordinals = new Map();
    const records = [];
    const symbols = [];
    let truncated = false;
    // Declaration order is the file's own order; it is what makes a pure
    // reordering detectable without any other signal changing.
    const ordered = [...file.symbols].sort((a, b) => a.line - b.line || compareCodeUnits(a.name, b.name));
    for (const [declarationIndex, symbol] of ordered.entries()) {
        if (records.length >= maxEntities) {
            truncated = true;
            break;
        }
        records.push(entityRecordFor(symbol, relativePath, lines, declarationIndex, ordinals, evidence.get(symbol)));
        symbols.push(symbol);
    }
    return { records, symbols, truncated, parseFailed: false };
}
function entityRecordFor(symbol, relativePath, lines, declarationIndex, ordinals, evidence) {
    // A test block keeps its own kind: the graph models it as a function so call
    // attribution works, but as an entity it is a test, not a method.
    const kind = symbol.testRole ?? toNormalizedSymbolKind(symbol.type, symbol.containerName);
    const qualifiedName = qualify(symbol.name, symbol.containerName);
    const ordinalKey = `${kind}\u0000${qualifiedName}`;
    const ordinal = ordinals.get(ordinalKey) ?? 0;
    ordinals.set(ordinalKey, ordinal + 1);
    const startLine = symbol.line;
    const endLine = symbol.endLine ?? symbol.line;
    // The same slice `readCodeSource` returns, so bodyHash keeps the frozen
    // `code.symbol.bodyHash` contract rather than a parallel definition.
    const source = lines.slice(startLine - 1, endLine).join('\n');
    // Keep the legacy line-span tier frozen. V2 exactness is additive through
    // `sourceSpan`; changing this flag would also change the legacy hash algo.
    const spanExact = endLine > startLine;
    const hashes = computeEntityHashes({ source, relativePath, spanExact });
    if (evidence)
        hashes.exactBodyHash = evidence.exactBodyHash;
    const record = {
        name: symbol.name,
        qualifiedName,
        kind,
        relativePath,
        startLine,
        endLine,
        isExported: symbol.exported === true,
        ordinal,
        declarationIndex,
        spanExact,
        hashes,
    };
    if (evidence) {
        record.sourceSpan = evidence.sourceSpan;
        record.syntaxEvidence = evidence.syntaxEvidence;
        record.signatureEvidence = evidence.signatureEvidence;
    }
    if (symbol.containerName)
        record.containerName = symbol.containerName;
    if (symbol.signature)
        record.signature = symbol.signature;
    return record;
}
/** Index a snapshot's entities by the file they live in. */
export function groupByPath(entities) {
    const byPath = new Map();
    for (const entity of entities) {
        const existing = byPath.get(entity.relativePath);
        if (existing)
            existing.push(entity);
        else
            byPath.set(entity.relativePath, [entity]);
    }
    return byPath;
}
/** Stable key for an entity's position in a file, used by the exact-identity rung. */
export function entityStateKey(entity) {
    return `${entity.relativePath}\u0000${entity.kind}\u0000${entity.qualifiedName}\u0000${entity.ordinal}`;
}
/**
 * Turn one data-format document into entity records.
 *
 * The same hash machinery as code: a format entity's span is text like any
 * other. `normalizedHash` and `structuralHash` remain identity heuristics, so
 * a renamed JSON key can be paired without either hash making a semantic
 * no-op claim.
 */
export function entitiesFromFormat(content, relativePath, maxEntities = Number.POSITIVE_INFINITY, enabled = true) {
    if (!enabled)
        return { records: [], symbols: [], truncated: false, parseFailed: false };
    const found = extractFormatEntities(relativePath, content);
    if (!found)
        return { records: [], symbols: [], truncated: false, parseFailed: false };
    if (found.entities.length === 0) {
        return { records: [], symbols: [], truncated: false, parseFailed: found.parseFailed };
    }
    const lines = content.split(/\r?\n/);
    const ordinals = new Map();
    const records = [];
    let truncated = false;
    for (const [declarationIndex, entity] of found.entities.entries()) {
        if (records.length >= maxEntities) {
            truncated = true;
            break;
        }
        const ordinalKey = `${entity.kind}\u0000${entity.qualifiedName}`;
        const ordinal = ordinals.get(ordinalKey) ?? 0;
        ordinals.set(ordinalKey, ordinal + 1);
        const endLine = Math.max(entity.startLine, Math.min(entity.endLine, lines.length));
        const source = lines.slice(entity.startLine - 1, endLine).join('\n');
        const spanExact = endLine > entity.startLine;
        const record = {
            name: entity.name,
            qualifiedName: entity.qualifiedName,
            kind: entity.kind,
            relativePath,
            startLine: entity.startLine,
            endLine,
            // A document has no export surface; saying `false` is accurate rather
            // than a placeholder.
            isExported: false,
            ordinal,
            declarationIndex,
            spanExact,
            hashes: computeEntityHashes({ source, relativePath, spanExact }),
        };
        if (entity.containerName)
            record.containerName = entity.containerName;
        records.push(record);
    }
    return { records, symbols: [], truncated, parseFailed: found.parseFailed };
}
//# sourceMappingURL=snapshot.js.map