const UNIT_SEPARATOR = '\x1f';
const DEFAULT_MAX_COCHANGE_COMMIT_FILES = 50;
export class GitParseError extends Error {
    constructor(message) {
        super(message);
        this.name = 'GitParseError';
    }
}
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;
}
export function decodeGitPath(input) {
    return Buffer.isBuffer(input) ? input.toString('utf8') : input;
}
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';
    }
}
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 countsAndMaybePath = decodeGitPath(fields[index] ?? Buffer.alloc(0));
    const parts = countsAndMaybePath.split('\t');
    if (parts.length < 2) {
        throw new GitParseError(`Malformed numstat record "${countsAndMaybePath}".`);
    }
    const added = parseCount(parts[0]);
    const deleted = parseCount(parts[1]);
    const binary = added == null || deleted == null;
    const isRename = parts.length < 3 || parts[2] === '';
    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: parts.slice(2).join('\t'),
            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 = splitNul(_input);
    const files = [];
    for (let i = 0; i < fields.length;) {
        const status = decodeGitPath(fields[i]);
        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 = splitNul(_input);
    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 => decodeGitPath(field))
        .filter(field => field.length > 0)
        .map(field => parseCommitField(field));
}
export function parseLogNumstatZ(_input) {
    const fields = splitNul(_input);
    const records = [];
    let current;
    for (let i = 0; i < fields.length;) {
        const field = decodeGitPath(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 : a.path.localeCompare(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((a, b) => a.localeCompare(b));
        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 = a.fileA.localeCompare(b.fileA);
        return fileADiff !== 0 ? fileADiff : a.fileB.localeCompare(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