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

import { assert, assertEquals } from "../../../std/testing/asserts.ts";
export {
  assert,
  assertThrows,
  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();

  for (const unitTestDefinition of REGISTERED_UNIT_TESTS) {
    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;
  perms?: UnitTestPermissions;
}

interface UnitTestDefinition extends Deno.TestDefinition {
  ignore: 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,
    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
        );
      }
    });
  }
);