// =============================================================================
// DSL

type TestTree = TestGroup | Test;
type TestGroup = { name: string; children: TestTree[] };
type Test = { name: string; test: (t: TestController) => void };

// A registrar provides a central location to register test suites.
export class TestRegistrar {
    // Note that, while this is equivalent to a new tree node sans a name, the
    // lack of a name provides the illusion of multiple “top-level” suites,
    // while still allowing reporters to pick their favorite name—say, “JS
    // tests”—were they to need to label all suites together.
    #suites: TestGroup[];

    constructor() {
        this.#suites = [];
    }

    suite(name: string, children: TestTree[]) {
        checkDuplicates(name, children);
        this.#suites.push({ name, children });
    }

    run(reporter: Reporter) {
        for (const suite of this.#suites) {
            for (const c of suite.children) runTests(reporter, [suite.name], c);
        }
        reporter.finalize();
    }
}

export let GLOBAL_REGISTRAR = new TestRegistrar();

// Register a suite in the global registrar.
export function suite(name: string, children: TestTree[]) {
    GLOBAL_REGISTRAR.suite(name, children);
}

export function group(name: string, children: TestTree[]): TestTree {
    checkDuplicates(name, children);
    return { name, children };
}
export const context = group;
export const describe = group;

export function test(name: string, test: (t: TestController) => void): TestTree {
    return { name, test };
}

// While this function could certainly refine the type to a map instead of
// simply checking for duplicates and discarding that knowledge, these test
// trees are primarily for flooding—that is, iteration—for which an array is
// better suited.
function checkDuplicates(parent: string, names: { name: string }[]) {
    let seen = new Set<string>();
    for (const { name } of names) {
        if (seen.has(name)) {
            throw new RangeError(`duplicate name '${name}' in '${parent}'`);
        }
        seen.add(name);
    }
}

export type TestState = "success" | "failure" | "skip";

class AbortSentinel {}

export class TestController {
    #state: TestState;
    logs: string[];

    constructor() {
        this.#state = "success";
        this.logs = [];
    }

    getState(): TestState {
        return this.#state;
    }

    fail() {
        this.#state = "failure";
    }

    failed(): boolean {
        return this.#state === "failure";
    }

    failNow(): never {
        this.fail();
        throw new AbortSentinel();
    }

    log(message: string) {
        this.logs.push(message);
    }

    error(message: string) {
        this.log(message);
        this.fail();
    }

    fatal(message: string): never {
        this.log(message);
        this.failNow();
    }

    skip(message?: string): never {
        if (message != null) this.log(message);
        if (this.#state !== "failure") this.#state = "skip";
        throw new AbortSentinel();
    }

    skipped(): boolean {
        return this.#state === "skip";
    }
}

// =============================================================================
// Execution

export interface TestResult {
    state: TestState;
    logs: string[];
}

function runTests(reporter: Reporter, parents: string[], node: TestTree) {
    const path = [...parents, node.name];
    if ("children" in node) {
        for (const c of node.children) runTests(reporter, path, c);
        return;
    }
    let controller = new TestController();
    try {
        node.test(controller);
    } catch (e) {
        if (!(e instanceof AbortSentinel)) {
            controller.error(extractExceptionString(e));
        }
    }
    reporter.update(path, { state: controller.getState(), logs: controller.logs });
}

function extractExceptionString(e: any): string {
    // String() instead of .toString() as null and undefined don't have
    // properties.
    const s = String(e);
    if (!(e instanceof Error && e.stack)) return s;
    // v8 (Chromium, NodeJS) includes the error message, while Firefox and
    // WebKit do not.
    if (e.stack.startsWith(s)) return e.stack;
    return `${s}\n${e.stack}`;
}

// =============================================================================
// Reporting

export interface Reporter {
    // While we could simply call a function with a tree representing all
    // results, which would indeed greatly simplify implementation of reporters,
    // simply registering a path and allowing the reporter to—either implicitly
    // or explicitly—construct a tree themselves allows for results to be
    // *incrementally reported*, instead of a great deal of silence until all
    // tests finish.
    update(path: string[], result: TestResult): void;
    // With just update(), the reporter never knows when all tests have
    // completed. The simplest possible use for this is to notify the user, but
    // its intent is actually more tailored to the StreamReporter scenario:
    // while destructively updated report rendering (as with a tree of DOM nodes
    // which are mutated) always displays the results in a structured manner
    // matching that of the tests, “rerendering” or otherwise destructively
    // updating the rendered output might be infeasible in some paradigms, such
    // as command-line applications—all existing implementations of such
    // rendering both mess up the scrollback position and necessarily crop out
    // some of the data at the bottom (since the top of the tree is forced to be
    // at the top of the screen, as one cannot unscroll portably). Explicitly
    // signaling to the reporter that no more results will be received permits
    // it to simply display live test progress linearly, while building up
    // a tree and displaying it once it's known the tree is complete.
    finalize(): void;
}

// A reporter that diligently reports absolutely nothing. This is essentially
// a way to “undo” the incremental reporting update() provides, getting back the
// underlying result tree; this makes it extremely convenient in some cases like
// testing ourself.
export class NoOpReporter implements Reporter {
    results: ({ path: string[] } & TestResult)[];
    finalized: boolean;

    constructor() {
        this.results = [];
        this.finalized = false;
    }

    update(path: string[], result: TestResult) {
        this.results.push({ path, ...result });
    }

    finalize() {
        this.finalized = true;
    }
}

export interface Stream {
    writeln(s: string): void;
}

const SEP = " ❯ ";

// A simple reporter that outputs to some stream; suitable for CLIs.
export class StreamReporter implements Reporter {
    stream: Stream;
    verbose: boolean;
    #successes: ({ path: string[] } & TestResult)[];
    #failures: ({ path: string[] } & TestResult)[];
    #skips: ({ path: string[] } & TestResult)[];

    constructor(stream: Stream, verbose: boolean = false) {
        this.stream = stream;
        this.verbose = verbose;
        this.#successes = [];
        this.#failures = [];
        this.#skips = [];
    }

    succeeded(): boolean {
        return this.#successes.length > 0 && this.#failures.length === 0;
    }

    update(path: string[], result: TestResult) {
        if (path.length === 0) throw new RangeError("path is empty");
        const pathStr = path.join(SEP);
        switch (result.state) {
        case "success":
            this.#successes.push({ path, ...result });
            // NOTE: emojis are used instead of colored Unicode symbols as
            // coloring isn't possible through all streams and detecting if
            // colors should be used is very difficult¹. Furthermore, ensuring
            // reasonable contrast is retained on every possible theme is
            // difficult, with reverse video often being the only way (which
            // also has questionable support across terminal emulators), and the
            // Unicode characters might be too small to be immediately
            // noticeable—consider ✓ and ⚠ and ✗. Emojis have an upper hand in
            // that they're more common than obscure Unicode characters—which
            // also means you're likely to have an emoji font but not a font for
            // some weird symbols—and that they're double-width. Finally,
            // Unicode characters are often very different across fonts; some
            // fonts make ⚠ filled in, while others have just an outline for the
            // triangle (which is much harder to comprehend), and the various
            // crosses like all of x ⨯ × ✕ ✖ ✗ 🗙 🞨 🞩 🞪 🞫 🞬 🞭 🞮 look different
            // across different fonts, which makes using them for some specific
            // purpose difficult. Emojis don't have this problem because emoji
            // vendors try to make them look similar to each other.
            //
            // ¹This necessitates checking if the stream is a TTY, checking if
            //  $TERM is `dumb` when connected to a TTY, checking
            //  https://no-color.org, https://bixense.com/clicolors, and
            //  https://force-color.org, checking if setting the
            //  ENABLE_VIRTUAL_TERMINAL_PROCESSING bit on the TTY works when on
            //  on Windows, and doing something similar for Cygwin.
            if (this.verbose) this.stream.writeln(`✅️ ${pathStr}`);
            break;
        case "failure":
            this.#failures.push({ path, ...result });
            this.stream.writeln(`⚠️ ${pathStr}`);
            break;
        case "skip":
            this.#skips.push({ path, ...result });
            this.stream.writeln(`⏭️ ${pathStr}`);
            break;
        }
    }

    finalize() {
        if (this.verbose) this.#displaySection("successes", this.#successes, true);
        this.#displaySection("failures", this.#failures);
        this.#displaySection("skips", this.#skips);
        this.stream.writeln("");
        this.stream.writeln(
            `${this.#successes.length} succeeded, ${this.#failures.length} failed` +
                (this.#skips.length ? `, ${this.#skips.length} skipped` : ""),
        );
    }

    #displaySection(name: string, data: ({ path: string[] } & TestResult)[], ignoreEmpty: boolean = false) {
        if (!data.length) return;

        // Transform [{ path: ["a", "b", "c"] }, { path: ["a", "b", "d"] }] into
        // { "a ❯ b": ["c", "d"] }. NOTE: intermediate nodes are collapsed as
        // excessive nesting is difficult to convey clearly in a text-only
        // environment.
        let pathMap = new Map<string, ({ name: string } & TestResult)[]>();
        for (const t of data) {
            if (t.path.length === 0) throw new RangeError("path is empty");
            const key = t.path.slice(0, -1).join(SEP);
            if (!pathMap.has(key)) pathMap.set(key, []);
            pathMap.get(key)!.push({ name: t.path.at(-1)!, ...t });
        }

        this.stream.writeln("");
        this.stream.writeln(name.toUpperCase());
        this.stream.writeln("=".repeat(name.length));

        for (let [path, tests] of pathMap) {
            if (ignoreEmpty) tests = tests.filter((t) => t.logs.length);
            if (tests.length === 0) continue;
            if (tests.length === 1) {
                this.#writeOutput(tests[0], path ? `${path}${SEP}` : "", false);
            } else {
                this.stream.writeln(path);
                for (const t of tests) this.#writeOutput(t, "  - ", true);
            }
        }
    }

    #writeOutput(test: { name: string } & TestResult, prefix: string, nested: boolean) {
        let output = "";
        if (test.logs.length) {
            // Individual logs might span multiple lines, so join them together
            // then split it again.
            const logStr = test.logs.join("\n");
            const lines = logStr.split("\n");
            if (lines.length <= 1) {
                output = `: ${logStr}`;
            } else {
                const padding = nested ? "      " : "  ";
                output = ":\n" + lines.map((line) => padding + line).join("\n");
            }
        }
        this.stream.writeln(`${prefix}${test.name}${output}`);
    }
}

function assertGetElementById(id: string): HTMLElement {
    let elem = document.getElementById(id);
    if (elem == null) throw new ReferenceError(`element with ID '${id}' missing from DOM`);
    return elem;
}

// A reporter that directly translates a tree of results into a tree of
// collapsible elements in the DOM.
export class DOMReporter implements Reporter {
    update(path: string[], result: TestResult) {
        if (path.length === 0) throw new RangeError("path is empty");
        if (result.state === "skip") {
            assertGetElementById("skip-counter-text").hidden = false;
        }
        const counter = assertGetElementById(`${result.state}-counter`);
        counter.innerText = (Number(counter.innerText) + 1).toString();

        let parent = assertGetElementById("root");
        for (const node of path) {
            let child: HTMLDetailsElement | null = null;
            let summary: HTMLElement | null = null;
            let d: Element;
            outer: for (d of parent.children) {
                if (!(d instanceof HTMLDetailsElement)) continue;
                for (const s of d.children) {
                    if (!(s instanceof HTMLElement)) continue;
                    if (!(s.tagName === "SUMMARY" && s.innerText === node)) continue;
                    child = d;
                    summary = s;
                    break outer;
                }
            }
            if (!child) {
                child = document.createElement("details");
                child.className = "test-node";
                child.ariaRoleDescription = "test";
                summary = document.createElement("summary");
                summary.appendChild(document.createTextNode(node));
                summary.ariaRoleDescription = "test name";
                child.appendChild(summary);
                parent.appendChild(child);
            }
            if (!summary) throw new Error("unreachable as assigned above");

            switch (result.state) {
            case "failure":
                // Only expand failures, to minimize successes and skips.
                child.open = true;
                child.classList.add("failure");
                child.classList.remove("skip");
                child.classList.remove("success");
                // The summary marker does not appear in the AOM, so setting its
                // alt text is fruitless; label the summary itself instead.
                summary.setAttribute("aria-labelledby", "failure-description");
                break;
            case "skip":
                if (child.classList.contains("failure")) break;
                child.classList.add("skip");
                child.classList.remove("success");
                summary.setAttribute("aria-labelledby", "skip-description");
                break;
            case "success":
                if (child.classList.contains("failure") || child.classList.contains("skip")) break;
                child.classList.add("success");
                summary.setAttribute("aria-labelledby", "success-description");
                break;
            }

            parent = child;
        }

        const p = document.createElement("p");
        p.classList.add("test-desc");
        if (result.logs.length) {
            const pre = document.createElement("pre");
            pre.appendChild(document.createTextNode(result.logs.join("\n")));
            p.appendChild(pre);
        } else {
            p.classList.add("italic");
            p.appendChild(document.createTextNode("No output."));
        }
        parent.appendChild(p);
    }

    finalize() {}
}
