import * as fs from "fs";
import * as process from "process";
import { promisify } from "util";

import * as _ from "lodash";
import { Moment } from "moment";
import * as moment from "moment";
import PQueue from "p-queue";
import * as rimraf from "rimraf";
import { v4 as getUUID } from "uuid";

import * as api from "./api";
import { LangConfig, langs } from "./langs";

function parseIntOr(thing: any, def: number) {
  const num = parseInt(thing);
  return Number.isNaN(num) ? def : num;
}

const TIMEOUT_FACTOR = parseIntOr(process.env.TIMEOUT_FACTOR, 1);
const CONCURRENCY = parseIntOr(process.env.CONCURRENCY, 2);
const BASE_TIMEOUT_SECS = 5;

function findPosition(str: string, idx: number) {
  const lines = str.substring(0, idx).split("\n");
  const line = lines.length - 1;
  const character = lines[lines.length - 1].length;
  return { line, character };
}

async function sendInput(send: (msg: any) => any, input: string) {
  for (const line of input.split("\n")) {
    if (line.startsWith("DELAY:")) {
      const delay = parseFloat(line.replace(/DELAY: */, ""));
      if (Number.isNaN(delay)) continue;
      await new Promise((resolve) =>
        setTimeout(resolve, delay * 1000 * TIMEOUT_FACTOR)
      );
    } else {
      send({ event: "terminalInput", input: line + "\r" });
    }
  }
}

class Test {
  lang: string;
  type: string;
  messages: any[] = [];
  timedOut: boolean = false;
  handledMessages: number = 0;
  handleUpdate: () => void = () => {};
  startTime: Moment | null = null;

  get config() {
    return langs[this.lang];
  }

  ws: any = null;

  record = (msg: any) => {
    const dur = moment.duration(moment().diff(this.startTime!));
    this.messages.push({ time: dur.asSeconds(), ...msg });
  };

  send = (msg: any) => {
    this.ws.onMessage(JSON.stringify(msg));
    this.record(msg);
    this.handledMessages += 1;
  };

  constructor(lang: string, type: string) {
    this.lang = lang;
    this.type = type;
  }

  getLog = (opts?: any) => {
    opts = opts || {};
    return this.messages
      .map((msg: any) => JSON.stringify(msg, null, opts.pretty && 2))
      .join("\n");
  };

  run = async () => {
    if ((this.config.skip || []).includes(this.type)) {
      return "skipped";
    }
    this.startTime = moment();
    let session = null;
    let timeout = null;
    try {
      const that = this;
      this.ws = {
        on: function (type: string, handler: any) {
          switch (type) {
            case "message":
              this.onMessage = handler;
              for (const msg of this.messageQueue) {
                this.onMessage(msg);
              }
              this.messageQueue = [];
              break;
            case "close":
            case "error":
              // No need to clean up, we'll call teardown() explicitly.
              break;
            default:
              throw new Error(`unexpected websocket handler type: ${type}`);
          }
        },
        onMessage: function (msg: any) {
          this.messageQueue.push(msg);
        },
        messageQueue: [] as any[],
        send: function (data: string) {
          that.record(JSON.parse(data));
          that.handleUpdate();
        },
        terminate: function () {},
      };
      session = new api.Session(this.ws, this.lang, (msg: string) => {
        this.record({ event: "serverLog", message: msg });
      });
      timeout = setTimeout(() => {
        this.timedOut = true;
        this.handleUpdate();
      }, (this.config.timeout || BASE_TIMEOUT_SECS) * 1000 * TIMEOUT_FACTOR);
      await session.setup();
      switch (this.type) {
        case "ensure":
          await this.testEnsure();
          break;
        case "run":
          await this.testRun();
          break;
        case "repl":
          await this.testRepl();
          break;
        case "runrepl":
          await this.testRunRepl();
          break;
        case "scope":
          await this.testScope();
          break;
        case "format":
          await this.testFormat();
          break;
        case "lsp":
          await this.testLsp();
          break;
        default:
          throw new Error(`Unexpected test type: ${this.type}`);
      }
    } finally {
      this.ws = null;
      if (timeout) {
        clearTimeout(timeout);
      }
      if (session) {
        await session.teardown();
      }
    }
  };

  wait = async <T>(desc: string, handler: (msg: any) => T) => {
    return await new Promise((resolve, reject) => {
      this.handleUpdate = () => {
        if (this.timedOut) {
          reject(new Error(`timeout while waiting for ${desc}`));
        } else {
          while (this.handledMessages < this.messages.length) {
            const msg = this.messages[this.handledMessages];
            const result = handler(msg);
            if (![undefined, null, false].includes(result as any)) {
              resolve(result);
            }
            this.handledMessages += 1;
          }
        }
      };
      this.handleUpdate();
    });
  };

  waitForOutput = async (pattern: string, maxLength?: number) => {
    let output = "";
    return await this.wait(`output ${JSON.stringify(pattern)}`, (msg: any) => {
      const prevLength = output.length;
      if (msg.event === "terminalOutput") {
        output += msg.output;
      }
      if (typeof maxLength === "number") {
        return (
          output
            .substring(prevLength - maxLength)
            .match(new RegExp(pattern)) !== null
        );
      } else {
        return output.indexOf(pattern, prevLength - pattern.length) != -1;
      }
    });
  };

  testEnsure = async () => {
    this.send({ event: "ensure" });
    const code = await this.wait("ensure response", (msg: any) => {
      if (msg.event === "ensured") {
        return msg.code;
      }
    });
    if (code !== 0) {
      throw new Error(`ensure failed with code ${code}`);
    }
  };
  testRun = async () => {
    const pattern = this.config.hello || "Hello, world!";
    this.send({ event: "runCode", code: this.config.template });
    if (this.config.helloInput !== undefined) {
      sendInput(this.send, this.config.helloInput);
    }
    await this.waitForOutput(pattern, this.config.helloMaxLength);
  };
  testRepl = async () => {
    const input = this.config.input || "123 * 234";
    const output = this.config.output || "28782";
    sendInput(this.send, input);
    await this.waitForOutput(output);
  };
  testRunRepl = async () => {
    const input = this.config.runReplInput || this.config.input || "123 * 234";
    const output = this.config.runReplOutput || this.config.output || "28782";
    this.send({ event: "runCode", code: this.config.template });
    sendInput(this.send, input);
    await this.waitForOutput(output);
  };
  testScope = async () => {
    const code = this.config.scope!.code;
    const after = this.config.scope!.after;
    const input = this.config.scope!.input || "x";
    const output = this.config.scope!.output || "28782";
    let allCode = this.config.template;
    if (!allCode.endsWith("\n")) {
      allCode += "\n";
    }
    if (after) {
      allCode = allCode.replace(after + "\n", after + "\n" + code + "\n");
    } else {
      allCode = allCode + code + "\n";
    }
    this.send({ event: "runCode", code: allCode });
    sendInput(this.send, input);
    await this.waitForOutput(output);
  };
  testFormat = async () => {
    const input = this.config.format!.input;
    const output = this.config.format!.output || this.config.template;
    this.send({ event: "formatCode", code: input });
    const result = await this.wait("formatter response", (msg: any) => {
      if (msg.event === "formattedCode") {
        return msg.code;
      }
    });
    if (output !== result) {
      throw new Error("formatted code did not match");
    }
  };
  testLsp = async () => {
    const insertedCode = this.config.lsp!.code!;
    const after = this.config.lsp!.after;
    const item = this.config.lsp!.item!;
    const idx = after
      ? this.config.template.indexOf(after) + after.length
      : this.config.template.length;
    const code =
      this.config.template.slice(0, idx) +
      insertedCode +
      this.config.template.slice(idx);
    const root = await this.wait("lspStarted message", (msg: any) => {
      if (msg.event === "lspStarted") {
        return msg.root;
      }
    });
    this.send({
      event: "lspInput",
      input: {
        jsonrpc: "2.0",
        id: "0d75333a-47d8-4da8-8030-c81d7bd9eed7",
        method: "initialize",
        params: {
          processId: null,
          clientInfo: { name: "vscode" },
          rootPath: root,
          rootUri: `file://${root}`,
          capabilities: {
            workspace: {
              applyEdit: true,
              workspaceEdit: {
                documentChanges: true,
                resourceOperations: ["create", "rename", "delete"],
                failureHandling: "textOnlyTransactional",
              },
              didChangeConfiguration: { dynamicRegistration: true },
              didChangeWatchedFiles: { dynamicRegistration: true },
              symbol: {
                dynamicRegistration: true,
                symbolKind: {
                  valueSet: [
                    1,
                    2,
                    3,
                    4,
                    5,
                    6,
                    7,
                    8,
                    9,
                    10,
                    11,
                    12,
                    13,
                    14,
                    15,
                    16,
                    17,
                    18,
                    19,
                    20,
                    21,
                    22,
                    23,
                    24,
                    25,
                    26,
                  ],
                },
              },
              executeCommand: { dynamicRegistration: true },
              configuration: true,
              workspaceFolders: true,
            },
            textDocument: {
              publishDiagnostics: {
                relatedInformation: true,
                versionSupport: false,
                tagSupport: { valueSet: [1, 2] },
              },
              synchronization: {
                dynamicRegistration: true,
                willSave: true,
                willSaveWaitUntil: true,
                didSave: true,
              },
              completion: {
                dynamicRegistration: true,
                contextSupport: true,
                completionItem: {
                  snippetSupport: true,
                  commitCharactersSupport: true,
                  documentationFormat: ["markdown", "plaintext"],
                  deprecatedSupport: true,
                  preselectSupport: true,
                  tagSupport: { valueSet: [1] },
                },
                completionItemKind: {
                  valueSet: [
                    1,
                    2,
                    3,
                    4,
                    5,
                    6,
                    7,
                    8,
                    9,
                    10,
                    11,
                    12,
                    13,
                    14,
                    15,
                    16,
                    17,
                    18,
                    19,
                    20,
                    21,
                    22,
                    23,
                    24,
                    25,
                  ],
                },
              },
              hover: {
                dynamicRegistration: true,
                contentFormat: ["markdown", "plaintext"],
              },
              signatureHelp: {
                dynamicRegistration: true,
                signatureInformation: {
                  documentationFormat: ["markdown", "plaintext"],
                  parameterInformation: { labelOffsetSupport: true },
                },
                contextSupport: true,
              },
              definition: { dynamicRegistration: true, linkSupport: true },
              references: { dynamicRegistration: true },
              documentHighlight: { dynamicRegistration: true },
              documentSymbol: {
                dynamicRegistration: true,
                symbolKind: {
                  valueSet: [
                    1,
                    2,
                    3,
                    4,
                    5,
                    6,
                    7,
                    8,
                    9,
                    10,
                    11,
                    12,
                    13,
                    14,
                    15,
                    16,
                    17,
                    18,
                    19,
                    20,
                    21,
                    22,
                    23,
                    24,
                    25,
                    26,
                  ],
                },
                hierarchicalDocumentSymbolSupport: true,
              },
              codeAction: {
                dynamicRegistration: true,
                isPreferredSupport: true,
                codeActionLiteralSupport: {
                  codeActionKind: {
                    valueSet: [
                      "",
                      "quickfix",
                      "refactor",
                      "refactor.extract",
                      "refactor.inline",
                      "refactor.rewrite",
                      "source",
                      "source.organizeImports",
                    ],
                  },
                },
              },
              codeLens: { dynamicRegistration: true },
              formatting: { dynamicRegistration: true },
              rangeFormatting: { dynamicRegistration: true },
              onTypeFormatting: { dynamicRegistration: true },
              rename: { dynamicRegistration: true, prepareSupport: true },
              documentLink: { dynamicRegistration: true, tooltipSupport: true },
              typeDefinition: { dynamicRegistration: true, linkSupport: true },
              implementation: { dynamicRegistration: true, linkSupport: true },
              colorProvider: { dynamicRegistration: true },
              foldingRange: {
                dynamicRegistration: true,
                rangeLimit: 5000,
                lineFoldingOnly: true,
              },
              declaration: { dynamicRegistration: true, linkSupport: true },
            },
          },
          initializationOptions: this.config.lsp!.init || {},
          trace: "off",
          workspaceFolders: [
            {
              uri: `file://${root}`,
              name: `file://${root}`,
            },
          ],
        },
      },
    });
    await this.wait("response to lsp initialize", (msg: any) => {
      return (
        msg.event === "lspOutput" &&
        msg.output.id === "0d75333a-47d8-4da8-8030-c81d7bd9eed7"
      );
    });
    this.send({
      event: "lspInput",
      input: { jsonrpc: "2.0", method: "initialized", params: {} },
    });
    this.send({
      event: "lspInput",
      input: {
        jsonrpc: "2.0",
        method: "textDocument/didOpen",
        params: {
          textDocument: {
            uri: `file://${root}/${this.config.main}`,
            languageId:
              this.config.lsp!.lang || this.config.monacoLang || "plaintext",
            version: 1,
            text: code,
          },
        },
      },
    });
    this.send({
      event: "lspInput",
      input: {
        jsonrpc: "2.0",
        id: "ecdb8a55-f755-4553-ae8e-91d6ebbc2045",
        method: "textDocument/completion",
        params: {
          textDocument: {
            uri: `file://${root}/${this.config.main}`,
          },
          position: findPosition(code, idx + insertedCode.length),
          context: { triggerKind: 1 },
        },
      },
    });
    const items: any = await this.wait(
      "response to lsp completion request",
      (msg: any) => {
        if (msg.event === "lspOutput") {
          if (msg.output.method === "workspace/configuration") {
            this.send({
              event: "lspInput",
              input: {
                jsonrpc: "2.0",
                id: msg.output.id,
                result: Array(msg.output.params.items.length).fill(
                  this.config.lsp!.config !== undefined
                    ? this.config.lsp!.config
                    : {}
                ),
              },
            });
          } else if (msg.output.id === "ecdb8a55-f755-4553-ae8e-91d6ebbc2045") {
            return msg.output.result.items || msg.output.result;
          }
        }
      }
    );
    if (
      !(items && items.filter(({ label }: any) => label === item).length > 0)
    ) {
      throw new Error("completion item did not appear");
    }
  };
}

function lint(lang: string) {
  const config = langs[lang];
  if (!config.template.endsWith("\n")) {
    throw new Error("template is missing a trailing newline");
  }
  // These can be removed when the types are adjusted to make these
  // situations impossible.
  if (
    config.format &&
    !config.format.input &&
    !(config.skip || []).includes("format")
  ) {
    throw new Error("formatter is missing test");
  }
  if (
    config.lsp &&
    !(config.lsp.code && config.lsp.item) &&
    !(config.skip || []).includes("lsp")
  ) {
    throw new Error("LSP is missing test");
  }
}

const testTypes: {
  [key: string]: {
    pred: (cfg: LangConfig) => boolean;
  };
} = {
  ensure: {
    pred: ({ ensure }) => (ensure ? true : false),
  },
  run: { pred: (config) => true },
  repl: {
    pred: ({ repl }) => (repl ? true : false),
  },
  runrepl: {
    pred: ({ repl }) => (repl ? true : false),
  },
  scope: {
    pred: ({ scope }) => (scope ? true : false),
  },
  format: {
    pred: ({ format }) => (format ? true : false),
  },
  lsp: { pred: ({ lsp }) => (lsp && lsp.code ? true : false) },
};

function getTestList() {
  const tests: { lang: string; type: string }[] = [];
  for (const [id, cfg] of Object.entries(langs)) {
    for (const [type, { pred }] of Object.entries(testTypes)) {
      if (pred(cfg)) {
        tests.push({ lang: id, type });
      }
    }
  }
  return tests;
}

async function writeLog(
  lang: string,
  type: string,
  result: string,
  log: string
) {
  log = `${result.toUpperCase()}: ${lang}/${type}\n` + log;
  await promisify(fs.mkdir)(`tests/${lang}`, { recursive: true });
  await promisify(fs.writeFile)(`tests/${lang}/${type}.log`, log);
  await promisify(fs.mkdir)(`tests-run/${lang}`, { recursive: true });
  await promisify(fs.symlink)(
    `../../tests/${lang}/${type}.log`,
    `tests-run/${lang}/${type}.log`
  );
  await promisify(fs.mkdir)(`tests-${result}/${lang}`, { recursive: true });
  await promisify(fs.symlink)(
    `../../tests/${lang}/${type}.log`,
    `tests-${result}/${lang}/${type}.log`
  );
}

async function main() {
  let tests = getTestList();
  const args = process.argv.slice(2);
  for (const arg of args) {
    tests = tests.filter(
      ({ lang, type }) =>
        arg
          .split(",")
          .filter((arg) =>
            [lang, type].concat(langs[lang].aliases || []).includes(arg)
          ).length > 0
    );
  }
  if (tests.length === 0) {
    console.error("no tests selected");
    process.exit(1);
  }
  console.error(`Running ${tests.length} test${tests.length !== 1 ? "s" : ""}`);
  const lintSeen = new Set();
  let lintPassed = new Set();
  let lintFailed = new Map();
  for (const { lang } of tests) {
    if (!lintSeen.has(lang)) {
      lintSeen.add(lang);
      try {
        lint(lang);
        lintPassed.add(lang);
      } catch (err) {
        lintFailed.set(lang, err);
      }
    }
  }
  if (lintFailed.size > 0) {
    console.error(
      `Language${lintFailed.size !== 1 ? "s" : ""} failed linting:`
    );
    console.error(
      Array.from(lintFailed)
        .map(([lang, err]) => `  - ${lang} (${err})`)
        .join("\n")
    );
    process.exit(1);
  }
  await promisify(rimraf)("tests-run");
  await promisify(rimraf)("tests-passed");
  await promisify(rimraf)("tests-skipped");
  await promisify(rimraf)("tests-failed");
  const queue = new PQueue({ concurrency: CONCURRENCY });
  let passed = new Set();
  let skipped = new Set();
  let failed = new Map();
  for (const { lang, type } of tests) {
    queue.add(async () => {
      const test = new Test(lang, type);
      let err;
      try {
        err = await test.run();
      } catch (error) {
        err = error;
      }
      if (err === "skipped") {
        skipped.add({ lang, type });
        console.error(`SKIPPED: ${lang}/${type}`);
        await writeLog(lang, type, "skipped", "");
      } else if (!err) {
        passed.add({ lang, type });
        console.error(`PASSED: ${lang}/${type}`);
        await writeLog(
          lang,
          type,
          "passed",
          test.getLog({ pretty: true }) + "\n"
        );
      } else {
        failed.set({ lang, type }, err);
        console.error(`FAILED: ${lang}/${type}`);
        console.error(test.getLog());
        console.error(err);
        await writeLog(
          lang,
          type,
          "failed",
          test.getLog({ pretty: true }) +
            "\n" +
            (err.stack ? err.stack + "\n" : err ? `${err}` : "")
        );
      }
    });
  }
  await queue.onIdle();
  console.error();
  console.error(
    "================================================================================"
  );
  console.error();
  if (passed.size > 0) {
    console.error(`${passed.size} test${passed.size !== 1 ? "s" : ""} PASSED`);
  }
  if (skipped.size > 0) {
    console.error(
      `${skipped.size} test${skipped.size !== 1 ? "s" : ""} SKIPPED`
    );
  }
  if (failed.size > 0) {
    console.error(`${failed.size} test${failed.size !== 1 ? "s" : ""} FAILED`);
    _.sortBy(Array.from(failed), [
      ([{ lang }, _]: any) => lang,
      ([{ type }, _]: any) => type,
    ]).forEach(([{ lang, type }, err]) =>
      console.error(`  - ${lang}/${type} (${err})`)
    );
  }
  process.exit(failed.size > 0 ? 1 : 0);
}

main().catch(console.error);