const assert = require("assert");
const Mocha = require("mocha");
const path = require("path");
const fs = require("fs");
const os = require("os");

/** @typedef {{
    file?: string;
    keepFailed?: boolean;
    reporter?: Mocha.ReporterConstructor | keyof Mocha.reporters;
    reporterOptions?: any; // TODO(jakebailey): what?
}} ReporterOptions */

/**
 * .failed-tests reporter
 *
 * @property {string} [file]
 * @property {boolean} [keepFailed]
 * @property {string|Mocha.ReporterConstructor} [reporter]
 * @property {*} [reporterOptions]
 */
class FailedTestsReporter extends Mocha.reporters.Base {
    /**
     * @param {Mocha.Runner} runner
     * @param {{ reporterOptions?: ReporterOptions }} [options]
     */
    constructor(runner, options) {
        super(runner, options);
        if (!runner) return;

        const reporterOptions = this.reporterOptions = options?.reporterOptions || {};
        if (reporterOptions.file === undefined) reporterOptions.file = ".failed-tests";
        if (reporterOptions.keepFailed === undefined) reporterOptions.keepFailed = false;
        if (reporterOptions.reporter) {
            /** @type {Mocha.ReporterConstructor} */
            let reporter;
            if (typeof reporterOptions.reporter === "function") {
                reporter = reporterOptions.reporter;
            }
            else if (Mocha.reporters[reporterOptions.reporter]) {
                reporter = Mocha.reporters[reporterOptions.reporter];
            }
            else {
                try {
                    reporter = require(reporterOptions.reporter);
                }
                catch (_) {
                    reporter = require(path.resolve(process.cwd(), reporterOptions.reporter));
                }
            }

            const newOptions = { ...options, reporterOptions: reporterOptions.reporterOptions || {} };
            if (reporterOptions.reporter === "xunit") {
                newOptions.reporterOptions.output = "TEST-results.xml";
            }
            this.reporter = new reporter(runner, newOptions);
        }

        /** @type {Mocha.Test[]} */
        this.passes = [];

        /** @type {(Mocha.Test)[]} */
        this.failures = [];

        runner.on("pass", test => this.passes.push(test));
        runner.on("fail", test => this.failures.push(test));
    }

    /**
     * @param {string} file
     * @param {ReadonlyArray<Mocha.Test>} passes
     * @param {ReadonlyArray<Mocha.Test | Mocha.Hook>} failures
     * @param {boolean} keepFailed
     * @param {(err?: NodeJS.ErrnoException | null) => void} done
     */
    static writeFailures(file, passes, failures, keepFailed, done) {
        const failingTests = new Set(fs.existsSync(file) ? readTests() : undefined);
        const possiblyPassingSuites = /**@type {Set<string>}*/(new Set());

        // Remove tests that are now passing and track suites that are now
        // possibly passing.
        if (failingTests.size > 0 && !keepFailed) {
            for (const test of passes) {
                failingTests.delete(test.fullTitle().trim());
                assert(test.parent);
                possiblyPassingSuites.add(test.parent.fullTitle().trim());
            }
        }

        // Add tests that are now failing. If a hook failed, track its
        // containing suite as failing. If the suite for a test or hook was
        // possibly passing then it is now definitely failing.
        for (const test of failures) {
            assert(test.parent);
            const suiteTitle = test.parent.fullTitle().trim();
            if (test.type === "test") {
                failingTests.add(test.fullTitle().trim());
            }
            else {
                failingTests.add(suiteTitle);
            }
            possiblyPassingSuites.delete(suiteTitle);
        }

        // Remove all definitely passing suites.
        for (const suite of possiblyPassingSuites) {
            failingTests.delete(suite);
        }

        if (failingTests.size > 0) {
            const failed = Array
                .from(failingTests)
                .sort()
                .join(os.EOL);
            fs.writeFile(file, failed, "utf8", done);
        }
        else if (!keepFailed && fs.existsSync(file)) {
            fs.unlink(file, done);
        }
        else {
            done();
        }

        function readTests() {
            return fs.readFileSync(file, "utf8")
                .split(/\r?\n/g)
                .map(line => line.trim())
                .filter(line => line.length > 0);
        }
    }

    /**
     * @param {number} failures
     * @param {(failures: number) => void} [fn]
     * @override
     */
    done(failures, fn) {
        assert(this.reporterOptions);
        assert(this.reporterOptions.file);
        FailedTestsReporter.writeFailures(this.reporterOptions.file, this.passes, this.failures, this.reporterOptions.keepFailed || this.stats.tests === 0, (err) => {
            const reporter = this.reporter;
            if (reporter && reporter.done) {
                reporter.done(failures, fn);
            }
            else if (fn) {
                fn(failures);
            }

            if (err) console.error(err);
        });
    }
}

module.exports = FailedTestsReporter;
