/*
 This file is part of GNU Taler
 (C) 2019 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 
 */
/**
 * Implementation of the refund operation.
 *
 * @author Florian Dold
 */
/**
 * Imports.
 */
import { InternalWalletState } from "./state";
import {
  OperationError,
  getTimestampNow,
  RefreshReason,
  CoinPublicKey,
} from "../types/walletTypes";
import {
  Stores,
  updateRetryInfoTimeout,
  initRetryInfo,
  CoinStatus,
  RefundReason,
  RefundEventRecord,
  RefundInfo,
} from "../types/dbTypes";
import { NotificationType } from "../types/notifications";
import { parseRefundUri } from "../util/taleruri";
import { createRefreshGroup, getTotalRefreshCost } from "./refresh";
import * as Amounts from "../util/amounts";
import {
  MerchantRefundPermission,
  MerchantRefundResponse,
  RefundRequest,
} from "../types/talerTypes";
import { AmountJson } from "../util/amounts";
import { guardOperationException, OperationFailedError } from "./errors";
import { randomBytes } from "../crypto/primitives/nacl-fast";
import { encodeCrock } from "../crypto/talerCrypto";
import { HttpResponseStatus } from "../util/http";
async function incrementPurchaseQueryRefundRetry(
  ws: InternalWalletState,
  proposalId: string,
  err: OperationError | undefined,
): Promise {
  console.log("incrementing purchase refund query retry with error", err);
  await ws.db.runWithWriteTransaction([Stores.purchases], async tx => {
    const pr = await tx.get(Stores.purchases, proposalId);
    if (!pr) {
      return;
    }
    if (!pr.refundStatusRetryInfo) {
      return;
    }
    pr.refundStatusRetryInfo.retryCounter++;
    updateRetryInfoTimeout(pr.refundStatusRetryInfo);
    pr.lastRefundStatusError = err;
    await tx.put(Stores.purchases, pr);
  });
  ws.notify({ type: NotificationType.RefundStatusOperationError });
}
async function incrementPurchaseApplyRefundRetry(
  ws: InternalWalletState,
  proposalId: string,
  err: OperationError | undefined,
): Promise {
  console.log("incrementing purchase refund apply retry with error", err);
  await ws.db.runWithWriteTransaction([Stores.purchases], async tx => {
    const pr = await tx.get(Stores.purchases, proposalId);
    if (!pr) {
      return;
    }
    if (!pr.refundApplyRetryInfo) {
      return;
    }
    pr.refundApplyRetryInfo.retryCounter++;
    updateRetryInfoTimeout(pr.refundStatusRetryInfo);
    pr.lastRefundApplyError = err;
    await tx.put(Stores.purchases, pr);
  });
  ws.notify({ type: NotificationType.RefundApplyOperationError });
}
export async function getFullRefundFees(
  ws: InternalWalletState,
  refundPermissions: MerchantRefundPermission[],
): Promise {
  if (refundPermissions.length === 0) {
    throw Error("no refunds given");
  }
  const coin0 = await ws.db.get(Stores.coins, refundPermissions[0].coin_pub);
  if (!coin0) {
    throw Error("coin not found");
  }
  let feeAcc = Amounts.getZero(
    Amounts.parseOrThrow(refundPermissions[0].refund_amount).currency,
  );
  const denoms = await ws.db
    .iterIndex(Stores.denominations.exchangeBaseUrlIndex, coin0.exchangeBaseUrl)
    .toArray();
  for (const rp of refundPermissions) {
    const coin = await ws.db.get(Stores.coins, rp.coin_pub);
    if (!coin) {
      throw Error("coin not found");
    }
    const denom = await ws.db.get(Stores.denominations, [
      coin0.exchangeBaseUrl,
      coin.denomPub,
    ]);
    if (!denom) {
      throw Error(`denom not found (${coin.denomPub})`);
    }
    // FIXME:  this assumes that the refund already happened.
    // When it hasn't, the refresh cost is inaccurate.  To fix this,
    // we need introduce a flag to tell if a coin was refunded or
    // refreshed normally (and what about incremental refunds?)
    const refundAmount = Amounts.parseOrThrow(rp.refund_amount);
    const refundFee = Amounts.parseOrThrow(rp.refund_fee);
    const refreshCost = getTotalRefreshCost(
      denoms,
      denom,
      Amounts.sub(refundAmount, refundFee).amount,
    );
    feeAcc = Amounts.add(feeAcc, refreshCost, refundFee).amount;
  }
  return feeAcc;
}
export async function acceptRefundResponse(
  ws: InternalWalletState,
  proposalId: string,
  refundResponse: MerchantRefundResponse,
  reason: RefundReason,
): Promise {
  const refundPermissions = refundResponse.refund_permissions;
  let numNewRefunds = 0;
  const refundGroupId = encodeCrock(randomBytes(32));
  await ws.db.runWithWriteTransaction([Stores.purchases], async tx => {
    const p = await tx.get(Stores.purchases, proposalId);
    if (!p) {
      console.error("purchase not found, not adding refunds");
      return;
    }
    if (!p.refundStatusRequested) {
      return;
    }
    for (const perm of refundPermissions) {
      const isDone = p.refundState.refundsDone[perm.merchant_sig];
      const isPending = p.refundState.refundsPending[perm.merchant_sig];
      if (!isDone && !isPending) {
        p.refundState.refundsPending[perm.merchant_sig] = {
          perm,
          refundGroupId,
        };
        numNewRefunds++;
      }
    }
    // Are we done with querying yet, or do we need to do another round
    // after a retry delay?
    let queryDone = true;
    if (numNewRefunds === 0) {
      if (
        p.autoRefundDeadline &&
        p.autoRefundDeadline.t_ms > getTimestampNow().t_ms
      ) {
        queryDone = false;
      }
    }
    if (queryDone) {
      p.lastRefundStatusTimestamp = getTimestampNow();
      p.lastRefundStatusError = undefined;
      p.refundStatusRetryInfo = initRetryInfo();
      p.refundStatusRequested = false;
      console.log("refund query done");
    } else {
      // No error, but we need to try again!
      p.lastRefundStatusTimestamp = getTimestampNow();
      p.refundStatusRetryInfo.retryCounter++;
      updateRetryInfoTimeout(p.refundStatusRetryInfo);
      p.lastRefundStatusError = undefined;
      console.log("refund query not done");
    }
    if (numNewRefunds > 0) {
      const now = getTimestampNow();
      p.lastRefundApplyError = undefined;
      p.refundApplyRetryInfo = initRetryInfo();
      p.refundState.refundGroups.push({
        timestampQueried: now,
        reason,
      });
    }
    await tx.put(Stores.purchases, p);
  });
  ws.notify({
    type: NotificationType.RefundQueried,
  });
  if (numNewRefunds > 0) {
    await processPurchaseApplyRefund(ws, proposalId);
  }
}
async function startRefundQuery(
  ws: InternalWalletState,
  proposalId: string,
): Promise {
  const success = await ws.db.runWithWriteTransaction(
    [Stores.purchases],
    async tx => {
      const p = await tx.get(Stores.purchases, proposalId);
      if (!p) {
        console.log("no purchase found for refund URL");
        return false;
      }
      p.refundStatusRequested = true;
      p.lastRefundStatusError = undefined;
      p.refundStatusRetryInfo = initRetryInfo();
      await tx.put(Stores.purchases, p);
      return true;
    },
  );
  if (!success) {
    return;
  }
  ws.notify({
    type: NotificationType.RefundStarted,
  });
  await processPurchaseQueryRefund(ws, proposalId);
}
/**
 * Accept a refund, return the contract hash for the contract
 * that was involved in the refund.
 */
export async function applyRefund(
  ws: InternalWalletState,
  talerRefundUri: string,
): Promise {
  const parseResult = parseRefundUri(talerRefundUri);
  console.log("applying refund");
  if (!parseResult) {
    throw Error("invalid refund URI");
  }
  const purchase = await ws.db.getIndexed(Stores.purchases.orderIdIndex, [
    parseResult.merchantBaseUrl,
    parseResult.orderId,
  ]);
  if (!purchase) {
    throw Error("no purchase for the taler://refund/ URI was found");
  }
  console.log("processing purchase for refund");
  await startRefundQuery(ws, purchase.proposalId);
  return purchase.contractTermsHash;
}
export async function processPurchaseQueryRefund(
  ws: InternalWalletState,
  proposalId: string,
  forceNow: boolean = false,
): Promise {
  const onOpErr = (e: OperationError) =>
    incrementPurchaseQueryRefundRetry(ws, proposalId, e);
  await guardOperationException(
    () => processPurchaseQueryRefundImpl(ws, proposalId, forceNow),
    onOpErr,
  );
}
async function resetPurchaseQueryRefundRetry(
  ws: InternalWalletState,
  proposalId: string,
) {
  await ws.db.mutate(Stores.purchases, proposalId, x => {
    if (x.refundStatusRetryInfo.active) {
      x.refundStatusRetryInfo = initRetryInfo();
    }
    return x;
  });
}
async function processPurchaseQueryRefundImpl(
  ws: InternalWalletState,
  proposalId: string,
  forceNow: boolean,
): Promise {
  if (forceNow) {
    await resetPurchaseQueryRefundRetry(ws, proposalId);
  }
  const purchase = await ws.db.get(Stores.purchases, proposalId);
  if (!purchase) {
    return;
  }
  if (!purchase.refundStatusRequested) {
    return;
  }
  const refundUrlObj = new URL(
    "refund",
    purchase.contractTerms.merchant_base_url,
  );
  refundUrlObj.searchParams.set("order_id", purchase.contractTerms.order_id);
  const refundUrl = refundUrlObj.href;
  let resp;
  try {
    resp = await ws.http.get(refundUrl);
  } catch (e) {
    console.error("error downloading refund permission", e);
    throw e;
  }
  if (resp.status !== 200) {
    throw Error(`unexpected status code (${resp.status}) for /refund`);
  }
  const refundResponse = MerchantRefundResponse.checked(await resp.json());
  await acceptRefundResponse(
    ws,
    proposalId,
    refundResponse,
    RefundReason.NormalRefund,
  );
}
export async function processPurchaseApplyRefund(
  ws: InternalWalletState,
  proposalId: string,
  forceNow: boolean = false,
): Promise {
  const onOpErr = (e: OperationError) =>
    incrementPurchaseApplyRefundRetry(ws, proposalId, e);
  await guardOperationException(
    () => processPurchaseApplyRefundImpl(ws, proposalId, forceNow),
    onOpErr,
  );
}
async function resetPurchaseApplyRefundRetry(
  ws: InternalWalletState,
  proposalId: string,
) {
  await ws.db.mutate(Stores.purchases, proposalId, x => {
    if (x.refundApplyRetryInfo.active) {
      x.refundApplyRetryInfo = initRetryInfo();
    }
    return x;
  });
}
async function processPurchaseApplyRefundImpl(
  ws: InternalWalletState,
  proposalId: string,
  forceNow: boolean,
): Promise {
  if (forceNow) {
    await resetPurchaseApplyRefundRetry(ws, proposalId);
  }
  const purchase = await ws.db.get(Stores.purchases, proposalId);
  if (!purchase) {
    console.error("not submitting refunds, payment not found:");
    return;
  }
  const pendingKeys = Object.keys(purchase.refundState.refundsPending);
  if (pendingKeys.length === 0) {
    console.log("no pending refunds");
    return;
  }
  const newRefundsDone: { [sig: string]: RefundInfo } = {};
  const newRefundsFailed: { [sig: string]: RefundInfo } = {};
  for (const pk of pendingKeys) {
    const info = purchase.refundState.refundsPending[pk];
    const perm = info.perm;
    const req: RefundRequest = {
      coin_pub: perm.coin_pub,
      h_contract_terms: purchase.contractTermsHash,
      merchant_pub: purchase.contractTerms.merchant_pub,
      merchant_sig: perm.merchant_sig,
      refund_amount: perm.refund_amount,
      refund_fee: perm.refund_fee,
      rtransaction_id: perm.rtransaction_id,
    };
    console.log("sending refund permission", perm);
    // FIXME: not correct once we support multiple exchanges per payment
    const exchangeUrl = purchase.payReq.coins[0].exchange_url;
    const reqUrl = new URL("refund", exchangeUrl);
    const resp = await ws.http.postJson(reqUrl.href, req);
    console.log("sent refund permission");
    switch (resp.status) {
      case HttpResponseStatus.Ok:
        newRefundsDone[pk] = info;
        break;
      case HttpResponseStatus.Gone:
        // We're too late, refund is expired.
        newRefundsFailed[pk] = info;
        break;
      default:
        let body: string | null = null;
        try {
          body = await resp.json();
        } catch {}
        const m = "refund request (at exchange) failed";
        throw new OperationFailedError(m, {
          message: m,
          type: "network",
          details: {
            body,
          },
        });
    }
  }
  let allRefundsProcessed = false;
  await ws.db.runWithWriteTransaction(
    [Stores.purchases, Stores.coins, Stores.refreshGroups, Stores.refundEvents],
    async tx => {
      const p = await tx.get(Stores.purchases, proposalId);
      if (!p) {
        return;
      }
      // Groups that failed/succeeded
      let groups: { [refundGroupId: string]: boolean } = {};
      // Avoid duplicates
      const refreshCoinsMap: { [coinPub: string]: CoinPublicKey } = {};
      const modCoin = async (perm: MerchantRefundPermission) => {
        const c = await tx.get(Stores.coins, perm.coin_pub);
        if (!c) {
          console.warn("coin not found, can't apply refund");
          return;
        }
        refreshCoinsMap[c.coinPub] = { coinPub: c.coinPub };
        const refundAmount = Amounts.parseOrThrow(perm.refund_amount);
        const refundFee = Amounts.parseOrThrow(perm.refund_fee);
        c.status = CoinStatus.Dormant;
        c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount;
        c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount;
        await tx.put(Stores.coins, c);
      };
      for (const pk of Object.keys(newRefundsFailed)) {
        const r = newRefundsFailed[pk];
        groups[r.refundGroupId] = true;
        delete p.refundState.refundsPending[pk];
        p.refundState.refundsFailed[pk] = r;
        await modCoin(r.perm);
      }
      for (const pk of Object.keys(newRefundsDone)) {
        const r = newRefundsDone[pk];
        groups[r.refundGroupId] = true;
        delete p.refundState.refundsPending[pk];
        p.refundState.refundsDone[pk] = r;
        await modCoin(r.perm);
      }
      const now = getTimestampNow();
      for (const g of Object.keys(groups)) {
        let groupDone = true;
        for (const pk of Object.keys(p.refundState.refundsPending)) {
          const r  = p.refundState.refundsPending[pk];
          if (r.refundGroupId == g) {
            groupDone = false;
          }
        }
        if (groupDone) {
          const refundEvent: RefundEventRecord = {
            proposalId,
            refundGroupId: g,
            timestamp: now,
          }
          await tx.put(Stores.refundEvents, refundEvent);
        }
      }
      if (Object.keys(p.refundState.refundsPending).length === 0) {
        p.refundStatusRetryInfo = initRetryInfo();
        p.lastRefundStatusError = undefined;
        allRefundsProcessed = true;
      }
      await tx.put(Stores.purchases, p);
      await createRefreshGroup(
        tx,
        Object.values(refreshCoinsMap),
        RefreshReason.Refund,
      );
    },
  );
  if (allRefundsProcessed) {
    ws.notify({
      type: NotificationType.RefundFinished,
    });
  }
  ws.notify({
    type: NotificationType.RefundsSubmitted,
    proposalId,
  });
}