/*
 This file is part of GNU Taler
 (C) 2022-2023 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 {
  AbsoluteTime,
  AcceptPeerPushPaymentResponse,
  Amounts,
  ConfirmPeerPushCreditRequest,
  ContractTermsUtil,
  ExchangePurseMergeRequest,
  HttpStatusCode,
  Logger,
  NotificationType,
  PeerContractTerms,
  PreparePeerPushCreditRequest,
  PreparePeerPushCreditResponse,
  TalerErrorCode,
  TalerPreciseTimestamp,
  Transaction,
  TransactionAction,
  TransactionIdStr,
  TransactionMajorState,
  TransactionMinorState,
  TransactionState,
  TransactionType,
  WalletAccountMergeFlags,
  WalletKycUuid,
  assertUnreachable,
  checkDbInvariant,
  codecForAny,
  codecForExchangeGetContractResponse,
  codecForPeerContractTerms,
  codecForWalletKycUuid,
  decodeCrock,
  eddsaGetPublic,
  encodeCrock,
  getRandomBytes,
  j2s,
  makeErrorDetail,
  parsePayPushUri,
  talerPaytoFromExchangeReserve,
} from "@gnu-taler/taler-util";
import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
import {
  PendingTaskType,
  TaskIdStr,
  TaskIdentifiers,
  TaskRunResult,
  TaskRunResultType,
  TombstoneTag,
  TransactionContext,
  constructTaskIdentifier,
  requireExchangeTosAcceptedOrThrow,
} from "./common.js";
import {
  KycPendingInfo,
  KycUserType,
  OperationRetryRecord,
  PeerPushCreditStatus,
  PeerPushPaymentIncomingRecord,
  WalletDbAllStoresReadOnlyTransaction,
  WithdrawalGroupRecord,
  WithdrawalGroupStatus,
  WithdrawalRecordType,
  timestampPreciseFromDb,
  timestampPreciseToDb,
} from "./db.js";
import { fetchFreshExchange } from "./exchanges.js";
import {
  codecForExchangePurseStatus,
  getMergeReserveInfo,
} from "./pay-peer-common.js";
import {
  TransitionInfo,
  constructTransactionIdentifier,
  isUnsuccessfulTransaction,
  notifyTransition,
  parseTransactionIdentifier,
} from "./transactions.js";
import { WalletExecutionContext } from "./wallet.js";
import {
  PerformCreateWithdrawalGroupResult,
  getExchangeWithdrawalInfo,
  internalPerformCreateWithdrawalGroup,
  internalPrepareCreateWithdrawalGroup,
  waitWithdrawalFinal,
} from "./withdraw.js";

const logger = new Logger("pay-peer-push-credit.ts");

export class PeerPushCreditTransactionContext implements TransactionContext {
  readonly transactionId: TransactionIdStr;
  readonly taskId: TaskIdStr;

  constructor(
    public wex: WalletExecutionContext,
    public peerPushCreditId: string,
  ) {
    this.transactionId = constructTransactionIdentifier({
      tag: TransactionType.PeerPushCredit,
      peerPushCreditId,
    });
    this.taskId = constructTaskIdentifier({
      tag: PendingTaskType.PeerPushCredit,
      peerPushCreditId,
    });
  }

  /**
   * Get the full transaction details for the transaction.
   *
   * Returns undefined if the transaction is in a state where we do not have a
   * transaction item (e.g. if it was deleted).
   */
  async lookupFullTransaction(
    tx: WalletDbAllStoresReadOnlyTransaction,
  ): Promise<Transaction | undefined> {
    const pushInc = await tx.peerPushCredit.get(this.peerPushCreditId);
    if (!pushInc) {
      return undefined;
    }

    let wg: WithdrawalGroupRecord | undefined = undefined;
    let wgRetryRecord: OperationRetryRecord | undefined = undefined;
    if (pushInc.withdrawalGroupId) {
      wg = await tx.withdrawalGroups.get(pushInc.withdrawalGroupId);
      if (wg) {
        const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
        wgRetryRecord = await tx.operationRetries.get(withdrawalOpId);
      }
    }
    const pushIncOpId = TaskIdentifiers.forPeerPushCredit(pushInc);
    const pushRetryRecord = await tx.operationRetries.get(pushIncOpId);

    const ct = await tx.contractTerms.get(pushInc.contractTermsHash);

    if (!ct) {
      throw Error("contract terms for P2P payment not found");
    }

    const peerContractTerms = ct.contractTermsRaw;

    if (wg) {
      if (wg.wgInfo.withdrawalType !== WithdrawalRecordType.PeerPushCredit) {
        throw Error("invalid withdrawal group type for push payment credit");
      }
      checkDbInvariant(wg.instructedAmount !== undefined, "wg uninitialized");
      checkDbInvariant(wg.denomsSel !== undefined, "wg uninitialized");
      checkDbInvariant(wg.exchangeBaseUrl !== undefined, "wg uninitialized");

      const txState = computePeerPushCreditTransactionState(pushInc);
      return {
        type: TransactionType.PeerPushCredit,
        txState,
        txActions: computePeerPushCreditTransactionActions(pushInc),
        amountEffective: isUnsuccessfulTransaction(txState)
          ? Amounts.stringify(Amounts.zeroOfAmount(wg.instructedAmount))
          : Amounts.stringify(wg.denomsSel.totalCoinValue),
        amountRaw: Amounts.stringify(wg.instructedAmount),
        exchangeBaseUrl: wg.exchangeBaseUrl,
        info: {
          expiration: peerContractTerms.purse_expiration,
          summary: peerContractTerms.summary,
        },
        timestamp: timestampPreciseFromDb(wg.timestampStart),
        transactionId: constructTransactionIdentifier({
          tag: TransactionType.PeerPushCredit,
          peerPushCreditId: pushInc.peerPushCreditId,
        }),
        kycUrl: pushInc.kycUrl,
        ...(wgRetryRecord?.lastError ? { error: wgRetryRecord.lastError } : {}),
      };
    }

    const txState = computePeerPushCreditTransactionState(pushInc);
    return {
      type: TransactionType.PeerPushCredit,
      txState,
      txActions: computePeerPushCreditTransactionActions(pushInc),
      amountEffective: isUnsuccessfulTransaction(txState)
        ? Amounts.stringify(Amounts.zeroOfAmount(peerContractTerms.amount))
        : // FIXME: This is wrong, needs to consider fees!
          Amounts.stringify(peerContractTerms.amount),
      amountRaw: Amounts.stringify(peerContractTerms.amount),
      exchangeBaseUrl: pushInc.exchangeBaseUrl,
      info: {
        expiration: peerContractTerms.purse_expiration,
        summary: peerContractTerms.summary,
      },
      kycUrl: pushInc.kycUrl,
      timestamp: timestampPreciseFromDb(pushInc.timestamp),
      transactionId: constructTransactionIdentifier({
        tag: TransactionType.PeerPushCredit,
        peerPushCreditId: pushInc.peerPushCreditId,
      }),
      ...(pushRetryRecord?.lastError
        ? { error: pushRetryRecord.lastError }
        : {}),
    };
  }

  async deleteTransaction(): Promise<void> {
    const { wex, peerPushCreditId } = this;
    await wex.db.runReadWriteTx(
      { storeNames: ["withdrawalGroups", "peerPushCredit", "tombstones"] },
      async (tx) => {
        const pushInc = await tx.peerPushCredit.get(peerPushCreditId);
        if (!pushInc) {
          return;
        }
        if (pushInc.withdrawalGroupId) {
          const withdrawalGroupId = pushInc.withdrawalGroupId;
          const withdrawalGroupRecord =
            await tx.withdrawalGroups.get(withdrawalGroupId);
          if (withdrawalGroupRecord) {
            await tx.withdrawalGroups.delete(withdrawalGroupId);
            await tx.tombstones.put({
              id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
            });
          }
        }
        await tx.peerPushCredit.delete(peerPushCreditId);
        await tx.tombstones.put({
          id: TombstoneTag.DeletePeerPushCredit + ":" + peerPushCreditId,
        });
      },
    );
    return;
  }

  async suspendTransaction(): Promise<void> {
    const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this;
    const transitionInfo = await wex.db.runReadWriteTx(
      { storeNames: ["peerPushCredit"] },
      async (tx) => {
        const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
        if (!pushCreditRec) {
          logger.warn(`peer push credit ${peerPushCreditId} not found`);
          return;
        }
        let newStatus: PeerPushCreditStatus | undefined = undefined;
        switch (pushCreditRec.status) {
          case PeerPushCreditStatus.DialogProposed:
          case PeerPushCreditStatus.Done:
          case PeerPushCreditStatus.SuspendedMerge:
          case PeerPushCreditStatus.SuspendedMergeKycRequired:
          case PeerPushCreditStatus.SuspendedWithdrawing:
            break;
          case PeerPushCreditStatus.PendingMergeKycRequired:
            newStatus = PeerPushCreditStatus.SuspendedMergeKycRequired;
            break;
          case PeerPushCreditStatus.PendingMerge:
            newStatus = PeerPushCreditStatus.SuspendedMerge;
            break;
          case PeerPushCreditStatus.PendingWithdrawing:
            // FIXME: Suspend internal withdrawal transaction!
            newStatus = PeerPushCreditStatus.SuspendedWithdrawing;
            break;
          case PeerPushCreditStatus.Aborted:
            break;
          case PeerPushCreditStatus.Failed:
            break;
          default:
            assertUnreachable(pushCreditRec.status);
        }
        if (newStatus != null) {
          const oldTxState =
            computePeerPushCreditTransactionState(pushCreditRec);
          pushCreditRec.status = newStatus;
          const newTxState =
            computePeerPushCreditTransactionState(pushCreditRec);
          await tx.peerPushCredit.put(pushCreditRec);
          return {
            oldTxState,
            newTxState,
          };
        }
        return undefined;
      },
    );
    notifyTransition(wex, transactionId, transitionInfo);
    wex.taskScheduler.stopShepherdTask(retryTag);
  }

  async abortTransaction(): Promise<void> {
    const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this;
    const transitionInfo = await wex.db.runReadWriteTx(
      { storeNames: ["peerPushCredit"] },
      async (tx) => {
        const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
        if (!pushCreditRec) {
          logger.warn(`peer push credit ${peerPushCreditId} not found`);
          return;
        }
        let newStatus: PeerPushCreditStatus | undefined = undefined;
        switch (pushCreditRec.status) {
          case PeerPushCreditStatus.DialogProposed:
            newStatus = PeerPushCreditStatus.Aborted;
            break;
          case PeerPushCreditStatus.Done:
            break;
          case PeerPushCreditStatus.SuspendedMerge:
          case PeerPushCreditStatus.SuspendedMergeKycRequired:
          case PeerPushCreditStatus.SuspendedWithdrawing:
            newStatus = PeerPushCreditStatus.Aborted;
            break;
          case PeerPushCreditStatus.PendingMergeKycRequired:
            newStatus = PeerPushCreditStatus.Aborted;
            break;
          case PeerPushCreditStatus.PendingMerge:
            newStatus = PeerPushCreditStatus.Aborted;
            break;
          case PeerPushCreditStatus.PendingWithdrawing:
            newStatus = PeerPushCreditStatus.Aborted;
            break;
          case PeerPushCreditStatus.Aborted:
            break;
          case PeerPushCreditStatus.Failed:
            break;
          default:
            assertUnreachable(pushCreditRec.status);
        }
        if (newStatus != null) {
          const oldTxState =
            computePeerPushCreditTransactionState(pushCreditRec);
          pushCreditRec.status = newStatus;
          const newTxState =
            computePeerPushCreditTransactionState(pushCreditRec);
          await tx.peerPushCredit.put(pushCreditRec);
          return {
            oldTxState,
            newTxState,
          };
        }
        return undefined;
      },
    );
    notifyTransition(wex, transactionId, transitionInfo);
    wex.taskScheduler.startShepherdTask(retryTag);
  }

  async resumeTransaction(): Promise<void> {
    const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this;
    const transitionInfo = await wex.db.runReadWriteTx(
      { storeNames: ["peerPushCredit"] },
      async (tx) => {
        const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
        if (!pushCreditRec) {
          logger.warn(`peer push credit ${peerPushCreditId} not found`);
          return;
        }
        let newStatus: PeerPushCreditStatus | undefined = undefined;
        switch (pushCreditRec.status) {
          case PeerPushCreditStatus.DialogProposed:
          case PeerPushCreditStatus.Done:
          case PeerPushCreditStatus.PendingMergeKycRequired:
          case PeerPushCreditStatus.PendingMerge:
          case PeerPushCreditStatus.PendingWithdrawing:
          case PeerPushCreditStatus.SuspendedMerge:
            newStatus = PeerPushCreditStatus.PendingMerge;
            break;
          case PeerPushCreditStatus.SuspendedMergeKycRequired:
            newStatus = PeerPushCreditStatus.PendingMergeKycRequired;
            break;
          case PeerPushCreditStatus.SuspendedWithdrawing:
            // FIXME: resume underlying "internal-withdrawal" transaction.
            newStatus = PeerPushCreditStatus.PendingWithdrawing;
            break;
          case PeerPushCreditStatus.Aborted:
            break;
          case PeerPushCreditStatus.Failed:
            break;
          default:
            assertUnreachable(pushCreditRec.status);
        }
        if (newStatus != null) {
          const oldTxState =
            computePeerPushCreditTransactionState(pushCreditRec);
          pushCreditRec.status = newStatus;
          const newTxState =
            computePeerPushCreditTransactionState(pushCreditRec);
          await tx.peerPushCredit.put(pushCreditRec);
          return {
            oldTxState,
            newTxState,
          };
        }
        return undefined;
      },
    );
    notifyTransition(wex, transactionId, transitionInfo);
    wex.taskScheduler.startShepherdTask(retryTag);
  }

  async failTransaction(): Promise<void> {
    const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this;
    const transitionInfo = await wex.db.runReadWriteTx(
      { storeNames: ["peerPushCredit"] },
      async (tx) => {
        const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
        if (!pushCreditRec) {
          logger.warn(`peer push credit ${peerPushCreditId} not found`);
          return;
        }
        let newStatus: PeerPushCreditStatus | undefined = undefined;
        switch (pushCreditRec.status) {
          case PeerPushCreditStatus.Done:
          case PeerPushCreditStatus.Aborted:
          case PeerPushCreditStatus.Failed:
            // Already in a final state.
            return;
          case PeerPushCreditStatus.DialogProposed:
          case PeerPushCreditStatus.PendingMergeKycRequired:
          case PeerPushCreditStatus.PendingMerge:
          case PeerPushCreditStatus.PendingWithdrawing:
          case PeerPushCreditStatus.SuspendedMerge:
          case PeerPushCreditStatus.SuspendedMergeKycRequired:
          case PeerPushCreditStatus.SuspendedWithdrawing:
            newStatus = PeerPushCreditStatus.Failed;
            break;
          default:
            assertUnreachable(pushCreditRec.status);
        }
        if (newStatus != null) {
          const oldTxState =
            computePeerPushCreditTransactionState(pushCreditRec);
          pushCreditRec.status = newStatus;
          const newTxState =
            computePeerPushCreditTransactionState(pushCreditRec);
          await tx.peerPushCredit.put(pushCreditRec);
          return {
            oldTxState,
            newTxState,
          };
        }
        return undefined;
      },
    );
    wex.taskScheduler.stopShepherdTask(retryTag);
    notifyTransition(wex, transactionId, transitionInfo);
    wex.taskScheduler.startShepherdTask(retryTag);
  }
}

export async function preparePeerPushCredit(
  wex: WalletExecutionContext,
  req: PreparePeerPushCreditRequest,
): Promise<PreparePeerPushCreditResponse> {
  const uri = parsePayPushUri(req.talerUri);

  if (!uri) {
    throw Error("got invalid taler://pay-push URI");
  }

  const existing = await wex.db.runReadOnlyTx(
    { storeNames: ["contractTerms", "peerPushCredit"] },
    async (tx) => {
      const existingPushInc =
        await tx.peerPushCredit.indexes.byExchangeAndContractPriv.get([
          uri.exchangeBaseUrl,
          uri.contractPriv,
        ]);
      if (!existingPushInc) {
        return;
      }
      const existingContractTermsRec = await tx.contractTerms.get(
        existingPushInc.contractTermsHash,
      );
      if (!existingContractTermsRec) {
        throw Error(
          "contract terms for peer push payment credit not found in database",
        );
      }
      const existingContractTerms = codecForPeerContractTerms().decode(
        existingContractTermsRec.contractTermsRaw,
      );
      return { existingPushInc, existingContractTerms };
    },
  );

  if (existing) {
    return {
      amount: existing.existingContractTerms.amount,
      amountEffective: existing.existingPushInc.estimatedAmountEffective,
      amountRaw: existing.existingContractTerms.amount,
      contractTerms: existing.existingContractTerms,
      peerPushCreditId: existing.existingPushInc.peerPushCreditId,
      transactionId: constructTransactionIdentifier({
        tag: TransactionType.PeerPushCredit,
        peerPushCreditId: existing.existingPushInc.peerPushCreditId,
      }),
      exchangeBaseUrl: existing.existingPushInc.exchangeBaseUrl,
    };
  }

  const exchangeBaseUrl = uri.exchangeBaseUrl;

  const contractPriv = uri.contractPriv;
  const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));

  const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);

  const contractHttpResp = await wex.http.fetch(getContractUrl.href);

  const contractResp = await readSuccessResponseJsonOrThrow(
    contractHttpResp,
    codecForExchangeGetContractResponse(),
  );

  const pursePub = contractResp.purse_pub;

  const dec = await wex.cryptoApi.decryptContractForMerge({
    ciphertext: contractResp.econtract,
    contractPriv: contractPriv,
    pursePub: pursePub,
  });

  const getPurseUrl = new URL(`purses/${pursePub}/deposit`, exchangeBaseUrl);

  const purseHttpResp = await wex.http.fetch(getPurseUrl.href);

  const contractTerms = codecForPeerContractTerms().decode(dec.contractTerms);

  const purseStatus = await readSuccessResponseJsonOrThrow(
    purseHttpResp,
    codecForExchangePurseStatus(),
  );

  logger.info(
    `peer push credit, purse balance ${purseStatus.balance}, contract amount ${contractTerms.amount}`,
  );

  const peerPushCreditId = encodeCrock(getRandomBytes(32));

  const contractTermsHash = ContractTermsUtil.hashContractTerms(
    dec.contractTerms,
  );

  const withdrawalGroupId = encodeCrock(getRandomBytes(32));

  const wi = await getExchangeWithdrawalInfo(
    wex,
    exchangeBaseUrl,
    Amounts.parseOrThrow(purseStatus.balance),
    undefined,
  );

  if (wi.selectedDenoms.selectedDenoms.length === 0) {
    throw Error(
      `unable to prepare push credit from ${exchangeBaseUrl}, can't select denominations for instructed amount (${purseStatus.balance}`,
    );
  }

  const transitionInfo = await wex.db.runReadWriteTx(
    { storeNames: ["contractTerms", "peerPushCredit"] },
    async (tx) => {
      const rec: PeerPushPaymentIncomingRecord = {
        peerPushCreditId,
        contractPriv: contractPriv,
        exchangeBaseUrl: exchangeBaseUrl,
        mergePriv: dec.mergePriv,
        pursePub: pursePub,
        timestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()),
        contractTermsHash,
        status: PeerPushCreditStatus.DialogProposed,
        withdrawalGroupId,
        currency: Amounts.currencyOf(purseStatus.balance),
        estimatedAmountEffective: Amounts.stringify(
          wi.withdrawalAmountEffective,
        ),
      };
      await tx.peerPushCredit.add(rec);
      await tx.contractTerms.put({
        h: contractTermsHash,
        contractTermsRaw: dec.contractTerms,
      });

      const newTxState = computePeerPushCreditTransactionState(rec);

      return {
        oldTxState: {
          major: TransactionMajorState.None,
        },
        newTxState,
      } satisfies TransitionInfo;
    },
  );

  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.PeerPushCredit,
    peerPushCreditId,
  });

  notifyTransition(wex, transactionId, transitionInfo);

  wex.ws.notify({
    type: NotificationType.BalanceChange,
    hintTransactionId: transactionId,
  });

  return {
    amount: purseStatus.balance,
    amountEffective: wi.withdrawalAmountEffective,
    amountRaw: purseStatus.balance,
    contractTerms: dec.contractTerms,
    peerPushCreditId,
    transactionId,
    exchangeBaseUrl,
  };
}

async function longpollKycStatus(
  wex: WalletExecutionContext,
  peerPushCreditId: string,
  exchangeUrl: string,
  kycInfo: KycPendingInfo,
  userType: KycUserType,
): Promise<TaskRunResult> {
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.PeerPushCredit,
    peerPushCreditId,
  });
  const url = new URL(
    `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
    exchangeUrl,
  );
  logger.info(`kyc url ${url.href}`);
  const kycStatusRes = await wex.ws.runLongpollQueueing(
    wex,
    url.hostname,
    async (timeoutMs) => {
      url.searchParams.set("timeout_ms", `${timeoutMs}`);
      return await wex.http.fetch(url.href, {
        method: "GET",
        cancellationToken: wex.cancellationToken,
      });
    },
  );

  if (
    kycStatusRes.status === HttpStatusCode.Ok ||
    //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
    // remove after the exchange is fixed or clarified
    kycStatusRes.status === HttpStatusCode.NoContent
  ) {
    const transitionInfo = await wex.db.runReadWriteTx(
      { storeNames: ["peerPushCredit"] },
      async (tx) => {
        const peerInc = await tx.peerPushCredit.get(peerPushCreditId);
        if (!peerInc) {
          return;
        }
        if (peerInc.status !== PeerPushCreditStatus.PendingMergeKycRequired) {
          return;
        }
        const oldTxState = computePeerPushCreditTransactionState(peerInc);
        peerInc.status = PeerPushCreditStatus.PendingMerge;
        const newTxState = computePeerPushCreditTransactionState(peerInc);
        await tx.peerPushCredit.put(peerInc);
        return { oldTxState, newTxState };
      },
    );
    notifyTransition(wex, transactionId, transitionInfo);
    return TaskRunResult.progress();
  } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
    // FIXME: Do we have to update the URL here?
    return TaskRunResult.longpollReturnedPending();
  } else {
    throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
  }
}

async function processPeerPushCreditKycRequired(
  wex: WalletExecutionContext,
  peerInc: PeerPushPaymentIncomingRecord,
  kycPending: WalletKycUuid,
): Promise<TaskRunResult> {
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.PeerPushCredit,
    peerPushCreditId: peerInc.peerPushCreditId,
  });
  const { peerPushCreditId } = peerInc;

  const userType = "individual";
  const url = new URL(
    `kyc-check/${kycPending.requirement_row}/${kycPending.h_payto}/${userType}`,
    peerInc.exchangeBaseUrl,
  );

  logger.info(`kyc url ${url.href}`);
  const kycStatusRes = await wex.http.fetch(url.href, {
    method: "GET",
    cancellationToken: wex.cancellationToken,
  });

  if (
    kycStatusRes.status === HttpStatusCode.Ok ||
    //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
    // remove after the exchange is fixed or clarified
    kycStatusRes.status === HttpStatusCode.NoContent
  ) {
    logger.warn("kyc requested, but already fulfilled");
    return TaskRunResult.finished();
  } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
    const kycStatus = await kycStatusRes.json();
    logger.info(`kyc status: ${j2s(kycStatus)}`);
    const { transitionInfo, result } = await wex.db.runReadWriteTx(
      { storeNames: ["peerPushCredit"] },
      async (tx) => {
        const peerInc = await tx.peerPushCredit.get(peerPushCreditId);
        if (!peerInc) {
          return {
            transitionInfo: undefined,
            result: TaskRunResult.finished(),
          };
        }
        const oldTxState = computePeerPushCreditTransactionState(peerInc);
        peerInc.kycInfo = {
          paytoHash: kycPending.h_payto,
          requirementRow: kycPending.requirement_row,
        };
        peerInc.kycUrl = kycStatus.kyc_url;
        peerInc.status = PeerPushCreditStatus.PendingMergeKycRequired;
        const newTxState = computePeerPushCreditTransactionState(peerInc);
        await tx.peerPushCredit.put(peerInc);
        // We'll remove this eventually!  New clients should rely on the
        // kycUrl field of the transaction, not the error code.
        const res: TaskRunResult = {
          type: TaskRunResultType.Error,
          errorDetail: makeErrorDetail(
            TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED,
            {
              kycUrl: kycStatus.kyc_url,
            },
          ),
        };
        return {
          transitionInfo: { oldTxState, newTxState },
          result: res,
        };
      },
    );
    notifyTransition(wex, transactionId, transitionInfo);
    return result;
  } else {
    throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
  }
}

async function handlePendingMerge(
  wex: WalletExecutionContext,
  peerInc: PeerPushPaymentIncomingRecord,
  contractTerms: PeerContractTerms,
): Promise<TaskRunResult> {
  const { peerPushCreditId } = peerInc;
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.PeerPushCredit,
    peerPushCreditId,
  });

  const amount = Amounts.parseOrThrow(contractTerms.amount);

  const mergeReserveInfo = await getMergeReserveInfo(wex, {
    exchangeBaseUrl: peerInc.exchangeBaseUrl,
  });

  const timestamp = timestampPreciseFromDb(peerInc.timestamp);

  const mergeTimestamp = AbsoluteTime.toProtocolTimestamp(
    AbsoluteTime.fromPreciseTimestamp(timestamp),
  );

  const reservePayto = talerPaytoFromExchangeReserve(
    peerInc.exchangeBaseUrl,
    mergeReserveInfo.reservePub,
  );

  const sigRes = await wex.cryptoApi.signPurseMerge({
    contractTermsHash: ContractTermsUtil.hashContractTerms(contractTerms),
    flags: WalletAccountMergeFlags.MergeFullyPaidPurse,
    mergePriv: peerInc.mergePriv,
    mergeTimestamp: mergeTimestamp,
    purseAmount: Amounts.stringify(amount),
    purseExpiration: contractTerms.purse_expiration,
    purseFee: Amounts.stringify(Amounts.zeroOfCurrency(amount.currency)),
    pursePub: peerInc.pursePub,
    reservePayto,
    reservePriv: mergeReserveInfo.reservePriv,
  });

  const mergePurseUrl = new URL(
    `purses/${peerInc.pursePub}/merge`,
    peerInc.exchangeBaseUrl,
  );

  const mergeReq: ExchangePurseMergeRequest = {
    payto_uri: reservePayto,
    merge_timestamp: mergeTimestamp,
    merge_sig: sigRes.mergeSig,
    reserve_sig: sigRes.accountSig,
  };

  const mergeHttpResp = await wex.http.fetch(mergePurseUrl.href, {
    method: "POST",
    body: mergeReq,
  });

  if (mergeHttpResp.status === HttpStatusCode.UnavailableForLegalReasons) {
    const respJson = await mergeHttpResp.json();
    const kycPending = codecForWalletKycUuid().decode(respJson);
    logger.info(`kyc uuid response: ${j2s(kycPending)}`);
    return processPeerPushCreditKycRequired(wex, peerInc, kycPending);
  }

  logger.trace(`merge request: ${j2s(mergeReq)}`);
  const res = await readSuccessResponseJsonOrThrow(
    mergeHttpResp,
    codecForAny(),
  );
  logger.trace(`merge response: ${j2s(res)}`);

  const withdrawalGroupPrep = await internalPrepareCreateWithdrawalGroup(wex, {
    amount,
    wgInfo: {
      withdrawalType: WithdrawalRecordType.PeerPushCredit,
    },
    forcedWithdrawalGroupId: peerInc.withdrawalGroupId,
    exchangeBaseUrl: peerInc.exchangeBaseUrl,
    reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
    reserveKeyPair: {
      priv: mergeReserveInfo.reservePriv,
      pub: mergeReserveInfo.reservePub,
    },
  });

  const txRes = await wex.db.runReadWriteTx(
    {
      storeNames: [
        "contractTerms",
        "peerPushCredit",
        "withdrawalGroups",
        "reserves",
        "exchanges",
        "exchangeDetails",
      ],
    },
    async (tx) => {
      const peerInc = await tx.peerPushCredit.get(peerPushCreditId);
      if (!peerInc) {
        return undefined;
      }
      const oldTxState = computePeerPushCreditTransactionState(peerInc);
      let wgCreateRes: PerformCreateWithdrawalGroupResult | undefined =
        undefined;
      switch (peerInc.status) {
        case PeerPushCreditStatus.PendingMerge:
        case PeerPushCreditStatus.PendingMergeKycRequired: {
          peerInc.status = PeerPushCreditStatus.PendingWithdrawing;
          wgCreateRes = await internalPerformCreateWithdrawalGroup(
            wex,
            tx,
            withdrawalGroupPrep,
          );
          peerInc.withdrawalGroupId =
            wgCreateRes.withdrawalGroup.withdrawalGroupId;
          break;
        }
      }
      await tx.peerPushCredit.put(peerInc);
      const newTxState = computePeerPushCreditTransactionState(peerInc);
      return {
        peerPushCreditTransition: { oldTxState, newTxState },
        wgCreateRes,
      };
    },
  );
  // Transaction was committed, now we can emit notifications.
  if (txRes?.wgCreateRes?.exchangeNotif) {
    wex.ws.notify(txRes.wgCreateRes.exchangeNotif);
  }
  notifyTransition(
    wex,
    withdrawalGroupPrep.transactionId,
    txRes?.wgCreateRes?.transitionInfo,
  );
  notifyTransition(wex, transactionId, txRes?.peerPushCreditTransition);

  return TaskRunResult.backoff();
}

async function handlePendingWithdrawing(
  wex: WalletExecutionContext,
  peerInc: PeerPushPaymentIncomingRecord,
): Promise<TaskRunResult> {
  if (!peerInc.withdrawalGroupId) {
    throw Error("invalid db state (withdrawing, but no withdrawal group ID");
  }
  await waitWithdrawalFinal(wex, peerInc.withdrawalGroupId);
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.PeerPushCredit,
    peerPushCreditId: peerInc.peerPushCreditId,
  });
  const wgId = peerInc.withdrawalGroupId;
  let finished: boolean = false;
  const transitionInfo = await wex.db.runReadWriteTx(
    { storeNames: ["peerPushCredit", "withdrawalGroups"] },
    async (tx) => {
      const ppi = await tx.peerPushCredit.get(peerInc.peerPushCreditId);
      if (!ppi) {
        finished = true;
        return;
      }
      if (ppi.status !== PeerPushCreditStatus.PendingWithdrawing) {
        finished = true;
        return;
      }
      const oldTxState = computePeerPushCreditTransactionState(ppi);
      const wg = await tx.withdrawalGroups.get(wgId);
      if (!wg) {
        // FIXME: Fail the operation instead?
        return undefined;
      }
      switch (wg.status) {
        case WithdrawalGroupStatus.Done:
          finished = true;
          ppi.status = PeerPushCreditStatus.Done;
          break;
        // FIXME: Also handle other final states!
      }
      await tx.peerPushCredit.put(ppi);
      const newTxState = computePeerPushCreditTransactionState(ppi);
      return {
        oldTxState,
        newTxState,
      };
    },
  );
  notifyTransition(wex, transactionId, transitionInfo);
  if (finished) {
    return TaskRunResult.finished();
  } else {
    // FIXME: Return indicator that we depend on the other operation!
    return TaskRunResult.backoff();
  }
}

export async function processPeerPushCredit(
  wex: WalletExecutionContext,
  peerPushCreditId: string,
): Promise<TaskRunResult> {
  if (!wex.ws.networkAvailable) {
    return TaskRunResult.networkRequired();
  }

  let peerInc: PeerPushPaymentIncomingRecord | undefined;
  let contractTerms: PeerContractTerms | undefined;
  await wex.db.runReadWriteTx(
    { storeNames: ["contractTerms", "peerPushCredit"] },
    async (tx) => {
      peerInc = await tx.peerPushCredit.get(peerPushCreditId);
      if (!peerInc) {
        return;
      }
      const ctRec = await tx.contractTerms.get(peerInc.contractTermsHash);
      if (ctRec) {
        contractTerms = ctRec.contractTermsRaw;
      }
      await tx.peerPushCredit.put(peerInc);
    },
  );

  if (!peerInc) {
    throw Error(
      `can't accept unknown incoming p2p push payment (${peerPushCreditId})`,
    );
  }

  logger.info(
    `processing peerPushCredit in state ${peerInc.status.toString(16)}`,
  );

  checkDbInvariant(
    !!contractTerms,
    `not contract terms for peer push ${peerPushCreditId}`,
  );

  switch (peerInc.status) {
    case PeerPushCreditStatus.PendingMergeKycRequired: {
      if (!peerInc.kycInfo) {
        throw Error("invalid state, kycInfo required");
      }
      return await longpollKycStatus(
        wex,
        peerPushCreditId,
        peerInc.exchangeBaseUrl,
        peerInc.kycInfo,
        "individual",
      );
    }

    case PeerPushCreditStatus.PendingMerge:
      return handlePendingMerge(wex, peerInc, contractTerms);

    case PeerPushCreditStatus.PendingWithdrawing:
      return handlePendingWithdrawing(wex, peerInc);

    default:
      return TaskRunResult.finished();
  }
}

export async function confirmPeerPushCredit(
  wex: WalletExecutionContext,
  req: ConfirmPeerPushCreditRequest,
): Promise<AcceptPeerPushPaymentResponse> {
  // PeerPushPaymentIncomingRecord | undefined;
  let peerPushCreditId: string;
  const parsedTx = parseTransactionIdentifier(req.transactionId);
  if (!parsedTx) {
    throw Error("invalid transaction ID");
  }
  if (parsedTx.tag !== TransactionType.PeerPushCredit) {
    throw Error("invalid transaction ID type");
  }
  peerPushCreditId = parsedTx.peerPushCreditId;

  logger.trace(`confirming peer-push-credit ${peerPushCreditId}`);

  const peerInc = await wex.db.runReadWriteTx(
    { storeNames: ["contractTerms", "peerPushCredit"] },
    async (tx) => {
      const rec = await tx.peerPushCredit.get(peerPushCreditId);
      if (!rec) {
        return;
      }
      if (rec.status === PeerPushCreditStatus.DialogProposed) {
        rec.status = PeerPushCreditStatus.PendingMerge;
      }
      await tx.peerPushCredit.put(rec);
      return rec;
    },
  );

  if (!peerInc) {
    throw Error(
      `can't accept unknown incoming p2p push payment (${req.transactionId})`,
    );
  }

  const exchange = await fetchFreshExchange(wex, peerInc.exchangeBaseUrl);
  requireExchangeTosAcceptedOrThrow(exchange);

  const ctx = new PeerPushCreditTransactionContext(wex, peerPushCreditId);

  wex.taskScheduler.startShepherdTask(ctx.taskId);

  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.PeerPushCredit,
    peerPushCreditId,
  });

  return {
    transactionId,
  };
}

export function computePeerPushCreditTransactionState(
  pushCreditRecord: PeerPushPaymentIncomingRecord,
): TransactionState {
  switch (pushCreditRecord.status) {
    case PeerPushCreditStatus.DialogProposed:
      return {
        major: TransactionMajorState.Dialog,
        minor: TransactionMinorState.Proposed,
      };
    case PeerPushCreditStatus.PendingMerge:
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.Merge,
      };
    case PeerPushCreditStatus.Done:
      return {
        major: TransactionMajorState.Done,
      };
    case PeerPushCreditStatus.PendingMergeKycRequired:
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.KycRequired,
      };
    case PeerPushCreditStatus.PendingWithdrawing:
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.Withdraw,
      };
    case PeerPushCreditStatus.SuspendedMerge:
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.Merge,
      };
    case PeerPushCreditStatus.SuspendedMergeKycRequired:
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.MergeKycRequired,
      };
    case PeerPushCreditStatus.SuspendedWithdrawing:
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.Withdraw,
      };
    case PeerPushCreditStatus.Aborted:
      return {
        major: TransactionMajorState.Aborted,
      };
    case PeerPushCreditStatus.Failed:
      return {
        major: TransactionMajorState.Failed,
      };
    default:
      assertUnreachable(pushCreditRecord.status);
  }
}

export function computePeerPushCreditTransactionActions(
  pushCreditRecord: PeerPushPaymentIncomingRecord,
): TransactionAction[] {
  switch (pushCreditRecord.status) {
    case PeerPushCreditStatus.DialogProposed:
      return [TransactionAction.Retry, TransactionAction.Delete];
    case PeerPushCreditStatus.PendingMerge:
      return [
        TransactionAction.Retry,
        TransactionAction.Abort,
        TransactionAction.Suspend,
      ];
    case PeerPushCreditStatus.Done:
      return [TransactionAction.Delete];
    case PeerPushCreditStatus.PendingMergeKycRequired:
      return [
        TransactionAction.Retry,
        TransactionAction.Abort,
        TransactionAction.Suspend,
      ];
    case PeerPushCreditStatus.PendingWithdrawing:
      return [
        TransactionAction.Retry,
        TransactionAction.Suspend,
        TransactionAction.Fail,
      ];
    case PeerPushCreditStatus.SuspendedMerge:
      return [TransactionAction.Resume, TransactionAction.Abort];
    case PeerPushCreditStatus.SuspendedMergeKycRequired:
      return [TransactionAction.Resume, TransactionAction.Abort];
    case PeerPushCreditStatus.SuspendedWithdrawing:
      return [TransactionAction.Resume, TransactionAction.Fail];
    case PeerPushCreditStatus.Aborted:
      return [TransactionAction.Delete];
    case PeerPushCreditStatus.Failed:
      return [TransactionAction.Delete];
    default:
      assertUnreachable(pushCreditRecord.status);
  }
}
