/*
 This file is part of GNU Taler
 (C) 2022 Taler Systems SA

 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.

 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
 TALER; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
 */

/**
 * Implementation of dev experiments, i.e. scenarios
 * triggered by taler://dev-experiment URIs.
 *
 * @author Florian Dold <dold@taler.net>
 */

/**
 * Imports.
 */

import {
  AbsoluteTime,
  Amounts,
  ContractTermsUtil,
  DenomLossEventType,
  DevExperimentUri,
  Duration,
  Logger,
  MerchantContractTermsV0,
  MerchantContractVersion,
  PeerContractTerms,
  RefreshReason,
  TalerError,
  TalerErrorCode,
  TalerPreciseTimestamp,
  encodeCrock,
  getRandomBytes,
  j2s,
  parseDevExperimentUri,
} from "@gnu-taler/taler-util";
import {
  HttpRequestLibrary,
  HttpRequestOptions,
  HttpResponse,
} from "@gnu-taler/taler-util/http";
import { PendingTaskType, constructTaskIdentifier } from "./common.js";
import {
  DenomLossEventRecord,
  DenomLossStatus,
  PeerPushCreditStatus,
  PeerPushDebitStatus,
  PurchaseStatus,
  RefreshGroupRecord,
  RefreshOperationStatus,
  WithdrawalGroupStatus,
  WithdrawalRecordType,
  timestampPreciseToDb,
  timestampProtocolToDb,
} from "./db.js";
import {
  DenomLossTransactionContext,
  fetchFreshExchange,
} from "./exchanges.js";
import { PayMerchantTransactionContext } from "./pay-merchant.js";
import { PeerPushCreditTransactionContext } from "./pay-peer-push-credit.js";
import { PeerPushDebitTransactionContext } from "./pay-peer-push-debit.js";
import { RefreshTransactionContext } from "./refresh.js";
import { rematerializeTransactions } from "./transactions.js";
import {
  DevExperimentState,
  WalletExecutionContext,
  handleAddGlobalCurrencyExchange,
  handleRemoveGlobalCurrencyExchange,
} from "./wallet.js";
import { WithdrawTransactionContext } from "./withdraw.js";

const logger = new Logger("dev-experiments.ts");

/**
 * Apply a dev experiment to the wallet database / state.
 */
export async function applyDevExperiment(
  wex: WalletExecutionContext,
  uri: string,
): Promise<void> {
  logger.info(`applying dev experiment ${uri}`);
  const parsedUri = parseDevExperimentUri(uri);
  if (!parsedUri) {
    logger.info("unable to parse dev experiment URI");
    return;
  }
  if (!wex.ws.config.testing.devModeActive) {
    throw Error("can't handle devmode URI unless devmode is active");
  }

  switch (parsedUri.devExperimentId) {
    case "start-block-refresh": {
      wex.ws.devExperimentState.blockRefreshes = true;
      return;
    }
    case "stop-block-refresh": {
      wex.ws.devExperimentState.blockRefreshes = false;
      return;
    }
    case "insert-pending-refresh": {
      const refreshGroupId = encodeCrock(getRandomBytes(32));
      await wex.db.runReadWriteTx(
        { storeNames: ["refreshGroups", "transactionsMeta"] },
        async (tx) => {
          const newRg: RefreshGroupRecord = {
            currency: "TESTKUDOS",
            expectedOutputPerCoin: [],
            inputPerCoin: [],
            oldCoinPubs: [],
            operationStatus: RefreshOperationStatus.Pending,
            reason: RefreshReason.Manual,
            refreshGroupId,
            statusPerCoin: [],
            timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
            timestampFinished: undefined,
            originatingTransactionId: undefined,
            infoPerExchange: {},
            refundRequests: {},
          };
          await tx.refreshGroups.put(newRg);
          const ctx = new RefreshTransactionContext(wex, refreshGroupId);
          await ctx.updateTransactionMeta(tx);
        },
      );
      wex.taskScheduler.startShepherdTask(
        constructTaskIdentifier({
          tag: PendingTaskType.Refresh,
          refreshGroupId,
        }),
      );
      return;
    }
    case "insert-denom-loss": {
      await wex.db.runReadWriteTx(
        { storeNames: ["denomLossEvents", "transactionsMeta"] },
        async (tx) => {
          const eventId = encodeCrock(getRandomBytes(32));
          const newRg: DenomLossEventRecord = {
            amount: "TESTKUDOS:42",
            currency: "TESTKUDOS",
            exchangeBaseUrl: "https://exchange.test.taler.net/",
            denomLossEventId: eventId,
            denomPubHashes: [
              encodeCrock(getRandomBytes(64)),
              encodeCrock(getRandomBytes(64)),
            ],
            eventType: DenomLossEventType.DenomExpired,
            status: DenomLossStatus.Done,
            timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
          };
          await tx.denomLossEvents.put(newRg);
          const ctx = new DenomLossTransactionContext(
            wex,
            newRg.denomLossEventId,
          );
          await ctx.updateTransactionMeta(tx);
        },
      );
      return;
    }
    case "merchant-deposit-insufficient": {
      wex.ws.devExperimentState.merchantDepositInsufficient = true;
      return;
    }
    case "start-fakeprotover": {
      const baseUrl = parsedUri.query?.get("base_url");
      if (!baseUrl) {
        throw Error("base_url required");
      }
      const fakeVer = parsedUri.query?.get("fake_ver");
      if (!fakeVer) {
        throw Error("fake_ver required");
      }
      let fakeSt = wex.ws.devExperimentState.fakeProtoVer;
      if (!fakeSt) {
        fakeSt = wex.ws.devExperimentState.fakeProtoVer = new Map();
      }
      fakeSt.set(baseUrl, {
        fakeVer,
      });
      const fup = parsedUri.query?.get("fup");
      if (fup) {
        try {
          await fetchFreshExchange(wex, baseUrl, {
            forceUpdate: true,
          });
        } catch (e) {
          logger.error(
            `forcing exchange update failed: ${j2s(
              TalerError.fromException(e),
            )}`,
          );
        }
      }
      return;
    }
    case "start-fakemasterpub": {
      const baseUrl = parsedUri.query?.get("base_url");
      if (!baseUrl) {
        throw Error("base_url required");
      }
      const fakePub = parsedUri.query?.get("fake_pub");
      if (!fakePub) {
        throw Error("fake_pub required");
      }
      let fakeSt = wex.ws.devExperimentState.fakeMasterPub;
      if (!fakeSt) {
        fakeSt = wex.ws.devExperimentState.fakeMasterPub = new Map();
      }
      fakeSt.set(baseUrl, {
        fakeMasterPub: fakePub,
      });
      const fup = parsedUri.query?.get("fup");
      if (fup) {
        try {
          await fetchFreshExchange(wex, baseUrl, {
            forceUpdate: true,
          });
        } catch (e) {
          logger.error(
            `forcing exchange update failed: ${j2s(
              TalerError.fromException(e),
            )}`,
          );
        }
      }
      return;
    }
    case "rebuild-transactions": {
      await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
        // Re-build / fix up info about exchanges for purchase transactions.
        await tx.purchases.iter().forEachAsync(async (rec) => {
          let exchangeSet = new Set<string>();
          if (!rec.exchanges || rec.exchanges.length === 0) {
            if (rec.payInfo?.payCoinSelection) {
              const coinPubs = rec.payInfo.payCoinSelection.coinPubs;

              for (const pub of coinPubs) {
                const coin = await tx.coins.get(pub);
                if (!coin) {
                  logger.warn(
                    `coin ${coinPubs} not found, unable to compute full scope`,
                  );
                  continue;
                }
                exchangeSet.add(coin.exchangeBaseUrl);
              }
            }
            rec.exchanges = [...exchangeSet];
            rec.exchanges.sort();
            await tx.purchases.put(rec);
          }
        });
        await rematerializeTransactions(wex, tx);
      });
      return;
    }
    case "stop-fakeprotover": {
      const baseUrl = parsedUri.query?.get("base_url");
      if (!baseUrl) {
        throw Error("base_url required");
      }
      wex.ws.devExperimentState.fakeProtoVer?.delete(baseUrl);
      return;
    }
    case "add-fake-tx": {
      await addFakeTx(wex, parsedUri);
      return;
    }
    case "add-global-exchange": {
      const currency = parsedUri.query?.get("c");
      if (!currency || currency.length === 0) {
        throw Error("missing currency (c)");
      }
      const exchangeBaseUrl = parsedUri.query?.get("e");
      if (!exchangeBaseUrl || exchangeBaseUrl.length === 0) {
        throw Error("missing exchange base URL (e)");
      }
      const exchangeMasterPub = parsedUri.query?.get("p");
      if (!exchangeMasterPub || exchangeMasterPub.length === 0) {
        throw Error("missing exchange master pub (p)");
      }
      await handleAddGlobalCurrencyExchange(wex, {
        currency,
        exchangeBaseUrl,
        exchangeMasterPub,
      });
      return;
    }
    case "remove-global-exchange": {
      const currency = parsedUri.query?.get("c");
      if (!currency || currency.length === 0) {
        throw Error("missing currency (c)");
      }
      const exchangeBaseUrl = parsedUri.query?.get("e");
      if (!exchangeBaseUrl || exchangeBaseUrl.length === 0) {
        throw Error("missing exchange base URL (e)");
      }
      const exchangeMasterPub = parsedUri.query?.get("p");
      if (!exchangeMasterPub || exchangeMasterPub.length === 0) {
        throw Error("missing exchange master pub (p)");
      }
      await handleRemoveGlobalCurrencyExchange(wex, {
        currency,
        exchangeBaseUrl,
        exchangeMasterPub,
      });
      return;
    }
    case "pretend-no-fees": {
      const setVal = parsedUri.query?.get("val");
      let v: boolean;
      if (setVal == null) {
        v = true;
      } else if (setVal === "1") {
        v = true;
      } else if (setVal === "0") {
        v = false;
      } else {
        throw Error("invalid val");
      }
      wex.ws.devExperimentState.pretendNoFees = v;
      return;
    }
    case "pretend-post-wop-failed": {
      // Pretend that POSTing to a withdrawal operation
      // returns a permanent error response.
      const setVal = parsedUri.query?.get("val");
      let v: boolean;
      if (setVal == null) {
        v = true;
      } else if (setVal === "1") {
        v = true;
      } else if (setVal === "0") {
        v = false;
      } else {
        throw Error("invalid val");
      }
      wex.ws.devExperimentState.pretendPostWopFailed = v;
      return;
    }
    case "flag-disable-peer-payments": {
      wex.ws.devExperimentState.flagDisablePeerPayments = getValFlag(parsedUri);
      return;
    }
    case "flag-disable-direct-deposits": {
      const flag = getValFlag(parsedUri);
      const exchange = parsedUri.query?.get("e");
      let mySet = wex.ws.devExperimentState.flagDisableDirectDeposits;
      if (!(flag || exchange)) {
        mySet?.clear();
        return;
      }
      if (!exchange) {
        logger.warn(`${parsedUri.devExperimentId}: no exchange specified`);
        return;
      }
      if (flag) {
        if (!mySet) {
          mySet = wex.ws.devExperimentState.flagDisableDirectDeposits =
            new Set();
        }
        mySet.add(exchange);
      } else {
        mySet?.delete(exchange);
      }
      return;
    }
    case "fake-shopping-url": {
      const url = parsedUri.query?.get("url") ?? undefined;
      wex.ws.devExperimentState.fakeShoppingUrl = url;
      return;
    }
    case "flag-confirm-pay-no-wait": {
      wex.ws.devExperimentState.flagConfirmPayNoWait = getValFlag(parsedUri);
      return;
    }
    case "fake-chf-bban": {
      wex.ws.devExperimentState.fakeChfBban = getValFlag(parsedUri);
      return;
    }
    case "block-pay-response": {
      const val = getValFlag(parsedUri);
      logger.info(`setting dev experiment blockPayResponse=${val}`);
      wex.ws.devExperimentState.blockPayResponse = val;
      return;
    }
    case "pretend-no-denoms": {
      wex.ws.devExperimentState.pretendNoDenoms = true;
      return;
    }
    default:
      throw Error(
        `dev-experiment id not understood ${parsedUri.devExperimentId}`,
      );
  }
}

function getValFlag(parsedUri: DevExperimentUri): boolean {
  const setVal = parsedUri.query?.get("val");
  if (setVal == null) {
    return true;
  } else if (setVal === "0") {
    return false;
  } else if (setVal === "1") {
    return true;
  } else {
    throw Error("param 'val' must be 0 or 1");
  }
}

async function addFakeTx(
  wex: WalletExecutionContext,
  parsedUri: DevExperimentUri,
): Promise<void> {
  const txType = parsedUri.query?.get("txType") ?? "withdrawal";
  const withdrawalGroupId = encodeCrock(getRandomBytes(32));
  const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
  switch (txType) {
    case "withdrawal": {
      const secretSeed = encodeCrock(getRandomBytes(32));
      const reservePair = await wex.ws.cryptoApi.createEddsaKeypair({});
      const exchangeBaseUrl =
        parsedUri.query?.get("exchangeBaseUrl") ??
        "https://exchange.demo.taler.net/";
      const trelStr = parsedUri.query?.get("tRel") ?? undefined;
      const amountEffectiveStr = parsedUri.query?.get("amountEffective");
      if (!amountEffectiveStr) {
        throw Error("missing amountEffective option");
      }
      const amountEffective = Amounts.parseOrThrow(amountEffectiveStr);
      let timestamp: AbsoluteTime;
      if (trelStr) {
        timestamp = AbsoluteTime.subtractDuraction(
          AbsoluteTime.now(),
          Duration.fromPrettyString(trelStr),
        );
      } else {
        timestamp = AbsoluteTime.now();
      }
      await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
        await tx.withdrawalGroups.add({
          reservePriv: reservePair.priv,
          reservePub: reservePair.pub,
          secretSeed,
          status: WithdrawalGroupStatus.Done,
          timestampStart: timestampPreciseToDb(
            AbsoluteTime.toPreciseTimestamp(timestamp),
          ),
          timestampFinish: timestampPreciseToDb(
            AbsoluteTime.toPreciseTimestamp(timestamp),
          ),
          withdrawalGroupId,
          wgInfo: {
            withdrawalType: WithdrawalRecordType.BankManual,
            exchangeCreditAccounts: [
              {
                paytoUri: "payto://x-taler-bank/test/test",
                status: "ok",
              },
            ],
          },
          denomsSel: {
            totalCoinValue: Amounts.stringify(amountEffective),
            hasDenomWithAgeRestriction: false,
            totalWithdrawCost: Amounts.stringify(amountEffective),
            selectedDenoms: [],
          },
          exchangeBaseUrl,
          instructedAmount: Amounts.stringify(amountEffective),
          effectiveWithdrawalAmount: Amounts.stringify(amountEffective),
          rawWithdrawalAmount: Amounts.stringify(amountEffective),
        });
        await ctx.updateTransactionMeta(tx);
      });
      break;
    }
    case "payment": {
      const proposalId = encodeCrock(getRandomBytes(32));
      const ctx = new PayMerchantTransactionContext(wex, proposalId);
      const summary = parsedUri.query?.get("summary") ?? "Test";
      const amountEffectiveStr = parsedUri.query?.get("amountEffective");
      if (!amountEffectiveStr) {
        throw Error("missing amountEffective option");
      }
      const merchantName =
        parsedUri.query?.get("merchantName") ?? "Test Merchant";
      const exchangeBaseUrl =
        parsedUri.query?.get("exchangeBaseUrl") ??
        "https://exchange.demo.taler.net/";
      const merchantBaseUrl =
        parsedUri.query?.get("merchantBaseUrl") ??
        "https://backend.demo.taler.net/";
      const amountEffective = Amounts.parseOrThrow(amountEffectiveStr);
      const trelStr = parsedUri.query?.get("tRel") ?? undefined;
      let timestamp: AbsoluteTime;
      if (trelStr) {
        timestamp = AbsoluteTime.subtractDuraction(
          AbsoluteTime.now(),
          Duration.fromPrettyString(trelStr),
        );
      } else {
        timestamp = AbsoluteTime.now();
      }
      const orderId = encodeCrock(getRandomBytes(8));
      const noncePair = await wex.ws.cryptoApi.createEddsaKeypair({});
      const merchantPub = encodeCrock(getRandomBytes(32));
      const ct: MerchantContractTermsV0 = {
        amount: Amounts.stringify(amountEffective),
        exchanges: [
          {
            master_pub: encodeCrock(getRandomBytes(32)),
            priority: 1,
            url: exchangeBaseUrl,
          },
        ],
        h_wire: encodeCrock(getRandomBytes(64)),
        max_fee: Amounts.stringify(Amounts.zeroOfAmount(amountEffective)),
        timestamp: AbsoluteTime.toProtocolTimestamp(timestamp),
        summary,
        order_id: orderId,
        merchant_base_url: merchantBaseUrl,
        merchant_pub: merchantPub,
        wire_method: "x-taler-bank",
        version: MerchantContractVersion.V0,
        nonce: encodeCrock(getRandomBytes(32)),
        merchant: {
          name: merchantName,
        },
        pay_deadline: AbsoluteTime.toProtocolTimestamp(timestamp),
        refund_deadline: AbsoluteTime.toProtocolTimestamp(timestamp),
        wire_transfer_deadline: AbsoluteTime.toProtocolTimestamp(timestamp),
      };
      const contractTermsHash = ContractTermsUtil.hashContractTerms(ct);
      await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
        await tx.contractTerms.put({
          contractTermsRaw: ct,
          h: contractTermsHash,
        });
        await tx.purchases.put({
          autoRefundDeadline: undefined,
          claimToken: encodeCrock(getRandomBytes(32)),
          downloadSessionId: encodeCrock(getRandomBytes(32)),
          orderId,
          proposalId,
          download: {
            contractTermsHash,
            contractTermsMerchantSig: encodeCrock(getRandomBytes(64)),
            currency: Amounts.currencyOf(amountEffective),
          },
          merchantBaseUrl,
          purchaseStatus: PurchaseStatus.Done,
          repurchaseProposalId: undefined,
          lastSessionId: undefined,
          merchantPaySig: encodeCrock(getRandomBytes(64)),
          noncePriv: noncePair.priv,
          noncePub: noncePair.pub,
          payInfo: {
            totalPayCost: Amounts.stringify(amountEffective),
          },
          posConfirmation: undefined,
          secretSeed: encodeCrock(getRandomBytes(32)),
          timestamp: timestampPreciseToDb(
            AbsoluteTime.toPreciseTimestamp(timestamp),
          ),
          shared: false,
          timestampFirstSuccessfulPay: timestampPreciseToDb(
            AbsoluteTime.toPreciseTimestamp(timestamp),
          ),
          timestampAccept: timestampPreciseToDb(
            AbsoluteTime.toPreciseTimestamp(timestamp),
          ),
          timestampLastRefundStatus: timestampPreciseToDb(
            AbsoluteTime.toPreciseTimestamp(timestamp),
          ),
          refundAmountAwaiting: undefined,
        });
        await ctx.updateTransactionMeta(tx);
      });
      break;
    }
    case "peer-push-debit": {
      const pursePair = await wex.cryptoApi.createEddsaKeypair({});
      const pursePub = pursePair.pub;
      const ctx = new PeerPushDebitTransactionContext(wex, pursePub);
      const summary = parsedUri.query?.get("summary") ?? "Test";
      const iconId = parsedUri.query?.get("iconId") ?? undefined;
      const amountEffectiveStr = parsedUri.query?.get("amountEffective");
      if (!amountEffectiveStr) {
        throw Error("missing amountEffective option");
      }
      const exchangeBaseUrl =
        parsedUri.query?.get("exchangeBaseUrl") ??
        "https://exchange.demo.taler.net/";
      const amountEffective = Amounts.parseOrThrow(amountEffectiveStr);
      const trelStr = parsedUri.query?.get("tRel") ?? undefined;
      let timestamp: AbsoluteTime;
      if (trelStr) {
        timestamp = AbsoluteTime.subtractDuraction(
          AbsoluteTime.now(),
          Duration.fromPrettyString(trelStr),
        );
      } else {
        timestamp = AbsoluteTime.now();
      }
      const ct: PeerContractTerms = {
        amount: Amounts.stringify(amountEffective),
        purse_expiration: AbsoluteTime.toProtocolTimestamp(timestamp),
        summary,
      };
      if (iconId != null) {
        ct.icon_id = iconId;
      }
      const contractTermsHash = ContractTermsUtil.hashContractTerms(ct);
      const contractPair = await wex.cryptoApi.createEddsaKeypair({});
      const mergePair = await wex.cryptoApi.createEddsaKeypair({});
      await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
        await tx.contractTerms.put({
          contractTermsRaw: ct,
          h: contractTermsHash,
        });
        await tx.peerPushDebit.put({
          amount: Amounts.stringify(amountEffective),
          contractEncNonce: encodeCrock(getRandomBytes(32)),
          contractPriv: contractPair.priv,
          contractPub: contractPair.pub,
          contractTermsHash: contractTermsHash,
          exchangeBaseUrl,
          mergePriv: mergePair.priv,
          mergePub: mergePair.pub,
          purseExpiration: timestampProtocolToDb(ct.purse_expiration),
          pursePriv: pursePair.priv,
          pursePub: pursePair.pub,
          status: PeerPushDebitStatus.Done,
          timestampCreated: timestampPreciseToDb(
            AbsoluteTime.toPreciseTimestamp(timestamp),
          ),
          totalCost: Amounts.stringify(amountEffective),
        });
        await ctx.updateTransactionMeta(tx);
      });
      break;
    }
    case "peer-push-credit": {
      const pursePair = await wex.cryptoApi.createEddsaKeypair({});
      const pursePub = pursePair.pub;
      const peerPushCreditId = encodeCrock(getRandomBytes(32));
      const ctx = new PeerPushCreditTransactionContext(wex, peerPushCreditId);
      const summary = parsedUri.query?.get("summary") ?? "Test";
      const iconId = parsedUri.query?.get("iconId") ?? undefined;
      const amountEffectiveStr = parsedUri.query?.get("amountEffective");
      if (!amountEffectiveStr) {
        throw Error("missing amountEffective option");
      }
      const exchangeBaseUrl =
        parsedUri.query?.get("exchangeBaseUrl") ??
        "https://exchange.demo.taler.net/";
      const amountEffective = Amounts.parseOrThrow(amountEffectiveStr);
      const trelStr = parsedUri.query?.get("tRel") ?? undefined;
      let timestamp: AbsoluteTime;
      if (trelStr) {
        timestamp = AbsoluteTime.subtractDuraction(
          AbsoluteTime.now(),
          Duration.fromPrettyString(trelStr),
        );
      } else {
        timestamp = AbsoluteTime.now();
      }
      const ct: PeerContractTerms = {
        amount: Amounts.stringify(amountEffective),
        purse_expiration: AbsoluteTime.toProtocolTimestamp(timestamp),
        summary,
      };
      if (iconId != null) {
        ct.icon_id = iconId;
      }
      const contractTermsHash = ContractTermsUtil.hashContractTerms(ct);
      const contractPair = await wex.cryptoApi.createEddsaKeypair({});
      const mergePair = await wex.cryptoApi.createEddsaKeypair({});
      await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
        await tx.contractTerms.put({
          contractTermsRaw: ct,
          h: contractTermsHash,
        });
        await tx.peerPushCredit.put({
          contractPriv: contractPair.priv,
          contractTermsHash,
          currency: Amounts.currencyOf(amountEffective),
          estimatedAmountEffective: Amounts.stringify(amountEffective),
          exchangeBaseUrl,
          mergePriv: mergePair.priv,
          peerPushCreditId,
          pursePub,
          status: PeerPushCreditStatus.Done,
          timestamp: timestampPreciseToDb(
            AbsoluteTime.toPreciseTimestamp(timestamp),
          ),
          withdrawalGroupId: undefined,
        });
        await ctx.updateTransactionMeta(tx);
      });
      break;
    }
    default: {
      throw Error("transaction type not supported");
    }
  }
}

function mockResponseJson(resp: HttpResponse, respJson: any): HttpResponse {
  const textEncoder = new TextEncoder();
  return {
    requestMethod: resp.requestMethod,
    requestUrl: resp.requestUrl,
    status: resp.status,
    headers: resp.headers,
    async bytes() {
      return textEncoder.encode(JSON.stringify(respJson, undefined, 2));
    },
    async json() {
      return respJson;
    },
    async text() {
      return JSON.stringify(respJson, undefined, 2);
    },
  };
}

function mockInternalServerError(resp: HttpResponse): HttpResponse {
  const textEncoder = new TextEncoder();
  const respJson = {
    code: TalerErrorCode.GENERIC_INTERNAL_INVARIANT_FAILURE,
    hint: "internal server error (test mock)",
    message: "internal server error (test mock)",
  };
  return {
    requestMethod: resp.requestMethod,
    requestUrl: resp.requestUrl,
    status: 500,
    headers: resp.headers,
    async bytes() {
      return textEncoder.encode(JSON.stringify(respJson, undefined, 2));
    },
    async json() {
      return respJson;
    },
    async text() {
      return JSON.stringify(respJson, undefined, 2);
    },
  };
}

export class DevExperimentHttpLib implements HttpRequestLibrary {
  _isDevExperimentLib = true;
  underlyingLib: HttpRequestLibrary;

  constructor(
    lib: HttpRequestLibrary,
    private devExperimentState: DevExperimentState,
  ) {
    this.underlyingLib = lib;
  }

  async fetch(
    url: string,
    opt?: HttpRequestOptions | undefined,
  ): Promise<HttpResponse> {
    logger.warn(`dev experiment request ${url}`);
    logger.info(`devExperimentState: ${j2s(this.devExperimentState)}`);
    const method = (opt?.method ?? "get").toLowerCase();
    if (this.devExperimentState.fakeProtoVer != null) {
      if (method == "get") {
        let verBaseUrl: string | undefined;
        const confSuffix = "/config";
        const keysSuffix = "/keys";
        if (url.endsWith(confSuffix)) {
          verBaseUrl = url.substring(0, url.length - confSuffix.length + 1);
        } else if (url.endsWith(keysSuffix)) {
          verBaseUrl = url.substring(0, url.length - keysSuffix.length + 1);
        }
        const fakeSt =
          verBaseUrl && this.devExperimentState.fakeProtoVer.get(verBaseUrl);
        if (fakeSt) {
          const resp = await this.underlyingLib.fetch(url, opt);
          if (resp.status !== 200) {
            return resp;
          }
          logger.info(`replacing proto version with ${fakeSt.fakeVer}`);
          const respJson = await resp.json();
          respJson.version = fakeSt.fakeVer;
          return mockResponseJson(resp, respJson);
        }
      }
    } else if (this.devExperimentState.blockPayResponse) {
      logger.warn(`have blockPayResponse`);
      logger.info(`endsWithPay: ${url.endsWith("/pay")}`);
      if (method === "post" && url.endsWith("/pay")) {
        logger.warn(`blocking /pay response`);
        const realResp = await this.underlyingLib.fetch(url, opt);
        return mockInternalServerError(realResp);
      }
    }
    return this.underlyingLib.fetch(url, opt);
  }
}
