/*
 This file is part of GNU Taler
 (C) 2019 GNUnet e.V.
 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 
 */
import { AmountJson } from "../util/amounts";
import {
  DenominationRecord,
  Stores,
  DenominationStatus,
  CoinStatus,
  CoinRecord,
  PlanchetRecord,
  initRetryInfo,
  updateRetryInfoTimeout,
} from "../types/dbTypes";
import * as Amounts from "../util/amounts";
import {
  getTimestampNow,
  AcceptWithdrawalResponse,
  BankWithdrawDetails,
  ExchangeWithdrawDetails,
  WithdrawDetails,
  OperationError,
} from "../types/walletTypes";
import { WithdrawOperationStatusResponse } from "../types/talerTypes";
import { InternalWalletState } from "./state";
import { parseWithdrawUri } from "../util/taleruri";
import { Logger } from "../util/logging";
import {
  oneShotGet,
  oneShotPut,
  oneShotIterIndex,
  oneShotGetIndexed,
  runWithWriteTransaction,
  oneShotMutate,
} from "../util/query";
import {
  updateExchangeFromUrl,
  getExchangePaytoUri,
  getExchangeTrust,
} from "./exchanges";
import { createReserve, processReserveBankStatus } from "./reserves";
import { WALLET_PROTOCOL_VERSION } from "../wallet";
import * as LibtoolVersion from "../util/libtoolVersion";
import { guardOperationException } from "./errors";
import { NotificationType } from "../types/notifications";
const logger = new Logger("withdraw.ts");
function isWithdrawableDenom(d: DenominationRecord) {
  const now = getTimestampNow();
  const started = now.t_ms >= d.stampStart.t_ms;
  const stillOkay = d.stampExpireWithdraw.t_ms + 60 * 1000 > now.t_ms;
  return started && stillOkay;
}
/**
 * Get a list of denominations (with repetitions possible)
 * whose total value is as close as possible to the available
 * amount, but never larger.
 */
export function getWithdrawDenomList(
  amountAvailable: AmountJson,
  denoms: DenominationRecord[],
): DenominationRecord[] {
  let remaining = Amounts.copy(amountAvailable);
  const ds: DenominationRecord[] = [];
  denoms = denoms.filter(isWithdrawableDenom);
  denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value));
  // This is an arbitrary number of coins
  // we can withdraw in one go.  It's not clear if this limit
  // is useful ...
  for (let i = 0; i < 1000; i++) {
    let found = false;
    for (const d of denoms) {
      const cost = Amounts.add(d.value, d.feeWithdraw).amount;
      if (Amounts.cmp(remaining, cost) < 0) {
        continue;
      }
      found = true;
      remaining = Amounts.sub(remaining, cost).amount;
      ds.push(d);
      break;
    }
    if (!found) {
      break;
    }
  }
  return ds;
}
/**
 * Get information about a withdrawal from
 * a taler://withdraw URI by asking the bank.
 */
async function getBankWithdrawalInfo(
  ws: InternalWalletState,
  talerWithdrawUri: string,
): Promise {
  const uriResult = parseWithdrawUri(talerWithdrawUri);
  if (!uriResult) {
    throw Error("can't parse URL");
  }
  const resp = await ws.http.get(uriResult.statusUrl);
  if (resp.status !== 200) {
    throw Error(`unexpected status (${resp.status}) from bank for ${uriResult.statusUrl}`);
  }
  const respJson = await resp.json();
  console.log("resp:", respJson);
  const status = WithdrawOperationStatusResponse.checked(respJson);
  return {
    amount: Amounts.parseOrThrow(status.amount),
    confirmTransferUrl: status.confirm_transfer_url,
    extractedStatusUrl: uriResult.statusUrl,
    selectionDone: status.selection_done,
    senderWire: status.sender_wire,
    suggestedExchange: status.suggested_exchange,
    transferDone: status.transfer_done,
    wireTypes: status.wire_types,
  };
}
export async function acceptWithdrawal(
  ws: InternalWalletState,
  talerWithdrawUri: string,
  selectedExchange: string,
): Promise {
  const withdrawInfo = await getBankWithdrawalInfo(ws, talerWithdrawUri);
  const exchangeWire = await getExchangePaytoUri(
    ws,
    selectedExchange,
    withdrawInfo.wireTypes,
  );
  const reserve = await createReserve(ws, {
    amount: withdrawInfo.amount,
    bankWithdrawStatusUrl: withdrawInfo.extractedStatusUrl,
    exchange: selectedExchange,
    senderWire: withdrawInfo.senderWire,
    exchangeWire: exchangeWire,
  });
  // We do this here, as the reserve should be registered before we return,
  // so that we can redirect the user to the bank's status page.
  await processReserveBankStatus(ws, reserve.reservePub);
  console.log("acceptWithdrawal: returning");
  return {
    reservePub: reserve.reservePub,
    confirmTransferUrl: withdrawInfo.confirmTransferUrl,
  };
}
async function getPossibleDenoms(
  ws: InternalWalletState,
  exchangeBaseUrl: string,
): Promise {
  return await oneShotIterIndex(
    ws.db,
    Stores.denominations.exchangeBaseUrlIndex,
    exchangeBaseUrl,
  ).filter(d => {
    return (
      d.status === DenominationStatus.Unverified ||
      d.status === DenominationStatus.VerifiedGood
    );
  });
}
/**
 * Given a planchet, withdraw a coin from the exchange.
 */
async function processPlanchet(
  ws: InternalWalletState,
  withdrawalSessionId: string,
  coinIdx: number,
): Promise {
  const withdrawalSession = await oneShotGet(
    ws.db,
    Stores.withdrawalSession,
    withdrawalSessionId,
  );
  if (!withdrawalSession) {
    return;
  }
  if (withdrawalSession.withdrawn[coinIdx]) {
    return;
  }
  if (withdrawalSession.source.type === "reserve") {
  }
  const planchet = withdrawalSession.planchets[coinIdx];
  if (!planchet) {
    console.log("processPlanchet: planchet not found");
    return;
  }
  const exchange = await oneShotGet(
    ws.db,
    Stores.exchanges,
    withdrawalSession.exchangeBaseUrl,
  );
  if (!exchange) {
    console.error("db inconsistent: exchange for planchet not found");
    return;
  }
  const denom = await oneShotGet(ws.db, Stores.denominations, [
    withdrawalSession.exchangeBaseUrl,
    planchet.denomPub,
  ]);
  if (!denom) {
    console.error("db inconsistent: denom for planchet not found");
    return;
  }
  const wd: any = {};
  wd.denom_pub_hash = planchet.denomPubHash;
  wd.reserve_pub = planchet.reservePub;
  wd.reserve_sig = planchet.withdrawSig;
  wd.coin_ev = planchet.coinEv;
  const reqUrl = new URL("reserve/withdraw", exchange.baseUrl).href;
  const resp = await ws.http.postJson(reqUrl, wd);
  if (resp.status !== 200) {
    throw Error(`unexpected status ${resp.status} for withdraw`);
  }
  const r = await resp.json();
  const denomSig = await ws.cryptoApi.rsaUnblind(
    r.ev_sig,
    planchet.blindingKey,
    planchet.denomPub,
  );
  const isValid = await ws.cryptoApi.rsaVerify(planchet.coinPub, denomSig, planchet.denomPub);
  if (!isValid) {
    throw Error("invalid RSA signature by the exchange");
  }
  const coin: CoinRecord = {
    blindingKey: planchet.blindingKey,
    coinPriv: planchet.coinPriv,
    coinPub: planchet.coinPub,
    currentAmount: planchet.coinValue,
    denomPub: planchet.denomPub,
    denomPubHash: planchet.denomPubHash,
    denomSig,
    exchangeBaseUrl: withdrawalSession.exchangeBaseUrl,
    reservePub: planchet.reservePub,
    status: CoinStatus.Fresh,
    coinIndex: coinIdx,
    withdrawSessionId: withdrawalSessionId,
  };
  let withdrawSessionFinished = false;
  let reserveDepleted = false;
  const success = await runWithWriteTransaction(
    ws.db,
    [Stores.coins, Stores.withdrawalSession, Stores.reserves],
    async tx => {
      const ws = await tx.get(Stores.withdrawalSession, withdrawalSessionId);
      if (!ws) {
        return false;
      }
      if (ws.withdrawn[coinIdx]) {
        // Already withdrawn
        return false;
      }
      ws.withdrawn[coinIdx] = true;
      ws.lastCoinErrors[coinIdx] = undefined;
      let numDone = 0;
      for (let i = 0; i < ws.withdrawn.length; i++) {
        if (ws.withdrawn[i]) {
          numDone++;
        }
      }
      if (numDone === ws.denoms.length) {
        ws.finishTimestamp = getTimestampNow();
        ws.lastError = undefined;
        ws.retryInfo = initRetryInfo(false);
        withdrawSessionFinished = true;
      }
      await tx.put(Stores.withdrawalSession, ws);
      if (!planchet.isFromTip) {
        const r = await tx.get(Stores.reserves, planchet.reservePub);
        if (r) {
          r.withdrawCompletedAmount = Amounts.add(
            r.withdrawCompletedAmount,
            Amounts.add(denom.value, denom.feeWithdraw).amount,
          ).amount;
          if (Amounts.cmp(r.withdrawCompletedAmount, r.withdrawAllocatedAmount) == 0) {
            reserveDepleted = true;
          }
          await tx.put(Stores.reserves, r);
        }
      }
      await tx.add(Stores.coins, coin);
      return true;
    },
  );
  if (success) {
    ws.notify( {
      type: NotificationType.CoinWithdrawn,
    } );
  }
  if (withdrawSessionFinished) {
    ws.notify({
      type: NotificationType.WithdrawSessionFinished,
      withdrawSessionId: withdrawalSessionId,
    });
  }
  if (reserveDepleted && withdrawalSession.source.type === "reserve") {
    ws.notify({
      type: NotificationType.ReserveDepleted,
      reservePub: withdrawalSession.source.reservePub,
    });
  }
}
/**
 * Get a list of denominations to withdraw from the given exchange for the
 * given amount, making sure that all denominations' signatures are verified.
 *
 * Writes to the DB in order to record the result from verifying
 * denominations.
 */
export async function getVerifiedWithdrawDenomList(
  ws: InternalWalletState,
  exchangeBaseUrl: string,
  amount: AmountJson,
): Promise {
  const exchange = await oneShotGet(ws.db, Stores.exchanges, exchangeBaseUrl);
  if (!exchange) {
    console.log("exchange not found");
    throw Error(`exchange ${exchangeBaseUrl} not found`);
  }
  const exchangeDetails = exchange.details;
  if (!exchangeDetails) {
    console.log("exchange details not available");
    throw Error(`exchange ${exchangeBaseUrl} details not available`);
  }
  console.log("getting possible denoms");
  const possibleDenoms = await getPossibleDenoms(ws, exchange.baseUrl);
  console.log("got possible denoms");
  let allValid = false;
  let selectedDenoms: DenominationRecord[];
  do {
    allValid = true;
    const nextPossibleDenoms = [];
    selectedDenoms = getWithdrawDenomList(amount, possibleDenoms);
    console.log("got withdraw denom list");
    for (const denom of selectedDenoms || []) {
      if (denom.status === DenominationStatus.Unverified) {
        console.log(
          "checking validity",
          denom,
          exchangeDetails.masterPublicKey,
        );
        const valid = await ws.cryptoApi.isValidDenom(
          denom,
          exchangeDetails.masterPublicKey,
        );
        console.log("done checking validity");
        if (!valid) {
          denom.status = DenominationStatus.VerifiedBad;
          allValid = false;
        } else {
          denom.status = DenominationStatus.VerifiedGood;
          nextPossibleDenoms.push(denom);
        }
        await oneShotPut(ws.db, Stores.denominations, denom);
      } else {
        nextPossibleDenoms.push(denom);
      }
    }
  } while (selectedDenoms.length > 0 && !allValid);
  console.log("returning denoms");
  return selectedDenoms;
}
async function makePlanchet(
  ws: InternalWalletState,
  withdrawalSessionId: string,
  coinIndex: number,
): Promise {
  const withdrawalSession = await oneShotGet(
    ws.db,
    Stores.withdrawalSession,
    withdrawalSessionId,
  );
  if (!withdrawalSession) {
    return;
  }
  const src = withdrawalSession.source;
  if (src.type !== "reserve") {
    throw Error("invalid state");
  }
  const reserve = await oneShotGet(ws.db, Stores.reserves, src.reservePub);
  if (!reserve) {
    return;
  }
  const denom = await oneShotGet(ws.db, Stores.denominations, [
    withdrawalSession.exchangeBaseUrl,
    withdrawalSession.denoms[coinIndex],
  ]);
  if (!denom) {
    return;
  }
  const r = await ws.cryptoApi.createPlanchet({
    denomPub: denom.denomPub,
    feeWithdraw: denom.feeWithdraw,
    reservePriv: reserve.reservePriv,
    reservePub: reserve.reservePub,
    value: denom.value,
  });
  const newPlanchet: PlanchetRecord = {
    blindingKey: r.blindingKey,
    coinEv: r.coinEv,
    coinPriv: r.coinPriv,
    coinPub: r.coinPub,
    coinValue: r.coinValue,
    denomPub: r.denomPub,
    denomPubHash: r.denomPubHash,
    isFromTip: false,
    reservePub: r.reservePub,
    withdrawSig: r.withdrawSig,
  };
  await runWithWriteTransaction(ws.db, [Stores.withdrawalSession], async tx => {
    const myWs = await tx.get(Stores.withdrawalSession, withdrawalSessionId);
    if (!myWs) {
      return;
    }
    if (myWs.planchets[coinIndex]) {
      return;
    }
    myWs.planchets[coinIndex] = newPlanchet;
    await tx.put(Stores.withdrawalSession, myWs);
  });
}
async function processWithdrawCoin(
  ws: InternalWalletState,
  withdrawalSessionId: string,
  coinIndex: number,
) {
  logger.trace("starting withdraw for coin", coinIndex);
  const withdrawalSession = await oneShotGet(
    ws.db,
    Stores.withdrawalSession,
    withdrawalSessionId,
  );
  if (!withdrawalSession) {
    console.log("ws doesn't exist");
    return;
  }
  const coin = await oneShotGetIndexed(
    ws.db,
    Stores.coins.byWithdrawalWithIdx,
    [withdrawalSessionId, coinIndex],
  );
  if (coin) {
    console.log("coin already exists");
    return;
  }
  if (!withdrawalSession.planchets[coinIndex]) {
    const key = `${withdrawalSessionId}-${coinIndex}`;
    await ws.memoMakePlanchet.memo(key, async () => {
      logger.trace("creating planchet for coin", coinIndex);
      return makePlanchet(ws, withdrawalSessionId, coinIndex);
    });
  }
  await processPlanchet(ws, withdrawalSessionId, coinIndex);
}
async function incrementWithdrawalRetry(
  ws: InternalWalletState,
  withdrawalSessionId: string,
  err: OperationError | undefined,
): Promise {
  await runWithWriteTransaction(ws.db, [Stores.withdrawalSession], async tx => {
    const wsr = await tx.get(Stores.withdrawalSession, withdrawalSessionId);
    if (!wsr) {
      return;
    }
    if (!wsr.retryInfo) {
      return;
    }
    wsr.retryInfo.retryCounter++;
    updateRetryInfoTimeout(wsr.retryInfo);
    wsr.lastError = err;
    await tx.put(Stores.withdrawalSession, wsr);
  });
  ws.notify({ type: NotificationType.WithdrawOperationError });
}
export async function processWithdrawSession(
  ws: InternalWalletState,
  withdrawalSessionId: string,
  forceNow: boolean = false,
): Promise {
  const onOpErr = (e: OperationError) =>
    incrementWithdrawalRetry(ws, withdrawalSessionId, e);
  await guardOperationException(
    () => processWithdrawSessionImpl(ws, withdrawalSessionId, forceNow),
    onOpErr,
  );
}
async function resetWithdrawSessionRetry(
  ws: InternalWalletState,
  withdrawalSessionId: string,
) {
  await oneShotMutate(ws.db, Stores.withdrawalSession, withdrawalSessionId, (x) => {
    if (x.retryInfo.active) {
      x.retryInfo = initRetryInfo();
    }
    return x;
  });
}
async function processWithdrawSessionImpl(
  ws: InternalWalletState,
  withdrawalSessionId: string,
  forceNow: boolean,
): Promise {
  logger.trace("processing withdraw session", withdrawalSessionId);
  if (forceNow) {
    await resetWithdrawSessionRetry(ws, withdrawalSessionId);
  }
  const withdrawalSession = await oneShotGet(
    ws.db,
    Stores.withdrawalSession,
    withdrawalSessionId,
  );
  if (!withdrawalSession) {
    logger.trace("withdraw session doesn't exist");
    return;
  }
  const ps = withdrawalSession.denoms.map((d, i) =>
    processWithdrawCoin(ws, withdrawalSessionId, i),
  );
  await Promise.all(ps);
  return;
}
export async function getExchangeWithdrawalInfo(
  ws: InternalWalletState,
  baseUrl: string,
  amount: AmountJson,
): Promise {
  const exchangeInfo = await updateExchangeFromUrl(ws, baseUrl);
  const exchangeDetails = exchangeInfo.details;
  if (!exchangeDetails) {
    throw Error(`exchange ${exchangeInfo.baseUrl} details not available`);
  }
  const exchangeWireInfo = exchangeInfo.wireInfo;
  if (!exchangeWireInfo) {
    throw Error(`exchange ${exchangeInfo.baseUrl} wire details not available`);
  }
  const selectedDenoms = await getVerifiedWithdrawDenomList(
    ws,
    baseUrl,
    amount,
  );
  let acc = Amounts.getZero(amount.currency);
  for (const d of selectedDenoms) {
    acc = Amounts.add(acc, d.feeWithdraw).amount;
  }
  const actualCoinCost = selectedDenoms
    .map((d: DenominationRecord) => Amounts.add(d.value, d.feeWithdraw).amount)
    .reduce((a, b) => Amounts.add(a, b).amount);
  const exchangeWireAccounts: string[] = [];
  for (let account of exchangeWireInfo.accounts) {
    exchangeWireAccounts.push(account.url);
  }
  const { isTrusted, isAudited } = await getExchangeTrust(ws, exchangeInfo);
  let earliestDepositExpiration = selectedDenoms[0].stampExpireDeposit;
  for (let i = 1; i < selectedDenoms.length; i++) {
    const expireDeposit = selectedDenoms[i].stampExpireDeposit;
    if (expireDeposit.t_ms < earliestDepositExpiration.t_ms) {
      earliestDepositExpiration = expireDeposit;
    }
  }
  const possibleDenoms = await oneShotIterIndex(
    ws.db,
    Stores.denominations.exchangeBaseUrlIndex,
    baseUrl,
  ).filter(d => d.isOffered);
  const trustedAuditorPubs = [];
  const currencyRecord = await oneShotGet(
    ws.db,
    Stores.currencies,
    amount.currency,
  );
  if (currencyRecord) {
    trustedAuditorPubs.push(...currencyRecord.auditors.map(a => a.auditorPub));
  }
  let versionMatch;
  if (exchangeDetails.protocolVersion) {
    versionMatch = LibtoolVersion.compare(
      WALLET_PROTOCOL_VERSION,
      exchangeDetails.protocolVersion,
    );
    if (
      versionMatch &&
      !versionMatch.compatible &&
      versionMatch.currentCmp === -1
    ) {
      console.warn(
        `wallet version ${WALLET_PROTOCOL_VERSION} might be outdated ` +
          `(exchange has ${exchangeDetails.protocolVersion}), checking for updates`,
      );
    }
  }
  let tosAccepted = false;
  if (exchangeInfo.termsOfServiceAcceptedTimestamp) {
    if (exchangeInfo.termsOfServiceAcceptedEtag == exchangeInfo.termsOfServiceLastEtag) {
      tosAccepted = true;
    }
  }
  const ret: ExchangeWithdrawDetails = {
    earliestDepositExpiration,
    exchangeInfo,
    exchangeWireAccounts,
    exchangeVersion: exchangeDetails.protocolVersion || "unknown",
    isAudited,
    isTrusted,
    numOfferedDenoms: possibleDenoms.length,
    overhead: Amounts.sub(amount, actualCoinCost).amount,
    selectedDenoms,
    trustedAuditorPubs,
    versionMatch,
    walletVersion: WALLET_PROTOCOL_VERSION,
    wireFees: exchangeWireInfo,
    withdrawFee: acc,
    termsOfServiceAccepted: tosAccepted,
  };
  return ret;
}
export async function getWithdrawDetailsForUri(
  ws: InternalWalletState,
  talerWithdrawUri: string,
  maybeSelectedExchange?: string,
): Promise {
  const info = await getBankWithdrawalInfo(ws, talerWithdrawUri);
  let rci: ExchangeWithdrawDetails | undefined = undefined;
  if (maybeSelectedExchange) {
    rci = await getExchangeWithdrawalInfo(
      ws,
      maybeSelectedExchange,
      info.amount,
    );
  }
  return {
    bankWithdrawDetails: info,
    exchangeWithdrawDetails: rci,
  };
}