/**
 * The base class that is extended by all TAP-generating classes
 *
 * @module
 */
import { Domain } from 'async-hook-domain';
import { AsyncResource } from 'async_hooks';
import { Minipass } from 'minipass';
import { hrtime } from 'node:process';
import { format } from 'node:util';
import { Parser } from 'tap-parser';
import { Counts } from './counts.js';
import { diags } from './diags.js';
import { extraFromError } from './extra-from-error.js';
import { Lists } from './lists.js';
import { messageFromError } from './message-from-error.js';
const unsetContext = Symbol('TAP.context unset');
/**
 * Wrapper for the async-hook-domain that catches errors thrown during
 * test operation.
 *
 * @see {@link https://npmjs.com/async-hook-domain}
 */
export class TapWrap extends AsyncResource {
    test;
    onDestroy;
    constructor(test) {
        super(`tap.${test.constructor.name}`);
        this.test = test;
    }
    emitDestroy() {
        this.onDestroy?.();
        super.emitDestroy();
        return this;
    }
}
const debug = (name) => (...args) => {
    const prefix = `TAP ${process.pid} ${name}: `;
    const msg = format(...args).trim();
    console.error(prefix + msg.split('\n').join(`\n${prefix}`));
};
export class Base extends Minipass {
    /**
     * Set upon test completion when a child test is ready to be processed by its
     * parent.
     */
    readyToProcess = false;
    /**
     * Options provided to this test
     */
    options;
    /**
     * number of spaces to indent the TAP stream
     */
    indent;
    /**
     * TapWrap AsyncResource that limits the async-hook-domain
     *
     * @group Internal Machinery
     */
    hook;
    // this actually is deterministically set in the ctor, but
    // in the hook, so tsc doesn't see it.
    /**
     * the async-hook-domain that catches throws and Promise rejections
     *
     * @group Internal Machinery
     */
    hookDomain;
    /**
     * The timer that fires when the test times out
     *
     * @group Internal Machinery
     */
    timer;
    /**
     * Set to true when the test times out, so its failure status can be
     * determined later.
     */
    timedOut = false;
    /**
     * The tap parser attached to this TAP stream
     *
     * @group Internal Machinery
     */
    parser;
    /**
     * Method that writes to stderr when `debug: true` is set in the options,
     * or no-ops otherwise
     */
    debug;
    /**
     * The count of all assertions that this stream emitted
     *
     * @group Test Reflection
     */
    counts;
    /**
     * Lists of todo, skip, and failure test points. If `passes: true` is
     * set in the options, then passing test points will also be tracked.
     *
     * @group Test Reflection
     */
    lists;
    /**
     * the name of this test
     *
     * @group Test Reflection
     */
    name;
    /**
     * Set on completion. The results of the test run.
     *
     * @group Test Reflection
     */
    results;
    /**
     * Parent test of this TAP stream
     *
     * @group Internal Machinery
     */
    parent;
    /**
     * Nesting level, for serialization to node test runner
     *
     * Note that this is zero for parent-less tests, and *also* zero
     * for the first level of children.
     */
    nestingLevel = 0;
    /**
     * Bail out on the first failed test point
     */
    bail;
    /**
     * Treat non-TAP data as an error.
     * May be set with `pragma +strict` in the TAP stream, or unset with
     * `pragma: -strict`.
     */
    strict;
    /**
     * Do not emit the `TAP version 14` line at the start
     */
    omitVersion;
    /**
     * Do not elide extraneous whitespace and empty lines.
     */
    preserveWhitespace;
    /**
     * Unrecoverable TAP protocol errors in the stream
     *
     * @group Test Reflection
     */
    errors;
    /**
     * Numeric identifier for this test
     */
    childId;
    /**
     * Any arbitrary data that is provided to this test object. Often, this
     * is set in a `t.before()` or `t.beforeEach()` method. Scalar values
     * are inherited by child tests. Object values are extended in child
     * tests using `Object.create()`.
     */
    #context = unsetContext;
    get context() {
        if (this.#context === unsetContext)
            this.#context = Object.create(null);
        return this.#context;
    }
    set context(c) {
        this.#context = c;
    }
    /**
     * the TAP stream data for buffered tests
     *
     * @internal
     *
     * @group Internal Machinery
     */
    output;
    /**
     * True if this test should be buffered and only processed on completion
     *
     * @internal
     *
     * @group Internal Machinery
     */
    buffered;
    /**
     * True if this test emitted a bailout
     *
     * @group Test Reflection
     */
    bailedOut;
    /**
     * high resolution bigint time when this test started
     *
     * @group Internal Machinery
     */
    start;
    #started = false;
    /**
     * Amount of time in milliseconds that this test took to complete.
     *
     * @group Test Reflection
     */
    time;
    /**
     * High resolution time in ns that this test took to complete.
     *
     * @group Test Reflection
     */
    hrtime;
    /**
     * True if this test should be buffered and only emit data if it fails
     */
    silent;
    /**
     * A `Deferred` promise wrapper that is resolved when this test completes.
     *
     * @group Internal Machinery
     */
    deferred;
    #printedOutput = false;
    #writable = true;
    constructor(options = {}) {
        super({ encoding: 'utf8' });
        // always use the constructor name as toStringTag, so we get
        // [object Test] or [object TAP] and the appropriate typeName
        // in stack traces.
        Object.defineProperty(this, Symbol.toStringTag, {
            value: this.constructor.name,
            configurable: true,
        });
        // all tap streams are sync string minipasses
        this.hook = new TapWrap(this);
        this.options = options;
        this.counts = new Counts();
        this.lists = new Lists();
        this.silent = !!options.silent;
        this.bail = !!options.bail;
        this.strict = !!options.strict;
        this.omitVersion = !!options.omitVersion;
        this.preserveWhitespace = options.preserveWhitespace !== false;
        this.buffered =
            this.silent ? options.buffered === undefined : !!options.buffered;
        this.bailedOut = false;
        this.errors = [];
        this.parent = options.parent;
        if (this.parent?.parent) {
            this.nestingLevel = this.parent.nestingLevel + 1;
        }
        this.time = 0;
        this.hrtime = 0n;
        this.start = 0n;
        this.childId = options.childId || 0;
        // do we need this?  couldn't we just call the Minipass
        this.output = '';
        this.indent = options.indent || '';
        this.name = options.name || '(unnamed test)';
        this.hook.runInAsyncScope(() => {
            this.hookDomain = new Domain((er, type) => {
                /* c8 ignore start */
                if (!er || typeof er !== 'object') {
                    er = { error: er };
                }
                /* c8 ignore stop */
                ;
                er.tapCaught = type;
                this.threw(er);
            });
        });
        this.debug = !!options.debug ? debug(this.name) : () => { };
        this.parser =
            options.parser ||
                new Parser({
                    bail: this.bail,
                    strict: this.strict,
                    omitVersion: this.omitVersion,
                    preserveWhitespace: this.preserveWhitespace,
                    passes: this.options.passes,
                    name: this.name,
                });
        this.#setupParser();
        // ensure that a skip or todo on a child class reverts
        // back to Base's no-op main.
        if (options.skip || options.todo) {
            this.main = Base.prototype.main;
        }
    }
    #isFilterSkip(res) {
        return (typeof res.skip === 'string' &&
            /^filter: (only|\/.*\/)$/.test(res.skip));
    }
    #onParserResult(res) {
        this.counts.total++;
        const type = res.todo ? 'todo'
            : res.skip ? 'skip'
                : !res.ok ? 'fail'
                    : 'pass';
        this.counts[type]++;
        if (type === 'pass' && this.options.passes) {
            this.lists.pass.push(res);
        }
        else if (type === 'todo') {
            this.lists.todo.push(res);
        }
        else if (type === 'skip') {
            if (!this.#isFilterSkip(res))
                this.lists.skip.push(res);
        }
        else if (type === 'fail') {
            this.lists.fail.push(res);
        }
    }
    #setupParser() {
        this.parser.on('line', l => this.#online(l));
        this.parser.once('bailout', reason => this.onbail(reason));
        this.parser.on('complete', result => this.oncomplete(result));
        this.parser.on('result', res => this.#onParserResult(res));
    }
    /**
     * Set the amount of time in milliseconds before this test is considered
     * a timeout. The time is counted from right now, so for example, repeatedly
     * calling `t.setTimeout(100)` can keep it going indefinitely, as long as
     * you call it more often than every 100ms.
     *
     * Calling `setTimeout(0)` will remove the timer and allow the test to run
     * indefinitely.
     *
     * @group Test Lifecycle Management
     */
    setTimeout(n) {
        if (this.timer) {
            clearTimeout(this.timer);
        }
        if (n <= 0) {
            this.timer = undefined;
            delete this.options.timeout;
        }
        else {
            this.options.timeout = n;
            this.timer = setTimeout(() => this.timeout(), n);
            /* c8 ignore start */
            if (this.timer.unref)
                this.timer.unref();
            /* c8 ignore stop */
        }
    }
    /**
     * Called when a timeout occurs. Only exposed because it has to be called
     * and/or extended by other classes, which all have their own sorts of
     * timeout behavior specific to the type of thing they represent.
     *
     * @internal
     *
     * @group Internal Machinery
     */
    timeout(options = {
        expired: this.name,
        message: 'timeout!',
    }) {
        this.timedOut = true;
        const { message = 'timeout!' } = options;
        this.setTimeout(0);
        options.expired = options.expired || this.name;
        // timeouts don't generally have a useful callsite information,
        // and no sense trying to capture it from @tapjs/stack
        const extra = {
            ...options,
            message,
            stack: '',
            at: {},
        };
        const threw = this.threw({ message }, extra);
        delete extra.stack;
        delete extra.at;
        if (threw) {
            this.emit('timeout', threw);
        }
    }
    /**
     * Run the `main` test function. Called by
     * {@link @tapjs/core!test-base.TestBase} when starting a subtest.
     * Initializes the TapWrap hook
     *
     * @internal
     *
     * @group Internal Machinery
     */
    runMain(cb) {
        this.debug('BASE runMain');
        this.start = hrtime.bigint();
        this.#started = true;
        // if it's null or an object, inherit from it.  otherwise, copy it.
        if (this.#context === unsetContext) {
            const ctx = ('context' in this.options ?
                this.options.context
                : this.parent?.context) ?? null;
            this.#context = typeof ctx === 'object' ? Object.create(ctx) : ctx;
        }
        this.hook.runInAsyncScope(this.main, this, cb);
    }
    /**
     * Returns true if this test has begun
     *
     * @group Test Reflection
     */
    get started() {
        return this.#started;
    }
    /**
     * True if the test has printed *some* output of any kind
     *
     * @group Test Reflection
     */
    get printedOutput() {
        return this.#printedOutput;
    }
    /**
     * Boolean indicating whether the underlying stream can be written to,
     * or if it has been ended.
     */
    get streamWritable() {
        return this.#writable;
    }
    /**
     * The main test function. For this Base class, this is a no-op. Subclasses
     * implement this in their specific ways.
     *
     * @internal
     *
     * @group Internal Machinery
     */
    main(cb) {
        cb();
    }
    /**
     * Stream write method.
     *
     * For buffered tests, this collects the output in the
     * {@link @tapjs/core!base.Base#output}
     * field. Sets {@link @tapjs/core!base.Base#printedOutput} to `true` when
     * called.
     *
     * Note: you *probably* never need to call this. Instead, use the various
     * assertion methods and other parts of the API.
     *
     * @internal
     *
     * @group Internal Machinery
     */
    write(c) {
        this.#printedOutput = true;
        if (this.buffered || this.silent) {
            // need the silent output if it fails
            this.output += c;
            return true;
        }
        if (!this.#writable) {
            return false;
        }
        return super.write(c);
    }
    /**
     * Method called when the parser encounters a bail out
     *
     * Extended by {@link @tapjs/core!test-base.TestBase}
     */
    onbail(reason) {
        this.bailedOut = reason || true;
        this.emit('bailout', reason);
    }
    /**
     * Method called when parser emits a line of TAP data
     *
     * @internal
     *
     * @group Internal Machinery
     */
    #online(line) {
        this.debug('LINE %j', line, [this.name, this.indent]);
        return this.write(this.indent + line);
    }
    /**
     * Method called when the parser completes and emits its final results
     *
     * Extended by {@link @tapjs/core!worker.Worker} and
     * {@link @tapjs/core!tap.TAP} classes
     *
     * @internal
     *
     * @group Internal Machinery
     */
    oncomplete(results) {
        if (this.start) {
            this.hrtime = hrtime.bigint() - this.start;
            this.time =
                results.time || Math.floor(Number(this.hrtime) / 1000) / 1000;
        }
        this.debug('ONCOMPLETE %j %j', this.name, results);
        // should only ever happen once, but just in case
        /* c8 ignore start */
        if (this.results) {
            Object.assign(results, this.results);
        }
        /* c8 ignore stop */
        this.results = results;
        this.emit('complete', results);
        const errors = results.failures
            .filter(f => f.tapError)
            .map(f => {
            delete f.diag;
            delete f.ok;
            return f;
        });
        if (errors.length) {
            this.errors = errors;
        }
        this.#writable = false;
        super.end();
    }
    /**
     * extension point for plugins that want to be notified when the test
     * is about to end, whether explicitly or implicitly.
     *
     * If the function returns a Promise, it will be awaited before ending
     * the TAP stream.
     *
     * @internal
     *
     * @group Internal Machinery
     */
    onbeforeend() { }
    /**
     * extension point for plugins that want to be notified when the test
     * is completely done, and terminating its parser.
     *
     * @internal
     *
     * @group Internal Machinery
     */
    onEOF() { }
    /**
     * extension point for TestBase to know when a child tests is done being
     * processed and it's safe to move on to the next one.
     *
     * @internal
     *
     * @group Internal Machinery
     */
    ondone() { }
    /**
     * EventEmitter emit method, but closes the
     * {@link @tapjs/core!base.Base#hook} and
     * {@link @tapjs/core!base.Base#hookDomain} when emitting `'end'`.
     *
     * @internal
     *
     * @group Internal Machinery
     */
    emit(ev, ...data) {
        const ret = super.emit(ev, ...data);
        if (ev === 'end') {
            this.ondone();
            this.hook.emitDestroy();
            this.hookDomain.destroy();
        }
        return ret;
    }
    /**
     * Mark the test as ended. In this base class, this is a no-op, but
     * in {@link @tapjs/core!test-base.TestBase}, it also triggers the other
     * end-of-test operations.
     */
    end() {
        return this;
    }
    /**
     * Method called when an unrecoverable error is encountered in a test.
     *
     * Typically, in tests you would not call this, you'd just actually throw
     * an error.
     *
     * @internal
     *
     * @group Internal Machinery
     */
    threw(er, extra, proxy = false, ended = false) {
        this.debug('BASE.threw', er);
        this.hook.emitDestroy();
        this.hookDomain.destroy();
        if (typeof er === 'string') {
            er = { message: er };
        }
        else if (!er || typeof er !== 'object') {
            er = { error: er };
        }
        if (this.name && !proxy) {
            er.test = this.name;
        }
        const message = messageFromError(er);
        if (!extra) {
            extra = extraFromError(er, extra);
        }
        extra.message = message;
        this.parser.ok = false;
        // if possible to handle it here, then return the info so that this
        // Base subclass can do its thing
        if (!ended &&
            !this.results &&
            /* c8 ignore start */
            (this.parser.planEnd === -1 ||
                this.parser.count < this.parser.planEnd)
        /* c8 ignore stop */
        ) {
            return extra;
        }
        // if we ended, we have to report it SOMEWHERE, unless we're
        // already in the process of bailing out, in which case it's
        // a bit excessive. Do not print it here if it would trigger
        // a plan exceeded error, or if we already have results.
        this.debug('Base.threw, but finished', this.name, this.results, extra.message);
        const alreadyBailing = (this.results?.ok === false && this.bail) ||
            this.parser.bailedOut ||
            this.results?.bailout;
        if (this.results)
            this.results.ok = false;
        if (this.parent) {
            this.parent.threw(er, extra, true);
        }
        else if (!alreadyBailing) {
            // not already bailing out, so print the error as best we can
            if (!er.stack) {
                console.error(er);
            }
            else {
                /* c8 ignore start */
                const name = er.name || 'Error';
                /* c8 ignore stop */
                console.error('%s: %s', name, extra.message);
                console.error(diags(extra));
            }
        }
        // we are already bailing out, and this is the top level,
        // just make our way hastily to the nearest exit.
    }
    /**
     * returns true if the test has not as yet encountered any failures
     *
     * @group Test Reflection
     */
    passing() {
        return this.parser.ok && this.results?.ok !== false;
    }
}
//# sourceMappingURL=base.js.map