/*
 This file is part of GNU Taler
 (C) 2020-2025 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/>
 */

/**
 * Test harness for various GNU Taler components.
 * Also provides a fault-injection proxy.
 *
 * @author Florian Dold <dold@taler.net>
 */

/**
 * Imports
 */
import {
  AccessToken,
  AmountJson,
  Amounts,
  ConfigSources,
  Configuration,
  CoreApiResponse,
  Duration,
  EddsaKeyPair,
  InstanceAuthConfigurationMessage,
  InstanceConfigurationMessage,
  Logger,
  LoginTokenRequest,
  LoginTokenScope,
  MerchantAccountKycRedirectsResponse,
  MerchantAuthMethod,
  PaytoString,
  TalerCoreBankHttpClient,
  TalerCorebankApiClient,
  TalerError,
  TalerExchangeHttpClient,
  TalerMerchantApi,
  TalerMerchantInstanceHttpClient,
  TalerMerchantManagementHttpClient,
  TalerProtocolDuration,
  TalerWireGatewayAuth,
  TalerWireGatewayHttpClient,
  Transaction,
  TransactionIdStr,
  WalletNotification,
  codecForAccountKycRedirects,
  codecForQueryInstancesResponse,
  createEddsaKeyPair,
  eddsaGetPublic,
  encodeCrock,
  hash,
  j2s,
  openPromise,
  parsePaytoUri,
  stringToBytes,
  succeedOrThrow,
} from "@gnu-taler/taler-util";
import {
  HttpRequestLibrary,
  createPlatformHttpLib,
  expectSuccessResponseOrThrow,
  readSuccessResponseJsonOrThrow,
  readTalerErrorResponse,
} from "@gnu-taler/taler-util/http";
import {
  WalletApiOperation,
  WalletCoreApiClient,
  WalletCoreRequestType,
  WalletCoreResponseType,
  WalletOperations,
} from "@gnu-taler/taler-wallet-core";
import {
  RemoteWallet,
  WalletNotificationWaiter,
  createRemoteWallet,
  getClientFromRemoteWallet,
  makeNotificationWaiter,
} from "@gnu-taler/taler-wallet-core/remote";
import { deepStrictEqual } from "assert";
import { ChildProcess, spawn } from "child_process";
import * as http from "http";
import * as fs from "node:fs";
import * as net from "node:net";
import * as path from "node:path";
import * as readline from "readline";
import { CoinConfig } from "./denomStructures.js";

const logger = new Logger("harness.ts");

export async function delayMs(ms: number): Promise<void> {
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve(), ms);
  });
}

export interface WithAuthorization {
  Authorization?: string;
}

interface WaitResult {
  code: number | null;
  signal: NodeJS.Signals | null;
}

class CommandError extends Error {
  constructor(
    public message: string,
    public logName: string,
    public command: string,
    public args: string[],
    public env: Env,
    public code: number | null,
  ) {
    super(message);
  }
}
interface Env {
  [index: string]: string | undefined;
}
/**
 * Run a shell command, return stdout.
 */
export async function sh(
  t: GlobalTestState,
  logName: string,
  command: string,
  env: Env = process.env,
): Promise<string> {
  logger.trace(`running command ${command}`);
  return new Promise((resolve, reject) => {
    const stdoutChunks: Buffer[] = [];
    const proc = spawn(command, {
      stdio: ["inherit", "pipe", "pipe"],
      shell: true,
      env,
    });
    proc.stdout.on("data", (x) => {
      if (x instanceof Buffer) {
        stdoutChunks.push(x);
      } else {
        throw Error("unexpected data chunk type");
      }
    });
    const stderrLogFileName = path.join(t.testDir, `${logName}-stderr.log`);
    const stderrLog = fs.createWriteStream(stderrLogFileName, {
      flags: "a",
    });
    proc.stderr.pipe(stderrLog);
    proc.on("exit", (code, signal) => {
      logger.info(`child process ${logName} exited (${code} / ${signal})`);
      if (code != 0) {
        reject(
          new CommandError(
            `Unexpected exit code ${code}`,
            logName,
            command,
            [],
            env,
            code,
          ),
        );
        return;
      }
      const b = Buffer.concat(stdoutChunks).toString("utf-8");
      resolve(b);
    });
    proc.on("error", (err) => {
      reject(
        new CommandError(
          "Child process had error:" + err.message,
          logName,
          command,
          [],
          env,
          null,
        ),
      );
    });
  });
}

function shellescape(args: string[]) {
  const ret = args.map((s) => {
    if (/[^A-Za-z0-9_\/:=-]/.test(s)) {
      s = "'" + s.replace(/'/g, "'\\''") + "'";
      s = s.replace(/^(?:'')+/g, "").replace(/\\'''/g, "\\'");
    }
    return s;
  });
  return ret.join(" ");
}

/**
 * Run a shell command, return stdout.
 *
 * Log stderr to a log file.
 */
export async function runCommand(
  t: GlobalTestState,
  logName: string,
  command: string,
  args: string[],
  env: { [index: string]: string | undefined } = process.env,
): Promise<string> {
  logger.info(`running command ${shellescape([command, ...args])}`);

  return new Promise((resolve, reject) => {
    const stdoutChunks: Buffer[] = [];
    const proc = spawn(command, args, {
      stdio: ["inherit", "pipe", "pipe"],
      shell: false,
      env: env,
    });
    proc.stdout.on("data", (x) => {
      if (x instanceof Buffer) {
        stdoutChunks.push(x);
      } else {
        throw Error("unexpected data chunk type");
      }
    });
    const stderrLogFileName = path.join(t.testDir, `${logName}-stderr.log`);
    const stderrLog = fs.createWriteStream(stderrLogFileName, {
      flags: "a",
    });
    proc.stderr.pipe(stderrLog);
    proc.on("exit", (code, signal) => {
      logger.trace(`child process exited (${code} / ${signal})`);
      if (code != 0) {
        reject(
          new CommandError(
            `Unexpected exit code ${code}`,
            logName,
            command,
            [],
            env,
            code,
          ),
        );
        return;
      }
      const b = Buffer.concat(stdoutChunks).toString("utf-8");
      resolve(b);
    });
    proc.on("error", (err) => {
      reject(
        new CommandError(
          "Child process had error:" + err.message,
          logName,
          command,
          [],
          env,
          null,
        ),
      );
    });
  });
}

export class ProcessWrapper {
  private waitPromise: Promise<WaitResult>;
  constructor(public proc: ChildProcess) {
    this.waitPromise = new Promise((resolve, reject) => {
      proc.on("exit", (code, signal) => {
        resolve({ code, signal });
      });
      proc.on("error", (err) => {
        reject(err);
      });
    });
  }

  wait(): Promise<WaitResult> {
    return this.waitPromise;
  }
}

export class GlobalTestParams {
  testDir: string;
}

export class GlobalTestState {
  testDir: string;
  procs: ProcessWrapper[];
  servers: http.Server[];
  inShutdown: boolean = false;
  stepSet: Set<string> = new Set();

  spanStack: string[] = [];

  constructor(params: GlobalTestParams) {
    this.testDir = params.testDir;
    this.procs = [];
    this.servers = [];
  }

  async runSpanAsync(
    spanName: string,
    block: () => Promise<void>,
  ): Promise<void> {
    if (this.stepSet.has(spanName)) {
      throw Error(`duplicate step (${spanName})`);
    }
    let steps = `${this.testDir}/steps.txt`;
    const stepStart = `START ${spanName}`;
    fs.appendFileSync(steps, `${stepStart}\n`);
    logger.info(`STEP: ${stepStart}`);
    try {
      await block();
    } catch (e) {
      const stepFail = `FAIL ${spanName} - ${(e as any).message}`;
      fs.appendFileSync(steps, `${stepFail}\n`);
      logger.info(`STEP: ${stepFail}`);
      throw e;
    }
    const stepEnd = `END ${spanName}`;
    fs.appendFileSync(steps, `${stepEnd}\n`);
    logger.info(`STEP: ${stepEnd}`);
  }

  /**
   * @deprecated use {@link assertThrowsAsync} instead
   */
  async assertThrowsTalerErrorAsyncLegacy(
    block: Promise<unknown>,
  ): Promise<TalerError> {
    try {
      await block;
    } catch (e) {
      if (e instanceof TalerError) {
        return e;
      }
      throw Error(`expected TalerError to be thrown, but got ${e}`);
    }
    throw Error(
      `expected TalerError to be thrown, but block finished without throwing`,
    );
  }

  async assertThrowsTalerErrorAsync(
    block: () => Promise<unknown>,
  ): Promise<TalerError> {
    try {
      await block();
    } catch (e) {
      if (e instanceof TalerError) {
        return e;
      }
      throw Error(`expected TalerError to be thrown, but got ${e}`);
    }
    throw Error(
      `expected TalerError to be thrown, but block finished without throwing`,
    );
  }

  async assertThrowsAsync(block: () => Promise<void>): Promise<any> {
    try {
      await block();
    } catch (e) {
      return e;
    }
    throw Error(
      `expected exception to be thrown, but block finished without throwing`,
    );
  }

  assertTrue(b: boolean, message?: string): asserts b {
    if (!b) {
      if (message) {
        throw Error(`test assertion failed: ${message}`);
      } else {
        throw Error("test assertion failed");
      }
    }
  }

  fail(message?: string): never {
    if (!message) {
      throw Error(`test failed`);
    } else {
      throw Error(`test failed: ${message}`);
    }
  }

  assertDeepEqual<T>(actual: any, expected: T): asserts actual is T {
    deepStrictEqual(actual, expected);
  }

  assertAmountEquals(
    amtActual: string | AmountJson,
    amtExpected: string | AmountJson,
  ): void {
    if (Amounts.cmp(amtActual, amtExpected) != 0) {
      throw Error(
        `test assertion failed: expected ${Amounts.stringify(
          amtExpected,
        )} but got ${Amounts.stringify(amtActual)}`,
      );
    }
  }

  assertAmountLeq(a: string | AmountJson, b: string | AmountJson): void {
    if (Amounts.cmp(a, b) > 0) {
      throw Error(
        `test assertion failed: expected ${Amounts.stringify(
          a,
        )} to be less or equal (leq) than ${Amounts.stringify(b)}`,
      );
    }
  }

  shutdownSync(): void {
    for (const s of this.servers) {
      s.close();
      s.removeAllListeners();
    }
    for (const p of this.procs) {
      if (p.proc.exitCode == null) {
        p.proc.kill("SIGTERM");
      }
    }
  }

  spawnService(
    command: string,
    args: string[],
    logName: string,
    env: { [index: string]: string | undefined } = process.env,
  ): ProcessWrapper {
    logger.info(
      `spawning process (${logName}): ${shellescape([command, ...args])}`,
    );
    if (process.env["TALER_HARNESS_VALGRIND"] != null) {
      let vgExtraArgs: string[] = [];
      const vgParamsStr = process.env["TALER_HARNESS_VALGRIND_PARAMS"];
      if (vgParamsStr) {
        vgExtraArgs = vgParamsStr.split(/[ ]+/);
      }
      args = [...vgExtraArgs, "--", command, ...args];
      command = "valgrind";
    }
    const proc = spawn(command, args, {
      stdio: ["inherit", "pipe", "pipe"],
      env: env,
    });
    logger.trace(`spawned process (${logName}) with pid ${proc.pid}`);
    proc.on("error", (err) => {
      logger.warn(`could not start process (${command})`, err);
    });
    proc.on("exit", (code, signal) => {
      if (code == 0 && signal == null) {
        logger.trace(`process ${logName} exited with success`);
      } else {
        logger.warn(`process ${logName} exited ${j2s({ code, signal })}`);
      }
    });
    const stdoutLogFileName = this.testDir + `/${logName}-stdout.log`;
    const stdoutLog = fs.createWriteStream(stdoutLogFileName, {
      flags: "a",
      autoClose: true,
    });
    const stderrLogFileName = this.testDir + `/${logName}-stderr.log`;
    const stderrLog = fs.createWriteStream(stderrLogFileName, {
      flags: "a",
      autoClose: true,
    });
    proc.stderr.pipe(stderrLog);
    proc.stdout.pipe(stdoutLog).on("error", (err) => {
      console.error(err);
    });
    const procWrap = new ProcessWrapper(proc);
    this.procs.push(procWrap);
    return procWrap;
  }

  async shutdown(): Promise<void> {
    if (this.inShutdown) {
      return;
    }
    if (shouldLingerInTest()) {
      logger.trace("refusing to shut down, lingering was requested");
      return;
    }
    this.inShutdown = true;
    logger.trace("shutting down");
    for (const s of this.servers) {
      s.close();
      s.removeAllListeners();
    }
    for (const p of this.procs) {
      if (p.proc.exitCode == null) {
        logger.trace(`killing process ${p.proc.pid}`);
        p.proc.kill("SIGTERM");
        await p.wait();
        logger.trace(`done waiting for ${p.proc.pid}`);
      }
    }
  }

  /**
   * Log that the test arrived a certain step.
   *
   * The step name should be unique across the whole test run.
   */
  logStep(stepName: string): void {
    if (this.stepSet.has(stepName)) {
      throw Error(`duplicate step (${stepName})`);
    }
    // Now we just log, later we may report the steps that were done
    // to easily see where the test hangs.
    logger.info(`STEP: ${stepName}`);
    let steps = `${this.testDir}/steps.txt`;
    fs.appendFileSync(steps, `STEP ${stepName}\n`);
  }
}

export function shouldLingerInTest(): boolean {
  return !!process.env["TALER_TEST_LINGER"];
}

export interface TalerConfigSection {
  options: Record<string, string | undefined>;
}

export interface TalerConfig {
  sections: Record<string, TalerConfigSection>;
}

export interface DbInfo {
  /**
   * Postgres connection string.
   */
  connStr: string;

  dbname: string;
}

export interface SetupDbOpts {
  nameSuffix?: string;
}

export async function setupDb(
  t: GlobalTestState,
  opts: SetupDbOpts = {},
): Promise<DbInfo> {
  let dbname: string;
  if (!opts.nameSuffix) {
    dbname = "taler-integrationtest";
  } else {
    dbname = `taler-integrationtest-${opts.nameSuffix}`;
  }
  try {
    await runCommand(t, "dropdb", "dropdb", [dbname]);
  } catch (e: any) {
    logger.warn(`dropdb failed: ${e.toString()}`);
  }
  await runCommand(t, "createdb", "createdb", [dbname]);
  return {
    connStr: `postgres:///${dbname}`,
    dbname,
  };
}

/**
 * Make sure that the taler-integrationtest-shared database exists.
 * Don't delete it if it already exists.
 */
export async function setupSharedDb(t: GlobalTestState): Promise<DbInfo> {
  const dbname = "taler-integrationtest-shared";
  const databases = await runCommand(t, "list-dbs", "psql", ["-Aqtl"]);
  if (databases.indexOf("taler-integrationtest-shared") < 0) {
    await runCommand(t, "createdb", "createdb", [dbname]);
  }
  return {
    connStr: `postgres:///${dbname}`,
    dbname,
  };
}

export interface BankConfig {
  currency: string;
  httpPort: number;
  database: string;
  allowRegistrations: boolean;
  maxDebt?: string;
  overrideTestDir?: string;
}

export interface FakeBankConfig {
  currency: string;
  httpPort: number;
}

/**
 * @param name additional component name, needed when launching multiple instances of the same component
 */
function setTalerPaths(config: Configuration, home: string, name?: string) {
  config.setString("paths", "taler_home", home);
  // We need to make sure that the path of taler_runtime_dir isn't too long,
  // as it contains unix domain sockets (108 character limit).
  const extraName = name != null ? `${name}-` : "";
  const runDir = fs.mkdtempSync(`/tmp/taler-test-${extraName}`);
  config.setString("paths", "taler_runtime_dir", runDir);
  config.setString(
    "paths",
    "taler_data_home",
    "$TALER_HOME/.local/share/taler/",
  );
  config.setString("paths", "taler_config_home", "$TALER_HOME/.config/taler/");
  config.setString("paths", "taler_cache_home", "$TALER_HOME/.config/taler/");
}

function setExchangeCoin(config: Configuration, c: CoinConfig) {
  const s = `coin_${c.name}`;
  config.setString(s, "value", c.value);
  config.setString(s, "duration_withdraw", c.durationWithdraw);
  config.setString(s, "duration_spend", c.durationSpend);
  config.setString(s, "duration_legal", c.durationLegal);
  config.setString(s, "fee_deposit", c.feeDeposit);
  config.setString(s, "fee_withdraw", c.feeWithdraw);
  config.setString(s, "fee_refresh", c.feeRefresh);
  config.setString(s, "fee_refund", c.feeRefund);
  if (c.ageRestricted) {
    config.setString(s, "age_restricted", "yes");
  }
  if (c.cipher === "RSA") {
    config.setString(s, "rsa_keysize", `${c.rsaKeySize}`);
    config.setString(s, "cipher", "RSA");
  } else if (c.cipher === "CS") {
    config.setString(s, "cipher", "CS");
  } else {
    throw new Error();
  }
}

function backoffStart(): number {
  return 10;
}

function backoffIncrement(n: number): number {
  return Math.min(Math.floor(n * 1.5), 1000);
}

/**
 * Send an HTTP request until it succeeds or the process dies.
 */
export async function pingProc(
  proc: ProcessWrapper | undefined,
  url: string,
  serviceName: string,
): Promise<void> {
  if (!proc || proc.proc.exitCode !== null) {
    throw Error(`service process ${serviceName} not started, can't ping`);
  }
  let nextDelay = backoffStart();
  while (true) {
    try {
      logger.trace(`pinging ${serviceName} at ${url}`);
      const resp = await harnessHttpLib.fetch(url);
      if (resp.status !== 200) {
        const err = await readTalerErrorResponse(resp);
        logger.info(`error: ${j2s(err)}`);
        throw Error("non-200 status code");
      }
      logger.trace(`service ${serviceName} available`);
      return;
    } catch (e: any) {
      logger.warn(`service ${serviceName} not ready:`, e.toString());
      logger.info(`waiting ${nextDelay}ms on ${serviceName}`);
      await delayMs(nextDelay);
      nextDelay = backoffIncrement(nextDelay);
    }
    if (!proc || proc.proc.exitCode != null || proc.proc.signalCode != null) {
      throw Error(`service process ${serviceName} stopped unexpectedly`);
    }
  }
}

class BankServiceBase {
  proc: ProcessWrapper | undefined;

  protected constructor(
    protected globalTestState: GlobalTestState,
    protected bankConfig: BankConfig,
    protected configFile: string,
  ) {}

  getAdminAuth(): { username: string; password: string } {
    // Bank admin PW is brutally hard-coded in tests right now.
    return {
      username: "admin",
      password: "admin-password",
    };
  }
}

export type RestrictionFlag = "credit-restriction" | "debit-restriction";

export type HarnessAccountRestriction =
  | [RestrictionFlag, "deny"]
  | [RestrictionFlag, "regex", string, string, string];

export interface HarnessExchangeBankAccount {
  accountPaytoUri: PaytoString;
  wireGatewayApiBaseUrl: string;
  wireGatewayAuth: TalerWireGatewayAuth;
  conversionUrl?: string;
  /**
   * If set, the harness will not automatically configure the wire fee for this account.
   */
  skipWireFeeCreation?: boolean;
  accountRestrictions?: HarnessAccountRestriction[];
}

/**
 * Implementation of the bank service using the "taler-fakebank-run" tool.
 */
export class FakebankService
  extends BankServiceBase
  implements BankServiceHandle
{
  proc: ProcessWrapper | undefined;

  http = createPlatformHttpLib({ enableThrottling: false });

  // We store "created" accounts during setup and
  // register them after startup.
  private accounts: {
    accountName: string;
    accountPassword: string;
  }[] = [];

  /**
   * Create a new fakebank service handle.
   *
   * First generates the configuration for the fakebank and
   * then creates a fakebank handle, but doesn't start the fakebank
   * service yet.
   */
  static async create(
    gc: GlobalTestState,
    bc: BankConfig,
  ): Promise<FakebankService> {
    const config = new Configuration(ConfigSources["taler-exchange"]);
    const testDir = bc.overrideTestDir ?? gc.testDir;
    setTalerPaths(config, testDir + "/talerhome");
    config.setString("exchange", "currency", bc.currency);
    config.setString("bank", "http_port", `${bc.httpPort}`);
    config.setString("bank", "serve", "http");
    config.setString("bank", "max_debt_bank", `${bc.currency}:999999`);
    config.setString("bank", "max_debt", bc.maxDebt ?? `${bc.currency}:100`);
    config.setString("bank", "ram_limit", `${1024}`);
    const cfgFilename = testDir + "/bank.conf";
    config.writeTo(cfgFilename, { excludeDefaults: true });

    return new FakebankService(gc, bc, cfgFilename);
  }

  static fromExistingConfig(
    gc: GlobalTestState,
    opts: { overridePath?: string },
  ): FakebankService {
    const testDir = opts.overridePath ?? gc.testDir;
    const cfgFilename = testDir + `/bank.conf`;
    const config = Configuration.load(
      cfgFilename,
      ConfigSources["taler-exchange"],
    );
    const bc: BankConfig = {
      allowRegistrations:
        config.getYesNo("bank", "allow_registrations").orUndefined() ?? true,
      currency: config.getString("exchange", "currency").required(),
      database: "none",
      httpPort: config.getNumber("bank", "http_port").required(),
      maxDebt: config.getString("bank", "max_debt").required(),
    };
    return new FakebankService(gc, bc, cfgFilename);
  }

  changeConfig(f: (config: Configuration) => void) {
    const config = Configuration.load(
      this.configFile,
      ConfigSources["taler-exchange"],
    );
    f(config);
    config.writeTo(this.configFile, { excludeDefaults: true });
  }

  setSuggestedExchange(e: ExchangeServiceInterface, exchangePayto: string) {
    if (!!this.proc) {
      throw Error("Can't set suggested exchange while bank is running.");
    }
    const config = Configuration.load(
      this.configFile,
      ConfigSources["taler-exchange"],
    );
    config.setString("bank", "suggested_exchange", e.baseUrl);
    config.writeTo(this.configFile, { excludeDefaults: true });
  }

  get baseUrl(): string {
    return `http://localhost:${this.bankConfig.httpPort}/`;
  }

  get corebankApiBaseUrl(): string {
    return this.baseUrl;
  }

  // FIXME: Why do we have this function at all?
  // We now have a unified corebank API, we should just use that
  // to create bank accounts, also for the exchange.
  async createExchangeAccount(
    accountName: string,
    password: string,
  ): Promise<HarnessExchangeBankAccount> {
    this.accounts.push({
      accountName,
      accountPassword: password,
    });
    return {
      wireGatewayAuth: {
        username: accountName,
        password,
      },
      accountPaytoUri: getTestHarnessPaytoForLabel(accountName),
      wireGatewayApiBaseUrl: `http://localhost:${this.bankConfig.httpPort}/accounts/${accountName}/taler-wire-gateway/`,
    };
  }

  get port() {
    return this.bankConfig.httpPort;
  }

  async start(): Promise<void> {
    logger.info("starting fakebank");
    if (this.proc) {
      logger.info("fakebank already running, not starting again");
      return;
    }
    this.proc = this.globalTestState.spawnService(
      "taler-fakebank-run",
      [
        "-c",
        this.configFile,
        "--signup-bonus",
        `${this.bankConfig.currency}:100`,
      ],
      "bank",
    );
    await this.pingUntilAvailable();
    // Check version
    {
      const bankClient = new TalerCoreBankHttpClient(this.corebankApiBaseUrl);
      // This would fail/throw if the version doesn't match.
      const resp = await bankClient.getConfig();
      this.globalTestState.assertTrue(resp.type === "ok");
    }
    // Register bank accounts
    {
      // FIXME: This is using the old bank client!
      const bankClient = new TalerCorebankApiClient(this.corebankApiBaseUrl);
      for (const acc of this.accounts) {
        await bankClient.registerAccount(acc.accountName, acc.accountPassword);
      }
    }
  }

  async pingUntilAvailable(): Promise<void> {
    const url = `http://localhost:${this.bankConfig.httpPort}/config`;
    await pingProc(this.proc, url, "bank");
  }

  async stop(): Promise<void> {
    const bankProc = this.proc;
    if (bankProc) {
      bankProc.proc.kill("SIGTERM");
      await bankProc.wait();
      this.proc = undefined;
    }
  }
}

export interface NexusConfig {
  currency: string;
  /** DB connection string */
  database: string;
}

export class LibeufinNexusService {
  /**
   * Create a new fakebank service handle.
   *
   * First generates the configuration for the fakebank and
   * then creates a fakebank handle, but doesn't start the fakebank
   * service yet.
   */
  static async create(
    gc: GlobalTestState,
    bc: NexusConfig,
  ): Promise<LibeufinNexusService> {
    const config = new Configuration();
    const testDir = gc.testDir;
    setTalerPaths(config, testDir + "/talerhome");
    config.setString("libeufin-nexus", "currency", bc.currency);
    config.setString("libeufin-nexusdb-postgres", "config", bc.database);
    const cfgFilename = testDir + "/nexus.conf";
    config.writeTo(cfgFilename, { excludeDefaults: true });
    return new LibeufinNexusService(gc, bc, cfgFilename);
  }

  constructor(
    private gc: GlobalTestState,
    private bc: NexusConfig,
    private configFile: string,
  ) {}

  async dbinit(): Promise<void> {
    await sh(
      this.gc,
      "libeufin-nexus-dbinit",
      `libeufin-nexus dbinit -c "${this.configFile}"`,
    );
  }
}

/**
 * Implementation of the bank service using the libeufin-bank implementation.
 */
export class LibeufinBankService
  extends BankServiceBase
  implements BankServiceHandle
{
  proc: ProcessWrapper | undefined;

  http = createPlatformHttpLib({ enableThrottling: false });

  // We store "created" accounts during setup and
  // register them after startup.
  private accounts: {
    accountName: string;
    accountPassword: string;
  }[] = [];

  /**
   * Create a new fakebank service handle.
   *
   * First generates the configuration for the fakebank and
   * then creates a fakebank handle, but doesn't start the fakebank
   * service yet.
   */
  static async create(
    gc: GlobalTestState,
    bc: BankConfig,
  ): Promise<LibeufinBankService> {
    const config = new Configuration();
    const testDir = bc.overrideTestDir ?? gc.testDir;
    setTalerPaths(config, testDir + "/talerhome");
    config.setString("libeufin-bankdb-postgres", "config", bc.database);
    config.setString("libeufin-bank", "currency", bc.currency);
    config.setString("libeufin-bank", "port", `${bc.httpPort}`);
    config.setString("libeufin-bank", "serve", "tcp");
    config.setString(
      "libeufin-bank",
      "base_url",
      `http://localhost:${bc.httpPort}/`,
    );
    config.setString(
      "libeufin-bank",
      "x_taler_bank_payto_hostname",
      "localhost",
    );
    config.setString("libeufin-bank", "wire_type", "x-taler-bank");
    config.setString(
      "libeufin-bank",
      "default_debt_limit",
      bc.maxDebt ?? `${bc.currency}:999999`,
    );
    config.setString(
      "libeufin-bank",
      "registration_bonus",
      `${bc.currency}:100`,
    );
    config.setString("libeufin-bank", "ALLOW_REGISTRATION", "yes");
    config.setString("libeufin-bank", "PWD_HASH_CONFIG", '{ "cost": 4 }');
    config.setString("libeufin-bank", "PWD_AUTH_COMPAT", "yes");
    const cfgFilename = testDir + "/bank.conf";
    config.writeTo(cfgFilename, { excludeDefaults: true });
    console.log("conf WTD " + cfgFilename);

    return new LibeufinBankService(gc, bc, cfgFilename);
  }

  static fromExistingConfig(
    gc: GlobalTestState,
    opts: { overridePath?: string },
  ): FakebankService {
    const testDir = opts.overridePath ?? gc.testDir;
    const cfgFilename = testDir + `/bank.conf`;
    const config = Configuration.load(
      cfgFilename,
      ConfigSources["libeufin-bank"],
    );
    console.log("conf THERE " + cfgFilename);
    const bc: BankConfig = {
      allowRegistrations:
        config.getYesNo("libeufin-bank", "allow_registrations").orUndefined() ??
        true,
      currency: config.getString("libeufin-bank", "currency").required(),
      database: config
        .getString("libeufin-bankdb-postgres", "config")
        .required(),
      httpPort: config.getNumber("libeufin-bank", "port").required(),
      maxDebt: config
        .getString("libeufin-bank", "DEFAULT_DEBT_LIMIT")
        .required(),
    };
    return new FakebankService(gc, bc, cfgFilename);
  }

  changeConfig(f: (config: Configuration) => void) {
    const config = Configuration.load(
      this.configFile,
      ConfigSources["libeufin-bank"],
    );
    f(config);
    config.writeTo(this.configFile, { excludeDefaults: true });
  }

  setSuggestedExchange(e: ExchangeServiceInterface) {
    if (!!this.proc) {
      throw Error("Can't set suggested exchange while bank is running.");
    }
    const config = Configuration.load(
      this.configFile,
      ConfigSources["taler-exchange"],
    );
    config.setString(
      "libeufin-bank",
      "suggested_withdrawal_exchange",
      e.baseUrl,
    );
    config.writeTo(this.configFile, { excludeDefaults: true });
  }

  get baseUrl(): string {
    return `http://localhost:${this.bankConfig.httpPort}/`;
  }

  get corebankApiBaseUrl(): string {
    return this.baseUrl;
  }

  get port() {
    return this.bankConfig.httpPort;
  }

  async start(
    opts: {
      noReset?: boolean;
    } = {},
  ): Promise<void> {
    logger.info("starting libeufin-bank");
    if (this.proc) {
      logger.info("libeufin-bank already running, not starting again");
      return;
    }

    if (opts.noReset) {
      await sh(
        this.globalTestState,
        "libeufin-bank-dbinit",
        `libeufin-bank dbinit -c "${this.configFile}"`,
      );
    } else {
      // By default, reset database, since that's
      // what fakebank does (fakebank is only in-memory).
      await sh(
        this.globalTestState,
        "libeufin-bank-dbinit",
        `libeufin-bank dbinit -r -c "${this.configFile}"`,
      );
    }

    await sh(
      this.globalTestState,
      "libeufin-bank-passwd",
      `libeufin-bank passwd -c "${this.configFile}" admin admin-password`,
    );

    await sh(
      this.globalTestState,
      "libeufin-bank-edit-account",
      `libeufin-bank edit-account -c "${this.configFile}" admin --debit_threshold=${this.bankConfig.currency}:1000`,
    );

    this.proc = this.globalTestState.spawnService(
      "libeufin-bank",
      ["serve", "-c", this.configFile],
      "libeufin-bank-httpd",
    );
    await this.pingUntilAvailable();
    // Check version
    {
      const bankClient = new TalerCoreBankHttpClient(this.corebankApiBaseUrl);
      // This would fail/throw if the version doesn't match.
      const resp = await bankClient.getConfig();
      this.globalTestState.assertTrue(resp.type === "ok");
    }
    // Register accounts
    {
      // FIXME: This still uses the old-style client.
      const bankClient = new TalerCorebankApiClient(this.corebankApiBaseUrl);
      for (const acc of this.accounts) {
        await bankClient.registerAccount(acc.accountName, acc.accountPassword);
      }
    }
  }

  async pingUntilAvailable(): Promise<void> {
    const url = `http://localhost:${this.bankConfig.httpPort}/config`;
    await pingProc(this.proc, url, "libeufin-bank");
  }

  async stop(): Promise<void> {
    const bankProc = this.proc;
    if (bankProc) {
      bankProc.proc.kill("SIGTERM");
      await bankProc.wait();
      this.proc = undefined;
    }
  }
}

// Use libeufin bank instead of pybank.
export const useLibeufinBank = process.env["WITH_LIBEUFIN"] === "1";

export interface BankServiceHandle {
  readonly corebankApiBaseUrl: string;
  readonly http: HttpRequestLibrary;

  setSuggestedExchange(exchange: ExchangeService, exchangePayto: string): void;
  start(): Promise<void>;
  pingUntilAvailable(): Promise<void>;
  stop(): Promise<void>;
  getAdminAuth(): { username: string; password: string };

  changeConfig(f: (config: Configuration) => void): void;
}

export type BankService = BankServiceHandle;
export const BankService = useLibeufinBank
  ? LibeufinBankService
  : FakebankService;

export interface ExchangeConfig {
  name: string;
  currency: string;
  hostname?: string;
  roundUnit?: string;
  httpPort: number;
  database: string;
  allowExistingMasterPriv?: boolean;
  overrideTestDir?: string;
  overrideWireFee?: string;
  /**
   * Extra environment variables to pass to the exchange processes.
   */
  extraProcEnv?: Record<string, string>;
}

export interface ExchangeServiceInterface {
  readonly baseUrl: string;
  readonly port: number;
  readonly name: string;
  readonly masterPub: string;
  readonly currency: string;
}

export class ExchangeService implements ExchangeServiceInterface {
  static fromExistingConfig(
    gc: GlobalTestState,
    exchangeName: string,
    opts: { overridePath?: string },
  ) {
    const testDir = opts.overridePath ?? gc.testDir;
    const cfgFilename = testDir + `/exchange-${exchangeName}.conf`;
    const config = Configuration.load(
      cfgFilename,
      ConfigSources["taler-exchange"],
    );
    const ec: ExchangeConfig = {
      currency: config.getString("exchange", "currency").required(),
      database: config.getString("exchangedb-postgres", "config").required(),
      httpPort: config.getNumber("exchange", "port").required(),
      name: exchangeName,
      roundUnit: config.getString("exchange", "currency_round_unit").required(),
    };
    const privFile = config
      .getPath("exchange-offline", "master_priv_file")
      .required();
    const eddsaPriv = fs.readFileSync(privFile);
    const keyPair: EddsaKeyPair = {
      eddsaPriv,
      eddsaPub: eddsaGetPublic(eddsaPriv),
    };
    return new ExchangeService(gc, ec, cfgFilename, keyPair);
  }

  private currentTimetravelOffsetMs: number | undefined;

  private exchangeBankAccounts: HarnessExchangeBankAccount[] = [];

  setTimetravel(tMs: number | undefined): void {
    if (this.isRunning()) {
      throw Error("can't set time travel while the exchange is running");
    }
    this.currentTimetravelOffsetMs = tMs;
  }

  private get timetravelArg(): string | undefined {
    if (this.currentTimetravelOffsetMs != null) {
      // Convert to microseconds
      return `--timetravel=+${this.currentTimetravelOffsetMs * 1000}`;
    }
    return undefined;
  }

  /**
   * Return an empty array if no time travel is set,
   * and an array with the time travel command line argument
   * otherwise.
   */
  private get timetravelArgArr(): string[] {
    const tta = this.timetravelArg;
    if (tta) {
      return [tta];
    }
    return [];
  }

  async runWirewatchOnce() {
    await runCommand(
      this.globalState,
      `exchange-${this.name}-wirewatch-once`,
      "taler-exchange-wirewatch",
      [...this.timetravelArgArr, "-c", this.configFilename, "-t"],
    );
  }

  get currency() {
    return this.exchangeConfig.currency;
  }

  async runAggregatorOnceWithTimetravel(opts: {
    timetravelMicroseconds: number;
  }) {
    let timetravelArgArr = [];
    timetravelArgArr.push(`--timetravel=${opts.timetravelMicroseconds}`);
    await runCommand(
      this.globalState,
      `exchange-${this.name}-aggregator-once`,
      "taler-exchange-aggregator",
      [...timetravelArgArr, "-c", this.configFilename, "-t", "-LINFO"],
    );
  }

  async runAggregatorOnce() {
    await runCommand(
      this.globalState,
      `exchange-${this.name}-aggregator-once`,
      "taler-exchange-aggregator",
      [...this.timetravelArgArr, "-c", this.configFilename, "-t", "-LINFO"],
    );
  }

  async runTransferOnce() {
    await runCommand(
      this.globalState,
      `exchange-${this.name}-transfer-once`,
      "taler-exchange-transfer",
      [...this.timetravelArgArr, "-c", this.configFilename, "-t"],
    );
  }

  async runTransferOnceWithTimetravel(opts: {
    timetravelMicroseconds: number;
  }) {
    let timetravelArgArr = [];
    timetravelArgArr.push(`--timetravel=${opts.timetravelMicroseconds}`);
    await runCommand(
      this.globalState,
      `exchange-${this.name}-transfer-once`,
      "taler-exchange-transfer",
      [...timetravelArgArr, "-c", this.configFilename, "-t"],
    );
  }

  /**
   * Run the taler-exchange-expire command once in test mode.
   */
  async runExpireOnce() {
    await runCommand(
      this.globalState,
      `exchange-${this.name}-expire-once`,
      "taler-exchange-expire",
      [...this.timetravelArgArr, "-c", this.configFilename, "-t"],
    );
  }

  changeConfig(f: (config: Configuration) => void) {
    const config = Configuration.load(
      this.configFilename,
      ConfigSources["taler-exchange"],
    );
    f(config);
    config.writeTo(this.configFilename, { excludeDefaults: true });
  }

  static create(gc: GlobalTestState, e: ExchangeConfig) {
    const testDir = e.overrideTestDir ?? gc.testDir;
    const config = new Configuration();
    setTalerPaths(config, `${testDir}/talerhome-exchange-${e.name}`, e.name);
    config.setString("exchange", "currency", e.currency);
    // Required by the exchange but not really used yet.
    config.setString("exchange", "aml_threshold", `${e.currency}:1000000`);
    config.setString(
      "exchange",
      "currency_round_unit",
      e.roundUnit ?? `${e.currency}:0.01`,
    );
    // Set to a high value to not break existing test cases where the merchant
    // would cover all fees.
    config.setString("exchange", "STEFAN_ABS", `${e.currency}:1`);
    config.setString("exchange", "STEFAN_LOG", `${e.currency}:1`);
    config.setString(
      "exchange",
      "revocation_dir",
      "${TALER_DATA_HOME}/exchange/revocations",
    );
    config.setString("exchange", "max_keys_caching", "forever");
    config.setString("exchange", "db", "postgres");
    config.setString(
      "exchange-offline",
      "master_priv_file",
      "${TALER_DATA_HOME}/exchange/offline-keys/master.priv",
    );
    const hostname = e.hostname ?? "localhost";
    config.setString(
      "exchange",
      "base_url",
      `http://${hostname}:${e.httpPort}/`,
    );
    config.setString("exchange", "serve", "tcp");
    config.setString("exchange", "port", `${e.httpPort}`);

    config.setString("exchangedb-postgres", "config", e.database);

    config.setString("taler-exchange-secmod-eddsa", "lookahead_sign", "20 s");
    config.setString("taler-exchange-secmod-rsa", "lookahead_sign", "20 s");

    // FIXME: Remove once the exchange default config properly ships this.
    config.setString("exchange", "EXPIRE_IDLE_SLEEP_INTERVAL", "1 s");

    const masterPrivFile = config
      .getPath("exchange-offline", "master_priv_file")
      .required();

    let exchangeMasterKey: EddsaKeyPair;
    if (fs.existsSync(masterPrivFile)) {
      if (!e.allowExistingMasterPriv) {
        throw new Error(
          "master priv file already exists, can't create new exchange config",
        );
      }
      const masterPriv = fs.readFileSync(masterPrivFile);
      const masterPub = eddsaGetPublic(masterPriv);
      exchangeMasterKey = {
        eddsaPriv: masterPriv,
        eddsaPub: masterPub,
      };
    } else {
      exchangeMasterKey = createEddsaKeyPair();
      fs.mkdirSync(path.dirname(masterPrivFile), { recursive: true });
      fs.writeFileSync(
        masterPrivFile,
        Buffer.from(exchangeMasterKey.eddsaPriv),
      );
    }

    config.setString(
      "exchange",
      "master_public_key",
      encodeCrock(exchangeMasterKey.eddsaPub),
    );

    const cfgFilename = testDir + `/exchange-${e.name}.conf`;
    config.writeTo(cfgFilename, { excludeDefaults: true });
    return new ExchangeService(gc, e, cfgFilename, exchangeMasterKey);
  }

  addOfferedCoins(offeredCoins: ((curr: string) => CoinConfig)[]) {
    const config = Configuration.load(
      this.configFilename,
      ConfigSources["taler-exchange"],
    );
    offeredCoins.forEach((cc) =>
      setExchangeCoin(config, cc(this.exchangeConfig.currency)),
    );
    config.writeTo(this.configFilename, { excludeDefaults: true });
  }

  addCoinConfigList(ccs: CoinConfig[]) {
    const config = Configuration.load(
      this.configFilename,
      ConfigSources["taler-exchange"],
    );
    ccs.forEach((cc) => setExchangeCoin(config, cc));
    config.writeTo(this.configFilename, { excludeDefaults: true });
  }

  enableAgeRestrictions(maskStr: string) {
    const config = Configuration.load(
      this.configFilename,
      ConfigSources["taler-exchange"],
    );
    config.setString("exchange-extension-age_restriction", "enabled", "yes");
    config.setString(
      "exchange-extension-age_restriction",
      "age_groups",
      maskStr,
    );
    config.writeTo(this.configFilename, { excludeDefaults: true });
  }

  get masterPub() {
    return encodeCrock(this.keyPair.eddsaPub);
  }

  get port() {
    return this.exchangeConfig.httpPort;
  }

  /**
   * Run a function that modifies the existing exchange configuration.
   * The modified exchange configuration will then be written to the
   * file system.
   */
  async modifyConfig(
    f: (config: Configuration) => Promise<void>,
  ): Promise<void> {
    const config = Configuration.load(
      this.configFilename,
      ConfigSources["taler-exchange"],
    );
    await f(config);
    config.writeTo(this.configFilename, { excludeDefaults: true });
  }

  async addBankAccount(
    localName: string,
    exchangeBankAccount: HarnessExchangeBankAccount,
  ): Promise<void> {
    this.exchangeBankAccounts.push(exchangeBankAccount);
    const config = Configuration.load(
      this.configFilename,
      ConfigSources["taler-exchange"],
    );
    config.setString(
      `exchange-account-${localName}`,
      "wire_response",
      `\${TALER_DATA_HOME}/exchange/account-${localName}.json`,
    );
    config.setString(
      `exchange-account-${localName}`,
      "payto_uri",
      exchangeBankAccount.accountPaytoUri,
    );
    config.setString(`exchange-account-${localName}`, "enable_credit", "yes");
    config.setString(`exchange-account-${localName}`, "enable_debit", "yes");
    config.setString(
      `exchange-accountcredentials-${localName}`,
      "wire_gateway_url",
      exchangeBankAccount.wireGatewayApiBaseUrl,
    );
    config.setString(
      `exchange-accountcredentials-${localName}`,
      "wire_gateway_auth_method",
      "basic",
    );
    config.setString(
      `exchange-accountcredentials-${localName}`,
      "username",
      exchangeBankAccount.wireGatewayAuth.username,
    );
    config.setString(
      `exchange-accountcredentials-${localName}`,
      "password",
      exchangeBankAccount.wireGatewayAuth.password,
    );
    config.writeTo(this.configFilename, { excludeDefaults: true });
  }

  exchangeHttpProc: ProcessWrapper | undefined;
  exchangeWirewatchProc: ProcessWrapper | undefined;

  exchangeTransferProc: ProcessWrapper | undefined;
  exchangeAggregatorProc: ProcessWrapper | undefined;

  helperCryptoRsaProc: ProcessWrapper | undefined;
  helperCryptoEddsaProc: ProcessWrapper | undefined;
  helperCryptoCsProc: ProcessWrapper | undefined;

  constructor(
    private globalState: GlobalTestState,
    private exchangeConfig: ExchangeConfig,
    private configFilename: string,
    private keyPair: EddsaKeyPair,
  ) {}

  get name() {
    return this.exchangeConfig.name;
  }

  get baseUrl() {
    const host = this.exchangeConfig.hostname ?? "localhost";
    return `http://${host}:${this.exchangeConfig.httpPort}/`;
  }

  isRunning(): boolean {
    return !!this.exchangeWirewatchProc || !!this.exchangeHttpProc;
  }

  /**
   * Stop the wirewatch service (which runs by default).
   *
   * Useful for some tests.
   */
  async stopWirewatch(): Promise<void> {
    const wirewatch = this.exchangeWirewatchProc;
    if (wirewatch) {
      wirewatch.proc.kill("SIGTERM");
      await wirewatch.wait();
      this.exchangeWirewatchProc = undefined;
    }
  }

  async stopAggregator(): Promise<void> {
    const agg = this.exchangeAggregatorProc;
    if (agg) {
      agg.proc.kill("SIGTERM");
      await agg.wait();
      this.exchangeAggregatorProc = undefined;
    }
  }

  async startWirewatch(): Promise<void> {
    const wirewatch = this.exchangeWirewatchProc;
    if (wirewatch) {
      logger.warn("wirewatch already running");
    } else {
      this.internalCreateWirewatchProc();
    }
  }

  async stop(): Promise<void> {
    const wirewatch = this.exchangeWirewatchProc;
    if (wirewatch) {
      wirewatch.proc.kill("SIGTERM");
      await wirewatch.wait();
      this.exchangeWirewatchProc = undefined;
    }
    const aggregatorProc = this.exchangeAggregatorProc;
    if (aggregatorProc) {
      aggregatorProc.proc.kill("SIGTERM");
      await aggregatorProc.wait();
      this.exchangeAggregatorProc = undefined;
    }
    const transferProc = this.exchangeTransferProc;
    if (transferProc) {
      transferProc.proc.kill("SIGTERM");
      await transferProc.wait();
      this.exchangeTransferProc = undefined;
    }
    const httpd = this.exchangeHttpProc;
    if (httpd) {
      httpd.proc.kill("SIGTERM");
      await httpd.wait();
      this.exchangeHttpProc = undefined;
    }
    const cryptoRsa = this.helperCryptoRsaProc;
    if (cryptoRsa) {
      cryptoRsa.proc.kill("SIGTERM");
      await cryptoRsa.wait();
      this.helperCryptoRsaProc = undefined;
    }
    const cryptoEddsa = this.helperCryptoEddsaProc;
    if (cryptoEddsa) {
      cryptoEddsa.proc.kill("SIGTERM");
      await cryptoEddsa.wait();
      this.helperCryptoRsaProc = undefined;
    }
    const cryptoCs = this.helperCryptoCsProc;
    if (cryptoCs) {
      cryptoCs.proc.kill("SIGTERM");
      await cryptoCs.wait();
      this.helperCryptoCsProc = undefined;
    }
  }

  /**
   * Directly enable an account.
   */
  async enableAccount(paytoUri: string): Promise<void> {
    await runCommand(
      this.globalState,
      "exchange-offline",
      "taler-exchange-offline",
      ["-c", this.configFilename, "enable-account", paytoUri, "upload"],
    );
  }

  /**
   * Update keys signing the keys generated by the security module
   * with the offline signing key.
   */
  async keyup(): Promise<void> {
    await runCommand(
      this.globalState,
      "exchange-offline",
      "taler-exchange-offline",
      ["-c", this.configFilename, "download", "sign", "upload"],
    );

    const accountTargetTypes: Set<string> = new Set();

    for (const acct of this.exchangeBankAccounts) {
      const paytoUri = acct.accountPaytoUri;
      const p = parsePaytoUri(paytoUri);
      if (!p) {
        throw Error(`invalid payto uri in exchange config: ${paytoUri}`);
      }
      const optArgs: string[] = [];
      if (acct.conversionUrl != null) {
        optArgs.push("conversion-url", acct.conversionUrl);
      }
      if (acct.accountRestrictions != null) {
        optArgs.push(...acct.accountRestrictions.flat(1));
      }

      await runCommand(
        this.globalState,
        "exchange-offline",
        "taler-exchange-offline",
        [
          "-c",
          this.configFilename,
          "enable-account",
          paytoUri,
          ...optArgs,
          "upload",
        ],
      );

      const accTargetType = p.targetType;

      const covered = accountTargetTypes.has(p.targetType);
      if (!covered && !acct.skipWireFeeCreation) {
        const year = new Date().getFullYear();

        for (let i = year; i < year + 5; i++) {
          const wireFee =
            this.exchangeConfig.overrideWireFee ??
            `${this.exchangeConfig.currency}:0.01`;
          await runCommand(
            this.globalState,
            "exchange-offline",
            "taler-exchange-offline",
            [
              "-c",
              this.configFilename,
              "wire-fee",
              // Year
              `${i}`,
              // Wire method
              accTargetType,
              // Wire fee
              wireFee,
              // Closing fee
              `${this.exchangeConfig.currency}:0.01`,
              "upload",
            ],
          );
          accountTargetTypes.add(accTargetType);
        }
      }
    }

    await runCommand(
      this.globalState,
      "exchange-offline",
      "taler-exchange-offline",
      [
        "-c",
        this.configFilename,
        "global-fee",
        // year
        "now",
        // history fee
        `${this.exchangeConfig.currency}:0.01`,
        // account fee
        `${this.exchangeConfig.currency}:0.01`,
        // purse fee
        `${this.exchangeConfig.currency}:0.00`,
        // purse timeout
        "1h",
        // history expiration
        "1year",
        // free purses per account
        "5",
        "upload",
      ],
    );
  }

  async revokeDenomination(denomPubHash: string) {
    if (!this.isRunning()) {
      throw Error("exchange must be running when revoking denominations");
    }
    await runCommand(
      this.globalState,
      "exchange-offline",
      "taler-exchange-offline",
      [
        "-c",
        this.configFilename,
        "revoke-denomination",
        denomPubHash,
        "upload",
      ],
    );
  }

  /**
   * Purge all secmod keys, including message and coin signing keys.
   */
  async purgeSecmodKeys(): Promise<void> {
    const cfg = Configuration.load(
      this.configFilename,
      ConfigSources["taler-exchange"],
    );
    const rsaKeydir = cfg
      .getPath("taler-exchange-secmod-rsa", "KEY_DIR")
      .required();
    const eddsaKeydir = cfg
      .getPath("taler-exchange-secmod-eddsa", "KEY_DIR")
      .required();
    // Be *VERY* careful when changing this, or you will accidentally delete user data.
    await sh(this.globalState, "rm-secmod-keys", `rm -rf ${rsaKeydir}/COIN_*`);
    await sh(this.globalState, "rm-secmod-keys", `rm ${eddsaKeydir}/*`);
  }


  /**
   * Purge denom signing keys.
   * Keeps message signing keys (eddsa) in place.
   */
  async purgeSecmodDenomKeys(): Promise<void> {
    const cfg = Configuration.load(
      this.configFilename,
      ConfigSources["taler-exchange"],
    );
    const rsaKeydir = cfg
      .getPath("taler-exchange-secmod-rsa", "KEY_DIR")
      .required();
    const eddsaKeydir = cfg
      .getPath("taler-exchange-secmod-eddsa", "KEY_DIR")
      .required();
    // Be *VERY* careful when changing this, or you will accidentally delete user data.
    await sh(this.globalState, "rm-secmod-keys", `rm -rf ${rsaKeydir}/COIN_*`);
  }

  /**
   * Generate a new master public key for the exchange.
   */
  async regenerateMasterPub(): Promise<void> {
    const cfg = Configuration.load(
      this.configFilename,
      ConfigSources["taler-exchange"],
    );
    const masterPrivFile = cfg
      .getPath("exchange-offline", "master_priv_file")
      .required();
    fs.unlinkSync(masterPrivFile);
    const exchangeMasterKey = createEddsaKeyPair();
    fs.writeFileSync(masterPrivFile, Buffer.from(exchangeMasterKey.eddsaPriv));
    cfg.setString(
      "exchange",
      "master_public_key",
      encodeCrock(exchangeMasterKey.eddsaPub),
    );

    cfg.writeTo(this.configFilename, { excludeDefaults: true });
  }

  async purgeDatabase(): Promise<void> {
    await sh(
      this.globalState,
      "exchange-dbinit",
      `taler-exchange-dbinit -r -c "${this.configFilename}"`,
    );
  }

  private internalCreateWirewatchProc() {
    this.exchangeWirewatchProc = this.globalState.spawnService(
      "taler-exchange-wirewatch",
      [
        "-c",
        this.configFilename,
        "--longpoll-timeout=5s",
        ...this.timetravelArgArr,
      ],
      `exchange-wirewatch-${this.name}`,
    );
  }

  private internalCreateAggregatorProc() {
    this.exchangeAggregatorProc = this.globalState.spawnService(
      "taler-exchange-aggregator",
      ["-c", this.configFilename, ...this.timetravelArgArr],
      `exchange-aggregator-${this.name}`,
    );
  }

  private internalCreateTransferProc() {
    this.exchangeTransferProc = this.globalState.spawnService(
      "taler-exchange-transfer",
      ["-c", this.configFilename, ...this.timetravelArgArr],
      `exchange-transfer-${this.name}`,
    );
  }

  async dbinit() {
    await sh(
      this.globalState,
      "exchange-dbinit",
      `taler-exchange-dbinit -c "${this.configFilename}"`,
    );
  }

  async start(
    opts: { skipDbinit?: boolean; skipKeyup?: boolean } = {},
  ): Promise<void> {
    if (this.isRunning()) {
      throw Error("exchange is already running");
    }

    const skipDbinit = opts.skipDbinit ?? false;

    if (!skipDbinit) {
      await this.dbinit();
    }

    this.helperCryptoEddsaProc = this.globalState.spawnService(
      "taler-exchange-secmod-eddsa",
      ["-c", this.configFilename, "-LDEBUG", ...this.timetravelArgArr],
      `exchange-crypto-eddsa-${this.name}`,
    );

    this.helperCryptoCsProc = this.globalState.spawnService(
      "taler-exchange-secmod-cs",
      ["-c", this.configFilename, "-LDEBUG", ...this.timetravelArgArr],
      `exchange-crypto-cs-${this.name}`,
    );

    this.helperCryptoRsaProc = this.globalState.spawnService(
      "taler-exchange-secmod-rsa",
      ["-c", this.configFilename, "-LDEBUG", ...this.timetravelArgArr],
      `exchange-crypto-rsa-${this.name}`,
    );

    this.internalCreateWirewatchProc();
    this.internalCreateTransferProc();
    this.internalCreateAggregatorProc();

    this.exchangeHttpProc = this.globalState.spawnService(
      "taler-exchange-httpd",
      ["-LDEBUG", "-c", this.configFilename, ...this.timetravelArgArr],
      `exchange-httpd-${this.name}`,
      { ...process.env, ...(this.exchangeConfig.extraProcEnv ?? {}) },
    );

    await this.pingUntilAvailable();

    {
      const exchangeClient = new TalerExchangeHttpClient(this.baseUrl, {});
      // Would throw on incompatible version.
      const configResp = await exchangeClient.getConfig();
      this.globalState.assertTrue(configResp.type === "ok");
    }

    const skipKeyup = opts.skipKeyup ?? false;

    if (!skipKeyup) {
      await this.keyup();
    } else {
      logger.info("skipping keyup");
    }
  }

  async enableAmlAccount(
    amlStaffPub: string,
    legalName: string,
  ): Promise<void> {
    await runCommand(
      this.globalState,
      "exchange-offline",
      "taler-exchange-offline",
      [
        "-c",
        this.configFilename,
        "aml-enable",
        amlStaffPub,
        legalName,
        "rw",
        "upload",
      ],
    );
  }

  async pingUntilAvailable(): Promise<void> {
    // We request /management/keys, since /keys can block
    // when we didn't do the key setup yet.
    const url = `http://localhost:${this.exchangeConfig.httpPort}/management/keys`;
    await pingProc(this.exchangeHttpProc, url, `exchange (${this.name})`);
  }
}

export interface MerchantConfig {
  name: string;
  httpPort: number;
  database: string;
  overrideTestDir?: string;
}

export interface MerchantServiceInterface {
  makeInstanceBaseUrl(instanceName?: string): string;
  readonly port: number;
  readonly name: string;
}

/**
 * Default HTTP client handle for the integration test harness.
 */
export const harnessHttpLib = createPlatformHttpLib({
  enableThrottling: false,
});

export interface PartialMerchantInstanceConfig {
  auth?: InstanceAuthConfigurationMessage;
  id: string;
  name: string;
  paytoUris: string[];
  address?: unknown;
  jurisdiction?: unknown;
  defaultWireTransferDelay?: TalerProtocolDuration;
  defaultPayDelay?: TalerProtocolDuration;
}

/**
 * since we don't have external auth anymore
 */
export const MERCHANT_DEFAULT_AUTH: InstanceAuthConfigurationMessage = {
  method: MerchantAuthMethod.TOKEN,
  password: "123",
};

export const MERCHANT_DEFAULT_LOGIN_SCOPE: LoginTokenRequest = {
  scope: LoginTokenScope.All_Refreshable,
  description: "testing",
  duration: { d_us: "forever" },
};

export class MerchantService implements MerchantServiceInterface {
  static fromExistingConfig(
    gc: GlobalTestState,
    name: string,
    opts: { overridePath?: string },
  ) {
    const testDir = opts.overridePath ?? gc.testDir;
    const cfgFilename = testDir + `/merchant-${name}.conf`;
    const config = Configuration.load(
      cfgFilename,
      ConfigSources["taler-merchant"],
    );
    const mc: MerchantConfig = {
      database: config.getString("merchantdb-postgres", "config").required(),
      httpPort: config.getNumber("merchant", "port").required(),
      name,
    };
    return new MerchantService(gc, mc, cfgFilename);
  }

  procHttpd: ProcessWrapper | undefined;
  procExchangekeyupdate: ProcessWrapper | undefined;
  procDonaukeyupdate: ProcessWrapper | undefined;
  procKyccheck: ProcessWrapper | undefined;

  constructor(
    private globalState: GlobalTestState,
    private merchantConfig: MerchantConfig,
    private configFilename: string,
  ) {}

  private currentTimetravelOffsetMs: number | undefined;

  private isRunning(): boolean {
    return !!this.procHttpd;
  }

  setTimetravel(t: number | undefined): void {
    if (this.isRunning()) {
      throw Error("can't set time travel while the exchange is running");
    }
    this.currentTimetravelOffsetMs = t;
  }

  private get timetravelArg(): string | undefined {
    if (this.currentTimetravelOffsetMs != null) {
      // Convert to microseconds
      return `--timetravel=+${this.currentTimetravelOffsetMs * 1000}`;
    }
    return undefined;
  }

  /**
   * Return an empty array if no time travel is set,
   * and an array with the time travel command line argument
   * otherwise.
   */
  private get timetravelArgArr(): string[] {
    const tta = this.timetravelArg;
    if (tta) {
      return [tta];
    }
    return [];
  }

  get port(): number {
    return this.merchantConfig.httpPort;
  }

  get name(): string {
    return this.merchantConfig.name;
  }

  async stop(): Promise<void> {
    const httpd = this.procHttpd;
    if (httpd) {
      logger.info(`killing merchant httpd`);
      httpd.proc.kill("SIGTERM");
      await httpd.wait();
      logger.info(`done killing merchant httpd`);
      this.procHttpd = undefined;
    }
    const exchangekeyupdate = this.procExchangekeyupdate;
    if (exchangekeyupdate) {
      logger.info(`killing merchant exchangekeyupdate`);
      exchangekeyupdate.proc.kill("SIGTERM");
      await exchangekeyupdate.wait();
      logger.info(`done killing merchant exchangekeyupdate`);
      this.procExchangekeyupdate = undefined;
    }
    const donaukeyupdate = this.procDonaukeyupdate;
    if (donaukeyupdate) {
      logger.info(`killing merchant donaukeyupdate`);
      donaukeyupdate.proc.kill("SIGTERM");
      await donaukeyupdate.wait();
      logger.info(`done killing merchant donaukeyupdate`);
      this.procDonaukeyupdate = undefined;
    }
    const kyccheck = this.procKyccheck;
    if (kyccheck) {
      logger.info(`killing merchant kyccheck`);
      kyccheck.proc.kill("SIGTERM");
      await kyccheck.wait();
      logger.info(`done killing merchant kyccheck`);
      this.procKyccheck = undefined;
    }
  }

  async dbinit() {
    await runCommand(
      this.globalState,
      "merchant-dbinit",
      "taler-merchant-dbinit",
      ["-c", this.configFilename],
    );
  }

  async runReconciliationOnceWithTimetravel(opts: {
    timetravelMicroseconds: number;
  }) {
    let timetravelArgArr = [];
    timetravelArgArr.push(`--timetravel=${opts.timetravelMicroseconds}`);
    await runCommand(
      this.globalState,
      `merchant-${this.name}-reconciliation-once`,
      "taler-merchant-reconciliation",
      [...timetravelArgArr, "-LINFO", "-c", this.configFilename, "-t"],
    );
  }

  async runDepositcheckOnce() {
    await runCommand(
      this.globalState,
      `merchant-${this.name}-depositcheck-once`,
      "taler-merchant-depositcheck",
      [...this.timetravelArgArr, "-LINFO", "-c", this.configFilename, "-t"],
    );
  }

  async runKyccheckOnce() {
    await runCommand(
      this.globalState,
      `merchant-${this.name}-kyccheck-once`,
      "taler-merchant-kyccheck",
      [...this.timetravelArgArr, "-LINFO", "-c", this.configFilename, "-t"],
    );
  }

  async runDonaukeyupdateOnce() {
    await runCommand(
      this.globalState,
      `merchant-${this.name}-donaukeyupdate-once`,
      "taler-merchant-donaukeyupdate",
      [...this.timetravelArgArr, "-LTRACER", "-c", this.configFilename, "-t"],
    );
  }

  /**
   * Start the merchant.
   * Waits for the service to become fully available.
   */
  async start(
    opts: { skipDbinit?: boolean; useDonau?: boolean } = {},
  ): Promise<void> {
    const skipSetup = opts.skipDbinit ?? false;

    if (!skipSetup) {
      await this.dbinit();
    }

    this.procHttpd = this.globalState.spawnService(
      "taler-merchant-httpd",
      [
        "taler-merchant-httpd",
        "-LDEBUG",
        "-c",
        this.configFilename,
        ...this.timetravelArgArr,
      ],
      `merchant-httpd-${this.merchantConfig.name}`,
    );

    this.procExchangekeyupdate = this.globalState.spawnService(
      "taler-merchant-exchangekeyupdate",
      [
        "taler-merchant-exchangekeyupdate",
        "-LDEBUG",
        "-c",
        this.configFilename,
        ...this.timetravelArgArr,
      ],
      `merchant-exchangekeyupdate-${this.merchantConfig.name}`,
    );

    if (opts.useDonau) {
      this.procDonaukeyupdate = this.globalState.spawnService(
        "taler-merchant-donaukeyupdate",
        [
          "taler-merchant-donaukeyupdate",
          "-LDEBUG",
          "-c",
          this.configFilename,
          ...this.timetravelArgArr,
        ],
        `merchant-donaukeyupdate-${this.merchantConfig.name}`,
      );
    }

    this.procKyccheck = this.globalState.spawnService(
      "taler-merchant-kyccheck",
      [
        "taler-merchant-kyccheck",
        "-LDEBUG",
        "-c",
        this.configFilename,
        ...this.timetravelArgArr,
      ],
      `merchant-kyccheck-${this.merchantConfig.name}`,
    );

    await this.pingUntilAvailable();
    {
      const merchantClient = new TalerMerchantManagementHttpClient(
        this.makeInstanceBaseUrl(),
      );
      const configResp = await merchantClient.getConfig();
      this.globalState.assertTrue(configResp.type === "ok");
    }
  }

  static async create(
    gc: GlobalTestState,
    mc: MerchantConfig,
  ): Promise<MerchantService> {
    const testDir = mc.overrideTestDir ?? gc.testDir;
    const config = new Configuration();

    const cfgFilename = testDir + `/merchant-${mc.name}.conf`;
    setTalerPaths(config, testDir + "/talerhome");
    config.setString("merchant", "currency", "TESTKUDOS");
    config.setString("merchant", "serve", "tcp");
    config.setString("merchant", "port", `${mc.httpPort}`);
    config.setString(
      "merchant",
      "keyfile",
      "${TALER_DATA_HOME}/merchant/merchant.priv",
    );
    config.setString("merchantdb-postgres", "config", mc.database);
    // Do not contact demo.taler.net exchange in tests
    config.setString("merchant-exchange-kudos", "disabled", "yes");
    config.writeTo(cfgFilename, { excludeDefaults: true });

    return new MerchantService(gc, mc, cfgFilename);
  }

  /**
   * Run a function that modifies the existing exchange configuration.
   * The modified exchange configuration will then be written to the
   * file system.
   */
  async modifyConfig(
    f: (config: Configuration) => Promise<void>,
  ): Promise<void> {
    const config = Configuration.load(
      this.configFilename,
      ConfigSources["taler-merchant"],
    );
    await f(config);
    config.writeTo(this.configFilename, { excludeDefaults: true });
  }

  addExchange(e: ExchangeServiceInterface): void {
    const config = Configuration.load(
      this.configFilename,
      ConfigSources["taler-merchant"],
    );
    config.setString(
      `merchant-exchange-${e.name}`,
      "exchange_base_url",
      e.baseUrl,
    );
    config.setString(`merchant-exchange-${e.name}`, "currency", e.currency);
    config.setString(`merchant-exchange-${e.name}`, "master_key", e.masterPub);
    config.writeTo(this.configFilename, { excludeDefaults: true });
  }

  async addDefaultInstance(): Promise<{ accessToken: AccessToken }> {
    return await this.addInstanceWithWireAccount({
      id: "admin",
      name: "Admin Instance",
      paytoUris: [getTestHarnessPaytoForLabel("merchant-default")],
      auth: MERCHANT_DEFAULT_AUTH,
    });
  }

  /**
   * Add an instance together with a wire account.
   */
  async addInstanceWithWireAccount(
    instanceConfig: PartialMerchantInstanceConfig,
    { adminAccessToken }: { adminAccessToken?: AccessToken } = {},
  ): Promise<{ accessToken: AccessToken }> {
    if (!this.procHttpd) {
      throw Error("merchant must be running to add instance");
    }
    logger.info(`adding instance '${instanceConfig.id}'`);
    const url = `http://localhost:${this.merchantConfig.httpPort}/management/instances`;
    const auth = instanceConfig.auth ?? MERCHANT_DEFAULT_AUTH;

    const body: InstanceConfigurationMessage = {
      auth,
      id: instanceConfig.id,
      name: instanceConfig.name,
      address: instanceConfig.address ?? {},
      jurisdiction: instanceConfig.jurisdiction ?? {},
      // FIXME: In some tests, we might want to make this configurable
      use_stefan: true,
      default_wire_transfer_delay:
        instanceConfig.defaultWireTransferDelay ??
        Duration.toTalerProtocolDuration(
          Duration.fromSpec({
            days: 1,
          }),
        ),
      default_pay_delay:
        instanceConfig.defaultPayDelay ??
        Duration.toTalerProtocolDuration(Duration.getForever()),
    };
    const headers: Record<string, string> = {};
    if (adminAccessToken) {
      headers["Authorization"] = `Bearer ${adminAccessToken}`;
    }

    console.log("CREATING", body, headers);
    const resp = await harnessHttpLib.fetch(url, {
      method: "POST",
      body,
      headers,
    });
    await expectSuccessResponseOrThrow(resp);
    this.configFilename;
    const merchantApi = new TalerMerchantInstanceHttpClient(
      this.makeInstanceBaseUrl(instanceConfig.id),
    );

    const { access_token } = succeedOrThrow(
      await merchantApi.createAccessToken(
        instanceConfig.id,
        auth.password,
        MERCHANT_DEFAULT_LOGIN_SCOPE,
      ),
    );
    console.log(
      "CREATED",
      instanceConfig.id,
      auth.password,
      MERCHANT_DEFAULT_LOGIN_SCOPE,
    );

    const accountCreateUrl = `http://localhost:${this.merchantConfig.httpPort}/instances/${instanceConfig.id}/private/accounts`;
    for (const paytoUri of instanceConfig.paytoUris) {
      const accountReq: TalerMerchantApi.AccountAddDetails = {
        payto_uri: paytoUri as PaytoString,
      };
      const acctResp = await harnessHttpLib.fetch(accountCreateUrl, {
        method: "POST",
        body: accountReq,
        headers: {
          Authorization: `Bearer ${access_token}`,
        },
      });
      await expectSuccessResponseOrThrow(acctResp);
    }
    return { accessToken: access_token };
  }

  makeInstanceBaseUrl(instanceName?: string): string {
    if (instanceName === undefined || instanceName === "admin") {
      return `http://localhost:${this.merchantConfig.httpPort}/`;
    } else {
      return `http://localhost:${this.merchantConfig.httpPort}/instances/${instanceName}/`;
    }
  }

  async pingUntilAvailable(): Promise<void> {
    const url = `http://localhost:${this.merchantConfig.httpPort}/config`;
    await pingProc(
      this.procHttpd,
      url,
      `merchant (${this.merchantConfig.name})`,
    );
  }
}

type TestStatus = "pass" | "fail" | "skip";

export interface TestRunResult {
  /**
   * Name of the test.
   */
  name: string;

  /**
   * How long did the test run?
   */
  timeSec: number;

  status: TestStatus;

  reason?: string;
}

export async function runTestWithState(
  gc: GlobalTestState,
  testMain: (t: GlobalTestState) => Promise<void>,
  testName: string,
  linger: boolean = false,
): Promise<TestRunResult> {
  const startMs = new Date().getTime();

  const p = openPromise();
  let status: TestStatus;

  const handleSignal = (s: string) => {
    logger.warn(
      `**** received fatal process event (${s}), terminating test ${testName}`,
    );
    gc.shutdownSync();
    process.exit(1);
  };

  process.on("SIGINT", handleSignal);
  process.on("SIGTERM", handleSignal);

  process.on("unhandledRejection", (reason: unknown, promise: any) => {
    logger.warn(
      `**** received unhandled rejection (${reason}), terminating test ${testName}`,
    );
    logger.warn(`reason type: ${typeof reason}`);
    gc.shutdownSync();
    process.exit(1);
  });
  process.on("uncaughtException", (error, origin) => {
    logger.warn(
      `**** received uncaught exception (${error}), terminating test ${testName}`,
    );
    console.warn("stack", error.stack);
    gc.shutdownSync();
    process.exit(1);
  });

  try {
    logger.info("running test in directory", gc.testDir);
    await Promise.race([testMain(gc), p.promise]);
    logger.info("completed test in directory", gc.testDir);
    status = "pass";
    if (linger) {
      const rl = readline.createInterface({
        input: process.stdin,
        output: process.stdout,
        terminal: true,
      });
      await new Promise<void>((resolve, reject) => {
        rl.question("Press enter to shut down test.", () => {
          logger.error("Requested shutdown");
          resolve();
        });
      });
      rl.close();
    }
  } catch (e) {
    if (e instanceof CommandError) {
      console.error("FATAL: test failed for", e.logName);
      const errorLog = fs.readFileSync(
        path.join(gc.testDir, `${e.logName}-stderr.log`),
      );
      console.error(`${e.message}: "${e.command}"`);
      console.error(errorLog.toString());
      console.error(e);
    } else if (e instanceof TalerError) {
      console.error(
        "FATAL: test failed",
        e.message,
        `error detail: ${j2s(e.errorDetail)}`,
      );
      console.error(e.stack);
    } else {
      console.error("FATAL: test failed with exception", e);
    }
    let steps = `${gc.testDir}/steps.txt`;
    fs.appendFileSync(steps, `FAIL ${(e as any).message}\n`);
    status = "fail";
  } finally {
    await gc.shutdown();
  }
  const afterMs = new Date().getTime();
  return {
    name: testName,
    timeSec: (afterMs - startMs) / 1000,
    status,
  };
}

function shellWrap(s: string) {
  return "'" + s.replace("\\", "\\\\").replace("'", "\\'") + "'";
}

export interface WalletCliOpts {
  cryptoWorkerType?: "sync" | "node-worker-thread";
}

function tryUnixConnect(socketPath: string): Promise<void> {
  return new Promise((resolve, reject) => {
    const client = net.createConnection(socketPath);
    client.on("error", (e) => {
      reject(e);
    });
    client.on("connect", () => {
      client.end();
      resolve();
    });
  });
}

export interface WalletServiceOptions {
  useInMemoryDb?: boolean;
  /**
   * Use a particular DB path instead of the default one in the
   * test environment.
   */
  overrideDbPath?: string;
  name: string;
}

/**
 * A wallet service that listens on a unix domain socket for commands.
 */
export class WalletService {
  walletProc: ProcessWrapper | undefined;

  private internalDbPath: string;

  constructor(
    private globalState: GlobalTestState,
    private opts: WalletServiceOptions,
  ) {
    if (this.opts.overrideDbPath) {
      this.internalDbPath = this.opts.overrideDbPath;
    } else {
      if (this.opts.useInMemoryDb) {
        this.internalDbPath = ":memory:";
      } else {
        this.internalDbPath = path.join(
          this.globalState.testDir,
          `walletdb-${this.opts.name}.sqlite3`,
        );
      }
    }
  }

  get socketPath() {
    const unixPath = path.join(
      this.globalState.testDir,
      `${this.opts.name}.sock`,
    );
    return unixPath;
  }

  get dbPath() {
    return this.internalDbPath;
  }

  async stop(): Promise<void> {
    if (this.walletProc) {
      this.walletProc.proc.kill("SIGTERM");
      await this.walletProc.wait();
    }
  }

  async start(): Promise<void> {
    const unixPath = this.socketPath;
    this.walletProc = this.globalState.spawnService(
      "taler-wallet-cli",
      [
        "--wallet-db",
        this.dbPath,
        "-LTRACE", // FIXME: Make this configurable?
        "--no-throttle", // FIXME: Optionally do throttling for some tests?
        "advanced",
        "serve",
        "--unix-path",
        unixPath,
        "--no-init",
      ],
      `wallet-${this.opts.name}`,
    );
    logger.info(
      `hint: connect to wallet using taler-wallet-cli --wallet-connection=${unixPath}`,
    );
  }

  async pingUntilAvailable(): Promise<void> {
    let nextDelay = backoffStart();
    while (1) {
      try {
        await tryUnixConnect(this.socketPath);
      } catch (e) {
        logger.info(`wallet connection attempt failed: ${e}`);
        logger.info(`waiting on wallet for ${nextDelay}ms`);
        await delayMs(nextDelay);
        nextDelay = backoffIncrement(nextDelay);
        continue;
      }
      logger.info("connection to wallet-core succeeded");
      break;
    }
  }
}

export interface WalletClientArgs {
  name?: string;
  unixPath: string;
  onNotification?(n: WalletNotification): void;
}

export type CancelFn = () => void;
export type NotificationHandler = (n: WalletNotification) => void;

/**
 * Convenience wrapper around a (remote) wallet handle.
 */
export class WalletClient {
  remoteWallet: RemoteWallet | undefined = undefined;
  private waiter: WalletNotificationWaiter = makeNotificationWaiter();
  notificationHandlers: NotificationHandler[] = [];

  addNotificationListener(f: NotificationHandler): CancelFn {
    this.notificationHandlers.push(f);
    return () => {
      const idx = this.notificationHandlers.indexOf(f);
      if (idx >= 0) {
        this.notificationHandlers.splice(idx, 1);
      }
    };
  }

  async call<Op extends keyof WalletOperations>(
    operation: Op,
    payload: WalletCoreRequestType<Op>,
  ): Promise<WalletCoreResponseType<Op>> {
    if (!this.remoteWallet) {
      throw Error("wallet not connected");
    }
    const client = getClientFromRemoteWallet(this.remoteWallet);
    return client.call(operation, payload);
  }

  constructor(private args: WalletClientArgs) {}

  async connect(): Promise<void> {
    const waiter = this.waiter;
    const walletClient = this;
    const w = await createRemoteWallet({
      name: this.args.name,
      socketFilename: this.args.unixPath,
      notificationHandler(n) {
        if (walletClient.args.onNotification) {
          walletClient.args.onNotification(n);
        }
        waiter.notify(n);
        for (const h of walletClient.notificationHandlers) {
          h(n);
        }
      },
    });
    this.remoteWallet = w;
  }

  get client() {
    if (!this.remoteWallet) {
      throw Error("wallet not connected");
    }
    return getClientFromRemoteWallet(this.remoteWallet);
  }

  async getTx(id: TransactionIdStr): Promise<Transaction> {
    return this.call(WalletApiOperation.GetTransactionById, {
      transactionId: id,
    });
  }

  waitForNotificationCond<T>(
    cond: (n: WalletNotification) => T | undefined | false,
  ): Promise<T> {
    return this.waiter.waitForNotificationCond(cond);
  }

  async waitForNotificationCondOrTimeout<T>(
    cond: (n: WalletNotification) => T | undefined | false,
    timeout: number,
  ): Promise<T | undefined> {
    return Promise.race([
      waitMs(timeout).then((d) => undefined),
      this.waiter.waitForNotificationCond(cond),
    ]);
  }
}

export class WalletCli {
  private currentTimetravel: Duration | undefined;
  private _client: WalletCoreApiClient;

  setTimetravel(d: Duration | undefined) {
    this.currentTimetravel = d;
  }

  private get timetravelArg(): string | undefined {
    if (this.currentTimetravel && this.currentTimetravel.d_ms !== "forever") {
      // Convert to microseconds
      return `--timetravel=${this.currentTimetravel.d_ms * 1000}`;
    }
    return undefined;
  }

  constructor(
    private globalTestState: GlobalTestState,
    private name: string = "admin",
    cliOpts: WalletCliOpts = {},
  ) {
    const self = this;
    this._client = {
      async call(op: any, payload: any): Promise<any> {
        logger.info(
          `calling wallet with timetravel arg ${j2s(self.timetravelArg)}`,
        );
        const cryptoWorkerArg = cliOpts.cryptoWorkerType
          ? `--crypto-worker=${cliOpts.cryptoWorkerType}`
          : "";
        const logName = `wallet-${self.name}`;
        const command = `taler-wallet-cli ${
          self.timetravelArg ?? ""
        } ${cryptoWorkerArg} --no-throttle -LTRACE --skip-defaults --wallet-db '${
          self.dbfile
        }' api '${op}' ${shellWrap(JSON.stringify(payload))}`;
        const resp = await sh(self.globalTestState, logName, command);
        logger.info("--- wallet core response ---");
        logger.info(resp);
        logger.info("--- end of response ---");
        let ar: CoreApiResponse;
        try {
          ar = JSON.parse(resp);
        } catch (e) {
          throw new CommandError(
            "wallet CLI did not return a proper JSON response",
            logName,
            command,
            [],
            {},
            null,
          );
        }
        if (ar.type === "error") {
          throw TalerError.fromUncheckedDetail(ar.error);
        }
        return ar.result;
      },
    };
  }

  get dbfile(): string {
    return this.globalTestState.testDir + `/walletdb-${this.name}.json`;
  }

  deleteDatabase() {
    fs.unlinkSync(this.dbfile);
  }

  private get timetravelArgArr(): string[] {
    const tta = this.timetravelArg;
    if (tta) {
      return [tta];
    }
    return [];
  }

  get client(): WalletCoreApiClient {
    return this._client;
  }

  async runUntilDone(args: {} = {}): Promise<void> {
    await runCommand(
      this.globalTestState,
      `wallet-${this.name}`,
      "taler-wallet-cli",
      [
        "--no-throttle",
        ...this.timetravelArgArr,
        "-LTRACE",
        "--skip-defaults",
        "--wallet-db",
        this.dbfile,
        "run-until-done",
      ],
    );
  }

  async runPending(): Promise<void> {
    await runCommand(
      this.globalTestState,
      `wallet-${this.name}`,
      "taler-wallet-cli",
      [
        "--no-throttle",
        "--skip-defaults",
        "-LTRACE",
        ...this.timetravelArgArr,
        "--wallet-db",
        this.dbfile,
        "advanced",
        "run-pending",
      ],
    );
  }
}

export function generateRandomTestIban(salt: string | null = null): string {
  function getBban(salt: string | null): string {
    if (!salt) return Math.random().toString().substring(2, 6);
    let hashed = hash(stringToBytes(salt));
    let ret = "";
    for (let i = 0; i < hashed.length; i++) {
      ret += hashed[i].toString();
    }
    return ret.substring(0, 4);
  }

  let cc_no_check = "131400"; // == DE00
  let bban = getBban(salt);
  let check_digits = (
    98 -
    (Number.parseInt(`${bban}${cc_no_check}`) % 97)
  ).toString();
  if (check_digits.length == 1) {
    check_digits = `0${check_digits}`;
  }
  return `DE${check_digits}${bban}`;
}

export function getTestHarnessPaytoForLabel(label: string): PaytoString {
  // FIXME: This should also support iban!
  return `payto://x-taler-bank/localhost/${label}?receiver-name=${label}` as PaytoString;
}

export function waitMs(tMs: number): Promise<void> {
  return new Promise<void>((resolve) => {
    setTimeout(() => resolve(), tMs);
  });
}

export async function doMerchantKycAuth(
  t: GlobalTestState,
  req: {
    exchangeBankAccount: HarnessExchangeBankAccount;
    bankAdminAuth: { username: string; password: string };
    merchant: MerchantServiceInterface;
    merchantAdminAccessToken: AccessToken;
    bankClient: TalerCorebankApiClient;
  },
): Promise<void> {
  const { merchant, bankClient, merchantAdminAccessToken } = req;

  let accountPub: string;
  const headers = {
    Authorization: `Bearer ${merchantAdminAccessToken}`,
  };
  {
    const instanceUrl = new URL("private", merchant.makeInstanceBaseUrl());
    const resp = await harnessHttpLib.fetch(instanceUrl.href, { headers });
    const parsedResp = await readSuccessResponseJsonOrThrow(
      resp,
      codecForQueryInstancesResponse(),
    );
    accountPub = parsedResp.merchant_pub;
  }

  const wireGatewayApiClient = new TalerWireGatewayHttpClient(
    req.exchangeBankAccount.wireGatewayApiBaseUrl,
  );

  let kycRespOne: MerchantAccountKycRedirectsResponse | undefined = undefined;

  while (1) {
    const kycStatusUrl = new URL("private/kyc", merchant.makeInstanceBaseUrl())
      .href;
    logger.info(`requesting GET ${kycStatusUrl}`);
    const resp = await harnessHttpLib.fetch(kycStatusUrl, { headers });
    if (resp.status === 200) {
      kycRespOne = await readSuccessResponseJsonOrThrow(
        resp,
        codecForAccountKycRedirects(),
      );
      break;
    }
    // Wait 500ms
    await delayMs(500);
  }

  t.assertTrue(!!kycRespOne);

  logger.info(`mechant kyc status: ${j2s(kycRespOne)}`);

  t.assertDeepEqual(kycRespOne.kyc_data[0].exchange_http_status, 404);

  t.assertTrue(!!kycRespOne);

  await bankClient.registerAccountExtended({
    name: "merchant-default",
    password: "merchant-default",
    username: "merchant-default",
    payto_uri: kycRespOne.kyc_data[0].payto_uri, //this bank user needs to have the same payto that the exchange is asking from
  });
  succeedOrThrow(
    await wireGatewayApiClient.addKycAuth({
      auth: req.bankAdminAuth,
      body: {
        amount: "TESTKUDOS:0.1",
        debit_account: kycRespOne.kyc_data[0].payto_uri,
        account_pub: accountPub,
      },
    }),
  );

  let kycRespTwo: MerchantAccountKycRedirectsResponse | undefined = undefined;

  // We do this in a loop as a work-around.
  // Not exactly the correct behavior from the merchant right now.
  while (true) {
    const kycStatusLongpollUrl = new URL(
      "private/kyc",
      merchant.makeInstanceBaseUrl(),
    );
    kycStatusLongpollUrl.searchParams.set("lpt", "1");
    const resp = await harnessHttpLib.fetch(kycStatusLongpollUrl.href, {
      headers,
    });
    t.assertDeepEqual(resp.status, 200);
    const parsedResp = await readSuccessResponseJsonOrThrow(
      resp,
      codecForAccountKycRedirects(),
    );
    logger.info(`kyc resp 2: ${j2s(parsedResp)}`);
    if (parsedResp.kyc_data[0].payto_kycauths == null) {
      kycRespTwo = parsedResp;
      break;
    }
    // Wait 500ms
    await delayMs(500);
  }

  t.assertTrue(!!kycRespTwo);
}
