/*
 This file is part of GNU Taler
 (C) 2019 Taler Systems S.A.

 GNU Taler is free software; you can redistribute it and/or modify it under the
 terms of the GNU General Public License as published by the Free Software
 Foundation; either version 3, or (at your option) any later version.

 GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
 A PARTICULAR PURPOSE.  See the GNU General Public License for more details.

 You should have received a copy of the GNU General Public License along with
 GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
 */

/**
 * Imports.
 */
import {
  AccessToken,
  AmountJson,
  AmountString,
  Amounts,
  BalancesResponse,
  Configuration,
  DonauHttpClient,
  Duration,
  EddsaPrivP,
  HostPortPath,
  HttpStatusCode,
  Logger,
  LoginTokenScope,
  MerchantAuthMethod,
  PaytoString,
  Paytos,
  TalerBankConversionHttpClient,
  TalerCoreBankHttpClient,
  TalerExchangeHttpClient,
  TalerKycAml,
  TalerMerchantInstanceHttpClient,
  TalerMerchantManagementHttpClient,
  TalerProtocolTimestamp,
  TransactionsResponse,
  createRFC8959AccessTokenEncoded,
  createRFC8959AccessTokenPlain,
  decodeCrock,
  eddsaGetPublic,
  encodeCrock,
  generateIban,
  getRandomBytes,
  hashNormalizedPaytoUri,
  j2s,
  parsePaytoUriOrThrow,
  randomBytes,
  rsaBlind,
  setGlobalLogLevelFromString,
  signKycAuth,
  stringifyPayTemplateUri,
  succeedOrThrow,
} from "@gnu-taler/taler-util";
import { clk } from "@gnu-taler/taler-util/clk";
import { readlinePrompt } from "@gnu-taler/taler-util/compat";
import {
  HttpResponse,
  createPlatformHttpLib,
} from "@gnu-taler/taler-util/http";
import {
  CryptoDispatcher,
  SynchronousCryptoWorkerFactoryPlain,
  WalletApiOperation,
} from "@gnu-taler/taler-wallet-core";
import {
  downloadExchangeInfo,
  topupReserveWithBank,
} from "@gnu-taler/taler-wallet-core/dbless";
import { deepStrictEqual } from "assert";
import { AML_PROGRAM_FAIL_RECOVER } from "integrationtests/test-kyc-fail-recover-simple.js";
import { AML_PROGRAM_FROM_ATTRIBUTES_TO_CONTEXT } from "integrationtests/test-kyc-skip-expiration.js";
import { AML_PROGRAM_NEXT_MEASURE_FORM } from "integrationtests/test-kyc-two-forms.js";
import { execSync } from "node:child_process";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import postgres from "postgres";
import { URLImpl } from "../../taler-util/src/whatwg-url.js";
import { runBench1 } from "./bench1.js";
import { runBench2 } from "./bench2.js";
import { runBench3 } from "./bench3.js";
import { runEnvFull } from "./env-full.js";
import { runEnv1 } from "./env1.js";
import {
  createSimpleTestkudosEnvironmentV2,
  createWalletDaemonWithClient,
} from "./harness/environments.js";
import {
  GlobalTestState,
  WalletClient,
  delayMs,
  runTestWithState,
  waitMs,
} from "./harness/harness.js";
import { AML_PROGRAM_TEST_KYC_NEW_MEASURES_PROG } from "./integrationtests/test-kyc-new-measures-prog.js";
import { getTestInfo, runTests } from "./integrationtests/testrunner.js";
import { lintExchangeDeployment, lintExchangeUrl } from "./lint.js";

const logger = new Logger("taler-harness:index.ts");

process.on("unhandledRejection", (error: any) => {
  logger.error("unhandledRejection", error.message);
  logger.error("stack", error.stack);
  process.exit(2);
});

declare const __VERSION__: string;
declare const __GIT_HASH__: string;
function printVersion(): void {
  console.log(`${__VERSION__} ${__GIT_HASH__}`);
  process.exit(0);
}

export const talerHarnessCli = clk
  .program("testing", {
    help: "Command line interface for the GNU Taler test/deployment harness.",
  })
  .maybeOption("log", ["-L", "--log"], clk.STRING, {
    help: "configure log level (NONE, ..., TRACE)",
    onPresentHandler: (x) => {
      setGlobalLogLevelFromString(x);
    },
  })
  .flag("version", ["-v", "--version"], {
    onPresentHandler: printVersion,
  })
  .flag("verbose", ["-V", "--verbose"], {
    help: "Enable verbose output.",
  });

const testingCli = talerHarnessCli.subcommand("testingArgs", "testing", {
  help: "Subcommands for advanced operations (only use if you know what you're doing!).",
});

testingCli
  .subcommand("scenarioCoinAcceptor", "scenario-coin-acceptor", {
    help: "Test scenario for coin acceptor.",
  })
  .action(async (args) => {
    const httpLib = createPlatformHttpLib();
    const bankUrl = "https://bank.test.taler.net/";
    const bank = new TalerCoreBankHttpClient(bankUrl, httpLib);

    const username = "user-" + encodeCrock(getRandomBytes(10)).toLowerCase();
    const password = "pw-" + encodeCrock(getRandomBytes(10)).toLowerCase();
    succeedOrThrow(
      await bank.createAccount(undefined, {
        name: username,
        username,
        password,
      }),
    );
    // It's a test account, so it's safe to log credentials.
    logger.info(
      `Created test bank account ${username} with password ${password}`,
    );
    const tokRes = succeedOrThrow(
      await bank.createAccessTokenBasic(username, password, {
        scope: "readwrite",
      }),
    );
    const token = tokRes.access_token;
    const bankAuth = { username, token };
    logger.info(`Test account access token ${token}`);
    const createWithdrawalResp = succeedOrThrow(
      await bank.createWithdrawal(bankAuth, {
        no_amount_to_wallet: true,
      }),
    );
    // libeufin doesn't automatically append the external-confirmation.
    logger.info(
      `Created withdrawal operation ${createWithdrawalResp.taler_withdraw_uri}?external-confirmation=1`,
    );
    const wopid = createWithdrawalResp.withdrawal_id;
    while (true) {
      logger.info(`Waiting for status change.`);
      const statusRes = succeedOrThrow(
        await bank.getWithdrawalById(wopid, {
          timeoutMs: 30000,
          old_state: "pending",
        }),
      );
      logger.info(`Withdrawal status: ${j2s(statusRes.status)}`);
      if (statusRes.status === "selected") {
        break;
      }
    }
    const amountStr = await readlinePrompt("Amount to withdraw: ");
    succeedOrThrow(
      await bank.confirmWithdrawalById(
        bankAuth,
        {
          amount: amountStr as AmountString,
        },
        wopid,
      ),
    );
    logger.info("Withdrawal operation confirmed");
  });

testingCli
  .subcommand("scenarioInsufficientBalance", "scenario-insufficient-balance", {
    help: "Test scenario insufficient balance details.",
  })
  .action(async (args) => {
    const merchantBaseUrl = "https://backend.test.taler.net/instances/sandbox/";
    const merchantApi = new TalerMerchantInstanceHttpClient(merchantBaseUrl);
    const tokResp = succeedOrThrow(
      await merchantApi.createAccessToken("sandbox", "sandbox", {
        scope: LoginTokenScope.All,
      }),
    );

    const tok: AccessToken = tokResp.access_token;

    const createResp = succeedOrThrow(
      await merchantApi.createOrder(tok, {
        order: {
          amount: "TESTKUDOS:42",
          summary: "Hello",
        },
        // Intentional, test.taler.net merchant does not support it.
        payment_target: "bitcoin",
      }),
    );
    const orderId = createResp.order_id;
    const statusResp = succeedOrThrow(
      await merchantApi.getOrderDetails(tok, orderId),
    );
    if (statusResp.order_status !== "unpaid") {
      throw Error("unexpected order state");
    }
    console.log(
      "Insufficient balance order (undepositable due to bitcoin wire method):",
    );
    console.log(statusResp.taler_pay_uri);
  });

const advancedCli = talerHarnessCli.subcommand("advancedArgs", "advanced", {
  help: "Subcommands for advanced operations (only use if you know what you're doing!).",
});

advancedCli
  .subcommand("decode", "decode", {
    help: "Decode base32-crockford.",
  })
  .action((args) => {
    const enc = fs.readFileSync(0, "utf8");
    fs.writeSync(process.stdout.fd, decodeCrock(enc.trim()));
  });

advancedCli
  .subcommand("encode", "encode", {
    help: "Encode base32-crockford.",
  })
  .action((args) => {
    const dec = fs.readFileSync(0);

    console.log(encodeCrock(dec));
  });

advancedCli
  .subcommand("bench1", "bench1", {
    help: "Run the 'bench1' benchmark",
  })
  .requiredOption("configJson", ["--config-json"], clk.STRING)
  .action(async (args) => {
    let config: any;
    try {
      config = JSON.parse(args.bench1.configJson);
    } catch (e) {
      console.log("Could not parse config JSON");
    }
    await runBench1(config);
  });

advancedCli
  .subcommand("bench2", "bench2", {
    help: "Run the 'bench2' benchmark",
  })
  .requiredOption("configJson", ["--config-json"], clk.STRING)
  .action(async (args) => {
    let config: any;
    try {
      config = JSON.parse(args.bench2.configJson);
    } catch (e) {
      console.log("Could not parse config JSON");
    }
    await runBench2(config);
  });

advancedCli
  .subcommand("bench3", "bench3", {
    help: "Run the 'bench3' benchmark",
  })
  .requiredOption("configJson", ["--config-json"], clk.STRING)
  .action(async (args) => {
    let config: any;
    try {
      config = JSON.parse(args.bench3.configJson);
    } catch (e) {
      console.log("Could not parse config JSON");
    }
    await runBench3(config);
  });

advancedCli
  .subcommand("envFull", "env-full", {
    help: "Run a test environment for bench1",
  })
  .action(async (args) => {
    const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "taler-env-full-"));
    const testState = new GlobalTestState({
      testDir,
    });
    await runTestWithState(testState, runEnvFull, "env-full", true);
  });

advancedCli
  .subcommand("env1", "env1", {
    help: "Run a test environment for bench1",
  })
  .action(async (args) => {
    const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "taler-env1-"));
    const testState = new GlobalTestState({
      testDir,
    });
    await runTestWithState(testState, runEnv1, "env1", true);
  });

async function doDbChecks(
  t: GlobalTestState,
  walletClient: WalletClient,
  indir: string,
): Promise<void> {
  // Check that balance didn't break
  const balPath = `${indir}/wallet-balances.json`;
  const expectedBal: BalancesResponse = JSON.parse(
    fs.readFileSync(balPath, { encoding: "utf8" }),
  ) as BalancesResponse;
  const actualBal = await walletClient.call(WalletApiOperation.GetBalances, {});
  t.assertDeepEqual(actualBal.balances.length, expectedBal.balances.length);

  // Check that transactions didn't break
  const txnPath = `${indir}/wallet-transactions.json`;
  const expectedTxn: TransactionsResponse = JSON.parse(
    fs.readFileSync(txnPath, { encoding: "utf8" }),
  ) as TransactionsResponse;
  const actualTxn = await walletClient.call(
    WalletApiOperation.GetTransactions,
    { includeRefreshes: true },
  );
  t.assertDeepEqual(
    actualTxn.transactions.length,
    expectedTxn.transactions.length,
  );
}

advancedCli
  .subcommand("walletDbcheck", "wallet-dbcheck", {
    help: "Check a wallet database (used for migration testing).",
  })
  .requiredArgument("indir", clk.STRING)
  .action(async (args) => {
    const indir = args.walletDbcheck.indir;
    if (!fs.existsSync(indir)) {
      throw Error("directory to be checked does not exist");
    }

    const testRootDir = fs.mkdtempSync(path.join(os.tmpdir(), "taler-dbchk-"));
    const t: GlobalTestState = new GlobalTestState({
      testDir: testRootDir,
    });
    const origWalletDbPath = `${indir}/wallet-db.sqlite3`;
    const testWalletDbPath = `${testRootDir}/wallet-testdb.sqlite3`;
    fs.cpSync(origWalletDbPath, testWalletDbPath);
    if (!fs.existsSync(origWalletDbPath)) {
      throw new Error("wallet db to be checked does not exist");
    }
    const { walletClient, walletService } = await createWalletDaemonWithClient(
      t,
      { name: "wallet-loaded", overrideDbPath: testWalletDbPath },
    );

    await walletService.pingUntilAvailable();

    // Do DB checks with the DB we loaded.
    await doDbChecks(t, walletClient, indir);

    const {
      walletClient: freshWalletClient,
      walletService: freshWalletService,
    } = await createWalletDaemonWithClient(t, {
      name: "wallet-fresh",
      persistent: false,
    });

    await freshWalletService.pingUntilAvailable();

    // Check that we can still import the backup JSON.

    const backupPath = `${indir}/wallet-backup.json`;
    const backupData = JSON.parse(
      fs.readFileSync(backupPath, { encoding: "utf8" }),
    );
    await freshWalletClient.call(WalletApiOperation.ImportDb, {
      dump: backupData,
    });

    // Repeat same checks with wallet that we restored from backup
    // instead of from the DB file.
    await doDbChecks(t, freshWalletClient, indir);

    await t.shutdown();
  });

advancedCli
  .subcommand("walletDbgen", "wallet-dbgen", {
    help: "Generate a wallet test database (to be used for migration testing).",
  })
  .requiredArgument("outdir", clk.STRING)
  .action(async (args) => {
    const outdir = args.walletDbgen.outdir;
    if (fs.existsSync(outdir)) {
      throw new Error("outdir already exists, please delete first");
    }
    fs.mkdirSync(outdir, {
      recursive: true,
    });

    const testRootDir = fs.mkdtempSync(path.join(os.tmpdir(), "taler-dbgen-"));
    console.log(`generating data in ${testRootDir}`);
    const t = new GlobalTestState({
      testDir: testRootDir,
    });
    const { walletClient, walletService, bank, exchange, merchant } =
      await createSimpleTestkudosEnvironmentV2(t);
    await walletClient.call(WalletApiOperation.RunIntegrationTestV2, {
      corebankApiBaseUrl: bank.corebankApiBaseUrl,
      exchangeBaseUrl: exchange.baseUrl,
      merchantBaseUrl: merchant.makeInstanceBaseUrl(),
    });
    await walletClient.call(
      WalletApiOperation.TestingWaitTransactionsFinal,
      {},
    );

    const transactionsJson = await walletClient.call(
      WalletApiOperation.GetTransactions,
      {
        includeRefreshes: true,
      },
    );

    const balancesJson = await walletClient.call(
      WalletApiOperation.GetBalances,
      {},
    );

    const backupJson = await walletClient.call(WalletApiOperation.ExportDb, {});

    const versionJson = await walletClient.call(
      WalletApiOperation.GetVersion,
      {},
    );

    await walletService.stop();

    await t.shutdown();

    console.log(`generated data in ${testRootDir}`);

    fs.copyFileSync(walletService.dbPath, `${outdir}/wallet-db.sqlite3`);
    fs.writeFileSync(
      `${outdir}/wallet-transactions.json`,
      j2s(transactionsJson),
    );
    fs.writeFileSync(`${outdir}/wallet-balances.json`, j2s(balancesJson));
    fs.writeFileSync(`${outdir}/wallet-backup.json`, j2s(backupJson));
    fs.writeFileSync(`${outdir}/wallet-version.json`, j2s(versionJson));
    fs.writeFileSync(
      `${outdir}/meta.json`,
      j2s({
        timestamp: new Date(),
      }),
    );
  });

const configCli = talerHarnessCli
  .subcommand("configArgs", "config", {
    help: "Subcommands for handling the Taler configuration.",
  })
  .maybeOption("configEntryFile", ["-c", "--config"], clk.STRING, {
    help: "Configuration file to use.",
  })
  .maybeOption("project", ["--project"], clk.STRING, {
    help: `Selection of the project to inspect/change the config (default: taler).`,
  });

configCli
  .subcommand("show", "show", {
    help: "Show the current configuration.",
  })
  .action(async (args) => {
    const config = Configuration.load(
      args.configArgs.configEntryFile,
      args.configArgs.project,
    );
    const cfgStr = config.stringify({
      diagnostics: true,
    });
    console.log(cfgStr);
  });

configCli
  .subcommand("get", "get", {
    help: "Get a configuration option.",
  })
  .requiredArgument("section", clk.STRING)
  .requiredArgument("option", clk.STRING)
  .flag("file", ["-f"], {
    help: "Treat the value as a filename, expanding placeholders.",
  })
  .action(async (args) => {
    const config = Configuration.load(
      args.configArgs.configEntryFile,
      args.configArgs.project,
    );
    let res;
    if (args.get.file) {
      res = config.getPath(args.get.section, args.get.option);
    } else {
      res = config.getString(args.get.section, args.get.option);
    }
    if (res.isDefined()) {
      console.log(res.required());
    } else {
      console.warn("not found");
      process.exit(1);
    }
  });

configCli
  .subcommand("set", "set", {
    help: "Set a configuration option.",
  })
  .requiredArgument("section", clk.STRING)
  .requiredArgument("option", clk.STRING)
  .requiredArgument("value", clk.STRING)
  .flag("dry", ["--dry"], {
    help: "Do not write the changed config to disk, only write it to stdout.",
  })
  .action(async (args) => {
    const config = Configuration.load(
      args.configArgs.configEntryFile,
      args.configArgs.project,
    );
    config.setString(args.set.section, args.set.option, args.set.value);
    if (args.set.dry) {
      console.log(
        config.stringify({
          excludeDefaults: true,
        }),
      );
    } else {
      config.write({
        excludeDefaults: true,
      });
    }
  });

const deploymentCli = talerHarnessCli.subcommand(
  "deploymentArgs",
  "deployment",
  {
    help: "Subcommands for handling GNU Taler deployments.",
  },
);

deploymentCli
  .subcommand("testTalerdotnetDemo", "test-demodottalerdotnet")
  .action(async (args) => {
    const http = createPlatformHttpLib();
    const cryptiDisp = new CryptoDispatcher(
      new SynchronousCryptoWorkerFactoryPlain(),
    );
    const cryptoApi = cryptiDisp.cryptoApi;
    const reserveKeyPair = await cryptoApi.createEddsaKeypair({});
    const exchangeBaseUrl = "https://exchange.demo.taler.net/";
    const exchangeInfo = await downloadExchangeInfo(exchangeBaseUrl, http);
    await topupReserveWithBank({
      amount: "KUDOS:10" as AmountString,
      corebankApiBaseUrl: "https://bank.demo.taler.net/",
      exchangeInfo,
      http,
      reservePub: reserveKeyPair.pub,
    });
    let reserveUrl = new URL(`reserves/${reserveKeyPair.pub}`, exchangeBaseUrl);
    reserveUrl.searchParams.set("timeout_ms", "30000");
    console.log("requesting", reserveUrl.href);
    const longpollReq = http.fetch(reserveUrl.href, {
      method: "GET",
    });
    const reserveStatusResp = await longpollReq;
    console.log("reserve status", reserveStatusResp.status);
  });

deploymentCli
  .subcommand("testDemoTestdotdalerdotnet", "test-testdottalerdotnet")
  .action(async (args) => {
    const http = createPlatformHttpLib();
    const cryptiDisp = new CryptoDispatcher(
      new SynchronousCryptoWorkerFactoryPlain(),
    );
    const cryptoApi = cryptiDisp.cryptoApi;
    const reserveKeyPair = await cryptoApi.createEddsaKeypair({});
    const exchangeBaseUrl = "https://exchange.test.taler.net/";
    const exchangeInfo = await downloadExchangeInfo(exchangeBaseUrl, http);
    await topupReserveWithBank({
      amount: "TESTKUDOS:10" as AmountString,
      corebankApiBaseUrl: "https://bank.test.taler.net/",
      exchangeInfo,
      http,
      reservePub: reserveKeyPair.pub,
    });
    let reserveUrl = new URL(`reserves/${reserveKeyPair.pub}`, exchangeBaseUrl);
    reserveUrl.searchParams.set("timeout_ms", "30000");
    console.log("requesting", reserveUrl.href);
    const longpollReq = http.fetch(reserveUrl.href, {
      method: "GET",
    });
    const reserveStatusResp = await longpollReq;
    console.log("reserve status", reserveStatusResp.status);
  });

deploymentCli
  .subcommand("testLocalhostDemo", "test-demo-localhost")
  .action(async (args) => {
    // Run checks against the "env-full" demo deployment on localhost
    const http = createPlatformHttpLib();
    const cryptiDisp = new CryptoDispatcher(
      new SynchronousCryptoWorkerFactoryPlain(),
    );
    const cryptoApi = cryptiDisp.cryptoApi;
    const reserveKeyPair = await cryptoApi.createEddsaKeypair({});
    const exchangeBaseUrl = "http://localhost:8081/";
    const exchangeInfo = await downloadExchangeInfo(exchangeBaseUrl, http);
    await topupReserveWithBank({
      amount: "TESTKUDOS:10" as AmountString,
      corebankApiBaseUrl: "http://localhost:8082/taler-bank-access/",
      exchangeInfo,
      http,
      reservePub: reserveKeyPair.pub,
    });
    let reserveUrl = new URL(`reserves/${reserveKeyPair.pub}`, exchangeBaseUrl);
    reserveUrl.searchParams.set("timeout_ms", "30000");
    console.log("requesting", reserveUrl.href);
    const longpollReq = http.fetch(reserveUrl.href, {
      method: "GET",
    });
    const reserveStatusResp = await longpollReq;
    console.log("reserve status", reserveStatusResp.status);
  });

deploymentCli
  .subcommand("lintExchangeConfig", "lint-exchange-config", {
    help: "Run checks on the exchange deployment running on the current machine.",
  })
  .flag("cont", ["--continue"], {
    help: "Continue after errors if possible",
  })
  .flag("debug", ["--debug"], {
    help: "Output extra debug info",
  })
  .action(async (args) => {
    await lintExchangeDeployment(
      args.lintExchangeConfig.debug,
      args.lintExchangeConfig.cont,
    );
  });

deploymentCli
  .subcommand("lintExchangeUrl", "lint-exchange-url", {
    help: "Run checks on a remote exchange deployment.",
  })
  .requiredArgument("baseUrl", clk.STRING)
  .action(async (args) => {
    await lintExchangeUrl(args.lintExchangeUrl.baseUrl);
  });

deploymentCli
  .subcommand("waitService", "wait-taler-service", {
    help: "Wait for the config endpoint of a Taler-style service to be available",
  })
  .requiredArgument("serviceName", clk.STRING)
  .requiredArgument("serviceConfigUrl", clk.STRING)
  .action(async (args) => {
    const serviceName = args.waitService.serviceName;
    const serviceUrl = args.waitService.serviceConfigUrl;
    console.log(
      `Waiting for service ${serviceName} to be ready at ${serviceUrl}`,
    );
    const httpLib = createPlatformHttpLib();
    while (1) {
      console.log(`Fetching ${serviceUrl}`);
      let resp: HttpResponse;
      try {
        resp = await httpLib.fetch(serviceUrl);
      } catch (e) {
        console.log(
          `Got network error for service ${serviceName} at ${serviceUrl}`,
        );
        await delayMs(1000);
        continue;
      }
      if (resp.status != 200) {
        console.log(
          `Got unexpected status ${resp.status} for service at ${serviceUrl}`,
        );
        await delayMs(1000);
        continue;
      }
      let respJson: any;
      try {
        respJson = await resp.json();
      } catch (e) {
        console.log(
          `Got json error for service ${serviceName} at ${serviceUrl}`,
        );
        await delayMs(1000);
        continue;
      }
      const recServiceName = respJson.name;
      console.log(`Got name ${recServiceName}`);
      if (recServiceName != serviceName) {
        console.log(`A different service is still running at ${serviceUrl}`);
        await delayMs(1000);
        continue;
      }
      console.log(`service ${serviceName} at ${serviceUrl} is now available`);
      return;
    }
  });

deploymentCli
  .subcommand("waitEndpoint", "wait-endpoint", {
    help: "Wait for an endpoint to return an HTTP 200 Ok status with JSON body",
  })
  .requiredArgument("serviceEndpoint", clk.STRING)
  .action(async (args) => {
    const serviceUrl = args.waitEndpoint.serviceEndpoint;
    console.log(`Waiting for endpoint ${serviceUrl} to be ready`);
    const httpLib = createPlatformHttpLib();
    while (1) {
      console.log(`Fetching ${serviceUrl}`);
      let resp: HttpResponse;
      try {
        resp = await httpLib.fetch(serviceUrl);
      } catch (e) {
        console.log(`Got network error for service at ${serviceUrl}`);
        await delayMs(1000);
        continue;
      }
      if (resp.status != 200) {
        console.log(
          `Got unexpected status ${resp.status} for service at ${serviceUrl}`,
        );
        await delayMs(1000);
        continue;
      }
      let respJson: any;
      try {
        respJson = await resp.json();
      } catch (e) {
        console.log(`Got json error for service at ${serviceUrl}`);
        await delayMs(1000);
        continue;
      }
      return;
    }
  });

deploymentCli
  .subcommand("genIban", "gen-iban", {
    help: "Generate a random IBAN.",
  })
  .requiredArgument("countryCode", clk.STRING)
  .requiredArgument("length", clk.INT)
  .action(async (args) => {
    console.log(generateIban(args.genIban.countryCode, args.genIban.length));
  });

deploymentCli
  .subcommand("provisionBankMerchant", "provision-bank-and-merchant", {
    help: "Provision a bank account, merchant instance and link them together. It will fail if merchant backend requires MFA.",
  })
  .requiredArgument("merchantApiBaseUrl", clk.STRING, {
    help: "URL location of the merchant backend",
  })
  .requiredArgument("corebankApiBaseUrl", clk.STRING, {
    help: "URL location of the libeufin bank backend",
  })
  .maybeOption("merchantToken", ["--merchant-management-token"], clk.STRING, {
    help: "access token of the default instance in the merchant backend",
  })
  .maybeOption(
    "merchantPassword",
    ["--merchant-management-password"],
    clk.STRING,
    {
      help: "access token of the default instance in the merchant backend",
    },
  )
  .maybeOption("bankToken", ["--bank-admin-token"], clk.STRING, {
    help: "libeufin bank admin's token if the account creation is restricted",
  })
  .maybeOption("bankPassword", ["--bank-admin-password"], clk.STRING, {
    help: "libeufin bank admin's password if the account creation is restricted, it will override --bank-admin-token",
  })
  .requiredOption("name", ["--legal-name"], clk.STRING, {
    help: "legal name of the merchant",
  })
  .maybeOption("email", ["--email"], clk.STRING, {
    help: "email contact of the merchant",
  })
  .maybeOption("phone", ["--phone"], clk.STRING, {
    help: "phone contact of the merchant",
  })
  .requiredOption("id", ["--id"], clk.STRING, {
    help: "login id for the bank account and instance id of the merchant backend",
  })
  .flag("template", ["--create-template"], {
    help: "use this flag to create a default template for the merchant with fixed summary",
  })
  .requiredOption("password", ["--password"], clk.STRING, {
    help: "password of the accounts in libeufin bank and merchant backend",
  })
  .flag("randomPassword", ["--set-random-password"], {
    help: "if everything worked ok, change the password of the accounts at the end",
  })
  .action(async (args) => {
    const merchantAdminTokenArg = args.provisionBankMerchant.merchantToken
      ? createRFC8959AccessTokenPlain(args.provisionBankMerchant.merchantToken)
      : undefined;
    const merchantAdminPassword = args.provisionBankMerchant.merchantPassword;

    const bankAdminPassword = args.provisionBankMerchant.bankPassword;
    const bankAdminTokenArg = args.provisionBankMerchant.bankToken
      ? createRFC8959AccessTokenPlain(args.provisionBankMerchant.bankToken)
      : undefined;
    const id = args.provisionBankMerchant.id;
    const name = args.provisionBankMerchant.name;
    const email = args.provisionBankMerchant.email;
    const phone = args.provisionBankMerchant.phone;
    const password = args.provisionBankMerchant.password;

    const httpLib = createPlatformHttpLib({});
    const merchantManager = new TalerMerchantManagementHttpClient(
      args.provisionBankMerchant.merchantApiBaseUrl,
      httpLib,
    );
    const bank = new TalerCoreBankHttpClient(
      args.provisionBankMerchant.corebankApiBaseUrl,
      httpLib,
    );
    const instanceURL = merchantManager.getSubInstanceAPI(id) as HostPortPath;
    const merchantInstance = new TalerMerchantInstanceHttpClient(
      instanceURL,
      httpLib,
    );
    const conv = new TalerBankConversionHttpClient(
      bank.getConversionInfoAPI().href,
      httpLib,
    );

    const bc = succeedOrThrow(await bank.getConfig());
    const mc = succeedOrThrow(await merchantManager.getConfig());

    let bankAdminToken: AccessToken | undefined;
    if (bankAdminPassword) {
      const resp = await bank.createAccessTokenBasic(
        "admin",
        bankAdminPassword,
        {
          scope: "readwrite",
          duration: {
            d_us: 1000 * 1000 * 10, //10 secs
          },
          refreshable: false,
        },
      );
      if (resp.type === "fail") {
        logger.error(`could not get bank admin token from password.`);
        return;
      }
      bankAdminToken = resp.body.access_token;
    } else {
      bankAdminToken = bankAdminTokenArg;
    }

    let merchantAdminToken: AccessToken | undefined;
    if (merchantAdminPassword) {
      const resp = await merchantManager.createAccessToken(
        "admin",
        merchantAdminPassword,
        {
          scope: LoginTokenScope.All,
          duration: {
            d_us: 1000 * 1000 * 10, //10 secs
          },
        },
      );
      if (resp.type === "fail") {
        logger.error(`could not get bank admin token from password.`);
        return;
      }
      merchantAdminToken = resp.body.access_token;
    } else {
      merchantAdminToken = merchantAdminTokenArg;
    }

    /**
     * create bank account
     */
    let accountPayto: PaytoString;
    {
      const resp = await bank.createAccount(bankAdminToken, {
        name: name,
        password: password,
        username: id,
        contact_data:
          email || phone
            ? {
                email: email,
                phone: phone,
              }
            : undefined,
      });

      if (resp.type === "fail") {
        logger.error(
          `unable to provision bank account, HTTP response status ${resp.case}`,
        );
        logger.error(j2s(resp));
        process.exit(2);
      }
      logger.info(`account ${id} successfully provisioned`);
      accountPayto = resp.body.internal_payto_uri;
    }

    /**
     * create merchant account
     */
    {
      const resp = await merchantManager.createInstance(merchantAdminToken, {
        address: {},
        auth: {
          method: MerchantAuthMethod.TOKEN,
          password: password,
        },
        default_pay_delay: Duration.toTalerProtocolDuration(
          Duration.fromSpec({ hours: 1 }),
        ),
        default_wire_transfer_delay: Duration.toTalerProtocolDuration(
          Duration.fromSpec({ hours: 1 }),
        ),
        id: id,
        jurisdiction: {},
        name: name,
        use_stefan: true,
      });

      if (resp.type === "ok") {
        logger.info(`instance ${id} created successfully`);
      } else if (resp.case === HttpStatusCode.Conflict) {
        logger.info(`instance ${id} already exists`);
      } else {
        logger.error(
          `unable to create instance ${id}, HTTP status ${resp.case}`,
        );
        process.exit(2);
      }
    }

    const { access_token } = succeedOrThrow(
      await merchantInstance.createAccessToken(id, password, {
        scope: LoginTokenScope.All,
      }),
    );

    let wireAccount: string;
    /**
     * link bank account and merchant
     */
    {
      const resp = await merchantInstance.addBankAccount(access_token, {
        payto_uri: accountPayto,
        credit_facade_url: bank.getRevenueAPI(id).href,
        credit_facade_credentials: {
          type: "basic",
          username: id,
          password: password,
        },
      });
      if (resp.type === "fail") {
        console.error(
          `unable to configure bank account for instance ${id}, status ${resp.case}`,
        );
        console.error(
          resp.case === HttpStatusCode.Accepted
            ? `the merchant requires MFA for this account`
            : j2s(resp.detail),
        );
        process.exit(2);
      }
      wireAccount = resp.body.h_wire;
    }

    logger.info(`successfully configured bank account for ${id}`);

    let templateURI;
    /**
     * create template
     */
    if (args.provisionBankMerchant.template) {
      let currency = bc.currency;
      if (bc.allow_conversion) {
        const cc = await conv.getConfig();
        if (cc.type === "ok") {
          currency = cc.body.fiat_currency;
        } else {
          console.error(`could not get fiat currency status ${cc.case}`);
          console.error(j2s(cc.detail));
        }
      } else {
        console.log(`conversion is disabled, using bank currency`);
      }

      {
        const resp = await merchantInstance.addTemplate(
          createRFC8959AccessTokenEncoded(password),
          {
            template_id: "default",
            template_description: "First template",
            template_contract: {
              pay_duration: Duration.toTalerProtocolDuration(
                Duration.fromSpec({ hours: 1 }),
              ),
              minimum_age: 0,
              currency,
              summary: "Pay me!",
            },
            editable_defaults: {
              amount: currency,
            },
          },
        );
        if (resp.type === "fail") {
          console.error(
            `unable to create template for insntaince ${id}, status ${resp.case}`,
          );
          console.error(j2s(resp.detail));
          process.exit(2);
        }
      }

      logger.info(`template default successfully created`);
      templateURI = stringifyPayTemplateUri({
        merchantBaseUrl: instanceURL,
        templateId: "default",
      });
    }

    let finalPassword = password;
    if (args.provisionBankMerchant.randomPassword) {
      const prevPassword = password;
      const randomPassword = encodeCrock(randomBytes(16));
      logger.info("random password: ", randomPassword);
      let token: AccessToken;
      {
        const resp = await bank.createAccessTokenBasic(id, prevPassword, {
          scope: "readwrite",
          duration: Duration.toTalerProtocolDuration(
            Duration.fromSpec({ minutes: 1 }),
          ),
          refreshable: false,
        });
        if (resp.type === "fail") {
          if (resp.case === HttpStatusCode.Accepted) {
            console.error(
              `unable to login into bank accountfor user ${id}, 2fa required`,
            );
            console.error(j2s(resp.body));
            process.exit(2);
          }
          console.error(
            `unable to login into bank accountfor user ${id}, status ${resp.case}`,
          );
          console.error(j2s(resp.detail));
          process.exit(2);
        }
        token = resp.body.access_token;
      }

      {
        const resp = await bank.updatePassword(
          { username: id, token },
          {
            old_password: prevPassword,
            new_password: randomPassword,
          },
        );
        if (resp.type === "fail") {
          console.error(
            `unable to change bank password for user ${id}, status ${resp.case}`,
          );
          if (resp.case !== HttpStatusCode.Accepted) {
            console.error(j2s(resp.detail));
          } else {
            console.error("2FA required");
          }
          process.exit(2);
        }
      }

      const { access_token } = succeedOrThrow(
        await merchantInstance.createAccessToken(id, prevPassword, {
          scope: LoginTokenScope.All,
        }),
      );

      {
        const resp = await merchantInstance.updateCurrentInstanceAuthentication(
          access_token,
          {
            method: MerchantAuthMethod.TOKEN,
            password: randomPassword,
          },
        );
        if (resp.type === "fail") {
          console.error(
            `unable to change merchant password for instance ${id}, status ${resp.case}`,
          );
          console.error(
            resp.case === HttpStatusCode.Accepted
              ? `the merchant requires MFA for this account`
              : j2s(resp.detail),
          );
          process.exit(2);
        }
      }

      {
        const resp = await merchantInstance.updateBankAccount(
          createRFC8959AccessTokenEncoded(randomPassword),
          wireAccount,
          {
            credit_facade_url: bank.getRevenueAPI(id).href,
            credit_facade_credentials: {
              type: "basic",
              username: id,
              password: randomPassword,
            },
          },
        );
        if (resp.type != "ok") {
          console.error(
            `unable to update bank account for instance ${id}, status ${resp.case}`,
          );
          console.error(j2s(resp.detail));
          process.exit(2);
        }
      }
      finalPassword = randomPassword;
    }
    logger.info(`successfully configured bank account for ${id}`);

    /**
     * show result
     */
    console.log(
      JSON.stringify(
        {
          bankUser: id,
          bankURL: args.provisionBankMerchant.corebankApiBaseUrl,
          merchantURL: instanceURL,
          templateURI,
          password: finalPassword,
        },
        undefined,
        2,
      ),
    );
  });

deploymentCli
  .subcommand("provisionMerchantInstance", "provision-merchant-instance", {
    help: "Provision a merchant backend instance. It will fail if merchant backend require MFA.",
  })
  .requiredArgument("merchantApiBaseUrl", clk.STRING)
  .requiredOption("managementToken", ["--management-token"], clk.STRING, {})
  .requiredOption("instancePassword", ["--instance-password"], clk.STRING, {
    help: "New password to set for the provisioned instance.",
  })
  .requiredOption("name", ["--name"], clk.STRING)
  .requiredOption("id", ["--id"], clk.STRING)
  .requiredOption("payto", ["--payto"], clk.STRING)
  .maybeOption("bankURL", ["--bankURL"], clk.STRING)
  .maybeOption("bankUser", ["--bankUser"], clk.STRING)
  .maybeOption("bankPassword", ["--bankPassword"], clk.STRING)
  .maybeOption(
    "defaultWireTransferDelay",
    ["--default-wire-transfer-delay"],
    clk.STRING,
  )
  .maybeOption("defaultPayDelay", ["--default-pay-delay"], clk.STRING)
  .action(async (args) => {
    const httpLib = createPlatformHttpLib({});
    const baseUrl = args.provisionMerchantInstance.merchantApiBaseUrl;
    const managementApi = new TalerMerchantManagementHttpClient(
      baseUrl,
      httpLib,
    );
    const managementToken = createRFC8959AccessTokenEncoded(
      args.provisionMerchantInstance.managementToken,
    );
    const instancePassword = args.provisionMerchantInstance.instancePassword;
    const instanceId = args.provisionMerchantInstance.id;
    const instancceName = args.provisionMerchantInstance.name;
    const bankURL = args.provisionMerchantInstance.bankURL;
    const bankUser = args.provisionMerchantInstance.bankUser;
    const bankPassword = args.provisionMerchantInstance.bankPassword;
    const accountPayto = args.provisionMerchantInstance.payto;

    let defaultWireTransferDelay: Duration;
    if (args.provisionMerchantInstance.defaultWireTransferDelay) {
      defaultWireTransferDelay = Duration.fromPrettyString(
        args.provisionMerchantInstance.defaultWireTransferDelay,
      );
    } else {
      defaultWireTransferDelay = Duration.fromMilliseconds(1);
    }

    let defaultPayDelay: Duration;
    if (args.provisionMerchantInstance.defaultPayDelay) {
      defaultPayDelay = Duration.fromPrettyString(
        args.provisionMerchantInstance.defaultPayDelay,
      );
    } else {
      defaultPayDelay = Duration.fromSpec({ hours: 1 });
    }

    const createResp = await managementApi.createInstance(managementToken, {
      address: {},
      auth: {
        method: MerchantAuthMethod.TOKEN,
        password: instancePassword,
      },
      default_pay_delay: Duration.toTalerProtocolDuration(defaultPayDelay),
      default_wire_transfer_delay: Duration.toTalerProtocolDuration(
        defaultWireTransferDelay,
      ),
      id: instanceId,
      jurisdiction: {},
      name: instancceName,
      use_stefan: true,
    });

    if (createResp.type === "ok") {
      logger.info(`instance ${instanceId} created successfully`);
    } else if (createResp.case === HttpStatusCode.Conflict) {
      logger.info(`instance ${instanceId} already exists`);
    } else {
      logger.error(
        `unable to create instance ${instanceId}, HTTP status ${createResp.case}`,
      );
      process.exit(2);
    }

    const instanceUrl = managementApi.getSubInstanceAPI(instanceId);

    const instanceApi = new TalerMerchantInstanceHttpClient(
      instanceUrl,
      httpLib,
    );

    const { access_token } = succeedOrThrow(
      await instanceApi.createAccessToken(instanceId, instancePassword, {
        scope: LoginTokenScope.All,
      }),
    );

    const createAccountResp = await instanceApi.addBankAccount(access_token, {
      payto_uri: accountPayto,
      credit_facade_url: bankURL,
      credit_facade_credentials:
        bankUser && bankPassword
          ? {
              type: "basic",
              username: bankUser,
              password: bankPassword,
            }
          : undefined,
    });
    if (createAccountResp.type != "ok") {
      console.error(
        `unable to configure bank account for instance ${instanceId}, status ${createAccountResp.case}`,
      );
      console.error(
        createAccountResp.case === HttpStatusCode.Accepted
          ? `the merchant requires MFA for this account`
          : j2s(createAccountResp.detail),
      );
      process.exit(2);
    }
    logger.info(`successfully configured bank account for ${instanceId}`);
  });

deploymentCli
  .subcommand("provisionMerchantDonau", "provision-merchant-donau", {
    help: "Provision a merchant and donau connection.",
  })
  .requiredOption(
    "merchantAuthToken",
    ["--merchant-auth-token"],
    clk.STRING,
    {},
  )
  .requiredOption("donauAuthToken", ["--donau-auth-token"], clk.STRING, {})
  .requiredOption("merchantBaseUrl", ["--merchant-base-url"], clk.STRING, {})
  .requiredOption("donauBaseUrl", ["--donau-base-url"], clk.STRING, {})
  .requiredOption("currency", ["--currency"], clk.STRING, {})
  .action(async (args) => {
    const httpLib = createPlatformHttpLib({});
    const donauBaseUrl = args.provisionMerchantDonau.donauBaseUrl;
    const merchantUrl = args.provisionMerchantDonau.merchantBaseUrl;
    const merchantClient = new TalerMerchantInstanceHttpClient(
      merchantUrl,
      httpLib,
    );
    const currency = args.provisionMerchantDonau.currency;
    const merchantToken = args.provisionMerchantDonau
      .merchantAuthToken as AccessToken;
    const donauToken = args.provisionMerchantDonau
      .donauAuthToken as AccessToken;
    const instInfo = succeedOrThrow(
      await merchantClient.getCurrentInstanceDetails(merchantToken),
    );
    const merchantPub = instInfo.merchant_pub;
    const donauClient = new DonauHttpClient(donauBaseUrl, {
      httpClient: httpLib,
    });
    const myCharities = succeedOrThrow(
      await donauClient.getCharities(donauToken),
    );

    let existingCharity = myCharities.charities.find(
      (x) => x.charity_pub === merchantPub,
    );
    let charityId: number;
    if (!existingCharity) {
      const charityResp = succeedOrThrow(
        await donauClient.createCharity(donauToken, {
          charity_name: instInfo.name,
          charity_pub: merchantPub,
          charity_url: merchantUrl,
          current_year: new Date().getFullYear(),
          max_per_year: `${currency}:100000`,
          receipts_to_date: `${currency}:0`,
        }),
      );
      charityId = charityResp.charity_id;
    } else {
      charityId = existingCharity.charity_id;
    }
    succeedOrThrow(
      await merchantClient.postDonau({
        token: merchantToken,
        body: {
          charity_id: charityId,
          donau_url: donauBaseUrl,
        },
      }),
    );
    logger.info(`configured charity ${charityId} for ${merchantUrl}`);
  });

deploymentCli
  .subcommand("provisionBankAccount", "provision-bank-account", {
    help: "Provision a corebank account.",
  })
  .requiredArgument("corebankApiBaseUrl", clk.STRING)
  .flag("exchange", ["--exchange"])
  .flag("public", ["--public"])
  .requiredOption("login", ["--login"], clk.STRING)
  .requiredOption("name", ["--name"], clk.STRING)
  .requiredOption("password", ["--password"], clk.STRING)
  .maybeOption("internalPayto", ["--payto"], clk.STRING)
  .action(async (args) => {
    const httpLib = createPlatformHttpLib();
    const baseUrl = args.provisionBankAccount.corebankApiBaseUrl;
    const bankApi = new TalerCoreBankHttpClient(baseUrl, httpLib);

    // Strip question mark part from payto URI,
    // as libeufin bank wants a plain payto here.
    let strippedPaytoUri = args.provisionBankAccount.internalPayto;
    let idxQm = strippedPaytoUri?.indexOf("?");
    if (strippedPaytoUri && idxQm != null && idxQm >= 0) {
      strippedPaytoUri = strippedPaytoUri.substring(0, idxQm);
    }
    const accountLogin = args.provisionBankAccount.login;
    const resp = await bankApi.createAccount(undefined, {
      name: args.provisionBankAccount.name,
      password: args.provisionBankAccount.password,
      username: accountLogin,
      is_public: !!args.provisionBankAccount.public,
      is_taler_exchange: !!args.provisionBankAccount.exchange,
      payto_uri: strippedPaytoUri,
    });

    if (resp.type === "ok") {
      logger.info(`account ${accountLogin} successfully provisioned`);
      return;
    }
    logger.error(
      `unable to provision bank account, HTTP response status ${resp.case}`,
    );
    logger.error(j2s(resp));
    process.exit(2);
  });

function computeFee(args: {
  currency: string;
  spec: string;
  coinMin: AmountJson;
  coinMax: AmountJson;
}): string {
  if (args.spec === "none") {
    return `${args.currency}:0`;
  }

  if (args.spec === "const") {
    return Amounts.stringify(args.coinMin);
  }

  throw Error(`unsupported fee spec (${args.spec})`);
}

deploymentCli
  .subcommand("coincfg", "gen-coin-config", {
    help: "Generate a coin/denomination configuration for the exchange.",
  })
  .requiredOption("minAmount", ["--min-amount"], clk.STRING, {
    help: "Smallest denomination",
  })
  .requiredOption("maxAmount", ["--max-amount"], clk.STRING, {
    help: "Largest denomination",
  })
  .maybeOption("feeWithdraw", ["--fee-deposit"], clk.STRING)
  .maybeOption("feeDeposit", ["--fee-deposit"], clk.STRING)
  .maybeOption("feeRefresh", ["--fee-refresh"], clk.STRING)
  .maybeOption("feeRefund", ["--fee-refund"], clk.STRING)
  .maybeOption("fees", ["--fees"], clk.STRING)
  .flag("noFees", ["--no-fees"])
  .action(async (args) => {
    let feespecWithdraw;
    let feespecDeposit;
    let feespecRefund;
    let feespecRefresh;

    if (args.coincfg.noFees) {
      feespecDeposit = "none";
      feespecWithdraw = "none";
      feespecRefund = "none";
      feespecRefresh = "none";
    } else if (args.coincfg.fees) {
      feespecWithdraw = args.coincfg.fees;
      feespecDeposit = args.coincfg.fees;
      feespecRefund = args.coincfg.fees;
      feespecRefresh = args.coincfg.fees;
    } else {
      // Default: Only deposit fees
      feespecWithdraw = args.coincfg.feeWithdraw ?? "none";
      feespecDeposit = args.coincfg.feeDeposit ?? "const";
      feespecRefund = args.coincfg.feeRefund ?? "none";
      feespecRefresh = args.coincfg.feeRefresh ?? "none";
    }

    let out = "";

    const stamp = Math.floor(new Date().getTime() / 1000);

    const coinMin = Amounts.parseOrThrow(args.coincfg.minAmount);
    const coinMax = Amounts.parseOrThrow(args.coincfg.maxAmount);
    if (coinMin.currency != coinMax.currency) {
      console.error("currency mismatch");
      process.exit(1);
    }
    const currency = coinMin.currency;
    let x = coinMin;
    let n = 1;

    out += "# Coin configuration for the exchange.\n";
    out += '# Should be placed in "/etc/taler/config.d/exchange-coins.conf".\n';
    out += "\n";

    while (Amounts.cmp(x, coinMax) < 0) {
      const feeWithdraw = computeFee({
        currency,
        coinMax,
        coinMin,
        spec: feespecWithdraw,
      });
      const feeDeposit = computeFee({
        currency,
        coinMax,
        coinMin,
        spec: feespecDeposit,
      });
      const feeRefresh = computeFee({
        currency,
        coinMax,
        coinMin,
        spec: feespecRefresh,
      });
      const feeRefund = computeFee({
        currency,
        coinMax,
        coinMin,
        spec: feespecRefund,
      });

      out += `[COIN_${currency}_n${n}_t${stamp}]\n`;
      out += `VALUE = ${Amounts.stringify(x)}\n`;
      out += `DURATION_WITHDRAW = 7 days\n`;
      out += `DURATION_SPEND = 2 years\n`;
      out += `DURATION_LEGAL = 6 years\n`;
      out += `FEE_WITHDRAW = ${feeWithdraw}\n`;
      out += `FEE_DEPOSIT = ${feeDeposit}\n`;
      out += `FEE_REFRESH = ${feeRefresh}\n`;
      out += `FEE_REFUND = ${feeRefund}\n`;
      out += `RSA_KEYSIZE = 2048\n`;
      out += `CIPHER = RSA\n`;
      out += "\n";
      x = Amounts.add(x, x).amount;
      n++;
    }

    console.log(out);
  });

deploymentCli
  .subcommand("coincfg", "gen-doco-config", {
    help: "Generate a donation unit configuration for dibay.",
  })
  .requiredOption("minAmount", ["--min-amount"], clk.STRING, {
    help: "Smallest denomination",
  })
  .requiredOption("maxAmount", ["--max-amount"], clk.STRING, {
    help: "Largest denomination",
  })
  .action(async (args) => {
    let out = "";

    const stamp = Math.floor(new Date().getTime() / 1000);

    const coinMin = Amounts.parseOrThrow(args.coincfg.minAmount);
    const coinMax = Amounts.parseOrThrow(args.coincfg.maxAmount);
    if (coinMin.currency != coinMax.currency) {
      console.error("currency mismatch");
      process.exit(1);
    }
    const currency = coinMin.currency;
    let x = coinMin;
    let n = 1;

    out += "# Donation unit configuration for the donau.\n";
    out += '# Should be placed in "/etc/donau/config.d/units.conf".\n';
    out += "\n";

    while (Amounts.cmp(x, coinMax) < 0) {
      out += `[DOCO_${currency}_n${n}]\n`;
      out += `VALUE = ${Amounts.stringify(x)}\n`;
      out += `DURATION_WITHDRAW = 1 year\n`;
      out += `ANCHOR_ROUND = 1 year\n`;
      out += `DURATION_SPEND = 2 years\n`;
      out += `DURATION_LEGAL = 3 years\n`;
      out += `FEE_WITHDRAW = ${currency}:0\n`;
      out += `FEE_DEPOSIT = ${currency}:0\n`;
      out += `FEE_REFRESH = ${currency}:0\n`;
      out += `FEE_REFUND = ${currency}:0\n`;
      out += `RSA_KEYSIZE = 2048\n`;
      out += `CIPHER = RSA\n`;
      out += "\n";
      x = Amounts.add(x, x).amount;
      n++;
    }

    console.log(out);
  });

talerHarnessCli.subcommand("logtest", "logtest").action(async (args) => {
  logger.trace("This is a trace message.");
  logger.info("This is an info message.");
  logger.warn("This is an warning message.");
  logger.error("This is an error message.");
});

talerHarnessCli
  .subcommand("listIntegrationtests", "list-integrationtests")
  .action(async (args) => {
    for (const t of getTestInfo()) {
      let s = t.name;
      if (t.suites.length > 0) {
        s += ` (suites: ${t.suites.join(",")})`;
      }
      if (t.experimental) {
        s += ` [experimental]`;
      }
      console.log(s);
    }
  });

talerHarnessCli
  .subcommand("runIntegrationtests", "run-integrationtests")
  .maybeArgument("pattern", clk.STRING, {
    help: "Glob pattern to select which tests to run",
  })
  .maybeOption("suites", ["--suites"], clk.STRING, {
    help: "Only run selected suites (comma-separated list)",
  })
  .maybeOption("testDir", ["--test-dir"], clk.STRING, {
    help: "When to run the tests",
  })
  .flag("dryRun", ["--dry"], {
    help: "Only print tests that will be selected to run.",
  })
  .flag("experimental", ["--experimental"], {
    help: "Include tests marked as experimental",
  })
  .flag("failFast", ["--fail-fast"], {
    help: "Exit after the first error",
  })
  .flag("waitOnFail", ["--wait-on-fail"], {
    help: "Exit after the first error",
  })
  .flag("quiet", ["--quiet"], {
    help: "Produce less output.",
  })
  .flag("noTimeout", ["--no-timeout"], {
    help: "Do not time out tests.",
  })
  .action(async (args) => {
    const noTimeout =
      process.env["TALER_TEST_NO_TIMEOUT"] === "1" ? true : undefined;
    await runTests({
      includePattern: args.runIntegrationtests.pattern,
      failFast: args.runIntegrationtests.failFast,
      waitOnFail: args.runIntegrationtests.waitOnFail,
      suiteSpec: args.runIntegrationtests.suites,
      dryRun: args.runIntegrationtests.dryRun,
      verbosity: args.runIntegrationtests.quiet ? 0 : 1,
      includeExperimental: args.runIntegrationtests.experimental ?? false,
      noTimeout: noTimeout ?? args.runIntegrationtests.noTimeout,
      testDir: args.runIntegrationtests.testDir,
    });
  });

async function read(stream: NodeJS.ReadStream) {
  const chunks = [];
  for await (const chunk of stream) chunks.push(chunk);
  return Buffer.concat(chunks).toString("utf8");
}

talerHarnessCli.subcommand("tvgcheck", "tvgcheck").action(async (args) => {
  const data = await read(process.stdin);

  const lines = data.match(/[^\r\n]+/g);

  if (!lines) {
    throw Error("can't split lines");
  }

  const vals: Record<string, string> = {};

  let inBlindSigningSection = false;

  for (const line of lines) {
    if (line === "blind signing:") {
      inBlindSigningSection = true;
      continue;
    }
    if (line[0] !== " ") {
      inBlindSigningSection = false;
      continue;
    }
    if (inBlindSigningSection) {
      const m = line.match(/  (\w+) (\w+)/);
      if (!m) {
        console.log("bad format");
        process.exit(2);
      }
      vals[m[1]] = m[2];
    }
  }

  console.log(vals);

  const req = (k: string) => {
    if (!vals[k]) {
      throw Error(`no value for ${k}`);
    }
    return decodeCrock(vals[k]);
  };

  const myBm = rsaBlind(
    req("message_hash"),
    req("blinding_key_secret"),
    req("rsa_public_key"),
  );

  deepStrictEqual(req("blinded_message"), myBm);

  console.log("check passed!");
});

export const helpersCli = talerHarnessCli.subcommand(
  "helperProgram",
  "helper-program",
  {
    help: "Generic helper. Reads stdin and all arguments and send it as a JSON to the socket.",
  },
);

talerHarnessCli
  .subcommand("runHelper", "run-helper")
  .requiredOption("socket", ["-s", "--socket"], clk.STRING)
  .maybeArgument("rest1", clk.STRING)
  .maybeArgument("rest2", clk.STRING)
  .maybeArgument("rest3", clk.STRING)
  .maybeArgument("rest4", clk.STRING)
  .maybeArgument("rest5", clk.STRING)
  .maybeArgument("rest6", clk.STRING)
  .action(async (args) => {
    const socketPath = args.runHelper.socket;
    const input = fs.readFileSync(0, "utf-8").trim();

    const stream = net.createConnection({ path: socketPath });
    stream.write(
      JSON.stringify({
        stdin: input,
        args: process.argv,
      }),
    );
    stream.end();
  });

export const amlProgramCli = talerHarnessCli.subcommand(
  "amlProgram",
  "aml-program",
  {
    help: "Helper programs for AML testing.",
  },
);

const allAmlPrograms: TalerKycAml.AmlProgramDefinition[] = [
  {
    name: "no-rules",
    logic: async (_input, _config) => {
      const outcome: TalerKycAml.AmlOutcome = {
        new_rules: {
          expiration_time: TalerProtocolTimestamp.never(),
          rules: [],
          custom_measures: {},
        },
        events: [],
      };
      return outcome;
    },
    requiredAttributes: [],
    requiredInputs: [],
    requiredContext: [],
  },
  {
    name: "undefined-new-measures",
    logic: async (_input, _config) => {
      const outcome: TalerKycAml.AmlOutcome = {
        new_measures: "does-not-exist",
        new_rules: {
          expiration_time: TalerProtocolTimestamp.never(),
          rules: [],
          custom_measures: {},
        },
        events: [],
      };
      return outcome;
    },
    requiredAttributes: [],
    requiredInputs: [],
    requiredContext: [],
  },
  {
    name: "fail",
    logic: async (_input, _config) => {
      throw Error("I am a failed KYC program, oh my!");
    },
    requiredAttributes: [],
    requiredInputs: [],
    requiredContext: [],
  },
  {
    name: "fail-exec-child-error",
    logic: async (_input, _config) => {
      execSync("something-that-doesnt-exists make-this-fail");
      throw Error("unreachable");
    },
    requiredAttributes: [],
    requiredInputs: [],
    requiredContext: [],
  },
  {
    name: "hang",
    logic: async (_input, _config) => {
      console.log("going to wait ...");
      // Wait forever
      while (1) {
        await waitMs(1000);
        console.log("still waiting");
      }
      throw Error("not reached");
    },
    requiredAttributes: [],
    requiredInputs: [],
    requiredContext: [],
  },
  AML_PROGRAM_FROM_ATTRIBUTES_TO_CONTEXT,
  AML_PROGRAM_NEXT_MEASURE_FORM,
  AML_PROGRAM_FAIL_RECOVER,
  AML_PROGRAM_TEST_KYC_NEW_MEASURES_PROG,
];

amlProgramCli
  .subcommand("run", "run-program")
  .requiredOption("name", ["-n", "--name"], clk.STRING)
  .flag("requires", ["-r"])
  .flag("inputs", ["-i"])
  .flag("attributes", ["-a"])
  .maybeOption("config", ["-c", "--config"], clk.STRING)
  .action(async (args) => {
    const found = allAmlPrograms.find((p) => p.name === args.run.name);
    if (!found) {
      logger.error(`Program not found: ${args.run.name}`);
      logger.error(
        `you can try "${allAmlPrograms.map((p) => p.name).join(",")}"`,
      );
      return;
    }
    if (args.run.requires) {
      logger.info("Reporting requirements");
      console.log(found.requiredContext.join("\n"));
      return;
    }
    if (args.run.inputs) {
      logger.info("Reporting requirements");
      console.log(found.requiredInputs.join("\n"));
      return;
    }

    if (args.run.attributes) {
      logger.info("reporting attributes");
      console.log(found.requiredAttributes.join("\n"));
      return;
    }

    const buffers = [];
    // node.js readable streams implement the async iterator protocol
    for await (const data of process.stdin) {
      buffers.push(data);
    }

    const finalBuffer = Buffer.concat(buffers);
    const inputStr = finalBuffer.toString("utf-8");
    const inputJson = JSON.parse(inputStr);
    const progInput = TalerKycAml.codecForAmlProgramInput().decode(inputJson);

    logger.info(`got input: ${j2s(progInput)}`);

    const outcome = await found.logic(progInput, args.run.config);

    console.log(j2s(outcome));
  });

amlProgramCli
  .subcommand("noRules", "no-rules")
  .flag("requires", ["-r"])
  .flag("attributes", ["-a"])
  .maybeOption("config", ["-c", "--config"], clk.STRING)
  .action(async (args) => {
    logger.info("Hello, this is the no-rules AML program.");
    if (args.noRules.requires) {
      logger.info("Reporting requirements");
      // No requirements.
      return;
    }

    if (args.noRules.attributes) {
      logger.info("reporting attributes");
      // No attributes
      return;
    }

    const buffers = [];

    // node.js readable streams implement the async iterator protocol
    for await (const data of process.stdin) {
      buffers.push(data);
    }

    const finalBuffer = Buffer.concat(buffers);
    const inputStr = finalBuffer.toString("utf-8");
    const inputJson = JSON.parse(inputStr);
    const progInput = TalerKycAml.codecForAmlProgramInput().decode(inputJson);

    logger.info(`got input: ${j2s(progInput)}`);

    const outcome: TalerKycAml.AmlOutcome = {
      new_rules: {
        expiration_time: TalerProtocolTimestamp.never(),
        rules: [],
        custom_measures: {},
      },
      events: [],
    };

    console.log(j2s(outcome));
  });

export const merchantCli = talerHarnessCli.subcommand("merchant", "merchant", {
  help: "Merchant backend tools.",
});

merchantCli
  .subcommand("token", "token", {
    help: "Obtain login token from merchant.",
  })
  .requiredArgument("merchantBaseUrl", clk.STRING)
  .requiredArgument("instance", clk.STRING)
  .requiredOption("password", ["--password"], clk.STRING)
  .maybeOption("scope", ["--scope"], clk.STRING)
  .maybeOption("duration", ["--duration"], clk.STRING)
  .action(async (args) => {
    const merchantApi = new TalerMerchantInstanceHttpClient(
      args.token.merchantBaseUrl,
    );
    const instance = args.token.instance;
    const password = args.token.password;
    const scope = args.token.scope ?? "all";
    const duration = args.token.duration
      ? Duration.toTalerProtocolDuration(
          Duration.fromPrettyString(args.token.duration),
        )
      : undefined;
    const tokResp = await merchantApi.createAccessToken(instance, password, {
      scope: scope as LoginTokenScope,
      duration,
    });
    const tok = succeedOrThrow(tokResp);
    logger.info(`refreshable: ${tok.refreshable}`);
    logger.info(`scope: ${tok.scope}`);
    console.log(tok.access_token);
  });

merchantCli
  .subcommand("checkKyc", "check-kyc", {
    help: "gets updated information about the kyc state",
  })
  .requiredArgument("config", clk.STRING, {
    help: "configuration file",
  })
  .maybeOption("id", ["--id"], clk.STRING, {
    help: "selected instance id of the merchant backend, default to 'admin'",
    default: "admin",
  })
  .action(async (args) => {
    const config = Configuration.load(args.checkKyc.config);

    const db = merchantDatabase(config);

    const instanceId = args.checkKyc.id ?? "admin";
    const { merchant_priv } = await db.getInstancePrivateKey(instanceId);

    const merchantPub = eddsaGetPublic(merchant_priv);

    const allAccounts = await db.getInstanceKycStatus(instanceId);

    const accountOwnerSig = encodeCrock(signKycAuth(merchant_priv));

    const info = await Promise.all(
      allAccounts.map(async ({ exchange_url, payto_uri }) => {
        const exchangeApi = new TalerExchangeHttpClient(exchange_url, {});
        const kyc_status = await exchangeApi.checkKycStatus({
          accountPub: encodeCrock(merchantPub),
          accountSig: accountOwnerSig,
          paytoHash: encodeCrock(
            hashNormalizedPaytoUri(parsePaytoUriOrThrow(payto_uri)),
          ),
        });
        return {
          payto_uri,
          exchange_url,
          kyc_status,
        };
      }),
    );
    db.close();
    console.log(j2s(info));
  });

export function main() {
  talerHarnessCli.run();
}

function merchantDatabase(config: Configuration) {
  const dbUrl = config.getString("MERCHANTDB-POSTGRES", "CONFIG").getValue();
  if (!dbUrl) {
    throw Error("missing dbUrl");
  }

  let path: string | undefined;
  const { hostname: DB_HOST, port: DB_PORT } = new URLImpl(dbUrl);
  if (DB_HOST.startsWith("%2F")) {
    path = `${decodeURIComponent(DB_HOST)}/.s.PGSQL.${DB_PORT}`;
  }
  const postgresql = postgres(dbUrl, { path });

  return {
    close() {
      return postgresql.end();
    },
    async getInstancePrivateKey(instanceId: string) {
      type Result = { merchant_priv: EddsaPrivP }[];
      return (
        await postgresql<Result>`
          SELECT merchant_priv
          FROM merchant.merchant_keys AS mk
          JOIN merchant.merchant_instances AS mi ON mi.merchant_serial = mk.merchant_serial
          WHERE mi.merchant_id = ${instanceId}`
      )[0];
    },
    async getInstanceKycStatus(instanceId: string) {
      type Result = {
        // merchant_kyc
        kyc_ok: boolean;
        exchange_url: string;
        access_token: string | undefined;
        exchange_http_status: number;
        exchange_ec_code: number;
        aml_review: boolean;
        jaccount_limits: object;
        // merchant_accounts
        payto_uri: string;
      }[];
      return await postgresql<Result>`
        SELECT kyc.*, ma.payto_uri
        FROM merchant.merchant_instances AS mi
        JOIN merchant.merchant_kyc as kyc ON kyc.kyc_serial_id = mi.merchant_serial
        JOIN merchant.merchant_accounts ma ON ma.account_serial = kyc.account_serial
        WHERE mi.merchant_id = ${instanceId}`;
    },
  };
}
