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

/**
 * Helpers to create typical test environments.
 *
 * @author Florian Dold <dold@taler.net>
 */

/**
 * Imports
 */
import {
  AccessToken,
  AccountProperties,
  AmlDecisionRequest,
  AmlDecisionRequestWithoutSignature,
  AmountString,
  Configuration,
  ConfirmPayResultType,
  decodeCrock,
  Duration,
  encodeCrock,
  getRandomBytes,
  HttpStatusCode,
  j2s,
  LegitimizationRuleSet,
  Logger,
  NotificationType,
  PartialWalletRunConfig,
  PreparePayResultType,
  signAmlDecision,
  succeedOrThrow,
  TalerCorebankApiClient,
  TalerCoreBankHttpClient,
  TalerExchangeHttpClient,
  TalerMerchantApi,
  TalerMerchantInstanceHttpClient,
  TalerProtocolTimestamp,
  TalerWireGatewayHttpClient,
  TransactionIdStr,
  TransactionMajorState,
  TransactionMinorState,
  WalletNotification,
} from "@gnu-taler/taler-util";
import {
  createSyncCryptoApi,
  EddsaKeyPairStrings,
  WalletApiOperation,
} from "@gnu-taler/taler-wallet-core";
import { CoinConfig, defaultCoinConfig } from "./denomStructures.js";
import {
  FaultInjectedExchangeService,
  FaultInjectedMerchantService,
} from "./faultInjection.js";
import {
  BankService,
  BankServiceHandle,
  DbInfo,
  ExchangeService,
  ExchangeServiceInterface,
  FakebankService,
  getTestHarnessPaytoForLabel,
  GlobalTestState,
  HarnessAccountRestriction,
  HarnessExchangeBankAccount,
  harnessHttpLib,
  LibeufinBankService,
  MERCHANT_DEFAULT_AUTH,
  MERCHANT_DEFAULT_LOGIN_SCOPE,
  MerchantService,
  MerchantServiceInterface,
  setupDb,
  setupSharedDb,
  useLibeufinBank,
  WalletCli,
  WalletClient,
  WalletService,
  WithAuthorization,
} from "./harness.js";

import * as fs from "node:fs";

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

/**
 * @deprecated
 */
export interface SimpleTestEnvironment {
  commonDb: DbInfo;
  bank: BankService;
  exchange: ExchangeService;
  exchangeBankAccount: HarnessExchangeBankAccount;
  merchant: MerchantService;
  wallet: WalletCli;
}

/**
 * Improved version of the simple test environment,
 * with the daemonized wallet.
 */
export interface SimpleTestEnvironmentNg {
  commonDb: DbInfo;
  bank: FakebankService;
  exchange: ExchangeService;
  exchangeBankAccount: HarnessExchangeBankAccount;
  merchant: MerchantService;
  merchantAdminAccessToken: AccessToken;
  walletClient: WalletClient;
  walletService: WalletService;
}

/**
 * Improved version of the simple test environment,
 * passing bankClient instead of bank service.
 */
export interface SimpleTestEnvironmentNg3 {
  commonDb: DbInfo;
  bank: BankService;
  bankClient: TalerCorebankApiClient;
  exchange: ExchangeService;
  exchangeBankAccount: HarnessExchangeBankAccount;
  merchant: MerchantService;
  merchantAdminAccessToken: AccessToken;
  walletClient: WalletClient;
  walletService: WalletService;
}

export interface EnvOptions {
  /**
   * If provided, enable age restrictions with the specified age mask string.
   */
  ageMaskSpec?: string;

  mixedAgeRestriction?: boolean;

  skipWireFeeCreation?: boolean;

  walletTestObservability?: boolean;

  accountRestrictions?: HarnessAccountRestriction[];

  /**
   * Force usage of libeufin for this particular test.
   */
  forceLibeufin?: boolean;

  merchantUseDonau?: boolean;

  walletConfig?: PartialWalletRunConfig;

  additionalExchangeConfig?(e: ExchangeService): void;
  additionalMerchantConfig?(m: MerchantService): void;
  additionalBankConfig?(b: BankService): void;
}

export function getSharedTestDir(): string {
  return `/tmp/taler-harness@${process.env.USER}`;
}

export async function useSharedTestkudosEnvironment(t: GlobalTestState) {
  const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS"));

  const sharedDir = getSharedTestDir();

  fs.mkdirSync(sharedDir, { recursive: true });

  const db = await setupSharedDb(t);

  let bank: FakebankService;

  const prevSetupDone = fs.existsSync(sharedDir + "/setup-done");

  logger.info(`previous setup done: ${prevSetupDone}`);

  // Wallet has longer startup-time and no dependencies,
  // so we start it rather early.
  const walletStartProm = createWalletDaemonWithClient(t, { name: "wallet" });

  if (fs.existsSync(sharedDir + "/bank.conf")) {
    logger.info("reusing existing bank");
    bank = FakebankService.fromExistingConfig(t, {
      overridePath: sharedDir,
    });
  } else {
    logger.info("creating new bank config");
    bank = await FakebankService.create(t, {
      allowRegistrations: true,
      currency: "TESTKUDOS",
      database: db.connStr,
      httpPort: 8082,
      overrideTestDir: sharedDir,
    });
  }

  logger.info("setting up exchange");

  const exchangeName = "testexchange-1";
  const exchangeConfigFilename = sharedDir + `/exchange-${exchangeName}.conf`;

  logger.info(`exchange config filename: ${exchangeConfigFilename}`);

  let exchange: ExchangeService;

  if (fs.existsSync(exchangeConfigFilename)) {
    logger.info("reusing existing exchange config");
    exchange = ExchangeService.fromExistingConfig(t, exchangeName, {
      overridePath: sharedDir,
    });
  } else {
    logger.info("creating new exchange config");
    exchange = ExchangeService.create(t, {
      name: "testexchange-1",
      currency: "TESTKUDOS",
      httpPort: 8081,
      database: db.connStr,
      overrideTestDir: sharedDir,
    });
  }

  logger.info("setting up merchant");

  let merchant: MerchantService;
  const merchantName = "testmerchant-1";
  const merchantConfigFilename = sharedDir + `/merchant-${merchantName}}`;

  if (fs.existsSync(merchantConfigFilename)) {
    merchant = MerchantService.fromExistingConfig(t, merchantName, {
      overridePath: sharedDir,
    });
  } else {
    merchant = await MerchantService.create(t, {
      name: "testmerchant-1",
      httpPort: 8083,
      database: db.connStr,
      overrideTestDir: sharedDir,
    });
  }

  logger.info("creating bank account for exchange");

  const exchangeBankAccount = await bank.createExchangeAccount(
    "myexchange",
    "password",
  );

  logger.info("creating exchange bank account");
  await exchange.addBankAccount("1", exchangeBankAccount);

  bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);

  exchange.addCoinConfigList(coinConfig);

  merchant.addExchange(exchange);

  logger.info("basic setup done, starting services");

  if (!prevSetupDone) {
    // Must be done sequentially, due to a concurrency
    // issue in the *-dbinit tools.
    await exchange.dbinit();
    await merchant.dbinit();
  }

  await bank.start();

  // We *must* wait for the exchange to be started.
  // Otherwise the merchant might be able to see
  // partial /keys (e.g. with missing wire fees),
  // leading to flaky tests.
  await exchange.start({
    skipDbinit: true,
    skipKeyup: prevSetupDone,
  });

  const merchStart = async () => {
    await merchant.start({
      skipDbinit: true,
    });
    await merchant.pingUntilAvailable();

    if (!prevSetupDone) {
      const { accessToken: adminAccessToken } =
        await merchant.addInstanceWithWireAccount({
          id: "admin",
          name: "Default Instance",
          paytoUris: [getTestHarnessPaytoForLabel("merchant-default")],
          defaultWireTransferDelay: Duration.toTalerProtocolDuration(
            Duration.fromSpec({ minutes: 1 }),
          ),
        });

      await merchant.addInstanceWithWireAccount(
        {
          id: "minst1",
          name: "minst1",
          paytoUris: [getTestHarnessPaytoForLabel("minst1")],
          defaultWireTransferDelay: Duration.toTalerProtocolDuration(
            Duration.fromSpec({ minutes: 1 }),
          ),
        },
        { adminAccessToken },
      );
    }

    const merchApi = new TalerMerchantInstanceHttpClient(
      merchant.makeInstanceBaseUrl(),
    );
    const { access_token: merchantAdminAccessToken } = succeedOrThrow(
      await merchApi.createAccessToken(
        "admin",
        MERCHANT_DEFAULT_AUTH.password,
        MERCHANT_DEFAULT_LOGIN_SCOPE,
      ),
    );
    return { merchantAdminAccessToken };
  };

  const res = await Promise.all([merchStart(), walletStartProm]);

  const merchantAdminAccessToken = res[0].merchantAdminAccessToken;
  const walletClient = res[1].walletClient;
  const walletService = res[1].walletService;

  fs.writeFileSync(sharedDir + "/setup-done", "OK");

  logger.info("setup done!");

  return {
    commonDb: db,
    exchange,
    merchant,
    merchantAdminAccessToken,
    walletClient,
    walletService,
    bank,
    exchangeBankAccount,
  };
}

/**
 * Run a test case with a simple TESTKUDOS Taler environment, consisting
 * of one exchange, one bank and one merchant.
 *
 * V2 uses a daemonized wallet instead of the CLI wallet.
 */
export async function createSimpleTestkudosEnvironmentV2(
  t: GlobalTestState,
  coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS")),
  opts: EnvOptions = {},
): Promise<SimpleTestEnvironmentNg> {
  const db = await setupDb(t);

  const bank = await FakebankService.create(t, {
    allowRegistrations: true,
    currency: "TESTKUDOS",
    database: db.connStr,
    httpPort: 8082,
  });

  const exchange = ExchangeService.create(t, {
    name: "testexchange-1",
    currency: "TESTKUDOS",
    httpPort: 8081,
    database: db.connStr,
  });

  const merchant = await MerchantService.create(t, {
    name: "testmerchant-1",
    httpPort: 8083,
    database: db.connStr,
  });

  const exchangeBankAccount = await bank.createExchangeAccount(
    "myexchange",
    "password",
  );
  await exchange.addBankAccount("1", exchangeBankAccount);

  bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);

  if (opts.additionalBankConfig) {
    opts.additionalBankConfig(bank);
  }
  await bank.start();

  await bank.pingUntilAvailable();

  const ageMaskSpec = opts.ageMaskSpec;

  if (ageMaskSpec) {
    exchange.enableAgeRestrictions(ageMaskSpec);
    // Enable age restriction for all coins.
    exchange.addCoinConfigList(
      coinConfig.map((x) => ({
        ...x,
        name: `${x.name}-age`,
        ageRestricted: true,
      })),
    );
    // For mixed age restrictions, we also offer coins without age restrictions
    if (opts.mixedAgeRestriction) {
      exchange.addCoinConfigList(
        coinConfig.map((x) => ({ ...x, ageRestricted: false })),
      );
    }
  } else {
    exchange.addCoinConfigList(coinConfig);
  }

  if (opts.additionalExchangeConfig) {
    opts.additionalExchangeConfig(exchange);
  }
  await exchange.start();
  await exchange.pingUntilAvailable();

  merchant.addExchange(exchange);

  if (opts.additionalMerchantConfig) {
    opts.additionalMerchantConfig(merchant);
  }
  await merchant.start();
  await merchant.pingUntilAvailable();

  const { accessToken: adminAccessToken } =
    await merchant.addInstanceWithWireAccount({
      id: "admin",
      name: "Default Instance",
      paytoUris: [getTestHarnessPaytoForLabel("merchant-default")],
      defaultWireTransferDelay: Duration.toTalerProtocolDuration(
        Duration.fromSpec({ minutes: 1 }),
      ),
    });

  await merchant.addInstanceWithWireAccount(
    {
      id: "minst1",
      name: "minst1",
      paytoUris: [getTestHarnessPaytoForLabel("minst1")],
      defaultWireTransferDelay: Duration.toTalerProtocolDuration(
        Duration.fromSpec({ minutes: 1 }),
      ),
    },
    { adminAccessToken },
  );

  const { walletClient, walletService } = await createWalletDaemonWithClient(
    t,
    { name: "wallet", persistent: true },
  );

  console.log("setup done!");

  return {
    commonDb: db,
    exchange,
    merchant,
    merchantAdminAccessToken: adminAccessToken,
    walletClient,
    walletService,
    bank,
    exchangeBankAccount,
  };
}

/**
 * Run a test case with a simple TESTKUDOS Taler environment, consisting
 * of one exchange, one bank and one merchant.
 *
 * V3 uses the unified Corebank API and allows to choose between
 * Fakebank and Libeufin-bank.
 */
export async function createSimpleTestkudosEnvironmentV3(
  t: GlobalTestState,
  coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS")),
  opts: EnvOptions = {},
): Promise<SimpleTestEnvironmentNg3> {
  const db = await setupDb(t);

  const bc = {
    allowRegistrations: true,
    currency: "TESTKUDOS",
    database: db.connStr,
    httpPort: 8082,
  };

  const bank: BankService =
    useLibeufinBank || opts.forceLibeufin
      ? await LibeufinBankService.create(t, bc)
      : await FakebankService.create(t, bc);

  const exchange = ExchangeService.create(t, {
    name: "testexchange-1",
    currency: "TESTKUDOS",
    httpPort: 8081,
    database: db.connStr,
  });

  const merchant = await MerchantService.create(t, {
    name: "testmerchant-1",
    httpPort: 8083,
    database: db.connStr,
  });

  const receiverName = "Exchange";
  const exchangeBankUsername = "exchange";
  const exchangeBankPassword = "mypw-password";
  const exchangePaytoUri = getTestHarnessPaytoForLabel(exchangeBankUsername);
  const wireGatewayApiBaseUrl = new URL(
    `accounts/${exchangeBankUsername}/taler-wire-gateway/`,
    bank.corebankApiBaseUrl,
  ).href;

  const exchangeBankAccount: HarnessExchangeBankAccount = {
    wireGatewayApiBaseUrl,
    wireGatewayAuth: {
      username: exchangeBankUsername,
      password: exchangeBankPassword,
    },
    accountPaytoUri: exchangePaytoUri,
    skipWireFeeCreation: opts.skipWireFeeCreation === true,
    accountRestrictions: opts.accountRestrictions,
  };

  await exchange.addBankAccount("1", exchangeBankAccount);

  bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);

  if (opts.additionalBankConfig) {
    opts.additionalBankConfig(bank);
  }
  await bank.start();

  await bank.pingUntilAvailable();

  const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, {
    auth: {
      username: "admin",
      password: "admin-password",
    },
  });

  await bankClient.registerAccountExtended({
    name: receiverName,
    password: exchangeBankPassword,
    username: exchangeBankUsername,
    is_taler_exchange: true,
    payto_uri: exchangePaytoUri,
  });

  const ageMaskSpec = opts.ageMaskSpec;

  if (ageMaskSpec) {
    exchange.enableAgeRestrictions(ageMaskSpec);
    // Enable age restriction for all coins.
    exchange.addCoinConfigList(
      coinConfig.map((x) => ({
        ...x,
        name: `${x.name}-age`,
        ageRestricted: true,
      })),
    );
    // For mixed age restrictions, we also offer coins without age restrictions
    if (opts.mixedAgeRestriction) {
      exchange.addCoinConfigList(
        coinConfig.map((x) => ({ ...x, ageRestricted: false })),
      );
    }
  } else {
    exchange.addCoinConfigList(coinConfig);
  }

  if (opts.additionalExchangeConfig) {
    opts.additionalExchangeConfig(exchange);
  }
  await exchange.start();

  merchant.addExchange(exchange);

  if (opts.additionalMerchantConfig) {
    opts.additionalMerchantConfig(merchant);
  }
  await merchant.start({
    useDonau: opts.merchantUseDonau,
  });
  await merchant.pingUntilAvailable();

  const { accessToken: merchantAdminAccessToken } =
    await merchant.addInstanceWithWireAccount({
      id: "admin",
      name: "Default Instance",
      paytoUris: [getTestHarnessPaytoForLabel("merchant-default")],
      defaultWireTransferDelay: Duration.toTalerProtocolDuration(
        Duration.fromSpec({ minutes: 1 }),
      ),
    });

  await merchant.addInstanceWithWireAccount(
    {
      id: "minst1",
      name: "minst1",
      paytoUris: [getTestHarnessPaytoForLabel("minst1")],
      defaultWireTransferDelay: Duration.toTalerProtocolDuration(
        Duration.fromSpec({ minutes: 1 }),
      ),
    },
    { adminAccessToken: merchantAdminAccessToken },
  );

  const { walletClient, walletService } = await createWalletDaemonWithClient(
    t,
    {
      name: "wallet",
      persistent: true,
      emitObservabilityEvents: !!opts.walletTestObservability,
      config: opts.walletConfig,
    },
  );

  console.log("setup done!");

  return {
    commonDb: db,
    exchange,
    merchant,
    merchantAdminAccessToken,
    walletClient,
    walletService,
    bank,
    bankClient,
    exchangeBankAccount,
  };
}

export interface CreateWalletArgs {
  handleNotification?(wn: WalletNotification): void;
  name: string;
  persistent?: boolean;
  overrideDbPath?: string;
  config?: PartialWalletRunConfig;
  emitObservabilityEvents?: boolean;
}

export async function createWalletDaemonWithClient(
  t: GlobalTestState,
  args: CreateWalletArgs,
): Promise<{ walletClient: WalletClient; walletService: WalletService }> {
  const walletService = new WalletService(t, {
    name: args.name,
    useInMemoryDb: !args.persistent,
    overrideDbPath: args.overrideDbPath,
  });
  await walletService.start();
  await walletService.pingUntilAvailable();

  const observabilityEventFile = t.testDir + `/wallet-${args.name}-notifs.log`;

  const onNotif = (notif: WalletNotification) => {
    if (observabilityEventFile) {
      fs.appendFileSync(
        observabilityEventFile,
        new Date().toISOString() + " " + JSON.stringify(notif) + "\n",
      );
    }
    if (args.handleNotification) {
      args.handleNotification(notif);
    }
  };

  const walletClient = new WalletClient({
    name: args.name,
    unixPath: walletService.socketPath,
    onNotification: onNotif,
  });
  await walletClient.connect();
  const defaultRunConfig = {
    testing: {
      skipDefaults: true,
      emitObservabilityEvents:
        !!process.env["TALER_TEST_OBSERVABILITY"] ||
        !!args.emitObservabilityEvents,
    },
  } satisfies PartialWalletRunConfig;
  await walletClient.client.call(WalletApiOperation.InitWallet, {
    config: args.config ?? defaultRunConfig,
  });

  return { walletClient, walletService };
}

export interface FaultyMerchantTestEnvironment {
  commonDb: DbInfo;
  bank: FakebankService;
  exchange: ExchangeService;
  faultyExchange: FaultInjectedExchangeService;
  exchangeBankAccount: HarnessExchangeBankAccount;
  merchant: MerchantService;
  merchantAdminAccessToken: AccessToken;
  faultyMerchant: FaultInjectedMerchantService;
  walletClient: WalletClient;
}

export interface FaultyMerchantTestEnvironmentNg {
  commonDb: DbInfo;
  bankClient: TalerCorebankApiClient;
  exchange: ExchangeService;
  faultyExchange: FaultInjectedExchangeService;
  merchant: MerchantService;
  merchantAdminAccessToken: AccessToken;
  faultyMerchant: FaultInjectedMerchantService;
  walletClient: WalletClient;
}

/**
 * Run a test case with a simple TESTKUDOS Taler environment, consisting
 * of one exchange, one bank and one merchant.
 */
export async function createFaultInjectedMerchantTestkudosEnvironment(
  t: GlobalTestState,
): Promise<FaultyMerchantTestEnvironment> {
  const db = await setupDb(t);

  const bank = await FakebankService.create(t, {
    allowRegistrations: true,
    currency: "TESTKUDOS",
    database: db.connStr,
    httpPort: 8082,
  });

  const exchange = ExchangeService.create(t, {
    name: "testexchange-1",
    currency: "TESTKUDOS",
    httpPort: 8081,
    database: db.connStr,
  });

  const merchant = await MerchantService.create(t, {
    name: "testmerchant-1",
    httpPort: 8083,
    database: db.connStr,
  });

  const faultyMerchant = new FaultInjectedMerchantService(t, merchant, 9083);
  const faultyExchange = new FaultInjectedExchangeService(t, exchange, 9081);

  // Base URL must contain port that the proxy is listening on.
  await exchange.modifyConfig(async (config) => {
    config.setString("exchange", "base_url", "http://localhost:9081/");
  });

  const exchangeBankAccount = await bank.createExchangeAccount(
    "myexchange",
    "password",
  );
  exchange.addBankAccount("1", exchangeBankAccount);

  bank.setSuggestedExchange(
    faultyExchange,
    exchangeBankAccount.accountPaytoUri,
  );

  await bank.start();

  await bank.pingUntilAvailable();

  exchange.addOfferedCoins(defaultCoinConfig);

  await exchange.start();
  await exchange.pingUntilAvailable();

  merchant.addExchange(faultyExchange);

  await merchant.start();
  await merchant.pingUntilAvailable();

  const { accessToken: adminAccessToken } =
    await merchant.addInstanceWithWireAccount({
      id: "admin",
      name: "Default Instance",
      paytoUris: [getTestHarnessPaytoForLabel("merchant-default")],
    });

  await merchant.addInstanceWithWireAccount(
    {
      id: "minst1",
      name: "minst1",
      paytoUris: [getTestHarnessPaytoForLabel("minst1")],
    },
    { adminAccessToken },
  );

  console.log("setup done!");

  const { walletClient } = await createWalletDaemonWithClient(t, {
    name: "default",
  });

  return {
    commonDb: db,
    exchange,
    merchant,
    walletClient,
    merchantAdminAccessToken: adminAccessToken,
    bank,
    exchangeBankAccount,
    faultyMerchant,
    faultyExchange,
  };
}

export interface WithdrawViaBankResult {
  /**
   * Payto URI of the account that the withdrawal
   * originated from. Typically a new account used for testing.
   */
  accountPaytoUri: string;

  /**
   * Helper promise that resolves when withdrawal has finished successfully.
   */
  withdrawalFinishedCond: Promise<true>;

  /**
   * The wallet-core withdrawal transaction ID.
   */
  transactionId: string;
}

/**
 * Withdraw via a bank with the testing API enabled.
 * Uses the new Corebank API.
 */
export async function withdrawViaBankV3(
  t: GlobalTestState,
  p: {
    walletClient: WalletClient;
    bankClient: TalerCorebankApiClient;
    exchange: ExchangeServiceInterface;
    amount: AmountString | string;
    restrictAge?: number;
  },
): Promise<WithdrawViaBankResult> {
  const { walletClient: wallet, bankClient, exchange, amount } = p;

  const user = await bankClient.createRandomBankUser();
  const bankClient2 = new TalerCorebankApiClient(bankClient.baseUrl);
  bankClient2.setAuth({
    username: user.username,
    password: user.password,
  });

  const wop = await bankClient2.createWithdrawalOperation(
    user.username,
    amount,
  );

  // Hand it to the wallet

  await wallet.client.call(WalletApiOperation.GetWithdrawalDetailsForUri, {
    talerWithdrawUri: wop.taler_withdraw_uri,
    restrictAge: p.restrictAge,
  });

  // Withdraw (AKA select)

  const acceptRes = await wallet.client.call(
    WalletApiOperation.AcceptBankIntegratedWithdrawal,
    {
      exchangeBaseUrl: exchange.baseUrl,
      talerWithdrawUri: wop.taler_withdraw_uri,
      restrictAge: p.restrictAge,
    },
  );

  const withdrawalFinishedCond = wallet.waitForNotificationCond(
    (x) =>
      x.type === NotificationType.TransactionStateTransition &&
      x.newTxState.major === TransactionMajorState.Done &&
      x.transactionId === acceptRes.transactionId,
  );

  await wallet.call(WalletApiOperation.TestingWaitTransactionState, {
    transactionId: acceptRes.transactionId,
    txState: {
      major: TransactionMajorState.Pending,
      minor: TransactionMinorState.BankConfirmTransfer,
    },
  });

  // Confirm it

  await bankClient2.confirmWithdrawalOperation(user.username, {
    withdrawalOperationId: wop.withdrawal_id,
  });

  return {
    accountPaytoUri: user.accountPaytoUri,
    withdrawalFinishedCond,
    transactionId: acceptRes.transactionId,
  };
}

export async function applyTimeTravelV2(
  timetravelOffsetMs: number,
  s: {
    exchange?: ExchangeService;
    merchant?: MerchantService;
    walletClient?: WalletClient;
  },
): Promise<void> {
  if (s.exchange) {
    await s.exchange.stop();
    s.exchange.setTimetravel(timetravelOffsetMs);
    await s.exchange.start();
    await s.exchange.pingUntilAvailable();
  }

  if (s.merchant) {
    await s.merchant.stop();
    s.merchant.setTimetravel(timetravelOffsetMs);
    await s.merchant.start();
    await s.merchant.pingUntilAvailable();
  }

  if (s.walletClient) {
    await s.walletClient.call(WalletApiOperation.TestingSetTimetravel, {
      offsetMs: timetravelOffsetMs,
    });
  }
}

/**
 * Make a simple payment and check that it succeeded.
 */
export async function makeTestPaymentV2(
  t: GlobalTestState,
  args: {
    merchant: MerchantServiceInterface;
    merchantAdminAccessToken: AccessToken;
    walletClient: WalletClient;
    order: TalerMerchantApi.Order;
    instance?: string;
  },
  auth: WithAuthorization = {},
): Promise<{ transactionId: TransactionIdStr }> {
  // Set up order.

  const { walletClient, merchant, instance, merchantAdminAccessToken } = args;

  const merchantClient = new TalerMerchantInstanceHttpClient(
    merchant.makeInstanceBaseUrl(instance),
  );

  const orderResp = succeedOrThrow(
    await merchantClient.createOrder(merchantAdminAccessToken, {
      order: args.order,
    }),
  );

  let orderStatus = succeedOrThrow(
    await merchantClient.getOrderDetails(
      merchantAdminAccessToken,
      orderResp.order_id,
    ),
  );

  t.assertTrue(orderStatus.order_status === "unpaid");

  // Make wallet pay for the order

  const preparePayResult = await walletClient.call(
    WalletApiOperation.PreparePayForUri,
    {
      talerPayUri: orderStatus.taler_pay_uri,
    },
  );

  t.assertTrue(
    preparePayResult.status === PreparePayResultType.PaymentPossible,
  );

  const r2 = await walletClient.call(WalletApiOperation.ConfirmPay, {
    transactionId: preparePayResult.transactionId,
  });

  t.assertDeepEqual(r2.type, ConfirmPayResultType.Done);

  // Check if payment was successful.

  orderStatus = succeedOrThrow(
    await merchantClient.getOrderDetails(
      merchantAdminAccessToken,
      orderResp.order_id,
    ),
  );

  t.assertDeepEqual(orderStatus.order_status, "paid");

  return {
    transactionId: preparePayResult.transactionId,
  };
}

/**
 * Post an AML decision that no rules shall apply for the given account.
 */
export async function postAmlDecisionNoRules(
  t: GlobalTestState,
  req: {
    exchangeBaseUrl: string;
    paytoHash: string;
    amlPriv: string;
    amlPub: string;
  },
) {
  const { exchangeBaseUrl, paytoHash, amlPriv, amlPub } = req;

  const sigData: AmlDecisionRequestWithoutSignature = {
    decision_time: TalerProtocolTimestamp.now(),
    h_payto: paytoHash,
    justification: "Bla",
    keep_investigating: false,
    new_rules: {
      custom_measures: {},
      expiration_time: TalerProtocolTimestamp.never(),
      rules: [],
      successor_measure: undefined,
    },
    properties: {
      foo: "42",
    },
  };

  const sig = signAmlDecision(decodeCrock(amlPriv), sigData);

  const reqBody: AmlDecisionRequest = {
    ...sigData,
    officer_sig: encodeCrock(sig),
  };

  const reqUrl = new URL(`aml/${amlPub}/decision`, exchangeBaseUrl);

  const resp = await harnessHttpLib.fetch(reqUrl.href, {
    method: "POST",
    body: reqBody,
  });

  console.log(`aml decision status: ${resp.status}`);

  if (resp.status >= 400) {
    const body = await resp.json();
    console.log(j2s(body));
  }

  t.assertDeepEqual(resp.status, HttpStatusCode.NoContent);
}

/**
 * Post an AML decision that no rules shall apply for the given account.
 */
export async function postAmlDecision(
  t: GlobalTestState,
  req: {
    exchangeBaseUrl: string;
    paytoUri?: string;
    paytoHash: string;
    amlPriv: string;
    amlPub: string;
    newRules: LegitimizationRuleSet;
    newMeasures?: string;
    properties?: AccountProperties;
    attributes?: Object;
    attributes_expiration?: TalerProtocolTimestamp;
    events?: string[];
  },
) {
  const { exchangeBaseUrl, paytoHash, amlPriv, amlPub } = req;

  const sigData: AmlDecisionRequestWithoutSignature = {
    decision_time: TalerProtocolTimestamp.now(),
    h_payto: paytoHash,
    justification: "Bla",
    keep_investigating: false,
    new_rules: req.newRules,
    new_measures: req.newMeasures,
    properties: req.properties ?? {},
    attributes: req.attributes,
    attributes_expiration: req.attributes_expiration,
  };

  const sig = signAmlDecision(decodeCrock(amlPriv), sigData);

  const reqBody: AmlDecisionRequest = {
    ...sigData,
    officer_sig: encodeCrock(sig),
    payto_uri: req.paytoUri,
    events: req.events,
  };

  const reqUrl = new URL(`aml/${amlPub}/decision`, exchangeBaseUrl);

  const resp = await harnessHttpLib.fetch(reqUrl.href, {
    method: "POST",
    body: reqBody,
  });

  console.log(`aml decision status: ${resp.status}`);

  t.assertDeepEqual(resp.status, HttpStatusCode.NoContent);
}

function defaultOnNotification(n: WalletNotification): void {
  console.log("wallet-core notification", n);
}

export interface KycEnvOptions {
  coinConfig?: CoinConfig[];
  onWalletNotification?: (n: WalletNotification) => void;
  adjustExchangeConfig?(config: Configuration): void;
  adjustMerchantConfig?(config: Configuration): void;
}

export interface KycTestEnv {
  commonDb: DbInfo;
  bankClient: TalerCorebankApiClient;
  exchange: ExchangeService;
  exchangeBankAccount: HarnessExchangeBankAccount;
  walletClient: WalletClient;
  walletService: WalletService;
  amlKeypair: EddsaKeyPairStrings;
  merchant: MerchantService;
  merchantAdminAccessToken: AccessToken;
  bankApi: TalerCoreBankHttpClient;
  exchangeApi: TalerExchangeHttpClient;
  wireGatewayApi: TalerWireGatewayHttpClient;
  merchantApi: TalerMerchantInstanceHttpClient;
  bank: BankServiceHandle;
}

export async function createKycTestkudosEnvironment(
  t: GlobalTestState,
  opts: KycEnvOptions = {},
): Promise<KycTestEnv> {
  const db = await setupDb(t);

  let coinConfig: CoinConfig[];
  if (opts.coinConfig) {
    coinConfig = opts.coinConfig;
  } else {
    coinConfig = defaultCoinConfig.map((x) => x("TESTKUDOS"));
  }

  const bank = await BankService.create(t, {
    allowRegistrations: true,
    currency: "TESTKUDOS",
    database: db.connStr,
    httpPort: 8082,
  });

  const exchange = ExchangeService.create(t, {
    name: "testexchange-1",
    currency: "TESTKUDOS",
    httpPort: 8081,
    database: db.connStr,
  });

  let receiverName = "Exchange";
  let exchangeBankUsername = "exchange";
  let exchangeBankPassword = "mypw-password";
  let exchangePaytoUri = getTestHarnessPaytoForLabel(exchangeBankUsername);

  const wireGatewayApiBaseUrl = new URL(
    `accounts/${exchangeBankUsername}/taler-wire-gateway/`,
    bank.corebankApiBaseUrl,
  ).href;

  await exchange.addBankAccount("1", {
    wireGatewayAuth: {
      username: exchangeBankUsername,
      password: exchangeBankPassword,
    },
    wireGatewayApiBaseUrl,
    accountPaytoUri: exchangePaytoUri,
  });

  bank.setSuggestedExchange(exchange, exchangePaytoUri);

  await bank.start();

  await bank.pingUntilAvailable();

  const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, {
    auth: {
      username: "admin",
      password: "admin-password",
    },
  });

  await bankClient.registerAccountExtended({
    name: receiverName,
    password: exchangeBankPassword,
    username: exchangeBankUsername,
    is_taler_exchange: true,
    payto_uri: exchangePaytoUri,
  });

  exchange.addCoinConfigList(coinConfig);

  const adjustExchangeConfig = opts.adjustExchangeConfig;
  if (adjustExchangeConfig) {
    await exchange.modifyConfig(async (config) => {
      adjustExchangeConfig(config);
    });
  }

  await exchange.start();

  const cryptoApi = createSyncCryptoApi();
  const amlKeypair = await cryptoApi.createEddsaKeypair({});

  await exchange.enableAmlAccount(amlKeypair.pub, "Alice");

  const walletService = new WalletService(t, {
    name: "wallet",
    useInMemoryDb: true,
  });
  await walletService.start();
  await walletService.pingUntilAvailable();

  const walletClient = new WalletClient({
    name: "wallet",
    unixPath: walletService.socketPath,
    onNotification: opts.onWalletNotification ?? defaultOnNotification,
  });
  await walletClient.connect();
  await walletClient.client.call(WalletApiOperation.InitWallet, {
    config: {
      testing: {
        skipDefaults: true,
      },
    },
  });

  const merchant = await MerchantService.create(t, {
    name: "testmerchant-1",
    httpPort: 8083,
    database: db.connStr,
  });

  merchant.addExchange(exchange);

  const adjustMerchantConfig = opts.adjustMerchantConfig;
  if (adjustMerchantConfig) {
    await merchant.modifyConfig(async (config) => {
      adjustMerchantConfig(config);
    });
  }

  await merchant.start();

  const { accessToken: adminAccessToken } =
    await merchant.addInstanceWithWireAccount({
      id: "admin",
      name: "Default Instance",
      paytoUris: [getTestHarnessPaytoForLabel("merchant-default")],
      defaultWireTransferDelay: Duration.toTalerProtocolDuration(
        Duration.fromSpec({ minutes: 1 }),
      ),
    });

  await merchant.addInstanceWithWireAccount(
    {
      id: "minst1",
      name: "minst1",
      paytoUris: [getTestHarnessPaytoForLabel("minst1")],
      defaultWireTransferDelay: Duration.toTalerProtocolDuration(
        Duration.fromSpec({ minutes: 1 }),
      ),
    },
    { adminAccessToken },
  );

  const exchangeBankAccount: HarnessExchangeBankAccount = {
    wireGatewayAuth: {
      username: exchangeBankUsername,
      password: exchangeBankPassword,
    },
    accountPaytoUri: exchangePaytoUri,
    wireGatewayApiBaseUrl,
  };

  t.logStep("env-setup-done");

  const bankApi = new TalerCoreBankHttpClient(
    bankClient.baseUrl,
    harnessHttpLib,
  );

  const wireGatewayApi = new TalerWireGatewayHttpClient(
    bankApi.getWireGatewayAPI(
      exchangeBankAccount.wireGatewayAuth.username,
    ).href,
    {
      httpClient: harnessHttpLib,
    },
  );

  const merchantApi = new TalerMerchantInstanceHttpClient(
    merchant.makeInstanceBaseUrl(),
    harnessHttpLib,
  );

  const exchangeApi = new TalerExchangeHttpClient(exchange.baseUrl, {
    httpClient: harnessHttpLib,
  });

  return {
    commonDb: db,
    exchange,
    amlKeypair,
    walletClient,
    walletService,
    bankClient,
    exchangeBankAccount,
    merchant,
    merchantAdminAccessToken: adminAccessToken,
    bankApi,
    wireGatewayApi,
    merchantApi,
    exchangeApi,
    bank,
  };
}

export interface TestUserResult {
  username: string;
  password: string;
  token: AccessToken;
}

/**
 * Register a new bank user with a random name and obtain a
 * login token.
 */
export async function registerHarnessBankTestUser(
  bankClient: TalerCoreBankHttpClient,
): Promise<TestUserResult> {
  const username = "user-" + encodeCrock(getRandomBytes(10)).toLowerCase();
  const password = "pw-" + encodeCrock(getRandomBytes(10)).toLowerCase();
  const createRes = succeedOrThrow(
    await bankClient.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 bankClient.createAccessTokenBasic(username, password, {
      scope: "readwrite",
    }),
  );
  return {
    password,
    username,
    token: tokRes.access_token,
  };
}

/**
 * Add common AML configuration.
 *
 * In particular, the measure and program FREEZE and the program NONE is defined.
 */
export function configureCommonKyc(config: Configuration): void {
  config.setString("exchange", "enable_kyc", "yes");

  config.setString("KYC-MEASURE-FREEZE", "check_name", "SKIP");
  config.setString("KYC-MEASURE-FREEZE", "context", "{}");
  config.setString("KYC-MEASURE-FREEZE", "program", "FREEZE");
  config.setString(
    "AML-PROGRAM-FREEZE",
    "command",
    "taler-exchange-helper-measure-freeze",
  );
  config.setString("AML-PROGRAM-FREEZE", "enabled", "true");
  config.setString("AML-PROGRAM-FREEZE", "description", "Freeze account");
  config.setString("AML-PROGRAM-FREEZE", "description_i18n", "{}");
  config.setString("AML-PROGRAM-FREEZE", "fallback", "FREEZE");

  config.setString("AML-PROGRAM-NONE", "command", "/bin/true");
  config.setString("AML-PROGRAM-NONE", "enabled", "true");
  config.setString("AML-PROGRAM-NONE", "description", "this does nothing");
  config.setString("AML-PROGRAM-NONE", "fallback", "FREEZE");
}
