// Copyright 2018-2020 the Deno authors. All rights reserved. MIT license.

import { assert, assertEquals } from "../../../std/testing/asserts.ts";
import * as colors from "../../../std/fmt/colors.ts";
export { colors };
import { resolve } from "../../../std/path/mod.ts";
export {
    assert,
    assertThrows,
    assertThrowsAsync,
    assertEquals,
    assertMatch,
    assertNotEquals,
    assertStrictEquals,
    assertStringContains,
    unreachable,
    fail,
} from "../../../std/testing/asserts.ts";
export { readLines } from "../../../std/io/bufio.ts";
export { parse as parseArgs } from "../../../std/flags/mod.ts";

export interface Permissions {
    read: boolean;
    write: boolean;
    net: boolean;
    env: boolean;
    run: boolean;
    plugin: boolean;
    hrtime: boolean;
}

export function fmtPerms(perms: Permissions): string {
    const p = Object.keys(perms)
        .filter((e): boolean => perms[e as keyof Permissions] === true)
        .map((key) => `--allow-${key}`);

    if (p.length) {
        return p.join(" ");
    }

    return "<no permissions>";
}

const isGranted = async (name: Deno.PermissionName): Promise<boolean> =>
    (await Deno.permissions.query({ name })).state === "granted";

export async function getProcessPermissions(): Promise<Permissions> {
    return {
        run: await isGranted("run"),
        read: await isGranted("read"),
        write: await isGranted("write"),
        net: await isGranted("net"),
        env: await isGranted("env"),
        plugin: await isGranted("plugin"),
        hrtime: await isGranted("hrtime"),
    };
}

export function permissionsMatch(
    processPerms: Permissions,
    requiredPerms: Permissions
): boolean {
    for (const permName in processPerms) {
        if (
            processPerms[permName as keyof Permissions] !==
            requiredPerms[permName as keyof Permissions]
        ) {
            return false;
        }
    }

    return true;
}

export const permissionCombinations: Map<string, Permissions> = new Map();

function permToString(perms: Permissions): string {
    const r = perms.read ? 1 : 0;
    const w = perms.write ? 1 : 0;
    const n = perms.net ? 1 : 0;
    const e = perms.env ? 1 : 0;
    const u = perms.run ? 1 : 0;
    const p = perms.plugin ? 1 : 0;
    const h = perms.hrtime ? 1 : 0;
    return `permR${r}W${w}N${n}E${e}U${u}P${p}H${h}`;
}

function registerPermCombination(perms: Permissions): void {
    const key = permToString(perms);
    if (!permissionCombinations.has(key)) {
        permissionCombinations.set(key, perms);
    }
}

export async function registerUnitTests(): Promise<void> {
    const processPerms = await getProcessPermissions();

    const onlyTests = REGISTERED_UNIT_TESTS.filter(({ only }) => only);
    const unitTests = onlyTests.length > 0 ? onlyTests : REGISTERED_UNIT_TESTS;
    for (const unitTestDefinition of unitTests) {
        if (!permissionsMatch(processPerms, unitTestDefinition.perms)) {
            continue;
        }

        Deno.test(unitTestDefinition);
    }
}

function normalizeTestPermissions(perms: UnitTestPermissions): Permissions {
    return {
        read: !!perms.read,
        write: !!perms.write,
        net: !!perms.net,
        run: !!perms.run,
        env: !!perms.env,
        plugin: !!perms.plugin,
        hrtime: !!perms.hrtime,
    };
}

interface UnitTestPermissions {
    read?: boolean;
    write?: boolean;
    net?: boolean;
    env?: boolean;
    run?: boolean;
    plugin?: boolean;
    hrtime?: boolean;
}

interface UnitTestOptions {
    ignore?: boolean;
    only?: boolean;
    perms?: UnitTestPermissions;
}

interface UnitTestDefinition extends Deno.TestDefinition {
    ignore: boolean;
    only: boolean;
    perms: Permissions;
}

type TestFunction = () => void | Promise<void>;

export const REGISTERED_UNIT_TESTS: UnitTestDefinition[] = [];

export function unitTest(fn: TestFunction): void;
export function unitTest(options: UnitTestOptions, fn: TestFunction): void;
export function unitTest(
    optionsOrFn: UnitTestOptions | TestFunction,
    maybeFn?: TestFunction
): void {
    assert(optionsOrFn, "At least one argument is required");

    let options: UnitTestOptions;
    let name: string;
    let fn: TestFunction;

    if (typeof optionsOrFn === "function") {
        options = {};
        fn = optionsOrFn;
        name = fn.name;
        assert(name, "Missing test function name");
    } else {
        options = optionsOrFn;
        assert(maybeFn, "Missing test function definition");
        assert(
            typeof maybeFn === "function",
            "Second argument should be test function definition"
        );
        fn = maybeFn;
        name = fn.name;
        assert(name, "Missing test function name");
    }

    const normalizedPerms = normalizeTestPermissions(options.perms || {});
    registerPermCombination(normalizedPerms);

    const unitTestDefinition: UnitTestDefinition = {
        name,
        fn,
        ignore: !!options.ignore,
        only: !!options.only,
        perms: normalizedPerms,
    };

    REGISTERED_UNIT_TESTS.push(unitTestDefinition);
}

export interface ResolvableMethods<T> {
    resolve: (value?: T | PromiseLike<T>) => void;
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    reject: (reason?: any) => void;
}

export type Resolvable<T> = Promise<T> & ResolvableMethods<T>;

export function createResolvable<T>(): Resolvable<T> {
    let methods: ResolvableMethods<T>;
    const promise = new Promise<T>((resolve, reject): void => {
        methods = { resolve, reject };
    });
    // TypeScript doesn't know that the Promise callback occurs synchronously
    // therefore use of not null assertion (`!`)
    return Object.assign(promise, methods!) as Resolvable<T>;
}

const encoder = new TextEncoder();

// Replace functions with null, errors with their stack strings, and JSONify.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function serializeTestMessage(message: any): string {
    return JSON.stringify({
        start: message.start && {
            ...message.start,
            tests: message.start.tests.map((test: Deno.TestDefinition) => ({
                ...test,
                fn: null,
            })),
        },
        testStart: message.testStart && { ...message.testStart, fn: null },
        testEnd: message.testEnd && {
            ...message.testEnd,
            error: String(message.testEnd.error?.stack),
        },
        end: message.end && {
            ...message.end,
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
            results: message.end.results.map((result: any) => ({
                ...result,
                error: result.error?.stack,
            })),
        },
    });
}

export async function reportToConn(
    conn: Deno.Conn,
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    message: any
): Promise<void> {
    const line = serializeTestMessage(message);
    const encodedMsg = encoder.encode(line + (message.end == null ? "\n" : ""));
    await Deno.writeAll(conn, encodedMsg);
    if (message.end != null) {
        conn.closeWrite();
    }
}

unitTest(function permissionsMatches(): void {
    assert(
        permissionsMatch(
            {
                read: true,
                write: false,
                net: false,
                env: false,
                run: false,
                plugin: false,
                hrtime: false,
            },
            normalizeTestPermissions({ read: true })
        )
    );

    assert(
        permissionsMatch(
            {
                read: false,
                write: false,
                net: false,
                env: false,
                run: false,
                plugin: false,
                hrtime: false,
            },
            normalizeTestPermissions({})
        )
    );

    assertEquals(
        permissionsMatch(
            {
                read: false,
                write: true,
                net: true,
                env: true,
                run: true,
                plugin: true,
                hrtime: true,
            },
            normalizeTestPermissions({ read: true })
        ),
        false
    );

    assertEquals(
        permissionsMatch(
            {
                read: true,
                write: false,
                net: true,
                env: false,
                run: false,
                plugin: false,
                hrtime: false,
            },
            normalizeTestPermissions({ read: true })
        ),
        false
    );

    assert(
        permissionsMatch(
            {
                read: true,
                write: true,
                net: true,
                env: true,
                run: true,
                plugin: true,
                hrtime: true,
            },
            {
                read: true,
                write: true,
                net: true,
                env: true,
                run: true,
                plugin: true,
                hrtime: true,
            }
        )
    );
});

/*
 * Ensure all unit test files (e.g. xxx_test.ts) are present as imports in
 * cli/tests/unit/unit_tests.ts as it is easy to miss this out
 */
unitTest(
    { perms: { read: true } },
    function assertAllUnitTestFilesImported(): void {
        const directoryTestFiles = [...Deno.readDirSync("./cli/tests/unit/")]
            .map((k) => k.name)
            .filter(
                (file) =>
                    file!.endsWith(".ts") &&
                    !file!.endsWith("unit_tests.ts") &&
                    !file!.endsWith("test_util.ts") &&
                    !file!.endsWith("unit_test_runner.ts")
            );
        const unitTestsFile: Uint8Array = Deno.readFileSync(
            "./cli/tests/unit/unit_tests.ts"
        );
        const importLines = new TextDecoder("utf-8")
            .decode(unitTestsFile)
            .split("\n")
            .filter((line) => line.startsWith("import"));
        const importedTestFiles = importLines.map(
            (relativeFilePath) => relativeFilePath.match(/\/([^\/]+)";/)![1]
        );

        directoryTestFiles.forEach((dirFile) => {
            if (!importedTestFiles.includes(dirFile!)) {
                throw new Error(
                    "cil/tests/unit/unit_tests.ts is missing import of test file: cli/js/" +
                        dirFile
                );
            }
        });
    }
);

export function pathToAbsoluteFileUrl(path: string): URL {
    path = resolve(path);

    return new URL(`file://${Deno.build.os === "windows" ? "/" : ""}${path}`);
}
