/*
 This file is part of GNU Taler
 (C) 2022-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 {
  AbsoluteTime,
  AcceptPeerPushPaymentResponse,
  Amounts,
  ConfirmPeerPushCreditRequest,
  ContractTermsUtil,
  ExchangePurseMergeRequest,
  ExchangeWalletKycStatus,
  HttpStatusCode,
  LegitimizationNeededResponse,
  Logger,
  NotificationType,
  PeerContractTerms,
  PreparePeerPushCreditRequest,
  PreparePeerPushCreditResponse,
  TalerErrorDetail,
  TalerPreciseTimestamp,
  Transaction,
  TransactionAction,
  TransactionIdStr,
  TransactionMajorState,
  TransactionMinorState,
  TransactionState,
  TransactionType,
  WalletAccountMergeFlags,
  WalletNotification,
  assertUnreachable,
  checkDbInvariant,
  checkProtocolInvariant,
  codecForPeerContractTerms,
  decodeCrock,
  eddsaGetPublic,
  encodeCrock,
  getRandomBytes,
  j2s,
  parsePayPushUri,
  talerPaytoFromExchangeReserve,
} from "@gnu-taler/taler-util";
import {
  PendingTaskType,
  TaskIdStr,
  TaskIdentifiers,
  TaskRunResult,
  TransactionContext,
  TransitionResultType,
  constructTaskIdentifier,
  genericWaitForStateVal,
  requireExchangeTosAcceptedOrThrow,
} from "./common.js";
import {
  OperationRetryRecord,
  PeerPushCreditStatus,
  PeerPushPaymentIncomingRecord,
  WalletDbAllStoresReadOnlyTransaction,
  WalletDbReadWriteTransaction,
  WithdrawalGroupRecord,
  WithdrawalGroupStatus,
  WithdrawalRecordType,
  timestampPreciseFromDb,
  timestampPreciseToDb,
} from "./db.js";
import {
  BalanceThresholdCheckResult,
  checkIncomingAmountLegalUnderKycBalanceThreshold,
  fetchFreshExchange,
  getExchangeScopeInfo,
  getScopeForAllExchanges,
  handleStartExchangeWalletKyc,
} from "./exchanges.js";
import {
  GenericKycStatusReq,
  checkPeerCreditHardLimitExceeded,
  getPeerCreditLimitInfo,
  isKycOperationDue,
  runKycCheckAlgo,
} from "./kyc.js";
import {
  getMergeReserveInfo,
  isPurseMerged,
  recordCreate,
  recordDelete,
  recordTransition,
  recordTransitionStatus,
  recordUpdateMeta,
} from "./pay-peer-common.js";
import {
  BalanceEffect,
  applyNotifyTransition,
  constructTransactionIdentifier,
  isUnsuccessfulTransaction,
  parseTransactionIdentifier,
} from "./transactions.js";
import { WalletExecutionContext, walletExchangeClient } from "./wallet.js";
import {
  PerformCreateWithdrawalGroupResult,
  WithdrawTransactionContext,
  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,
    });
  }

  readonly store = "peerPushCredit";
  readonly recordId = this.peerPushCreditId;
  readonly recordState = (rec: PeerPushPaymentIncomingRecord) => ({
    txState: computePeerPushCreditTransactionState(rec),
    stId: rec.status,
  });
  readonly recordMeta = (rec: PeerPushPaymentIncomingRecord) => ({
    transactionId: this.transactionId,
    status: rec.status,
    timestamp: rec.timestamp,
    currency: Amounts.currencyOf(rec.estimatedAmountEffective),
    exchanges: [rec.exchangeBaseUrl],
  });
  updateTransactionMeta = (
    tx: WalletDbReadWriteTransaction<["peerPushCredit", "transactionsMeta"]>,
  ) => recordUpdateMeta(this, tx);

  /**
   * 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;

    let kycUrl: string | undefined = undefined;
    let kycAccessToken: string | undefined = undefined;
    if (wg?.kycAccessToken && wg.exchangeBaseUrl) {
      // This should not really happen, as the p2p merge
      // should not count towards the withdrawal limit.
      kycUrl = new URL(`kyc-spa/${wg.kycAccessToken}`, wg.exchangeBaseUrl).href;
      kycAccessToken = wg.kycAccessToken;
    } else if (pushInc.kycAccessToken) {
      kycUrl = new URL(
        `kyc-spa/${pushInc.kycAccessToken}`,
        pushInc.exchangeBaseUrl,
      ).href;
      kycAccessToken = pushInc.kycAccessToken;
    }

    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,
        stId: pushInc.status,
        scopes: await getScopeForAllExchanges(tx, [pushInc.exchangeBaseUrl]),
        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,
          iconId: peerContractTerms.icon_id,
        },
        timestamp: timestampPreciseFromDb(wg.timestampStart),
        transactionId: this.transactionId,
        abortReason: pushInc.abortReason,
        failReason: pushInc.failReason,
        kycUrl,
        kycPaytoHash: wg.kycPaytoHash,
        ...(wgRetryRecord?.lastError ? { error: wgRetryRecord.lastError } : {}),
      };
    }

    const txState = computePeerPushCreditTransactionState(pushInc);
    return {
      type: TransactionType.PeerPushCredit,
      txState,
      stId: pushInc.status,
      scopes: await getScopeForAllExchanges(tx, [pushInc.exchangeBaseUrl]),
      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,
        iconId: peerContractTerms.icon_id,
      },
      kycUrl,
      kycPaytoHash: pushInc.kycPaytoHash,
      kycAccessToken,
      timestamp: timestampPreciseFromDb(pushInc.timestamp),
      transactionId: this.transactionId,
      abortReason: pushInc.abortReason,
      failReason: pushInc.failReason,
      ...(pushRetryRecord?.lastError
        ? { error: pushRetryRecord.lastError }
        : {}),
    };
  }

  async deleteTransaction(): Promise<void> {
    const res = await this.wex.db.runReadWriteTx(
      {
        storeNames: [
          "withdrawalGroups",
          "planchets",
          "peerPushCredit",
          "tombstones",
          "transactionsMeta",
        ],
      },
      async (tx) => this.deleteTransactionInTx(tx),
    );
    for (const notif of res.notifs) {
      this.wex.ws.notify(notif);
    }
  }

  async deleteTransactionInTx(
    tx: WalletDbReadWriteTransaction<
      [
        "withdrawalGroups",
        "planchets",
        "peerPushCredit",
        "tombstones",
        "transactionsMeta",
      ]
    >,
  ): Promise<{ notifs: WalletNotification[] }> {
    return recordDelete(this, tx, async (rec, notifs) => {
      if (rec.withdrawalGroupId) {
        const withdrawalGroupId = rec.withdrawalGroupId;
        const withdrawalCtx = new WithdrawTransactionContext(
          this.wex,
          withdrawalGroupId,
        );
        await withdrawalCtx.deleteTransactionInTx(tx);
      }
    });
  }

  async suspendTransaction(): Promise<void> {
    await recordTransition(this, {}, async (rec) => {
      switch (rec.status) {
        case PeerPushCreditStatus.DialogProposed:
        case PeerPushCreditStatus.Done:
        case PeerPushCreditStatus.SuspendedMerge:
        case PeerPushCreditStatus.SuspendedMergeKycRequired:
        case PeerPushCreditStatus.SuspendedWithdrawing:
        case PeerPushCreditStatus.SuspendedBalanceKycRequired:
        case PeerPushCreditStatus.SuspendedBalanceKycInit:
        case PeerPushCreditStatus.Failed:
        case PeerPushCreditStatus.Aborted:
          return TransitionResultType.Stay;
        case PeerPushCreditStatus.PendingBalanceKycRequired:
          rec.status = PeerPushCreditStatus.SuspendedBalanceKycRequired;
          return TransitionResultType.Transition;
        case PeerPushCreditStatus.PendingBalanceKycInit:
          rec.status = PeerPushCreditStatus.SuspendedBalanceKycInit;
          return TransitionResultType.Transition;
        case PeerPushCreditStatus.PendingMergeKycRequired:
          rec.status = PeerPushCreditStatus.SuspendedMergeKycRequired;
          return TransitionResultType.Transition;
        case PeerPushCreditStatus.PendingMerge:
          rec.status = PeerPushCreditStatus.SuspendedMerge;
          return TransitionResultType.Transition;
        case PeerPushCreditStatus.PendingWithdrawing:
          // FIXME: Suspend internal withdrawal transaction!
          rec.status = PeerPushCreditStatus.SuspendedWithdrawing;
          return TransitionResultType.Transition;
        default:
          assertUnreachable(rec.status);
      }
    });
    this.wex.taskScheduler.stopShepherdTask(this.taskId);
  }

  async abortTransaction(): Promise<void> {
    await recordTransition(this, {}, async (rec) => {
      switch (rec.status) {
        case PeerPushCreditStatus.Failed:
        case PeerPushCreditStatus.Aborted:
        case PeerPushCreditStatus.Done:
          return TransitionResultType.Stay;
        case PeerPushCreditStatus.SuspendedMerge:
        case PeerPushCreditStatus.DialogProposed:
        case PeerPushCreditStatus.SuspendedMergeKycRequired:
        case PeerPushCreditStatus.SuspendedWithdrawing:
        case PeerPushCreditStatus.PendingBalanceKycRequired:
        case PeerPushCreditStatus.SuspendedBalanceKycRequired:
        case PeerPushCreditStatus.PendingWithdrawing:
        case PeerPushCreditStatus.PendingMergeKycRequired:
        case PeerPushCreditStatus.PendingMerge:
        case PeerPushCreditStatus.PendingBalanceKycInit:
        case PeerPushCreditStatus.SuspendedBalanceKycInit:
          rec.status = PeerPushCreditStatus.Aborted;
          return TransitionResultType.Transition;
        default:
          assertUnreachable(rec.status);
      }
    });
    this.wex.taskScheduler.stopShepherdTask(this.taskId);
  }

  async resumeTransaction(): Promise<void> {
    await recordTransition(this, {}, async (rec) => {
      switch (rec.status) {
        case PeerPushCreditStatus.DialogProposed:
        case PeerPushCreditStatus.PendingMergeKycRequired:
        case PeerPushCreditStatus.PendingMerge:
        case PeerPushCreditStatus.PendingWithdrawing:
        case PeerPushCreditStatus.PendingBalanceKycRequired:
        case PeerPushCreditStatus.PendingBalanceKycInit:
        case PeerPushCreditStatus.Done:
        case PeerPushCreditStatus.Aborted:
        case PeerPushCreditStatus.Failed:
          return TransitionResultType.Stay;
        case PeerPushCreditStatus.SuspendedMerge:
          rec.status = PeerPushCreditStatus.PendingMerge;
          return TransitionResultType.Transition;
        case PeerPushCreditStatus.SuspendedMergeKycRequired:
          rec.status = PeerPushCreditStatus.PendingMergeKycRequired;
          return TransitionResultType.Transition;
        case PeerPushCreditStatus.SuspendedWithdrawing:
          // FIXME: resume underlying "internal-withdrawal" transaction.
          rec.status = PeerPushCreditStatus.PendingWithdrawing;
          return TransitionResultType.Transition;
        case PeerPushCreditStatus.SuspendedBalanceKycRequired:
          rec.status = PeerPushCreditStatus.PendingBalanceKycRequired;
          return TransitionResultType.Transition;
        case PeerPushCreditStatus.SuspendedBalanceKycInit:
          rec.status = PeerPushCreditStatus.PendingBalanceKycInit;
          return TransitionResultType.Transition;
        default:
          assertUnreachable(rec.status);
      }
    });
    this.wex.taskScheduler.startShepherdTask(this.taskId);
  }

  async failTransaction(reason?: TalerErrorDetail): Promise<void> {
    await recordTransition(this, {}, async (rec) => {
      switch (rec.status) {
        case PeerPushCreditStatus.Done:
        case PeerPushCreditStatus.Aborted:
        case PeerPushCreditStatus.Failed:
          // Already in a final state.
          return TransitionResultType.Stay;
        case PeerPushCreditStatus.DialogProposed:
        case PeerPushCreditStatus.PendingMergeKycRequired:
        case PeerPushCreditStatus.PendingMerge:
        case PeerPushCreditStatus.PendingWithdrawing:
        case PeerPushCreditStatus.SuspendedMerge:
        case PeerPushCreditStatus.SuspendedMergeKycRequired:
        case PeerPushCreditStatus.SuspendedWithdrawing:
        case PeerPushCreditStatus.PendingBalanceKycRequired:
        case PeerPushCreditStatus.SuspendedBalanceKycRequired:
        case PeerPushCreditStatus.PendingBalanceKycInit:
        case PeerPushCreditStatus.SuspendedBalanceKycInit:
          rec.status = PeerPushCreditStatus.Failed;
          rec.failReason = reason;
          return TransitionResultType.Transition;
        default:
          assertUnreachable(rec.status);
      }
    });
    this.wex.taskScheduler.stopShepherdTask(this.taskId);
  }
}

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

  // add exchange entry if it doesn't exist already!
  const exchangeBaseUrl = uri.exchangeBaseUrl;
  const exchange = await fetchFreshExchange(wex, exchangeBaseUrl);

  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) {
    const currency = Amounts.currencyOf(existing.existingContractTerms.amount);
    const exchangeBaseUrl = existing.existingPushInc.exchangeBaseUrl;
    const scopeInfo = await wex.db.runAllStoresReadOnlyTx(
      {},
      async (tx) => await getExchangeScopeInfo(tx, exchangeBaseUrl, currency),
    );
    return {
      amount: existing.existingContractTerms.amount,
      amountEffective: existing.existingPushInc.estimatedAmountEffective,
      amountRaw: existing.existingContractTerms.amount,
      contractTerms: existing.existingContractTerms,
      transactionId: constructTransactionIdentifier({
        tag: TransactionType.PeerPushCredit,
        peerPushCreditId: existing.existingPushInc.peerPushCreditId,
      }),
      scopeInfo,
      exchangeBaseUrl,
      ...getPeerCreditLimitInfo(
        exchange,
        existing.existingContractTerms.amount,
      ),
    };
  }

  const contractPriv = uri.contractPriv;
  const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
  const exchangeClient = walletExchangeClient(exchangeBaseUrl, wex);

  const contractResp = await exchangeClient.getContract(contractPub);
  switch (contractResp.case) {
    case "ok":
      break;
    case HttpStatusCode.NotFound:
      // FIXME: appropriated error code
      throw Error("unknown P2P contract");
    default:
      assertUnreachable(contractResp);
  }
  const pursePub = contractResp.body.purse_pub;

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

  const contractTerms = codecForPeerContractTerms().decode(dec.contractTerms);
  const resp = await exchangeClient.getPurseStatusAtDeposit(pursePub);
  switch (resp.case) {
    case "ok":
      break;
    case HttpStatusCode.Gone:
      // FIXME: appropriated error code
      throw Error("aborted peer push credit");
    case HttpStatusCode.NotFound:
      // FIXME: appropriated error code
      throw Error("unknown peer push credit");
    default:
      assertUnreachable(resp);
  }

  const purseStatus = resp.body;

  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),
    WithdrawalRecordType.PeerPushCredit,
    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 ctx = new PeerPushCreditTransactionContext(wex, peerPushCreditId);
  await recordCreate(
    ctx,
    {
      extraStores: ["contractTerms"],
    },
    async (tx) => {
      await tx.contractTerms.put({
        h: contractTermsHash,
        contractTermsRaw: dec.contractTerms,
      });
      return {
        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,
        ),
      };
    },
  );
  wex.taskScheduler.startShepherdTask(ctx.taskId);

  const currency = Amounts.currencyOf(wi.withdrawalAmountRaw);
  const scopeInfo = await wex.db.runReadOnlyTx(
    {
      storeNames: [
        "exchanges",
        "exchangeDetails",
        "globalCurrencyExchanges",
        "globalCurrencyAuditors",
      ],
    },
    (tx) => getExchangeScopeInfo(tx, exchangeBaseUrl, currency),
  );

  return {
    amount: purseStatus.balance,
    amountEffective: wi.withdrawalAmountEffective,
    amountRaw: purseStatus.balance,
    contractTerms: dec.contractTerms,
    transactionId: ctx.transactionId,
    exchangeBaseUrl,
    scopeInfo,
    ...getPeerCreditLimitInfo(exchange, purseStatus.balance),
  };
}

async function processPeerPushDebitMergeKyc(
  wex: WalletExecutionContext,
  peerInc: PeerPushPaymentIncomingRecord,
  contractTerms: PeerContractTerms,
): Promise<TaskRunResult> {
  const ctx = new PeerPushCreditTransactionContext(
    wex,
    peerInc.peerPushCreditId,
  );
  const { exchangeBaseUrl } = peerInc;
  // FIXME: What if this changes? Should be part of the p2p record
  const mergeReserveInfo = await getMergeReserveInfo(wex, {
    exchangeBaseUrl,
  });

  const accountPub = mergeReserveInfo.reservePub;
  const accountPriv = mergeReserveInfo.reservePriv;

  let myKycState: GenericKycStatusReq | undefined;

  if (peerInc.kycPaytoHash) {
    myKycState = {
      accountPriv,
      accountPub,
      // FIXME: Is this the correct amount?
      amount: peerInc.estimatedAmountEffective,
      exchangeBaseUrl,
      operation: "MERGE",
      paytoHash: peerInc.kycPaytoHash,
      lastAmlReview: peerInc.kycLastAmlReview,
      lastCheckCode: peerInc.kycLastCheckCode,
      lastCheckStatus: peerInc.kycLastCheckStatus,
      lastDeny: peerInc.kycLastDeny,
      lastRuleGen: peerInc.kycLastRuleGen,
      haveAccessToken: peerInc.kycAccessToken != null,
    };
  }

  if (myKycState == null || isKycOperationDue(myKycState)) {
    return processPendingMerge(wex, peerInc, contractTerms);
  }

  const algoRes = await runKycCheckAlgo(wex, myKycState);

  if (!algoRes.updatedStatus) {
    return algoRes.taskResult;
  }

  const updatedStatus = algoRes.updatedStatus;

  checkProtocolInvariant(algoRes.requiresAuth != true);

  recordTransition(ctx, {}, async (rec) => {
    rec.kycLastAmlReview = updatedStatus.lastAmlReview;
    rec.kycLastCheckStatus = updatedStatus.lastCheckStatus;
    rec.kycLastCheckCode = updatedStatus.lastCheckCode;
    rec.kycLastDeny = updatedStatus.lastDeny;
    rec.kycLastRuleGen = updatedStatus.lastRuleGen;
    rec.kycAccessToken = updatedStatus.accessToken;
    return TransitionResultType.Transition;
  });
  return algoRes.taskResult;
}

async function transitionPeerPushCreditKycRequired(
  wex: WalletExecutionContext,
  peerInc: PeerPushPaymentIncomingRecord,
  kycPending: LegitimizationNeededResponse,
): Promise<TaskRunResult> {
  const ctx = new PeerPushCreditTransactionContext(
    wex,
    peerInc.peerPushCreditId,
  );

  return await wex.db.runReadWriteTx(
    { storeNames: ["peerPushCredit", "transactionsMeta"] },
    async (tx) => {
      const peerInc = await tx.peerPushCredit.get(ctx.peerPushCreditId);
      if (!peerInc) {
        return TaskRunResult.finished();
      }
      const oldTxState = ctx.recordState(peerInc);
      peerInc.kycPaytoHash = kycPending.h_payto;
      peerInc.status = PeerPushCreditStatus.PendingMergeKycRequired;
      peerInc.kycLastDeny = timestampPreciseToDb(TalerPreciseTimestamp.now());
      const newTxState = computePeerPushCreditTransactionState(peerInc);
      await tx.peerPushCredit.put(peerInc);
      await ctx.updateTransactionMeta(tx);
      applyNotifyTransition(tx.notify, ctx.transactionId, {
        oldTxState: oldTxState.txState,
        newTxState,
        balanceEffect: BalanceEffect.Flags,
        newStId: peerInc.status,
        oldStId: oldTxState.stId,
      });
      return TaskRunResult.progress();
    },
  );
}

async function processPendingMerge(
  wex: WalletExecutionContext,
  peerInc: PeerPushPaymentIncomingRecord,
  contractTerms: PeerContractTerms,
): Promise<TaskRunResult> {
  const { peerPushCreditId } = peerInc;
  const ctx = new PeerPushCreditTransactionContext(wex, peerPushCreditId);

  const kycCheckRes = await checkIncomingAmountLegalUnderKycBalanceThreshold(
    wex,
    peerInc.exchangeBaseUrl,
    peerInc.estimatedAmountEffective,
  );

  if (kycCheckRes.result === "violation") {
    // Do this before we transition so that the exchange is already in the right state.
    await handleStartExchangeWalletKyc(wex, {
      amount: kycCheckRes.nextThreshold,
      exchangeBaseUrl: peerInc.exchangeBaseUrl,
    });
    await recordTransitionStatus(
      ctx,
      PeerPushCreditStatus.PendingMerge,
      PeerPushCreditStatus.PendingBalanceKycInit,
    );
    return TaskRunResult.progress();
  }

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

  // FIXME: What if this changes? Should be part of the p2p record
  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 exchangeClient = walletExchangeClient(peerInc.exchangeBaseUrl, wex);

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

  const mergeResp = await exchangeClient.postPurseMerge(
    peerInc.pursePub,
    mergeReq,
  );

  logger.trace(`merge request: ${j2s(mergeReq)}`);

  switch (mergeResp.case) {
    case "ok":
      logger.trace(`merge response: ${j2s(mergeResp.body)}`);
      break;
    case HttpStatusCode.UnavailableForLegalReasons:
      const kycLegiNeededResp = mergeResp.body;
      logger.info(`kyc legitimization needed response: ${j2s(mergeResp.body)}`);
      return transitionPeerPushCreditKycRequired(
        wex,
        peerInc,
        kycLegiNeededResp,
      );
    case HttpStatusCode.Conflict:
      // FIXME: Check signature.
      // FIXME: status completed by other
      await recordTransitionStatus(
        ctx,
        PeerPushCreditStatus.PendingMerge,
        PeerPushCreditStatus.Aborted,
      );
      return TaskRunResult.finished();
    case HttpStatusCode.Gone:
      // FIXME: status expired
      await ctx.abortTransaction();
      return TaskRunResult.finished();
    case HttpStatusCode.Forbidden:
    case HttpStatusCode.NotFound:
      await ctx.failTransaction(mergeResp.detail);
      return TaskRunResult.finished();
    default:
      assertUnreachable(mergeResp);
  }

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

  await wex.db.runReadWriteTx(
    {
      storeNames: [
        "contractTerms",
        "peerPushCredit",
        "withdrawalGroups",
        "reserves",
        "exchanges",
        "exchangeDetails",
        "transactionsMeta",
      ],
    },
    async (tx) => {
      const peerInc = await tx.peerPushCredit.get(peerPushCreditId);
      if (!peerInc) {
        return undefined;
      }
      const oldTxState = ctx.recordState(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);
      await ctx.updateTransactionMeta(tx);
      const newTxState = computePeerPushCreditTransactionState(peerInc);
      applyNotifyTransition(tx.notify, ctx.transactionId, {
        oldTxState: oldTxState.txState,
        newTxState,
        balanceEffect: BalanceEffect.Any,
        newStId: peerInc.status,
        oldStId: oldTxState.stId,
      });
    },
  );
  return TaskRunResult.backoff();
}

async function processPendingWithdrawing(
  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 ctx = new PeerPushCreditTransactionContext(
    wex,
    peerInc.peerPushCreditId,
  );
  const wgId = peerInc.withdrawalGroupId;
  let finished: boolean = false;
  await wex.db.runReadWriteTx(
    { storeNames: ["peerPushCredit", "withdrawalGroups", "transactionsMeta"] },
    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 = ctx.recordState(ppi);
      const wg = await tx.withdrawalGroups.get(wgId);
      if (!wg) {
        // FIXME: Fail the operation instead?
        return;
      }
      switch (wg.status) {
        case WithdrawalGroupStatus.Done:
          finished = true;
          ppi.status = PeerPushCreditStatus.Done;
          break;
        // FIXME: Also handle other final states!
      }
      await tx.peerPushCredit.put(ppi);
      await ctx.updateTransactionMeta(tx);
      const newTxState = ctx.recordState(ppi);
      applyNotifyTransition(tx.notify, ctx.transactionId, {
        oldTxState: oldTxState.txState,
        newTxState: newTxState.txState,
        balanceEffect: BalanceEffect.Any,
        oldStId: oldTxState.stId,
        newStId: newTxState.stId,
      });
      return;
    },
  );
  if (finished) {
    return TaskRunResult.finished();
  } else {
    // FIXME: Return indicator that we depend on the other operation!
    return TaskRunResult.backoff();
  }
}

async function processPeerPushDebitDialogProposed(
  wex: WalletExecutionContext,
  pullIni: PeerPushPaymentIncomingRecord,
): Promise<TaskRunResult> {
  const ctx = new PeerPushCreditTransactionContext(
    wex,
    pullIni.peerPushCreditId,
  );
  const exchangeClient = walletExchangeClient(pullIni.exchangeBaseUrl, wex);
  const resp = await exchangeClient.getPurseStatusAtMerge(
    pullIni.pursePub,
    true,
  );

  switch (resp.case) {
    case "ok":
      break;
    case HttpStatusCode.Gone:
      // Exchange says that purse doesn't exist anymore => expired!
      await recordTransitionStatus(
        ctx,
        PeerPushCreditStatus.DialogProposed,
        PeerPushCreditStatus.Aborted,
      );
      return TaskRunResult.finished();
    case HttpStatusCode.NotFound:
      await ctx.failTransaction(resp.detail);
      return TaskRunResult.finished();
    default:
      assertUnreachable(resp);
  }

  if (isPurseMerged(resp.body)) {
    logger.info("purse completed by another wallet");
    await recordTransitionStatus(
      ctx,
      PeerPushCreditStatus.DialogProposed,
      PeerPushCreditStatus.Aborted,
    );
    return TaskRunResult.finished();
  }

  return TaskRunResult.longpollReturnedPending();
}

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

  const { peerInc, contractTerms } = await wex.db.runReadOnlyTx(
    { storeNames: ["contractTerms", "peerPushCredit", "transactionsMeta"] },
    async (tx) => {
      const rec = await tx.peerPushCredit.get(peerPushCreditId);
      let contractTerms = null;
      if (rec != null) {
        const contract = await tx.contractTerms.get(rec.contractTermsHash);
        if (contract != null) {
          contractTerms = contract.contractTermsRaw;
        }
      }
      return {
        peerInc: rec,
        contractTerms,
      };
    },
  );

  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.DialogProposed:
      return processPeerPushDebitDialogProposed(wex, peerInc);
    case PeerPushCreditStatus.PendingMergeKycRequired:
      if (!peerInc.kycPaytoHash) {
        throw Error("invalid state, kycPaytoHash required");
      }
      return processPeerPushDebitMergeKyc(wex, peerInc, contractTerms);
    case PeerPushCreditStatus.PendingMerge:
      return processPendingMerge(wex, peerInc, contractTerms);
    case PeerPushCreditStatus.PendingWithdrawing:
      return processPendingWithdrawing(wex, peerInc);
    case PeerPushCreditStatus.PendingBalanceKycInit:
    case PeerPushCreditStatus.PendingBalanceKycRequired:
      return processPeerPushCreditBalanceKyc(ctx, peerInc);
    default:
      return TaskRunResult.finished();
  }
}

async function processPeerPushCreditBalanceKyc(
  ctx: PeerPushCreditTransactionContext,
  peerInc: PeerPushPaymentIncomingRecord,
): Promise<TaskRunResult> {
  const exchangeBaseUrl = peerInc.exchangeBaseUrl;
  const amount = peerInc.estimatedAmountEffective;

  const ret = await genericWaitForStateVal(ctx.wex, {
    async checkState(): Promise<BalanceThresholdCheckResult | undefined> {
      const checkRes = await checkIncomingAmountLegalUnderKycBalanceThreshold(
        ctx.wex,
        exchangeBaseUrl,
        amount,
      );
      logger.info(
        `balance check result for ${exchangeBaseUrl} +${amount}: ${j2s(
          checkRes,
        )}`,
      );
      if (checkRes.result === "ok") {
        return checkRes;
      }
      if (
        peerInc.status === PeerPushCreditStatus.PendingBalanceKycInit &&
        checkRes.walletKycStatus === ExchangeWalletKycStatus.Legi
      ) {
        return checkRes;
      }
      await handleStartExchangeWalletKyc(ctx.wex, {
        amount: checkRes.nextThreshold,
        exchangeBaseUrl,
      });
      return undefined;
    },
    filterNotification(notif) {
      return (
        (notif.type === NotificationType.ExchangeStateTransition &&
          notif.exchangeBaseUrl === exchangeBaseUrl) ||
        notif.type === NotificationType.BalanceChange
      );
    },
  });

  if (ret.result === "ok") {
    await recordTransitionStatus(
      ctx,
      PeerPushCreditStatus.PendingBalanceKycRequired,
      PeerPushCreditStatus.PendingMerge,
    );
    return TaskRunResult.progress();
  } else if (
    peerInc.status === PeerPushCreditStatus.PendingBalanceKycInit &&
    ret.walletKycStatus === ExchangeWalletKycStatus.Legi
  ) {
    await recordTransition(ctx, {}, async (rec) => {
      if (rec.status === PeerPushCreditStatus.PendingBalanceKycInit) {
        rec.status = PeerPushCreditStatus.PendingBalanceKycRequired;
        rec.kycAccessToken = ret.walletKycAccessToken;
        return TransitionResultType.Transition;
      } else {
        return TransitionResultType.Stay;
      }
    });
    return TaskRunResult.progress();
  } else {
    throw Error("not reached");
  }
}

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

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

  const res = await wex.db.runReadOnlyTx(
    { storeNames: ["contractTerms", "peerPushCredit", "transactionsMeta"] },
    async (tx) => {
      const rec = await tx.peerPushCredit.get(ctx.peerPushCreditId);
      if (!rec) {
        return;
      }
      const ct = await tx.contractTerms.get(rec.contractTermsHash);
      if (!ct) {
        return undefined;
      }
      return {
        peerInc: rec,
        contractTerms: ct.contractTermsRaw as PeerContractTerms,
      };
    },
  );

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

  const peerInc = res.peerInc;

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

  if (checkPeerCreditHardLimitExceeded(exchange, res.contractTerms.amount)) {
    throw Error("peer credit would exceed hard KYC limit");
  }
  await recordTransitionStatus(
    ctx,
    PeerPushCreditStatus.DialogProposed,
    PeerPushCreditStatus.PendingMerge,
  );

  wex.taskScheduler.stopShepherdTask(ctx.taskId);
  wex.taskScheduler.startShepherdTask(ctx.taskId);

  return {
    transactionId: ctx.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:
      if (pushCreditRecord.kycAccessToken) {
        return {
          major: TransactionMajorState.Pending,
          minor: TransactionMinorState.MergeKycRequired,
        };
      } else {
        return {
          major: TransactionMajorState.Pending,
          minor: TransactionMinorState.KycInit,
        };
      }
    case PeerPushCreditStatus.SuspendedMergeKycRequired:
      if (pushCreditRecord.kycAccessToken) {
        return {
          major: TransactionMajorState.Suspended,
          minor: TransactionMinorState.MergeKycRequired,
        };
      } else {
        return {
          major: TransactionMajorState.Suspended,
          minor: TransactionMinorState.KycInit,
        };
      }
    case PeerPushCreditStatus.PendingWithdrawing:
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.Withdraw,
      };
    case PeerPushCreditStatus.SuspendedMerge:
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.Merge,
      };
    case PeerPushCreditStatus.SuspendedWithdrawing:
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.Withdraw,
      };
    case PeerPushCreditStatus.Aborted:
      return {
        major: TransactionMajorState.Aborted,
      };
    case PeerPushCreditStatus.Failed:
      return {
        major: TransactionMajorState.Failed,
      };
    case PeerPushCreditStatus.PendingBalanceKycRequired:
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.BalanceKycRequired,
      };
    case PeerPushCreditStatus.SuspendedBalanceKycRequired:
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.BalanceKycRequired,
      };
    case PeerPushCreditStatus.PendingBalanceKycInit:
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.BalanceKycInit,
      };
    case PeerPushCreditStatus.SuspendedBalanceKycInit:
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.BalanceKycInit,
      };
    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.PendingBalanceKycRequired:
      return [TransactionAction.Suspend, TransactionAction.Abort];
    case PeerPushCreditStatus.SuspendedBalanceKycRequired:
      return [TransactionAction.Resume, TransactionAction.Abort];
    case PeerPushCreditStatus.PendingBalanceKycInit:
      return [TransactionAction.Suspend, TransactionAction.Abort];
    case PeerPushCreditStatus.SuspendedBalanceKycInit:
      return [TransactionAction.Resume, TransactionAction.Abort];
    case PeerPushCreditStatus.Aborted:
      return [TransactionAction.Delete];
    case PeerPushCreditStatus.Failed:
      return [TransactionAction.Delete];
    default:
      assertUnreachable(pushCreditRecord.status);
  }
}
