import { TextDecoder } from 'node:util';
import { compareCodeUnits } from '../utils.js';
const UNIT_SEPARATOR = '\x1f';
const DEFAULT_MAX_COCHANGE_COMMIT_FILES = 50;
export class GitParseError extends Error {
    constructor(message) {
        super(message);
        this.name = 'GitParseError';
    }
}
/** A path cannot be represented losslessly by the valid-UTF-8 V1 contracts. */
export class GitPathError extends GitParseError {
    constructor(message) {
        super(message);
        this.name = 'GitPathError';
    }
}
export function splitNul(input) {
    const fields = [];
    let start = 0;
    for (let i = 0; i < input.length; i++) {
        if (input[i] !== 0)
            continue;
        fields.push(input.subarray(start, i));
        start = i + 1;
    }
    if (start < input.length)
        fields.push(input.subarray(start));
    return fields;
}
/** Split a `-z` stream only when its final record is explicitly terminated. */
export function splitNulStrict(input, label = 'Git -z output') {
    if (input.length > 0 && input[input.length - 1] !== 0) {
        throw new GitParseError(`${label} ended with a truncated record.`);
    }
    return splitNul(input);
}
const fatalUtf8Decoder = new TextDecoder('utf-8', { fatal: true });
function assertNoUnpairedSurrogates(input) {
    for (let index = 0; index < input.length; index++) {
        const codeUnit = input.charCodeAt(index);
        if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
            const next = input.charCodeAt(index + 1);
            if (!Number.isFinite(next) || next < 0xdc00 || next > 0xdfff) {
                throw new GitPathError('Git path contains an unpaired UTF-16 surrogate.');
            }
            index++;
            continue;
        }
        if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
            throw new GitPathError('Git path contains an unpaired UTF-16 surrogate.');
        }
    }
}
/**
 * Validate a public string before it is persisted, joined, or passed to Git.
 * Entity/blob-lineage V1 paths deliberately support valid UTF-8 only.
 */
export function assertValidGitPath(input) {
    if (input.includes('\0'))
        throw new GitPathError('Git path contains a NUL byte.');
    assertNoUnpairedSurrogates(input);
    return input;
}
export function decodeGitPath(input) {
    if (typeof input === 'string')
        return assertValidGitPath(input);
    let decoded;
    try {
        decoded = fatalUtf8Decoder.decode(input);
    }
    catch {
        throw new GitPathError('Git path is not valid UTF-8.');
    }
    return assertValidGitPath(decoded);
}
function decodeAsciiField(input, label) {
    for (const byte of input) {
        if (byte > 0x7f)
            throw new GitParseError(`${label} is not ASCII.`);
    }
    return input.toString('ascii');
}
function decodeGitText(input) {
    return input.toString('utf8');
}
/**
 * Map a git status letter to the typed status. Exported so the raw
 * `diff-tree` reader in api.ts classifies exactly as `--name-status` parsing
 * does, rather than growing a second table that can drift.
 */
export function statusFromCode(status) {
    switch (status[0]) {
        case 'A':
            return 'added';
        case 'M':
            return 'modified';
        case 'D':
            return 'deleted';
        case 'R':
            return 'renamed';
        case 'C':
            return 'copied';
        case 'T':
            return 'type-changed';
        case 'U':
            return 'unmerged';
        default:
            return 'unknown';
    }
}
const ABSENT_OBJECT_ID = /^0+$/;
/**
 * Parse `--raw -z` diff records: the form emitted by `git diff-tree --raw` and
 * `git log --raw`. Each record is
 *
 *   `:<oldmode> <newmode> <oldsha> <newsha> <STATUS>\0<path>\0`
 *
 * with rename and copy statuses carrying a similarity score and a second path
 * (`R100\0<oldpath>\0<newpath>\0`).
 *
 * `--raw` is the only diff format that reports both blob object ids, which is
 * what makes historical entity extraction affordable: the ids address the exact
 * bytes of a file at a revision, so parse results can be cached
 * content-addressably. Git's all-zero placeholder for "absent on this side" is
 * normalized to `undefined`.
 *
 * Framing is deliberately strict. A prefix, malformed header, missing path, or
 * unterminated final record makes the complete stream unusable rather than
 * turning a valid prefix into apparently complete evidence.
 */
export function parseRawDiffRecordsZ(_input) {
    const fields = splitNulStrict(_input, 'Raw diff output');
    const records = [];
    for (let index = 0; index < fields.length;) {
        const header = decodeAsciiField(fields[index], 'Raw diff header');
        const match = header.match(/^:([0-7]{6}) ([0-7]{6}) ([0-9a-f]{40}|[0-9a-f]{64}) ([0-9a-f]{40}|[0-9a-f]{64}) ([A-Z](?:[0-9]{1,3})?|[A-Z]{2,})$/);
        if (!match)
            throw new GitParseError('Malformed raw diff header.');
        const [, oldMode, newMode, oldSha, newSha, rawStatus] = match;
        if (oldSha.length !== newSha.length)
            throw new GitParseError('Raw diff object formats do not match.');
        const status = statusFromCode(rawStatus);
        const wantsTwoPaths = rawStatus.startsWith('R') || rawStatus.startsWith('C');
        const firstPath = fields[index + 1];
        if (!firstPath || firstPath.length === 0)
            throw new GitParseError('Raw diff record is missing its path.');
        const secondPath = wantsTwoPaths ? fields[index + 2] : undefined;
        if (wantsTwoPaths && (!secondPath || secondPath.length === 0)) {
            throw new GitParseError('Raw rename/copy record is missing its second path.');
        }
        const record = {
            status,
            path: decodeGitPath(wantsTwoPaths && secondPath ? secondPath : firstPath),
            oldMode,
            newMode,
        };
        if (wantsTwoPaths)
            record.oldPath = decodeGitPath(firstPath);
        const similarity = parseSimilarity(rawStatus);
        if (similarity != null)
            record.similarity = similarity;
        if (!ABSENT_OBJECT_ID.test(oldSha))
            record.oldBlobSha = oldSha;
        if (!ABSENT_OBJECT_ID.test(newSha))
            record.newBlobSha = newSha;
        records.push(record);
        index += wantsTwoPaths ? 3 : 2;
    }
    return records;
}
/**
 * Parse `git ls-tree -r -z` output: `<mode> <type> <sha>\t<path>\0`. Blobs are
 * the only entries of interest, so submodules (`commit`) and trees are dropped.
 */
export function parseLsTreeZ(_input) {
    const entries = [];
    for (const field of splitNulStrict(_input, 'ls-tree output')) {
        const tab = field.indexOf(0x09);
        if (tab <= 0 || tab === field.length - 1)
            throw new GitParseError('Malformed ls-tree record.');
        const header = decodeAsciiField(field.subarray(0, tab), 'ls-tree header');
        const match = header.match(/^([0-7]{6}) (blob|tree|commit) ([0-9a-f]{40}|[0-9a-f]{64})$/);
        if (!match)
            throw new GitParseError('Malformed ls-tree header.');
        const [, mode, type, blobSha] = match;
        if (type !== 'blob')
            continue;
        entries.push({ mode, blobSha, path: decodeGitPath(field.subarray(tab + 1)) });
    }
    return entries;
}
/** Rename/copy similarity score: `R75` → `75`. Exported alongside `statusFromCode`. */
export function parseSimilarity(status) {
    if (status.length <= 1)
        return undefined;
    const value = Number.parseInt(status.slice(1), 10);
    return Number.isFinite(value) ? value : undefined;
}
function parseCount(value) {
    // git log --numstat -z prefixes the first entry of each commit with a newline,
    // so trim before matching the binary sentinel.
    if (value.trim() === '-')
        return null;
    const parsed = Number.parseInt(value, 10);
    if (!Number.isFinite(parsed)) {
        throw new GitParseError(`Invalid numstat count "${value}".`);
    }
    return parsed;
}
function parseNumstatAt(fields, index) {
    const field = fields[index];
    if (!field)
        throw new GitParseError('Malformed numstat record.');
    const firstTab = field.indexOf(0x09);
    const secondTab = firstTab === -1 ? -1 : field.indexOf(0x09, firstTab + 1);
    if (firstTab === -1 || secondTab === -1)
        throw new GitParseError('Malformed numstat record.');
    const added = parseCount(decodeAsciiField(field.subarray(0, firstTab), 'Numstat added count'));
    const deleted = parseCount(decodeAsciiField(field.subarray(firstTab + 1, secondTab), 'Numstat deleted count'));
    const binary = added == null || deleted == null;
    const pathBytes = field.subarray(secondTab + 1);
    const isRename = pathBytes.length === 0;
    if (isRename) {
        const oldPath = fields[index + 1];
        const newPath = fields[index + 2];
        if (!oldPath || !newPath) {
            throw new GitParseError('Malformed rename numstat record.');
        }
        return {
            stat: {
                path: decodeGitPath(newPath),
                oldPath: decodeGitPath(oldPath),
                added,
                deleted,
                binary,
            },
            nextIndex: index + 3,
        };
    }
    return {
        stat: {
            path: decodeGitPath(pathBytes),
            added,
            deleted,
            binary,
        },
        nextIndex: index + 1,
    };
}
function parseCommitField(field) {
    const parts = field.split(UNIT_SEPARATOR);
    if (parts.length < 7) {
        throw new GitParseError('Malformed git log record.');
    }
    const [hash, shortHash, authorName, authorEmail, authorDate, committerDate, subject, ...bodyParts] = parts;
    const body = bodyParts.join(UNIT_SEPARATOR);
    const commit = {
        hash,
        shortHash,
        authorName,
        authorEmail,
        authorDate,
        committerDate,
        subject,
    };
    if (body.length > 0)
        commit.body = body;
    return commit;
}
function isCommitField(field) {
    return field.includes(UNIT_SEPARATOR) && field.split(UNIT_SEPARATOR).length >= 7;
}
function stripGitPathPrefix(value) {
    if (value === '/dev/null')
        return undefined;
    if (value.startsWith('a/') || value.startsWith('b/'))
        return value.slice(2);
    return value;
}
// git wraps a path in double quotes and C-style-escapes it whenever the name
// contains a quote, backslash, or control character (tab/newline/…). Decode
// those escapes back to the literal bytes so downstream paths are usable.
function unquoteCStylePath(value) {
    if (!value.startsWith('"'))
        return value;
    const bytes = [];
    const pushUtf8 = (segment) => {
        for (const byte of Buffer.from(segment, 'utf8'))
            bytes.push(byte);
    };
    let i = 1;
    while (i < value.length) {
        const ch = value[i];
        if (ch === '"')
            break;
        if (ch !== '\\') {
            let j = i;
            while (j < value.length && value[j] !== '\\' && value[j] !== '"')
                j++;
            pushUtf8(value.slice(i, j));
            i = j;
            continue;
        }
        const next = value[i + 1];
        switch (next) {
            case 'a':
                bytes.push(0x07);
                i += 2;
                break;
            case 'b':
                bytes.push(0x08);
                i += 2;
                break;
            case 't':
                bytes.push(0x09);
                i += 2;
                break;
            case 'n':
                bytes.push(0x0a);
                i += 2;
                break;
            case 'v':
                bytes.push(0x0b);
                i += 2;
                break;
            case 'f':
                bytes.push(0x0c);
                i += 2;
                break;
            case 'r':
                bytes.push(0x0d);
                i += 2;
                break;
            case '"':
                bytes.push(0x22);
                i += 2;
                break;
            case '\\':
                bytes.push(0x5c);
                i += 2;
                break;
            default: {
                const octal = value.slice(i + 1, i + 4);
                if (/^[0-7]{3}$/.test(octal)) {
                    bytes.push(Number.parseInt(octal, 8));
                    i += 4;
                }
                else if (next != null) {
                    pushUtf8(next);
                    i += 2;
                }
                else {
                    i += 1;
                }
            }
        }
    }
    return Buffer.from(bytes).toString('utf8');
}
function readDiffToken(input, start) {
    if (input[start] === '"') {
        let i = start + 1;
        while (i < input.length) {
            if (input[i] === '\\') {
                i += 2;
                continue;
            }
            if (input[i] === '"')
                break;
            i++;
        }
        if (i >= input.length)
            return undefined;
        return { value: unquoteCStylePath(input.slice(start, i + 1)), next: i + 1 };
    }
    let i = start;
    while (i < input.length && input[i] !== ' ')
        i++;
    return { value: input.slice(start, i), next: i };
}
function parseHeaderPath(line, prefix) {
    const raw = line.slice(prefix.length);
    if (raw.startsWith('"')) {
        const token = readDiffToken(raw, 0);
        if (token)
            return stripGitPathPrefix(token.value);
    }
    const pathOnly = raw.split('\t')[0];
    return stripGitPathPrefix(pathOnly);
}
function parseDiffGitLine(line) {
    const rest = line.slice('diff --git '.length);
    if (rest.startsWith('"')) {
        const first = readDiffToken(rest, 0);
        if (first) {
            let pos = first.next;
            if (rest[pos] === ' ')
                pos++;
            const second = readDiffToken(rest, pos);
            if (second) {
                return { oldPath: stripGitPathPrefix(first.value), newPath: stripGitPathPrefix(second.value) };
            }
        }
    }
    const match = line.match(/^diff --git a\/(.+) b\/(.+)$/);
    if (!match)
        return undefined;
    return { oldPath: match[1], newPath: match[2] };
}
function createHunk(path, oldPath, header) {
    const match = header.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
    if (!match) {
        throw new GitParseError(`Malformed diff hunk header "${header}".`);
    }
    const oldStart = Number.parseInt(match[1], 10);
    const oldLines = match[2] == null ? 1 : Number.parseInt(match[2], 10);
    const newStart = Number.parseInt(match[3], 10);
    const newLines = match[4] == null ? 1 : Number.parseInt(match[4], 10);
    const hunk = {
        path,
        oldStart,
        oldLines,
        newStart,
        newLines,
        newEnd: newStart + Math.max(newLines, 1) - 1,
        header,
        addedLines: [],
        deletedLines: [],
    };
    if (oldPath)
        hunk.oldPath = oldPath;
    return hunk;
}
export function parseNameStatusZ(_input) {
    const fields = splitNulStrict(_input, 'Name-status output');
    const files = [];
    for (let i = 0; i < fields.length;) {
        const status = decodeAsciiField(fields[i], 'Name-status status');
        if (status.length === 0) {
            i++;
            continue;
        }
        const statusCode = statusFromCode(status);
        if (status[0] === 'R' || status[0] === 'C') {
            const oldPath = fields[i + 1];
            const newPath = fields[i + 2];
            if (!oldPath || !newPath) {
                throw new GitParseError(`Malformed ${status[0] === 'R' ? 'rename' : 'copy'} name-status record.`);
            }
            const change = {
                status: statusCode,
                path: decodeGitPath(newPath),
                oldPath: decodeGitPath(oldPath),
            };
            const similarity = parseSimilarity(status);
            if (similarity != null)
                change.similarity = similarity;
            files.push(change);
            i += 3;
            continue;
        }
        const path = fields[i + 1];
        if (!path)
            throw new GitParseError(`Malformed name-status record "${status}".`);
        files.push({ status: statusCode, path: decodeGitPath(path) });
        i += 2;
    }
    return files;
}
export function parseNumstatZ(_input) {
    const fields = splitNulStrict(_input, 'Numstat output');
    const stats = [];
    for (let i = 0; i < fields.length;) {
        const parsed = parseNumstatAt(fields, i);
        stats.push(parsed.stat);
        i = parsed.nextIndex;
    }
    return stats;
}
export function parseDiffPatchHunks(_patch, _range, _options = {}) {
    const files = [];
    let currentFile;
    let currentHunk;
    let oldLine = 0;
    let newLine = 0;
    const finishHunk = () => {
        if (!currentFile || !currentHunk)
            return;
        currentFile.hunks.push(currentHunk);
        currentHunk = undefined;
    };
    const startFile = (path, oldPath) => {
        finishHunk();
        currentFile = { path, binary: false, hunks: [] };
        if (oldPath && oldPath !== path)
            currentFile.oldPath = oldPath;
        files.push(currentFile);
    };
    for (const line of _patch.split(/\r?\n/)) {
        if (line.startsWith('diff --git ')) {
            const parsed = parseDiffGitLine(line);
            startFile(parsed?.newPath ?? parsed?.oldPath ?? '', parsed?.oldPath);
            continue;
        }
        if (!currentFile)
            continue;
        if (line.startsWith('Binary files ')) {
            currentFile.binary = true;
            continue;
        }
        if (!currentHunk && line.startsWith('--- ')) {
            const oldPath = parseHeaderPath(line, '--- ');
            if (oldPath && oldPath !== currentFile.path)
                currentFile.oldPath = oldPath;
            continue;
        }
        if (!currentHunk && line.startsWith('+++ ')) {
            const newPath = parseHeaderPath(line, '+++ ');
            if (newPath)
                currentFile.path = newPath;
            continue;
        }
        if (line.startsWith('@@ ')) {
            finishHunk();
            currentHunk = createHunk(currentFile.path, currentFile.oldPath, line);
            oldLine = currentHunk.oldStart;
            newLine = currentHunk.newStart;
            continue;
        }
        if (!currentHunk)
            continue;
        if (line.startsWith('+')) {
            currentHunk.addedLines.push(newLine);
            newLine++;
        }
        else if (line.startsWith('-')) {
            if (_options.includeLineText) {
                currentHunk.deletedLineTexts ??= [];
                currentHunk.deletedLineTexts.push({ line: oldLine, text: line.slice(1) });
            }
            currentHunk.deletedLines.push(oldLine);
            oldLine++;
        }
        else if (line.startsWith(' ')) {
            oldLine++;
            newLine++;
        }
    }
    finishHunk();
    return { range: _range, files: files.filter(file => file.path.length > 0) };
}
export function parseLogRecords(_input) {
    return splitNul(_input)
        .map(field => decodeGitText(field))
        .filter(field => field.length > 0)
        .map(field => parseCommitField(field));
}
export function parseLogNumstatZ(_input) {
    const fields = splitNulStrict(_input, 'Log numstat output');
    const records = [];
    let current;
    for (let i = 0; i < fields.length;) {
        const field = decodeGitText(fields[i]);
        if (field.length === 0) {
            i++;
            continue;
        }
        if (isCommitField(field)) {
            current = { commit: parseCommitField(field), numstat: [] };
            records.push(current);
            i++;
            continue;
        }
        if (!current) {
            throw new GitParseError('Encountered numstat record before a commit record.');
        }
        const parsed = parseNumstatAt(fields, i);
        current.numstat.push(parsed.stat);
        i = parsed.nextIndex;
    }
    return records;
}
function isLaterGitDate(candidate, current) {
    const candidateInstant = Date.parse(candidate);
    const currentInstant = Date.parse(current);
    return Number.isNaN(candidateInstant) || Number.isNaN(currentInstant)
        ? candidate > current
        : candidateInstant > currentInstant;
}
export function aggregateChurn(_records, _range, _sortedBy = 'churn') {
    const byPath = new Map();
    for (const record of _records) {
        const touchedInCommit = new Set();
        for (const stat of record.numstat) {
            const entry = byPath.get(stat.path) ?? {
                path: stat.path,
                commits: 0,
                added: 0,
                deleted: 0,
                lastTouched: record.commit.authorDate,
            };
            entry.added += stat.added ?? 0;
            entry.deleted += stat.deleted ?? 0;
            if (!touchedInCommit.has(stat.path)) {
                entry.commits++;
                touchedInCommit.add(stat.path);
            }
            // %aI carries a timezone offset, so compare the resolved instants rather
            // than the raw ISO strings to keep lastTouched chronologically correct
            // across commits authored in different timezones.
            if (isLaterGitDate(record.commit.authorDate, entry.lastTouched)) {
                entry.lastTouched = record.commit.authorDate;
            }
            byPath.set(stat.path, entry);
        }
    }
    const entries = [...byPath.values()].map(entry => ({
        path: entry.path,
        commits: entry.commits,
        added: entry.added,
        deleted: entry.deleted,
        net: entry.added - entry.deleted,
        churn: entry.added + entry.deleted,
        lastTouched: entry.lastTouched,
    }));
    entries.sort((a, b) => {
        const diff = b[_sortedBy] - a[_sortedBy];
        return diff !== 0 ? diff : compareCodeUnits(a.path, b.path);
    });
    return {
        range: _range,
        entries,
        totalCommits: _records.length,
        sortedBy: _sortedBy,
    };
}
export function aggregateCochange(_records, _range, _options = {}) {
    const maxCommitFiles = _options.maxCommitFiles ?? DEFAULT_MAX_COCHANGE_COMMIT_FILES;
    const target = _options.target;
    const fileCommitCounts = new Map();
    const pairCounts = new Map();
    let consideredCommits = 0;
    let skippedCommits = 0;
    for (const record of _records) {
        const touched = [...new Set(record.numstat.map(stat => stat.path))].sort(compareCodeUnits);
        if (touched.length === 0)
            continue;
        if (touched.length > maxCommitFiles) {
            skippedCommits++;
            continue;
        }
        consideredCommits++;
        for (const filePath of touched) {
            fileCommitCounts.set(filePath, (fileCommitCounts.get(filePath) ?? 0) + 1);
        }
        for (let i = 0; i < touched.length; i++) {
            const fileA = touched[i];
            if (fileA == null)
                continue;
            for (let j = i + 1; j < touched.length; j++) {
                const fileB = touched[j];
                if (fileB == null)
                    continue;
                if (target && fileA !== target && fileB !== target)
                    continue;
                const key = `${fileA}\0${fileB}`;
                const current = pairCounts.get(key);
                if (current) {
                    current.count++;
                    if (isLaterGitDate(record.commit.authorDate, current.lastCochangedAt)) {
                        current.lastCochangedAt = record.commit.authorDate;
                    }
                }
                else {
                    pairCounts.set(key, {
                        fileA,
                        fileB,
                        count: 1,
                        lastCochangedAt: record.commit.authorDate,
                    });
                }
            }
        }
    }
    const pairValues = [...pairCounts.values()].map(pair => {
        const fileACount = fileCommitCounts.get(pair.fileA) ?? 0;
        const fileBCount = fileCommitCounts.get(pair.fileB) ?? 0;
        const lift = consideredCommits > 0 && fileACount > 0 && fileBCount > 0
            ? (pair.count * consideredCommits) / (fileACount * fileBCount)
            : 0;
        return {
            fileA: pair.fileA,
            fileB: pair.fileB,
            count: pair.count,
            lift,
            lastCochangedAt: pair.lastCochangedAt,
        };
    });
    pairValues.sort((a, b) => {
        const liftDiff = b.lift - a.lift;
        if (liftDiff !== 0)
            return liftDiff;
        const countDiff = b.count - a.count;
        if (countDiff !== 0)
            return countDiff;
        if (a.lastCochangedAt !== b.lastCochangedAt) {
            return isLaterGitDate(b.lastCochangedAt, a.lastCochangedAt) ? 1 : -1;
        }
        const fileADiff = compareCodeUnits(a.fileA, b.fileA);
        return fileADiff !== 0 ? fileADiff : compareCodeUnits(a.fileB, b.fileB);
    });
    const pairs = _options.limit == null ? pairValues : pairValues.slice(0, _options.limit);
    const summary = {
        range: _range,
        pairs,
        totalCommits: _records.length,
        consideredCommits,
        skippedCommits,
        maxCommitFiles,
    };
    if (target)
        summary.target = target;
    return summary;
}
//# sourceMappingURL=parse.js.map