import assert from "assert";
import path from "path";
import fs from "fs";
import os from "os";
import * as snarkjs from "snarkjs";
import dree from "dree";
import { initialize, metadata } from "../index-node.js";

let zokratesProvider;
let tmpFolder;

describe("tests", () => {
  // initialize once before running tests
  before(() => {
    return initialize().then((defaultProvider) => {
      zokratesProvider = defaultProvider;
      return fs.promises
        .mkdtemp(path.join(os.tmpdir(), path.sep))
        .then((folder) => {
          tmpFolder = folder;
          return;
        });
    });
  });

  after(() => {
    if (globalThis.curve_bn128) {
      return globalThis.curve_bn128.terminate();
    }
  });

  describe("metadata", () => {
    it("is present", () => {
      assert.ok(metadata);
      assert.ok(metadata.version !== undefined);
    });
  });

  describe("compilation", () => {
    it("should compile", () => {
      const artifacts = zokratesProvider.compile(
        "def main() -> field { return 42; }"
      );
      assert.ok(artifacts);
      assert.ok(artifacts.snarkjs === undefined);
      assert.equal(artifacts.constraintCount, 1);
    });

    it("should compile with snarkjs output", () => {
      const artifacts = zokratesProvider.compile(
        "def main() -> field { return 42; }",
        { snarkjs: true }
      );
      assert.ok(artifacts);
      assert.ok(artifacts.snarkjs.program !== undefined);
    });

    it("should throw on invalid code", () => {
      assert.throws(() => zokratesProvider.compile(":-)"));
    });

    it("should resolve stdlib module", () => {
      const code = `import "utils/pack/bool/unpack_unchecked";\ndef main() {}`;
      zokratesProvider.compile(code);
    });

    it("should resolve user module", () => {
      const code =
        'import "./test" as test;\ndef main() -> field { return test(); }';
      const options = {
        resolveCallback: (_, path) => {
          return {
            source: "def main() -> field { return 1; }",
            location: path,
          };
        },
      };
      zokratesProvider.compile(code, options);
    });

    it("should throw on unresolved module", () => {
      assert.throws(() => {
        const code =
          'import "./test" as test;\ndef main() -> field { return test(); }';
        zokratesProvider.compile(code);
      });
    });
  });

  describe("computation", () => {
    it("should compute with valid inputs", () => {
      const code = "def main(private field a) -> field { return a * a; }";
      const artifacts = zokratesProvider.compile(code);
      const result = zokratesProvider.computeWitness(artifacts, ["2"]);
      const output = JSON.parse(result.output);
      assert.deepEqual(output, "4");
      assert.ok(result.snarkjs === undefined);
    });

    it("should compute with valid inputs with snarkjs output", () => {
      const code = "def main(private field a) -> field { return a * a; }";
      const artifacts = zokratesProvider.compile(code);

      const result = zokratesProvider.computeWitness(artifacts, ["2"], {
        snarkjs: true,
      });

      const output = JSON.parse(result.output);
      assert.deepEqual(output, "4");
      assert.ok(result.snarkjs.witness !== undefined);
    });

    it("should throw on invalid input count", () => {
      assert.throws(() => {
        const code = "def main(private field a) -> field { return a * a; }";
        const artifacts = zokratesProvider.compile(code);
        zokratesProvider.computeWitness(artifacts, ["1", "2"]);
      });
    });

    it("should throw on invalid input type", () => {
      assert.throws(() => {
        const code = "def main(private field a) -> field { return a * a; }";
        const artifacts = zokratesProvider.compile(code);
        zokratesProvider.computeWitness(artifacts, [true]);
      });
    });

    it("should log in debug", () => {
      const code = 'def main() { log("{}", 1f); log("{}", 2f); return; }';
      const artifacts = zokratesProvider.compile(code, {
        config: { debug: true },
      });
      let logs = [];
      zokratesProvider.computeWitness(artifacts, [], {
        logCallback: (l) => {
          logs.push(l);
        },
      });
      assert.deepEqual(logs, ['"1"', '"2"']);
    });
  });

  const runWithOptions = (options) => {
    let provider;
    let artifacts;
    let computationResult;
    let keypair;
    let proof;

    before(() => {
      provider = zokratesProvider.withOptions(options);
    });

    it("compile", () => {
      const code = `def main(private field a, field b) -> bool {
          bool check = if (a == 0) { true } else { a * a == b };
          assert(check);
          return true;
      }`;
      artifacts = provider.compile(code, { snarkjs: true });
    });

    it("compute witness", () => {
      computationResult = provider.computeWitness(
        artifacts,
        ["337", "113569"],
        {
          snarkjs: true,
        }
      );
    });

    it("setup", () => {
      if (options.scheme === "marlin") {
        const srs = provider.universalSetup(4);
        const srs2 = provider.universalSetup(4);
        // second call should return a new srs
        assert.notDeepEqual(srs, srs2);
        keypair = provider.setupWithSrs(srs, artifacts.program);
      } else {
        keypair = provider.setup(artifacts.program);
        const keypair2 = provider.setup(artifacts.program);
        // second call should return a new keypair
        assert.notDeepEqual(keypair, keypair2);
      }
    });

    it("setup with user-provided entropy", () => {
      let entropy = "f5c51ca46c331965";
      if (options.scheme === "marlin") {
        const srs = provider.universalSetup(4, entropy);
        const srs2 = provider.universalSetup(4, entropy);
        // second call with the same entropy should return the same srs
        assert.deepEqual(srs, srs2);
        keypair = provider.setupWithSrs(srs, artifacts.program);
      } else {
        keypair = provider.setup(artifacts.program, entropy);
        const keypair2 = provider.setup(artifacts.program, entropy);
        // second call with the same entropy should return the same keypair
        assert.deepEqual(keypair, keypair2);
      }
    });

    if (options.scheme === "g16" && options.curve == "bn128") {
      it("snarkjs setup", () => {
        // write program to fs
        let r1csPath = tmpFolder + "/prog.r1cs";
        let zkeyPath = tmpFolder + "/key.zkey";

        return fs.promises
          .writeFile(r1csPath, artifacts.snarkjs.program)
          .then(() => {
            return snarkjs.zKey.newZKey(
              r1csPath,
              "./tests/powersOfTau5_0000.ptau",
              zkeyPath
            );
          });
      });
    }

    if (options.curve === "bn128" && ["g16", "gm17"].includes(options.scheme)) {
      it("export verifier", () => {
        let verifier = provider.exportSolidityVerifier(keypair.vk);
        assert.ok(verifier.includes("contract"));
      });
    }

    it("generate proof", () => {
      proof = provider.generateProof(
        artifacts.program,
        computationResult.witness,
        keypair.pk
      );
      assert.ok(proof !== undefined);
      assert.equal(proof.inputs.length, 2);

      // second call should return a new proof
      let proof2 = provider.generateProof(
        artifacts.program,
        computationResult.witness,
        keypair.pk
      );
      assert.notDeepEqual(proof, proof2);
    });

    it("generate proof with user-provided entropy", () => {
      let entropy = "326e2c864f414ffb";
      proof = provider.generateProof(
        artifacts.program,
        computationResult.witness,
        keypair.pk,
        entropy
      );
      assert.ok(proof !== undefined);
      assert.equal(proof.inputs.length, 2);

      // second call with the same entropy should return the same proof
      let proof2 = provider.generateProof(
        artifacts.program,
        computationResult.witness,
        keypair.pk,
        entropy
      );
      assert.deepEqual(proof, proof2);
    });

    if (options.scheme === "g16" && options.curve == "bn128") {
      it("generate snarkjs proof", () => {
        // write witness to fs
        let witnessPath = tmpFolder + "/witness.wtns";
        let zkeyPath = tmpFolder + "/key.zkey";

        return fs.promises
          .writeFile(witnessPath, computationResult.snarkjs.witness)
          .then(() => {
            return snarkjs.groth16.prove(zkeyPath, witnessPath);
          })
          .then((r) => {
            return snarkjs.zKey.exportVerificationKey(zkeyPath).then((vk) => {
              return snarkjs.groth16
                .verify(vk, r.publicSignals, r.proof)
                .then((res) => {
                  assert.deepEqual(res, true);
                  return;
                });
            });
          });
      });
    }

    it("verify", () => {
      assert(provider.verify(keypair.vk, proof) === true);
    });
  };

  let combinations = {
    ark: {
      schemes: ["g16", "gm17", "marlin"],
      curves: ["bn128", "bls12_381", "bls12_377", "bw6_761"],
    },
    bellman: {
      schemes: ["g16"],
      curves: ["bn128"],
    },
  };

  for (const backend of Object.keys(combinations)) {
    describe(backend, () => {
      for (const scheme of combinations[backend].schemes) {
        describe(scheme, () => {
          for (const curve of combinations[backend].curves) {
            describe(curve, () => runWithOptions({ backend, scheme, curve }));
          }
        });
      }
    });
  }

  const testRunner = (rootPath, testPath, test) => {
    let entryPoint;
    if (!test.entry_point) {
      entryPoint = testPath.replace(".json", ".zok");
    } else {
      entryPoint = path.join(rootPath, test.entry_point);
    }

    const source = fs.readFileSync(entryPoint).toString();
    const curves = test.curves || ["Bn128"];
    const tests = test.tests || [];
    const withAbi = test.abi !== false;

    const fileSystemResolver = (from, to) => {
      let parsedPath = path.parse(
        path.resolve(path.dirname(path.resolve(from)), to)
      );
      const location = path.format({
        ...parsedPath,
        base: "",
        ext: ".zok",
      });
      const source = fs.readFileSync(location).toString();
      return { source, location };
    };

    for (const curve of curves) {
      it(curve, () => {
        let specializedProvider = zokratesProvider.withOptions({
          curve: curve.toLowerCase(),
          scheme: "g16",
        });

        let options = {
          location: entryPoint,
          resolveCallback: fileSystemResolver,
        };

        if (test.config) {
          options = Object.assign(options, { config: test.config });
        }

        let artifacts = specializedProvider.compile(source, options);
        for (const t of tests) {
          const withAbiOverride = typeof t.abi === "boolean" ? t.abi : withAbi;
          const input = withAbiOverride ? artifacts : artifacts.program;

          try {
            const result = specializedProvider.computeWitness(
              input,
              t.input.values
            );
            const value = JSON.parse(result.output);
            assert.deepEqual({ Ok: { value } }, t.output);
          } catch (err) {
            assert.ok(t.output["Err"], err); // we expected an error in this test
          }
        }
      }).timeout(300000);
    }
  };

  describe("core tests", () => {
    const rootPath = path.resolve("../zokrates_core_test");
    const testsPath = path.join(rootPath, "/tests/tests");

    const ignoreList = ["snark/"];
    const options = {
      extensions: ["json"],
    };

    dree.scan(testsPath, options, async function (file) {
      const test = JSON.parse(await fs.promises.readFile(file.path));
      const testName = file.path.substring(testsPath.length + 1);

      if (!ignoreList.some((v) => testName.startsWith(v)))
        describe(testName, () => testRunner(rootPath, file.path, test));
    });
  });
});
