/*
   This file is part of TALER
   Copyright (C) 2022-2024 Taler Systems SA

   TALER is free software; you can redistribute it and/or modify it under the
   terms of the GNU General Public License as published by the Free Software
   Foundation; either version 3, or (at your option) any later version.

   TALER is distributed in the hope that it will be useful, but WITHOUT ANY
   WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
   A PARTICULAR PURPOSE.  See the GNU General Public License for more details.

   You should have received a copy of the GNU General Public License along with
   TALER; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
 */
/**
 * @file backenddb/pg_increase_refund.c
 * @brief Implementation of the increase_refund function for Postgres
 * @author Christian Grothoff
 */
#include "platform.h"
#include <taler/taler_error_codes.h>
#include <taler/taler_dbevents.h>
#include <taler/taler_pq_lib.h>
#include "pg_increase_refund.h"
#include "pg_helper.h"


/**
 * Information about refund limits per exchange.
 */
struct ExchangeLimit
{
  /**
   * Kept in a DLL.
   */
  struct ExchangeLimit *next;

  /**
   * Kept in a DLL.
   */
  struct ExchangeLimit *prev;

  /**
   * Exchange the limit is about.
   */
  char *exchange_url;

  /**
   * Refund amount remaining at this exchange.
   */
  struct TALER_Amount remaining_refund_limit;

};


/**
 * Closure for #process_refund_cb().
 */
struct FindRefundContext
{

  /**
   * Plugin context.
   */
  struct PostgresClosure *pg;

  /**
   * Updated to reflect total amount refunded so far.
   */
  struct TALER_Amount refunded_amount;

  /**
   * Set to the largest refund transaction ID encountered.
   */
  uint64_t max_rtransaction_id;

  /**
   * Set to true on hard errors.
   */
  bool err;
};


/**
 * Closure for #process_deposits_for_refund_cb().
 */
struct InsertRefundContext
{
  /**
   * Used to provide a connection to the db
   */
  struct PostgresClosure *pg;

  /**
   * Head of DLL of per-exchange refund limits.
   */
  struct ExchangeLimit *el_head;

  /**
   * Tail of DLL of per-exchange refund limits.
   */
  struct ExchangeLimit *el_tail;

  /**
   * Amount to which increase the refund for this contract
   */
  const struct TALER_Amount *refund;

  /**
   * Human-readable reason behind this refund
   */
  const char *reason;

  /**
   * Function to call to determine per-exchange limits.
   * NULL for no limits.
   */
  TALER_MERCHANTDB_OperationLimitCallback olc;

  /**
   * Closure for @e olc.
   */
  void *olc_cls;

  /**
   * Transaction status code.
   */
  enum TALER_MERCHANTDB_RefundStatus rs;

  /**
   * Did we have to cap refunds of any coin
   * due to legal limits?
   */
  bool legal_capped;
};


/**
 * Data extracted per coin.
 */
struct RefundCoinData
{

  /**
   * Public key of a coin.
   */
  struct TALER_CoinSpendPublicKeyP coin_pub;

  /**
   * Amount deposited for this coin.
   */
  struct TALER_Amount deposited_with_fee;

  /**
   * Amount refunded already for this coin.
   */
  struct TALER_Amount refund_amount;

  /**
   * Order serial (actually not really per-coin).
   */
  uint64_t order_serial;

  /**
   * Maximum rtransaction_id for this coin so far.
   */
  uint64_t max_rtransaction_id;

  /**
   * Exchange this coin was issued by.
   */
  char *exchange_url;

};


/**
 * Find an exchange record for the refund limit enforcement.
 *
 * @param irc refund context
 * @param exchange_url base URL of the exchange
 */
static struct ExchangeLimit *
find_exchange (struct InsertRefundContext *irc,
               const char *exchange_url)
{
  if (NULL == irc->olc)
    return NULL; /* no limits */
  /* Check if entry exists, if so, do nothing */
  for (struct ExchangeLimit *el = irc->el_head;
       NULL != el;
       el = el->next)
    if (0 == strcmp (exchange_url,
                     el->exchange_url))
      return el;
  return NULL;
}


/**
 * Setup an exchange for the refund limit enforcement and initialize the
 * original refund limit for the exchange.
 *
 * @param irc refund context
 * @param exchange_url base URL of the exchange
 * @return limiting data structure
 */
static struct ExchangeLimit *
setup_exchange (struct InsertRefundContext *irc,
                const char *exchange_url)
{
  struct ExchangeLimit *el;

  if (NULL == irc->olc)
    return NULL; /* no limits */
  /* Check if entry exists, if so, do nothing */
  if (NULL !=
      (el = find_exchange (irc,
                           exchange_url)))
    return el;
  el = GNUNET_new (struct ExchangeLimit);
  el->exchange_url = GNUNET_strdup (exchange_url);
  /* olc only lowers, so set to the maximum amount we care about */
  el->remaining_refund_limit = *irc->refund;
  irc->olc (irc->olc_cls,
            exchange_url,
            &el->remaining_refund_limit);
  GNUNET_CONTAINER_DLL_insert (irc->el_head,
                               irc->el_tail,
                               el);
  return el;
}


/**
 * Lower the remaining refund limit in @a el by @a val.
 *
 * @param[in,out] el exchange limit to lower
 * @param val amount to lower limit by
 * @return true on success, false on failure
 */
static bool
lower_balance (struct ExchangeLimit *el,
               const struct TALER_Amount *val)
{
  if (NULL == el)
    return true;
  return 0 <= TALER_amount_subtract (&el->remaining_refund_limit,
                                     &el->remaining_refund_limit,
                                     val);
}


/**
 * Function to be called with the results of a SELECT statement
 * that has returned @a num_results results.
 *
 * @param cls closure, our `struct FindRefundContext`
 * @param result the postgres result
 * @param num_results the number of results in @a result
 */
static void
process_refund_cb (void *cls,
                   PGresult *result,
                   unsigned int num_results)
{
  struct FindRefundContext *ictx = cls;

  for (unsigned int i = 0; i<num_results; i++)
  {
    /* Sum up existing refunds */
    struct TALER_Amount acc;
    uint64_t rtransaction_id;
    struct GNUNET_PQ_ResultSpec rs[] = {
      TALER_PQ_result_spec_amount_with_currency ("refund_amount",
                                                 &acc),
      GNUNET_PQ_result_spec_uint64 ("rtransaction_id",
                                    &rtransaction_id),
      GNUNET_PQ_result_spec_end
    };

    if (GNUNET_OK !=
        GNUNET_PQ_extract_result (result,
                                  rs,
                                  i))
    {
      GNUNET_break (0);
      ictx->err = true;
      return;
    }
    if (GNUNET_OK !=
        TALER_amount_cmp_currency (&ictx->refunded_amount,
                                   &acc))
    {
      GNUNET_break (0);
      ictx->err = true;
      return;
    }
    if (0 >
        TALER_amount_add (&ictx->refunded_amount,
                          &ictx->refunded_amount,
                          &acc))
    {
      GNUNET_break (0);
      ictx->err = true;
      return;
    }
    ictx->max_rtransaction_id = GNUNET_MAX (ictx->max_rtransaction_id,
                                            rtransaction_id);
    GNUNET_log (GNUNET_ERROR_TYPE_DEBUG,
                "Found refund of %s\n",
                TALER_amount2s (&acc));
  }
}


/**
 * Function to be called with the results of a SELECT statement
 * that has returned @a num_results results.
 *
 * @param cls closure, our `struct InsertRefundContext`
 * @param result the postgres result
 * @param num_results the number of results in @a result
 */
static void
process_deposits_for_refund_cb (
  void *cls,
  PGresult *result,
  unsigned int num_results)
{
  struct InsertRefundContext *ctx = cls;
  struct PostgresClosure *pg = ctx->pg;
  struct TALER_Amount current_refund;
  struct RefundCoinData rcd[GNUNET_NZL (num_results)];
  struct GNUNET_TIME_Timestamp now;

  now = GNUNET_TIME_timestamp_get ();
  GNUNET_assert (GNUNET_OK ==
                 TALER_amount_set_zero (ctx->refund->currency,
                                        &current_refund));
  memset (rcd,
          0,
          sizeof (rcd));
  /* Pass 1:  Collect amount of existing refunds into current_refund.
   * Also store existing refunded amount for each deposit in deposit_refund. */
  for (unsigned int i = 0; i<num_results; i++)
  {
    struct RefundCoinData *rcdi = &rcd[i];
    struct GNUNET_PQ_ResultSpec rs[] = {
      GNUNET_PQ_result_spec_auto_from_type ("coin_pub",
                                            &rcdi->coin_pub),
      GNUNET_PQ_result_spec_uint64 ("order_serial",
                                    &rcdi->order_serial),
      GNUNET_PQ_result_spec_string ("exchange_url",
                                    &rcdi->exchange_url),
      TALER_PQ_result_spec_amount_with_currency ("amount_with_fee",
                                                 &rcdi->deposited_with_fee),
      GNUNET_PQ_result_spec_end
    };
    struct FindRefundContext ictx = {
      .pg = pg
    };
    struct ExchangeLimit *el;

    if (GNUNET_OK !=
        GNUNET_PQ_extract_result (result,
                                  rs,
                                  i))
    {
      GNUNET_break (0);
      ctx->rs = TALER_MERCHANTDB_RS_HARD_ERROR;
      goto cleanup;
    }
    el = setup_exchange (ctx,
                         rcdi->exchange_url);
    if (0 != strcmp (rcdi->deposited_with_fee.currency,
                     ctx->refund->currency))
    {
      GNUNET_break_op (0);
      ctx->rs = TALER_MERCHANTDB_RS_BAD_CURRENCY;
      goto cleanup;
    }

    {
      enum GNUNET_DB_QueryStatus ires;
      struct GNUNET_PQ_QueryParam params[] = {
        GNUNET_PQ_query_param_auto_from_type (&rcdi->coin_pub),
        GNUNET_PQ_query_param_uint64 (&rcdi->order_serial),
        GNUNET_PQ_query_param_end
      };

      GNUNET_assert (GNUNET_OK ==
                     TALER_amount_set_zero (
                       ctx->refund->currency,
                       &ictx.refunded_amount));
      ires = GNUNET_PQ_eval_prepared_multi_select (
        ctx->pg->conn,
        "find_refunds_by_coin",
        params,
        &process_refund_cb,
        &ictx);
      if ( (ictx.err) ||
           (GNUNET_DB_STATUS_HARD_ERROR == ires) )
      {
        GNUNET_break (0);
        ctx->rs = TALER_MERCHANTDB_RS_HARD_ERROR;
        goto cleanup;
      }
      if (GNUNET_DB_STATUS_SOFT_ERROR == ires)
      {
        ctx->rs = TALER_MERCHANTDB_RS_SOFT_ERROR;
        goto cleanup;
      }
    }
    if (0 >
        TALER_amount_add (&current_refund,
                          &current_refund,
                          &ictx.refunded_amount))
    {
      GNUNET_break (0);
      ctx->rs = TALER_MERCHANTDB_RS_HARD_ERROR;
      goto cleanup;
    }
    rcdi->refund_amount = ictx.refunded_amount;
    rcdi->max_rtransaction_id = ictx.max_rtransaction_id;
    GNUNET_log (GNUNET_ERROR_TYPE_DEBUG,
                "Existing refund for coin %s is %s\n",
                TALER_B2S (&rcdi->coin_pub),
                TALER_amount2s (&ictx.refunded_amount));
    GNUNET_break (lower_balance (el,
                                 &ictx.refunded_amount));
  } /* end for all deposited coins */

  GNUNET_log (GNUNET_ERROR_TYPE_DEBUG,
              "Total existing refund is %s\n",
              TALER_amount2s (&current_refund));

  /* stop immediately if we are 'done' === amount already
   * refunded.  */
  if (0 >= TALER_amount_cmp (ctx->refund,
                             &current_refund))
  {
    GNUNET_log (GNUNET_ERROR_TYPE_INFO,
                "Existing refund of %s at or above requested refund. Finished early.\n",
                TALER_amount2s (&current_refund));
    ctx->rs = TALER_MERCHANTDB_RS_SUCCESS;
    goto cleanup;
  }

  /* Phase 2:  Try to increase current refund until it matches desired refund */
  for (unsigned int i = 0; i<num_results; i++)
  {
    struct RefundCoinData *rcdi = &rcd[i];
    const struct TALER_Amount *increment;
    struct TALER_Amount left;
    struct TALER_Amount remaining_refund;
    struct ExchangeLimit *el;

    /* How much of the coin is left after the existing refunds? */
    if (0 >
        TALER_amount_subtract (&left,
                               &rcdi->deposited_with_fee,
                               &rcdi->refund_amount))
    {
      GNUNET_break (0);
      ctx->rs = TALER_MERCHANTDB_RS_HARD_ERROR;
      goto cleanup;
    }

    if (TALER_amount_is_zero (&left))
    {
      /* coin was fully refunded, move to next coin */
      GNUNET_log (GNUNET_ERROR_TYPE_DEBUG,
                  "Coin %s fully refunded, moving to next coin\n",
                  TALER_B2S (&rcdi->coin_pub));
      continue;
    }
    el = find_exchange (ctx,
                        rcdi->exchange_url);
    if ( (NULL != el) &&
         (TALER_amount_is_zero (&el->remaining_refund_limit)) )
    {
      /* legal limit reached, move to next coin */
      GNUNET_log (GNUNET_ERROR_TYPE_DEBUG,
                  "Exchange %s legal limit reached, moving to next coin\n",
                  rcdi->exchange_url);
      continue;
    }

    rcdi->max_rtransaction_id++;
    /* How much of the refund is still to be paid back? */
    if (0 >
        TALER_amount_subtract (&remaining_refund,
                               ctx->refund,
                               &current_refund))
    {
      GNUNET_break (0);
      ctx->rs = TALER_MERCHANTDB_RS_HARD_ERROR;
      goto cleanup;
    }
    /* cap by legal limit */
    if (NULL != el)
    {
      struct TALER_Amount new_limit;

      TALER_amount_min (&new_limit,
                        &remaining_refund,
                        &el->remaining_refund_limit);
      if (0 != TALER_amount_cmp (&new_limit,
                                 &remaining_refund))
      {
        remaining_refund = new_limit;
        ctx->legal_capped = true;
      }
    }
    /* By how much will we increase the refund for this coin? */
    if (0 >= TALER_amount_cmp (&remaining_refund,
                               &left))
    {
      /* remaining_refund <= left */
      increment = &remaining_refund;
    }
    else
    {
      increment = &left;
    }

    if (0 >
        TALER_amount_add (&current_refund,
                          &current_refund,
                          increment))
    {
      GNUNET_break (0);
      ctx->rs = TALER_MERCHANTDB_RS_HARD_ERROR;
      goto cleanup;
    }
    GNUNET_break (lower_balance (el,
                                 increment));
    /* actually run the refund */
    GNUNET_log (GNUNET_ERROR_TYPE_DEBUG,
                "Coin %s deposit amount is %s\n",
                TALER_B2S (&rcdi->coin_pub),
                TALER_amount2s (&rcdi->deposited_with_fee));
    GNUNET_log (GNUNET_ERROR_TYPE_DEBUG,
                "Coin %s refund will be incremented by %s\n",
                TALER_B2S (&rcdi->coin_pub),
                TALER_amount2s (increment));
    {
      enum GNUNET_DB_QueryStatus qs;
      struct GNUNET_PQ_QueryParam params[] = {
        GNUNET_PQ_query_param_uint64 (&rcdi->order_serial),
        GNUNET_PQ_query_param_uint64 (&rcdi->max_rtransaction_id), /* already inc'ed */
        GNUNET_PQ_query_param_timestamp (&now),
        GNUNET_PQ_query_param_auto_from_type (&rcdi->coin_pub),
        GNUNET_PQ_query_param_string (ctx->reason),
        TALER_PQ_query_param_amount_with_currency (pg->conn,
                                                   increment),
        GNUNET_PQ_query_param_end
      };

      check_connection (pg);
      qs = GNUNET_PQ_eval_prepared_non_select (pg->conn,
                                               "insert_refund",
                                               params);
      switch (qs)
      {
      case GNUNET_DB_STATUS_HARD_ERROR:
        GNUNET_break (0);
        ctx->rs = TALER_MERCHANTDB_RS_HARD_ERROR;
        goto cleanup;
      case GNUNET_DB_STATUS_SOFT_ERROR:
        ctx->rs = TALER_MERCHANTDB_RS_SOFT_ERROR;
        goto cleanup;
      default:
        ctx->rs = (enum TALER_MERCHANTDB_RefundStatus) qs;
        break;
      }
    }

    /* stop immediately if we are done */
    if (0 == TALER_amount_cmp (ctx->refund,
                               &current_refund))
    {
      ctx->rs = TALER_MERCHANTDB_RS_SUCCESS;
      goto cleanup;
    }
  }

  if (ctx->legal_capped)
  {
    ctx->rs = TALER_MERCHANTDB_RS_LEGAL_FAILURE;
    goto cleanup;
  }
  /**
   * We end up here if not all of the refund has been covered.
   * Although this should be checked as the business should never
   * issue a refund bigger than the contract's actual price, we cannot
   * rely upon the frontend being correct.
   */
  GNUNET_log (GNUNET_ERROR_TYPE_WARNING,
              "The refund of %s is bigger than the order's value\n",
              TALER_amount2s (ctx->refund));
  ctx->rs = TALER_MERCHANTDB_RS_TOO_HIGH;
cleanup:
  for (unsigned int i = 0; i<num_results; i++)
    GNUNET_free (rcd[i].exchange_url);
}


enum TALER_MERCHANTDB_RefundStatus
TMH_PG_increase_refund (
  void *cls,
  const char *instance_id,
  const char *order_id,
  const struct TALER_Amount *refund,
  TALER_MERCHANTDB_OperationLimitCallback olc,
  void *olc_cls,
  const char *reason)
{
  struct PostgresClosure *pg = cls;
  enum GNUNET_DB_QueryStatus qs;
  struct GNUNET_PQ_QueryParam params[] = {
    GNUNET_PQ_query_param_string (instance_id),
    GNUNET_PQ_query_param_string (order_id),
    GNUNET_PQ_query_param_end
  };
  struct InsertRefundContext ctx = {
    .pg = pg,
    .refund = refund,
    .olc = olc,
    .olc_cls = olc_cls,
    .reason = reason
  };

  // FIXME: return 'refund_serial' from this INSERT statement for #10577
  PREPARE (pg,
           "insert_refund",
           "INSERT INTO merchant_refunds"
           "(order_serial"
           ",rtransaction_id"
           ",refund_timestamp"
           ",coin_pub"
           ",reason"
           ",refund_amount"
           ") VALUES"
           "($1, $2, $3, $4, $5, $6)");
  PREPARE (pg,
           "find_refunds_by_coin",
           "SELECT"
           " refund_amount"
           ",rtransaction_id"
           " FROM merchant_refunds"
           " WHERE coin_pub=$1"
           "   AND order_serial=$2");
  PREPARE (pg,
           "find_deposits_for_refund",
           "SELECT"
           " dep.coin_pub"
           ",dco.order_serial"
           ",dep.amount_with_fee"
           ",dco.exchange_url"
           " FROM merchant_deposits dep"
           " JOIN merchant_deposit_confirmations dco"
           "   USING (deposit_confirmation_serial)"
           " WHERE order_serial="
           "  (SELECT order_serial"
           "     FROM merchant_contract_terms"
           "    WHERE order_id=$2"
           "      AND paid"
           "      AND merchant_serial="
           "        (SELECT merchant_serial"
           "           FROM merchant_instances"
           "          WHERE merchant_id=$1))");
  GNUNET_log (GNUNET_ERROR_TYPE_DEBUG,
              "Asked to refund %s on order %s\n",
              TALER_amount2s (refund),
              order_id);
  qs = GNUNET_PQ_eval_prepared_multi_select (pg->conn,
                                             "find_deposits_for_refund",
                                             params,
                                             &process_deposits_for_refund_cb,
                                             &ctx);
  {
    struct ExchangeLimit *el;

    while (NULL != (el = ctx.el_head))
    {
      GNUNET_CONTAINER_DLL_remove (ctx.el_head,
                                   ctx.el_tail,
                                   el);
      GNUNET_free (el->exchange_url);
      GNUNET_free (el);
    }
  }
  switch (qs)
  {
  case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS:
    /* never paid, means we clearly cannot refund anything */
    return TALER_MERCHANTDB_RS_NO_SUCH_ORDER;
  case GNUNET_DB_STATUS_SOFT_ERROR:
    return TALER_MERCHANTDB_RS_SOFT_ERROR;
  case GNUNET_DB_STATUS_HARD_ERROR:
    return TALER_MERCHANTDB_RS_HARD_ERROR;
  default:
    /* Got one or more deposits */
    return ctx.rs;
  }
}
