import * as stack from '@tapjs/stack';
import { createTwoFilesPatch } from 'diff';
import { readFileSync } from 'node:fs';
import { format, strict } from 'tcompare';
import { extraFromError } from './extra-from-error.js';
const tryReadFile = (path) => {
    try {
        return readFileSync(path, 'utf8');
    }
    catch (_) {
        return;
    }
};
const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
/**
 * Prepare an object for printing to YAML diagnostics.
 *
 * Looks up source, calculates diffs of actual/expected values, and so on.
 */
export const cleanYamlObject = (obj, seen = new Set()) => {
    seen.add(obj);
    const res = { ...obj };
    seen.add(res);
    if (hasOwn(res, 'stack') && !hasOwn(res, 'at')) {
        const st = Array.isArray(res.stack) ?
            res.stack.map(s => String(s).trimEnd() + '\n').join('')
            : String(res.stack);
        const p = stack.parseStack(st);
        res.at = p[0];
        res.stack = p.map(c => String(c) + '\n').join('');
    }
    if (typeof res.stack === 'string' &&
        res.stack &&
        !res.stack.endsWith('\n')) {
        res.stack = res.stack.trimEnd() + '\n';
    }
    /* c8 ignore start - old pre-Error.cause custom decorator */
    if (res.errorOrigin && typeof res.errorOrigin === 'object') {
        res.errorOrigin = cleanYamlObject(res.errorOrigin, seen);
    }
    /* c8 ignore stop */
    if (res.cause && typeof res.cause === 'object' && !seen.has(res.cause)) {
        seen.add(res.cause);
        const { message } = res.cause;
        const ex = extraFromError(res.cause);
        const clean = cleanYamlObject(ex, seen);
        if (message !== undefined)
            clean.message = message;
        res.cause = clean;
    }
    if (Array.isArray(res.errors)) {
        const cleaned = [];
        for (const e of res.errors) {
            if (!!e && typeof e === 'object') {
                seen.add(e);
                const { message } = e;
                const ex = extraFromError(e);
                const clean = cleanYamlObject(ex, seen);
                if (message !== undefined)
                    clean.message = message;
                cleaned.push(clean);
            }
            else {
                cleaned.push(e);
            }
        }
        res.errors = cleaned;
    }
    if (res.at &&
        res.at instanceof stack.CallSiteLike &&
        res.at.fileName &&
        res.at.absoluteFileName &&
        res.at.lineNumber &&
        !res.source) {
        const file = res.at.absoluteFileName;
        const content = tryReadFile(file);
        if (content) {
            const lines = content.split('\n');
            if (res.at.lineNumber <= lines.length) {
                const startLine = Math.max(res.at.lineNumber - 3, 0);
                const endLine = Math.min(res.at.lineNumber + 2, lines.length);
                const line = lines[res.at.lineNumber - 1];
                const caret = (res.at.columnNumber &&
                    line &&
                    res.at.columnNumber <= line.length) ?
                    ['-'.repeat(res.at.columnNumber - 1) + '^']
                    : [];
                const context = lines
                    .slice(startLine, res.at.lineNumber)
                    .concat(caret)
                    .concat(lines.slice(res.at.lineNumber, endLine));
                const csplit = context.join('\n').trimEnd();
                if (csplit)
                    res.source = csplit + '\n';
            }
        }
    }
    if (res.at && res.at instanceof stack.CallSiteLike) {
        res.at = res.at.toJSON();
    }
    // show a line by line string diff
    // diff the yaml, to make it more humane, especially
    // when strings or buffers are very large or multi-line
    // the shipped compare methods will generally supply
    // their own diff, which is much nicer.
    if ('found' in res &&
        'wanted' in res &&
        res.found !== res.wanted &&
        !res.diff) {
        const f = res.found;
        const w = res.wanted;
        if (typeof f === 'string' && typeof w === 'string')
            res.diff = createTwoFilesPatch('expected', 'actual', w + '\n', f + '\n').replace(/^=+\n/, '');
        else if (f && w && typeof f === 'object' && typeof w === 'object') {
            const s = strict(f, w);
            if (!s.match) {
                res.diff = s.diff;
            }
            else {
                res.note = 'object identities differ';
            }
        }
        else {
            // some mixed stringly bits
            const ff = format(f);
            const fw = format(w);
            const fs = (typeof f === 'string' ? f : ff) + '\n';
            const ws = (typeof w === 'string' ? w : fw) + '\n';
            if (fw !== ff) {
                res.diff = createTwoFilesPatch('expected', 'actual', ws, fs).replace(/^=+\n/, '');
            }
        }
        if (res.diff === '--- expected\n+++ actual\n') {
            delete res.diff;
        }
        if (res.diff) {
            delete res.found;
            delete res.wanted;
        }
    }
    for (const [key, value] of Object.entries(res)) {
        if (shouldDeleteKey(key, value)) {
            delete res[key];
        }
    }
    // if the 'message' is a string, then we print it on the
    // test point, so no need to repeat in the diags
    if (typeof res.message === 'string')
        delete res.message;
    // worker: remove inline code
    if (res.eval === true &&
        typeof res.filename === 'string' &&
        res.filename.includes('\n')) {
        res.filename = '<inline code>';
    }
    // always put cause at the end.
    if (res.cause) {
        const { cause, ...props } = res;
        return { ...props, cause };
    }
    return res;
};
/**
 * Properties that are *always* removed from the diagnostics, either because
 * they are internal (eg, `time`), overly noisy (eg, `parent`), or captured
 * elsewhere in the TAP output (eg, `skip`).
 */
export const deleteAlways = new Set([
    'todo',
    'time',
    'childId',
    'cb',
    'name',
    'indent',
    'skip',
    'bail',
    'diagnostic',
    'buffered',
    'parent',
    // only relevant if activated, a failedTodo or failedSkip will be added
    'failSkip',
    'failTodo',
    'failOnly',
    // TODO: keys added by plugins, but referenced here
    // How can this list be adjusted by plugins?
    'grep',
    'grepInvert',
    'only',
    'saveFixture',
    'env',
]);
/**
 * Fields on this list are removed from YAML diagnostics if they are empty
 * (ie, falsey, empty array, or object with no keys)
 */
export const deleteIfEmpty = new Set([
    'at',
    'stack',
    'context',
    'debug',
    // TODO: keys added by plugins, but referenced here
    // How can this list be adjusted by plugins?
    'runOnly',
    'compareOptions',
]);
/**
 * Fields are removed from YAML diagnostics if they match any of these
 * patterns.
 */
export const deleteIfMatch = [
    /^_?tapChild/,
    /^tapStream/,
    // TODO: create a @tapjs/mocha plugin
    /^tapMochaTest/,
];
const shouldDeleteKey = (key, value) => deleteAlways.has(key) ||
    (deleteIfEmpty.has(key) && isEmpty(value)) ||
    deleteIfMatch.some(r => r.test(key));
// return true if object is empty, including inherited properties
const isEmpty = (obj) => {
    if (!obj) {
        return true;
    }
    if (typeof obj !== 'object') {
        return false;
    }
    if (Array.isArray(obj)) {
        return obj.length === 0;
    }
    for (const _ in obj) {
        return false;
    }
    return true;
};
//# sourceMappingURL=clean-yaml-object.js.map