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

import {
  AccessToken,
  AmlDecision,
  Amounts,
  AmountString,
  ConfigSources,
  Configuration,
  decodeCrock,
  Duration,
  encodeCrock,
  getRandomBytes,
  hashNormalizedPaytoUri,
  j2s,
  KycRule,
  KycStatusLongPollingReason,
  LimitOperationType,
  MerchantAccountKycRedirectsResponse,
  MerchantAccountKycStatus,
  OfficerAccount,
  OfficerId,
  parsePaytoUriOrThrow,
  succeedOrThrow,
  TalerCorebankApiClient,
  TalerCoreBankHttpClient,
  TalerExchangeHttpClient,
  TalerMerchantInstanceHttpClient,
  TalerProtocolDuration,
  TalerProtocolTimestamp,
  TalerWireGatewayHttpClient,
} from "@gnu-taler/taler-util";
import {
  createSyncCryptoApi,
  EddsaKeyPairStrings,
  WalletApiOperation,
} from "@gnu-taler/taler-wallet-core";
import { logger } from "../integrationtests/test-tops-aml-kyx-natural.js";
import { CoinConfig, defaultCoinConfig } from "./denomStructures.js";
import {
  startFakeChallenger,
  TestfakeChallengerService,
} from "./fake-challenger.js";
import {
  BankService,
  BankServiceHandle,
  DbInfo,
  ExchangeService,
  getTestHarnessPaytoForLabel,
  GlobalTestState,
  HarnessExchangeBankAccount,
  harnessHttpLib,
  MerchantService,
  setupDb,
  waitMs,
  WalletClient,
  WalletService,
} from "./harness.js";

// TODO:
// * Use IBANs and libeufin-nexus
//   to be closer to prod setup

/**
 * Configuration to emulate the TOPS setup in testing.
 *
 * Ideally, configuration from the TOPS setup
 * can be copy+pasted here.
 */

export const topsKycRulesConf = `
[exchange]

# Better enable KYC.
ENABLE_KYC = YES

# Hard limits
[kyc-rule-withdraw-limit-monthly]
OPERATION_TYPE = WITHDRAW
NEXT_MEASURES = verboten
EXPOSED = YES
ENABLED = YES
THRESHOLD = CHF:2500
TIMEFRAME = "30 days"

[kyc-rule-withdraw-limit-annually]
OPERATION_TYPE = WITHDRAW
NEXT_MEASURES = verboten
EXPOSED = YES
ENABLED = YES
THRESHOLD = CHF:15000
TIMEFRAME = "365 days"

# Limit on merchant transactions
[kyc-rule-transaction-limit]
OPERATION_TYPE = TRANSACTION
NEXT_MEASURES = verboten
EXPOSED = YES
ENABLED = YES
THRESHOLD = CHF:1000
TIMEFRAME = "1 days"

[kyc-rule-balance-limit]
OPERATION_TYPE = BALANCE
NEXT_MEASURES = verboten
EXPOSED = YES
# Note: Disabled, kept in case we ever want to impose a limit on wallet balances.
ENABLED = NO
THRESHOLD = CHF:1000
TIMEFRAME = "1 days"

# SMS identification limit on withdraw (voluntary rule)
[kyc-rule-withdraw-limit-low]
OPERATION_TYPE = WITHDRAW
NEXT_MEASURES = sms-registration
EXPOSED = YES
ENABLED = YES
THRESHOLD = CHF:200
TIMEFRAME = "30 days"

# Deposit requires ToS acceptance, this way we ensure bank account is confirmed!
[kyc-rule-deposit-limit-zero]
OPERATION_TYPE = DEPOSIT
NEXT_MEASURES = accept-tos
EXPOSED = YES
ENABLED = YES
THRESHOLD = CHF:0
TIMEFRAME = "1 days"

# Aggregation limits
[kyc-rule-deposit-limit-monthly]
OPERATION_TYPE = AGGREGATE
NEXT_MEASURES = kyx
EXPOSED = YES
ENABLED = YES
THRESHOLD = CHF:2500
TIMEFRAME = "30 days"

[kyc-rule-deposit-limit-annually]
OPERATION_TYPE = AGGREGATE
NEXT_MEASURES = kyx
EXPOSED = YES
ENABLED = YES
THRESHOLD = CHF:15000
TIMEFRAME = "365 days"

# P2P limits
[kyc-rule-p2p-limit-monthly]
OPERATION_TYPE = MERGE
NEXT_MEASURES = verboten
EXPOSED = YES
ENABLED = YES
THRESHOLD = CHF:2500
TIMEFRAME = "30 days"

[kyc-rule-p2p-limit-annually]
OPERATION_TYPE = MERGE
NEXT_MEASURES = verboten
EXPOSED = YES
ENABLED = YES
THRESHOLD = CHF:15000
TIMEFRAME = "365 days"

[kyc-rule-p2p-domestic-identification-requirement]
OPERATION_TYPE = MERGE
NEXT_MEASURES = sms-registration postal-registration
IS_AND_COMBINATOR = NO
EXPOSED = YES
ENABLED = YES
THRESHOLD = CHF:0
TIMEFRAME = "30 days"

# #################### KYC measures #######################

# Fallback measure on errors.
[kyc-measure-freeze-investigate]
CHECK_NAME = skip
PROGRAM = freeze-investigate
VOLUNTARY = NO
CONTEXT = {}

[kyc-measure-inform-investigate]
CHECK_NAME = form-info-investigation
# It's an INFO, so the program will never run, but we still
# must specify one. Maybe make PROGRAM not required for
# INFO-checks? #9874
PROGRAM = preserve-investigate
VOLUNTARY = YES
CONTEXT = {}

[kyc-measure-inform-internal-error]
CHECK_NAME = form-info-internal-error
# It's an INFO, so the program will never run, but we still
# must specify one. Maybe make PROGRAM not required for
# INFO-checks? #9874
PROGRAM = preserve-investigate
VOLUNTARY = YES
CONTEXT = {}

[kyc-measure-sms-registration]
CHECK_NAME = sms-registration
PROGRAM = tops-sms-check
VOLUNTARY = YES
CONTEXT = {}

[kyc-measure-postal-registration]
CHECK_NAME = postal-registration
PROGRAM = tops-postal-check
VOLUNTARY = YES
CONTEXT = {}

[kyc-measure-accept-tos]
CHECK_NAME = form-accept-tos
PROGRAM = check-tos
CONTEXT = {"tos_url":"https://exchange.taler-ops.ch/terms","provider_name":"Taler Operations AG", "successor_measure":"accept-tos", "validity_years":10}
VOLUNTARY = NO

[kyc-measure-kyx]
CHECK_NAME = form-vqf-902.1
PROGRAM = tops-kyx-check
VOLUNTARY = NO
CONTEXT = {}

# Form triggered via tops-check-controlling-entity after vqf-902.11
[kyc-measure-form-vqf-902.9]
CHECK_NAME = form-vqf-902.9
PROGRAM = preserve-investigate
VOLUNTARY = NO
CONTEXT = {}

[kyc-measure-form-vqf-902.11]
CHECK_NAME = form-vqf-902.11
PROGRAM = tops-check-controlling-entity
VOLUNTARY = NO
CONTEXT = {}

# FIXME: #9825
#[kyc-measure-form-vqf-902.12]
#CHECK_NAME = form-vqf-902.12
#PROGRAM = preserve-investigate
#VOLUNTARY = NO
#CONTEXT = {}

# FIXME: #9827
#[kyc-measure-form-vqf-902.13]
#CHECK_NAME = form-vqf-902.13
#PROGRAM = preserve-investigate
#VOLUNTARY = NO
#CONTEXT = {}

# FIXME: #9826
#[kyc-measure-form-vqf-902.15]
#CHECK_NAME = form-vqf-902.15
#PROGRAM = preserve-investigate
#VOLUNTARY = NO
#CONTEXT = {}

# ##################### KYC checks ###########################

[kyc-check-form-info-internal-error]
TYPE = INFO
DESCRIPTION = "We encountered an internal error. Staff has been notified. Please be patient."
DESCRIPTION_I18N = {"de":"Interner Fehler. Mitarbeiter wurden informiert. Bitte warten."}
FALLBACK = default-investigate

[kyc-check-form-info-investigation]
TYPE = INFO
DESCRIPTION = "Staff is checking your case. Please be patient."
DESCRIPTION_I18N = {"de":"Mitarbeiter prüfen ihren Fall. Bitte warten."}
FALLBACK = default-investigate

[kyc-check-sms-registration]
TYPE = LINK
PROVIDER_ID = sms-challenger
DESCRIPTION = "Confirm Swiss mobile phone number via SMS TAN"
DESCRIPTION_I18N = {"de":"Schweizer Mobiltelefonnummer via SMS TAN bestätigen"}
OUTPUTS = "CONTACT_PHONE"
FALLBACK = default-investigate

[kyc-check-email-registration]
TYPE = LINK
PROVIDER_ID = email-challenger
DESCRIPTION = "Confirm email address via TAN"
DESCRIPTION_I18N = {"de":"Email addresses via TAN bestätigen"}
OUTPUTS = "CONTACT_EMAIL"
FALLBACK = default-investigate

[kyc-check-postal-registration]
TYPE = LINK
PROVIDER_ID = postal-challenger
DESCRIPTION = "Register Swiss postal address via TAN letter"
DESCRIPTION_I18N = {"de":"Schweizer Addresses via TAN Brief bestätigen"}
OUTPUTS = "CONTACT_NAME ADDRESS_LINES ADDRESS_COUNTRY"
FALLBACK = default-investigate

# This check can be triggered by AML programs and/or AML officers,
# it do not appear directly in this configuration as it is triggered
# only indirectly.
[kyc-check-kycaid-individual]
TYPE = LINK
PROVIDER_ID = kycaid-individual
DESCRIPTION = "Provider personal identification data via KYCAID provider"
DESCRIPTION_I18N = {"de":"Persönliche Identifikation via KYCAID Service druchführen"}
OUTPUTS = "PERSON_FULL_NAME PERSON_DATE_OF_BIRTH PERSON_NATIONALITY_CC ADDRESS_STREET ADDRESS_TOWN_LOCATION ADDRESS_ZIPCODE ADDRESS_COUNTRY_CC PERSON_NATIONAL_ID_SCAN TAX_ID"
FALLBACK = default-investigate

# This check can be triggered by AML programs and/or AML officers,
# it do not appear directly in this configuration as it is triggered
# only indirectly.
[kyc-check-kycaid-business]
TYPE = LINK
PROVIDER_ID = kycaid-business
DESCRIPTION = "Provide business identification via KYCAID provider"
DESCRIPTION_I18N = {"de":"Geschäftsidentifikation via KYCAID durchführen"}
# FIXME: correct output labels? FIXME: questionable we can get those from KYCAID...
# FIXME: lower case names are missing in GANA
OUTPUTS = "BUSINESS_NAME ADDRESS_STREET ADDRESS_TOWN_LOCATION ADDRESS_ZIPCODE ADDRESS_COUNTRY_CC company_identification_document power_of_atorney_document BUSINESS_REGISTRATION_ID business_registration_document registration_authority_name tops_controlling_owner_identifications"
FALLBACK = default-investigate

# FIXME: consider moving these into the exchange default config!
[kyc-check-form-accept-tos]
TYPE = FORM
FORM_NAME = accept-tos
DESCRIPTION = "Accept Taler Operations terms of service"
DESCRIPTION_I18N = {"de":"Geschäftsbedingungen akzeptieren"}
# This form field must be set to the etag (!) of the accepted /terms!
OUTPUTS = ACCEPTED_TERMS_OF_SERVICE
FALLBACK = preserve-investigate

[kyc-check-form-vqf-902.1]
TYPE = FORM
FORM_NAME = vqf_902_1_customer
DESCRIPTION = "Supply VQF form 902.1"
DESCRIPTION_I18N = {"de":"Formular VQF 902.1 hochladen"}
OUTPUTS = CUSTOMER_TYPE CUSTOMER_TYPE_VQF
# OPTIONAL: NAME, ADDRESS, ID DOCS, ETC. DEPENDING ON LEGAL ENTITY TYPE
# => aml program will decide on legal entity type between no more forms
# or vqf_902_9, 11, 12, 13, 15. => after that, AML officer
FALLBACK = preserve-investigate

[kyc-check-form-vqf-902.9]
TYPE = FORM
FORM_NAME = vqf_902_9_customer
DESCRIPTION = "Supply VQF form 902.9"
DESCRIPTION_I18N = {"de":"Formular VQF 902.9 hochladen"}
OUTPUTS = IDENTITY_CONTRACTING_PARTNER IDENTITY_LIST
FALLBACK = preserve-investigate

[kyc-check-form-vqf-902.11]
TYPE = FORM
FORM_NAME = vqf_902_11_customer
DESCRIPTION = "Supply VQF form 902.11"
DESCRIPTION_I18N = {"de":"Formular VQF 902.11 hochladen"}
OUTPUTS = IDENTITY_CONTRACTING_PARTNER CONTROL_REASON IDENTITY_LIST THIRD_PARTY_OWNERSHIP
FALLBACK = preserve-investigate

#[kyc-check-form-vqf-902.12]
#TYPE = FORM
# FIXME #9025: This form will not be supported for the TOPS MVP
#FORM_NAME = vqf_902_12
#DESCRIPTION = "Supply VQF form 902.12"
#DESCRIPTION_I18N = {"de":"Formular VQF 902.12 hochladen"}
# FIXME: list correct outputs for each form here (and update GANA)
#OUTPUTS = LEGAL_ENTITY_TYPE
#FALLBACK = preserve-investigate

#[kyc-check-form-vqf-902.13]
#TYPE = FORM
# FIXME: #9827 : This form will not be supported for the TOPS MVP
#FORM_NAME = vqf_902_13
#DESCRIPTION = "Supply VQF form 902.13"
#DESCRIPTION_I18N = {"de":"Formular VQF 902.13 hochladen"}
# FIXME: list correct outputs for each form here (and update GANA)
#OUTPUTS = LEGAL_ENTITY_TYPE
#FALLBACK = preserve-investigate

#[kyc-check-form-vqf-902.15]
#TYPE = FORM
# FIXME: #9826:  This form will not be supported for the TOPS MVP
#FORM_NAME = vqf_902_15
#DESCRIPTION = "Supply VQF form 902.15"
#DESCRIPTION_I18N = {"de":"Formular VQF 902.15 hochladen"}
# FIXME: list correct outputs for each form here (and update GANA)
#OUTPUTS = LEGAL_ENTITY_TYPE
#FALLBACK = preserve-investigate

[kyc-measure-preserve-investigate]
TYPE = SKIP
CONTEXT = {}
PROGRAM = preserve-investigate

[kyc-measure-default-investigate]
TYPE = SKIP
CONTEXT = {}
PROGRAM = default-investigate


# ##################### AML programs #########################

[aml-program-freeze-investigate]
DESCRIPTION = "Fallback measure on errors that freezes the account and asks AML staff to investigate the system failure."
COMMAND = taler-exchange-helper-measure-freeze
ENABLED = YES
FALLBACK = freeze-investigate

[aml-program-default-investigate]
DESCRIPTION = "Fallback measure on errors that keeps default rules on the account but asks AML staff to investigate the system failure."
COMMAND = taler-exchange-helper-measure-defaults-but-investigate
ENABLED = YES
FALLBACK = freeze-investigate

[aml-program-preserve-investigate]
DESCRIPTION = "Fallback measure on errors that preserves current rules on the account but asks AML staff to investigate the system failure."
COMMAND = taler-exchange-helper-measure-preserve-but-investigate
ENABLED = YES
FALLBACK = freeze-investigate

[aml-program-inform-investigate]
DESCRIPTION = "Measure that asks AML staff to investigate an account and informs the account owner about it."
COMMAND = taler-exchange-helper-measure-inform-investigate
ENABLED = YES
FALLBACK = freeze-investigate

[aml-program-challenger-postal-from-context]
DESCRIPTION = "Measure to validate a postal address given in the context. Optionally, a 'prog_name' given in the context can be used to automatically follow up with another AML program. By default, the AML program run after address validation is 'inform-investigate'"
COMMAND = taler-exchange-helper-measure-challenger-postal-context-check
ENABLED = YES
FALLBACK = freeze-investigate

[aml-program-challenger-sms-from-context]
DESCRIPTION = "Measure to validate an SMS phone number given in the context. Optionally, a 'prog_name' given in the context can be used to automatically follow up with another AML program. By default, the AML program run after address validation is 'inform-investigate'"
COMMAND = taler-exchange-helper-measure-challenger-sms-context-check
ENABLED = YES
FALLBACK = freeze-investigate

[aml-program-challenger-email-from-context]
DESCRIPTION = "Measure to validate an email address given in the context. Optionally, a 'prog_name' given in the context can be used to automatically follow up with another AML program. By default, the AML program run after address validation is 'inform-investigate'"
COMMAND = taler-exchange-helper-measure-challenger-email-context-check
ENABLED = YES
FALLBACK = freeze-investigate


# this program should require context 'tos_url' and 'provider_name'
# and require attribute "ACCEPTED_TERMS_OF_SERVICE"
[aml-program-check-tos]
DESCRIPTION = "AML program that enables functions after the ToS have been accepted."
COMMAND = taler-exchange-helper-measure-validate-accepted-tos
ENABLED = YES
FALLBACK = freeze-investigate

[aml-program-clear-measure-and-continue]
DESCRIPTION = "AML program that clears a measure 'clear_measure' and continues with another AML binary 'exec_name' with context 'next_context', all of which must be given in the context."
COMMAND = taler-exchange-helper-measure-clear-continue
ENABLED = YES
FALLBACK = freeze-investigate


[aml-program-preserve-set-expire-from-context]
DESCRIPTION = "Measure that preserves the current rules but sets them to expire based on the context. The successor measure to activate on expiration can also be specified in the context. Useful when AML staff merely wants to set an expiration date."
COMMAND = taler-exchange-helper-measure-preserve-set-expiration
ENABLED = YES
FALLBACK = freeze-investigate

[aml-program-preserve-set-expire-from-context]
DESCRIPTION = "Measure that modifies the current rules by combining them with those from the context. The expiration time and successor measure to activate on expiration can also be specified in the context. Useful when AML staff merely wants to update rules."
COMMAND = taler-exchange-helper-measure-update-from-context
ENABLED = YES
FALLBACK = freeze-investigate

[aml-program-tops-sms-check]
DESCRIPTION = "Program that checks that the user was able to receive an SMS at a Swiss mobile phone number. Enables receiving P2P payments by lifiting kyc-rule-p2p-domestic-identification-requirement and also lifts the kyc-rule-withdraw-limit-low. The new rules expire after 2 years."
COMMAND = taler-exchange-helper-measure-tops-sms-check
ENABLED = YES
FALLBACK = freeze-investigate

[aml-program-tops-postal-check]
DESCRIPTION = "Program that checks that the user was able to postal mail at a Swiss postal address. Enables receiving P2P payments by lifiting kyc-rule-p2p-domestic-identification-requirement and also lifts the kyc-rule-withdraw-limit-low. The new rules expire after 5 years."
COMMAND = taler-exchange-helper-measure-tops-postal-check
ENABLED = YES
FALLBACK = freeze-investigate

[aml-program-tops-kyx-check]
DESCRIPTION = "Program that determines what kind of KYC/KYB process should be run based on a first form supplied by the user. Determines the next checks to run. Always concludes by passing all results to an AML officer. Rules are preserved."
COMMAND = taler-exchange-helper-measure-tops-kyx-check
ENABLED = YES
FALLBACK = freeze-investigate

[aml-program-tops-check-controlling-entity]
DESCRIPTION = "Program that checks if the 'Controlling entity 3rd persion' checkbox was set, and if so triggers the optional form VQF 902.9. Then in either case ensures we run the address validation logic. Always concludes by passing all results to an AML officer. Rules are preserved."
COMMAND = taler-exchange-helper-measure-tops-3rdparty-check
ENABLED = YES
FALLBACK = freeze-investigate

`;

const topsProvidersTestConf = `
[kyc-provider-postal-challenger]
LOGIC = oauth2
KYC_OAUTH2_VALIDITY = 2 years
KYC_OAUTH2_AUTHORIZE_URL = http://localhost:6001/authorize#setup
KYC_OAUTH2_TOKEN_URL = http://localhost:6001/token
KYC_OAUTH2_INFO_URL = http://localhost:6001/info
KYC_OAUTH2_CLIENT_ID = test-postal-id
KYC_OAUTH2_CLIENT_SECRET = test-postal-secret
KYC_OAUTH2_POST_URL = http://localhost:6001/done
KYC_OAUTH2_CONVERTER_HELPER = taler-exchange-kyc-challenger-postal-converter
KYC_OAUTH2_DEBUG_MODE = YES

[kyc-provider-sms-challenger]
LOGIC = oauth2
KYC_OAUTH2_VALIDITY = 2 years
KYC_OAUTH2_AUTHORIZE_URL = http://localhost:6002/authorize#setup
KYC_OAUTH2_TOKEN_URL = http://localhost:6002/token
KYC_OAUTH2_INFO_URL = http://localhost:6002/info
KYC_OAUTH2_CLIENT_ID = test-sms-id
KYC_OAUTH2_CLIENT_SECRET = test-sms-secret
KYC_OAUTH2_POST_URL = http://localhost:6002/done
KYC_OAUTH2_CONVERTER_HELPER = taler-exchange-kyc-challenger-sms-converter
KYC_OAUTH2_DEBUG_MODE = YES

[kyc-provider-email-challenger]
LOGIC = oauth2
KYC_OAUTH2_VALIDITY = 2 years
KYC_OAUTH2_AUTHORIZE_URL = http://localhost:6003/authorize#setup
KYC_OAUTH2_TOKEN_URL = http://localhost:6003/token
KYC_OAUTH2_INFO_URL = http://localhost:6003/info
KYC_OAUTH2_CLIENT_ID = test-email-id
KYC_OAUTH2_CLIENT_SECRET = test-email-secret
KYC_OAUTH2_POST_URL = http://localhost:6003/done
KYC_OAUTH2_CONVERTER_HELPER = taler-exchange-kyc-challenger-email-converter
KYC_OAUTH2_DEBUG_MODE = YES

[kyc-provider-kycaid-business]
LOGIC = kycaid
KYC_KYCAID_VALIDITY = forever
KYC_KYCAID_AUTH_TOKEN = test-kycaid-access-token
# FIXME: correct converter? business should differ!
KYC_KYCAID_CONVERTER_HELPER = taler-exchange-kyc-kycaid-converter.sh
KYC_KYCAID_FORM_ID = form_business
KYC_KYCAID_POST_URL = http://localhost:6004/done

[kyc-provider-kycaid-individual]
LOGIC = kycaid
KYC_KYCAID_VALIDITY = forever
KYC_KYCAID_AUTH_TOKEN = test-kycaid-access-token
# FIXME: correct converter? business should differ!
KYC_KYCAID_CONVERTER_HELPER = taler-exchange-kyc-kycaid-converter.sh
KYC_KYCAID_FORM_ID = form_individual
KYC_KYCAID_POST_URL = http://localhost:6005/done
`;

export interface TopsTestEnv {
  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;
  officerAcc: OfficerAccount;
  bank: BankServiceHandle;
}

export async function createTopsEnvironment(
  t: GlobalTestState,
): Promise<TopsTestEnv> {
  const db = await setupDb(t);

  let coinConfig: CoinConfig[];
  coinConfig = defaultCoinConfig.map((x) => x("CHF"));

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

  const exchange = ExchangeService.create(t, {
    name: "testexchange-1",
    currency: "CHF",
    httpPort: 8081,
    database: db.connStr,
    // FIXME: This is a terrible way to configure the exchange, should be moved into config.
    extraProcEnv: {
      EXCHANGE_AML_PROGRAM_TOPS_ENABLE_DEPOSITS_TOS_NAME: "v1",
      EXCHANGE_AML_PROGRAM_TOPS_ENABLE_DEPOSITS_THRESHOLD: "CHF:0",
      EXCHANGE_AML_PROGRAM_TOPS_POSTAL_CHECK_COUNTRY_REGEX: "ch|CH|Ch",
    },
  });

  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);

  await exchange.modifyConfig(async (config) => {
    config.loadFromString(topsKycRulesConf);
    config.loadFromString(topsProvidersTestConf);
  });

  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,
  });
  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);

  await merchant.start();

  const merchantAdminPayto = getTestHarnessPaytoForLabel("merchant-default");

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

  await bankClient.registerAccountExtended({
    name: "merchant-default",
    password: encodeCrock(getRandomBytes(32)),
    username: "merchant-default",
    payto_uri: merchantAdminPayto,
  });

  const merchantInstId = "minst1";
  const merchantInstPaytoUri = getTestHarnessPaytoForLabel(merchantInstId);

  await merchant.addInstanceWithWireAccount(
    {
      id: merchantInstId,
      name: merchantInstId,
      paytoUris: [merchantInstPaytoUri],
      defaultWireTransferDelay: TalerProtocolDuration.fromSpec({ minutes: 1 }),
    },
    { adminAccessToken },
  );

  await bankClient.registerAccountExtended({
    name: merchantInstId,
    password: encodeCrock(getRandomBytes(32)),
    username: merchantInstId,
    payto_uri: merchantInstPaytoUri,
  });

  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,
  });

  const officerAcc: OfficerAccount = {
    id: amlKeypair.pub as OfficerId,
    signingKey: decodeCrock(amlKeypair.priv),
  };

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

export async function doFakeChallenger(
  t: GlobalTestState,
  args: {
    exchangeClient: TalerExchangeHttpClient;
    requirementId: string;
    challenger: TestfakeChallengerService;
    address: any;
  },
): Promise<{ setupRequest: any }> {
  const { exchangeClient, challenger, requirementId } = args;
  const startResp = succeedOrThrow(
    await exchangeClient.startExternalKycProcess(requirementId, {}),
  );
  console.log(`start resp`, j2s(startResp));

  let challengerRedirectUrl = startResp.redirect_url;

  const resp = await harnessHttpLib.fetch(challengerRedirectUrl);
  const respJson = await resp.json();
  console.log(`challenger resp: ${j2s(respJson)}`);

  const nonce = respJson.nonce;
  t.assertTrue(typeof nonce === "string");
  const proofRedirectUrl = respJson.redirect_url;

  challenger.fakeVerification(nonce, args.address);

  console.log("nonce", nonce);
  console.log("proof redirect URL", proofRedirectUrl);

  const proofResp = await harnessHttpLib.fetch(proofRedirectUrl, {
    redirect: "manual",
  });
  console.log("proof status:", proofResp.status);
  t.assertDeepEqual(proofResp.status, 303);

  const setupRequest = challenger.getSetupRequest(nonce);
  console.log(`setup request: ${j2s(setupRequest)}`);
  return {
    setupRequest,
  };
}

export async function doTopsKycAuth(
  t: GlobalTestState,
  args: {
    merchantClient: TalerMerchantInstanceHttpClient;
    merchantAdminAccessToken: AccessToken;
    exchangeBankAccount: HarnessExchangeBankAccount;
    wireGatewayApi: TalerWireGatewayHttpClient;
    bank: BankServiceHandle;
  },
): Promise<{ accessToken: AccessToken; merchantPaytoHash: string }> {
  const {
    merchantClient,
    wireGatewayApi,
    exchangeBankAccount,
    merchantAdminAccessToken,
  } = args;
  {
    let kycBody: MerchantAccountKycRedirectsResponse | undefined;
    while (kycBody == null) {
      const kycStatus = await merchantClient.getCurrentInstanceKycStatus(
        merchantAdminAccessToken,
        {},
      );

      console.log(`kyc status: ${j2s(kycStatus)}`);

      t.assertDeepEqual(kycStatus.case, "ok");

      t.assertTrue(kycStatus.body != null);

      if (
        kycStatus.body.kyc_data[0].status ===
        MerchantAccountKycStatus.EXCHANGE_UNREACHABLE
      ) {
        logger.info(`merchant claims exchange is still unreachable for KYC`);
        await waitMs(500);
      } else {
        t.assertDeepEqual(
          kycStatus.body.kyc_data[0].status,
          "kyc-wire-required",
        );
        kycBody = kycStatus.body;
      }
    }

    const depositPaytoUri = kycBody.kyc_data[0].payto_uri;
    t.assertTrue(kycBody.kyc_data[0].payto_kycauths != null);
    const authTxPayto = parsePaytoUriOrThrow(
      kycBody.kyc_data[0]?.payto_kycauths[0],
    );
    const authTxMessage = authTxPayto?.params["message"];
    t.assertTrue(typeof authTxMessage === "string");
    t.assertTrue(authTxMessage.startsWith("KYC:"));
    const accountPub = authTxMessage.substring(4);
    logger.info(`merchant account pub: ${accountPub}`);
    await wireGatewayApi.addKycAuth({
      auth: args.bank.getAdminAuth(),
      body: {
        amount: "CHF:0.1",
        debit_account: depositPaytoUri,
        account_pub: accountPub,
      },
    });
  }

  let accessToken: AccessToken;
  let merchantPaytoHash: string;

  // Wait for auth transfer to be registered by the exchange
  {
    const kycStatus = await merchantClient.getCurrentInstanceKycStatus(
      merchantAdminAccessToken,
      {
        reason: KycStatusLongPollingReason.AUTH_TRANSFER,
        timeout: 30000,
      },
    );
    logger.info(`kyc status after transfer: ${j2s(kycStatus)}`);
    t.assertDeepEqual(kycStatus.case, "ok");
    t.assertTrue(kycStatus.body != null);
    t.assertDeepEqual(kycStatus.body.kyc_data[0].status, "kyc-required");
    t.assertTrue(typeof kycStatus.body.kyc_data[0].access_token === "string");
    accessToken = kycStatus.body.kyc_data[0].access_token as AccessToken;
    merchantPaytoHash = encodeCrock(
      hashNormalizedPaytoUri(kycStatus.body.kyc_data[0].payto_uri),
    );
    return { accessToken, merchantPaytoHash };
  }
}

export async function doTopsAcceptTos(
  t: GlobalTestState,
  args: {
    accessToken: AccessToken;
    exchangeClient: TalerExchangeHttpClient;
    merchantClient: TalerMerchantInstanceHttpClient;
    merchantAdminAccessToken: AccessToken;
    merchant: MerchantService;
  },
): Promise<void> {
  const {
    exchangeClient,
    merchant,
    merchantClient,
    accessToken,
    merchantAdminAccessToken,
  } = args;
  {
    const kycInfo = await exchangeClient.checkKycInfo(
      accessToken,
      undefined,
      undefined,
    );
    console.log(j2s(kycInfo));

    t.assertDeepEqual(kycInfo.case, "ok");
    t.assertDeepEqual(kycInfo.body.requirements.length, 1);
    t.assertDeepEqual(kycInfo.body.requirements[0].form, "accept-tos");
    const requirementId = kycInfo.body.requirements[0].id;
    t.assertTrue(typeof requirementId === "string");

    const uploadRes = await exchangeClient.uploadKycForm(requirementId, {
      FORM_ID: "accept-tos",
      FORM_VERSION: 1,
      ACCEPTED_TERMS_OF_SERVICE: "v1",
      DOWNLOADED_TERMS_OF_SERVICE: true,
    });
    console.log("upload res", uploadRes);
    t.assertDeepEqual(uploadRes.case, "ok");
  }

  {
    const kycInfo = await exchangeClient.checkKycInfo(
      accessToken,
      undefined,
      undefined,
    );
    console.log(j2s(kycInfo));

    // FIXME: Do we expect volunary measures here?
    // => not yet, see https://bugs.gnunet.org/view.php?id=9879
  }

  await merchant.runKyccheckOnce();

  {
    const kycStatus = await merchantClient.getCurrentInstanceKycStatus(
      merchantAdminAccessToken,
      {
        reason: KycStatusLongPollingReason.AUTH_TRANSFER,
        timeout: 30000,
      },
    );
    logger.info(`kyc status after accept-tos: ${j2s(kycStatus)}`);
  }
}

/**
 * Helpers for the test.
 */
export interface MeasuresTestEnvironment {
  exchange: ExchangeService;
  expectInfo: () => Promise<void>;
  expectFrozen: () => Promise<void>;
  expectNotFrozen: () => Promise<void>;
  expectInvestigate: () => Promise<void>;
  expectNoInvestigate: () => Promise<void>;
  fakeChallenger: (
    challenger: TestfakeChallengerService,
    address: any,
  ) => Promise<void>;
  submitForm: (form: string, data: any) => Promise<void>;
  submitOfficerForm: (form: string, data: any) => Promise<void>;
  decideMeasure: (measure: string) => Promise<{
    currentDecision: AmlDecision;
  }>;
  decideReset: () => Promise<void>;
  challengerPostal: TestfakeChallengerService;
  challengerSms: TestfakeChallengerService;
  accountPaytoHash: string;
  officerAcc: OfficerAccount;
  exchangeClient: TalerExchangeHttpClient;
}

export async function setupMeasuresTestEnvironment(
  t: GlobalTestState,
): Promise<MeasuresTestEnvironment> {
  // Set up test environment
  const {
    exchange,
    officerAcc,
    merchant,
    exchangeBankAccount,
    wireGatewayApi,
    merchantAdminAccessToken,
    bank,
  } = await createTopsEnvironment(t);

  const challengerPostal = await startFakeChallenger({
    port: 6001,
    addressType: "postal-ch",
  });
  const challengerSms = await startFakeChallenger({
    port: 6002,
    addressType: "phone",
  });

  const merchantClient = new TalerMerchantInstanceHttpClient(
    merchant.makeInstanceBaseUrl(),
  );
  const exchangeClient = new TalerExchangeHttpClient(exchange.baseUrl, {
    httpClient: harnessHttpLib,
  });

  // Do KYC auth transfer
  const { accessToken, merchantPaytoHash } = await doTopsKycAuth(t, {
    merchantClient,
    merchantAdminAccessToken,
    exchangeBankAccount,
    wireGatewayApi,
    bank,
  });

  const myTriggerMeasure = (measure: string) =>
    doTriggerMeasure(t, {
      officerAcc,
      exchangeClient,
      merchantPaytoHash,
      measure,
    });

  const myTriggerReset = () =>
    doTriggerReset(t, {
      officerAcc,
      exchangeClient,
      merchantPaytoHash,
    });

  const submitForm = async (form: string, data: any) => {
    const kycInfoResp = await exchangeClient.checkKycInfo(accessToken);
    t.assertDeepEqual(kycInfoResp.case, "ok");
    t.assertDeepEqual(kycInfoResp.body.requirements[0].form, form);
    const requirementId = kycInfoResp.body.requirements[0].id;
    t.assertTrue(typeof requirementId === "string");
    const uploadRes = await exchangeClient.uploadKycForm(requirementId, data);
    console.log("upload res", uploadRes);
    t.assertDeepEqual(uploadRes.case, "ok");
  };

  const submitOfficerForm = async (form: string, data: any) => {
    const decisionsResp = succeedOrThrow(
      await exchangeClient.getAmlDecisions(officerAcc, {
        active: true,
      }),
    );
    console.log(`existing decision:`, j2s(decisionsResp));

    const currDec = decisionsResp.records[0];

    succeedOrThrow(
      await exchangeClient.makeAmlDesicion(officerAcc, {
        decision_time: TalerProtocolTimestamp.now(),
        h_payto: merchantPaytoHash,
        justification: "bla",
        properties: currDec.properties || {},
        keep_investigating: currDec.to_investigate,
        new_rules: currDec.limits,
        attributes: data,
      }),
    );
  };

  const fakeChallenger = async (
    challenger: TestfakeChallengerService,
    address: any,
  ) => {
    {
      const kycInfoResp = await exchangeClient.checkKycInfo(accessToken);
      console.log(
        `kyc info after my-postal-registration measure`,
        j2s(kycInfoResp),
      );
      t.assertDeepEqual(kycInfoResp.case, "ok");
      const kycInfo = kycInfoResp.body;
      t.assertDeepEqual(kycInfo.requirements[0].form, "LINK");
      t.assertTrue(typeof kycInfo.requirements[0].id === "string");
      t.assertDeepEqual(kycInfo.requirements.length, 1);

      const startResp = succeedOrThrow(
        await exchangeClient.startExternalKycProcess(
          kycInfo.requirements[0].id,
          {},
        ),
      );
      console.log(`start resp`, j2s(startResp));

      let challengerRedirectUrl = startResp.redirect_url;

      const resp = await harnessHttpLib.fetch(challengerRedirectUrl);
      const respJson = await resp.json();
      console.log(`challenger resp: ${j2s(respJson)}`);

      const nonce = respJson.nonce;
      t.assertTrue(typeof nonce === "string");
      const proofRedirectUrl = respJson.redirect_url;

      challenger.fakeVerification(nonce, address);

      console.log("nonce", nonce);
      console.log("proof redirect URL", proofRedirectUrl);

      const proofResp = await harnessHttpLib.fetch(proofRedirectUrl, {
        redirect: "manual",
      });
      console.log("proof status:", proofResp.status);
      if (proofResp.status === 404) {
        console.log(j2s(await proofResp.text()));
      }
      t.assertDeepEqual(proofResp.status, 303);

      const setupReq = challenger.getSetupRequest(nonce);
      console.log(`setup request: ${j2s(setupReq)}`);
    }
  };

  const expectInfo = async () => {
    const kycInfoResp = await exchangeClient.checkKycInfo(
      accessToken,
      undefined,
      undefined,
    );
    t.assertDeepEqual(kycInfoResp.case, "ok");
    t.assertDeepEqual(kycInfoResp.body.requirements[0].form, "INFO");
  };

  const getCurrentDecision = async () => {
    const decisionsResp = succeedOrThrow(
      await exchangeClient.getAmlDecisions(officerAcc, {
        active: true,
      }),
    );
    console.log(j2s(decisionsResp));
    return decisionsResp.records[0];
  };

  return {
    exchange,
    challengerPostal,
    challengerSms,
    accountPaytoHash: merchantPaytoHash,
    expectInfo,
    fakeChallenger,
    decideMeasure: myTriggerMeasure,
    decideReset: myTriggerReset,
    submitForm,
    submitOfficerForm,
    async expectFrozen() {
      const dec = await getCurrentDecision();
      t.assertTrue(isFrozen(dec));
    },
    async expectNotFrozen() {
      const dec = await getCurrentDecision();
      t.assertTrue(!isFrozen(dec));
    },
    async expectInvestigate() {
      const dec = await getCurrentDecision();
      t.assertDeepEqual(dec.to_investigate, true);
    },
    async expectNoInvestigate() {
      const dec = await getCurrentDecision();
      t.assertDeepEqual(dec.to_investigate, false);
    },
    officerAcc,
    exchangeClient,
  };
}

export function isFrozen(decision: AmlDecision): boolean {
  const txTypes = new Set<string>();
  for (const r of decision.limits.rules) {
    if (!Amounts.isZero(r.threshold)) {
      return false;
    }
    txTypes.add(r.operation_type);
  }
  if (
    !(
      txTypes.has("WITHDRAW") &&
      txTypes.has("DEPOSIT") &&
      txTypes.has("AGGREGATE") &&
      txTypes.has("MERGE") &&
      txTypes.has("BALANCE") &&
      txTypes.has("CLOSE") &&
      txTypes.has("TRANSACTION") &&
      txTypes.has("REFUND")
    )
  ) {
    return false;
  }
  return true;
}

async function doTriggerReset(
  t: GlobalTestState,
  args: {
    officerAcc: OfficerAccount;
    exchangeClient: TalerExchangeHttpClient;
    merchantPaytoHash: string;
  },
): Promise<void> {
  const { officerAcc, exchangeClient, merchantPaytoHash } = args;
  const config = new Configuration(ConfigSources["taler-exchange"]);
  config.loadFromString(topsKycRulesConf);
  const rules: KycRule[] = [];
  for (const secName of config.getSectionNames()) {
    const rulePrefix = "kyc-rule-";
    if (!secName.toLowerCase().startsWith(rulePrefix)!) {
      continue;
    }
    const enabled = config.getYesNo(secName, "ENABLED").required();
    if (!enabled) {
      continue;
    }
    console.log("duration", config.getString(secName, "TIMEFRAME").required());
    rules.push({
      display_priority: 0,
      measures: config
        .getString(secName, "NEXT_MEASURES")
        .required()
        .split(/[ ]+/),
      operation_type: config
        .getString(secName, "OPERATION_TYPE")
        .required() as LimitOperationType,
      threshold: config
        .getString(secName, "THRESHOLD")
        .required() as AmountString,
      timeframe: Duration.toTalerProtocolDuration(
        Duration.fromPrettyString(
          config.getString(secName, "TIMEFRAME").required(),
        ),
      ),
      exposed: config.getYesNo(secName, "EXPOSED").required(),
      is_and_combinator: config
        .getYesNo(secName, "IS_AND_COMBINATIOR")
        .orDefault(false),
      rule_name: secName.substring(rulePrefix.length).toLowerCase(),
    });
  }
  succeedOrThrow(
    await exchangeClient.makeAmlDesicion(officerAcc, {
      decision_time: TalerProtocolTimestamp.now(),
      h_payto: merchantPaytoHash,
      justification: "reset",
      properties: {},
      keep_investigating: false,
      new_rules: {
        custom_measures: {},
        expiration_time: TalerProtocolTimestamp.never(),
        rules,
      },
    }),
  );
}

async function doTriggerMeasure(
  t: GlobalTestState,
  args: {
    officerAcc: OfficerAccount;
    exchangeClient: TalerExchangeHttpClient;
    merchantPaytoHash: string;
    measure: string;
  },
): Promise<{ currentDecision: AmlDecision }> {
  const { officerAcc, exchangeClient, merchantPaytoHash } = args;
  const decisionsResp = succeedOrThrow(
    await exchangeClient.getAmlDecisions(officerAcc, {
      active: true,
    }),
  );
  console.log(j2s(decisionsResp));

  let toInvestigate: boolean;
  let properties;
  let rules: KycRule[];

  if (decisionsResp.records.length == 0) {
    toInvestigate = false;
    properties = {};
    rules = [];
  } else {
    t.assertDeepEqual(decisionsResp.records.length, 1);
    const rec = decisionsResp.records[0];
    t.assertDeepEqual(merchantPaytoHash, rec.h_payto);
    toInvestigate = rec.to_investigate;
    properties = rec.properties ?? {};
    rules = rec.limits.rules;
  }

  succeedOrThrow(
    await exchangeClient.makeAmlDesicion(officerAcc, {
      decision_time: TalerProtocolTimestamp.now(),
      h_payto: merchantPaytoHash,
      justification: "bla",
      properties: properties,
      keep_investigating: toInvestigate,
      new_measures: args.measure,
      new_rules: {
        custom_measures: {},
        expiration_time: TalerProtocolTimestamp.never(),
        rules,
      },
    }),
  );

  const decisionsRespAfter = succeedOrThrow(
    await exchangeClient.getAmlDecisions(officerAcc, {
      active: true,
    }),
  );

  t.assertDeepEqual(decisionsRespAfter.records.length, 1);
  return {
    currentDecision: decisionsRespAfter.records[0],
  };
}
