#include <xrpl/tx/transactors/lending/LendingHelpers.h>
// DO NOT REMOVE forces header file include to sort first
#include <xrpl/tx/transactors/vault/VaultCreate.h>

namespace xrpl {

bool
checkLendingProtocolDependencies(PreflightContext const& ctx)
{
    return ctx.rules.enabled(featureSingleAssetVault) && VaultCreate::checkExtraFeatures(ctx);
}

LoanPaymentParts&
LoanPaymentParts::operator+=(LoanPaymentParts const& other)
{
    XRPL_ASSERT(

        other.principalPaid >= beast::zero,
        "xrpl::LoanPaymentParts::operator+= : other principal "
        "non-negative");
    XRPL_ASSERT(
        other.interestPaid >= beast::zero,
        "xrpl::LoanPaymentParts::operator+= : other interest paid "
        "non-negative");
    XRPL_ASSERT(
        other.feePaid >= beast::zero,
        "xrpl::LoanPaymentParts::operator+= : other fee paid "
        "non-negative");

    principalPaid += other.principalPaid;
    interestPaid += other.interestPaid;
    valueChange += other.valueChange;
    feePaid += other.feePaid;
    return *this;
}

bool
LoanPaymentParts::operator==(LoanPaymentParts const& other) const
{
    return principalPaid == other.principalPaid && interestPaid == other.interestPaid &&
        valueChange == other.valueChange && feePaid == other.feePaid;
}

/* Converts annualized interest rate to per-payment-period rate.
 * The rate is prorated based on the payment interval in seconds.
 *
 * Equation (1) from XLS-66 spec, Section A-2 Equation Glossary
 */
Number
loanPeriodicRate(TenthBips32 interestRate, std::uint32_t paymentInterval)
{
    // Need floating point math, since we're dividing by a large number
    return tenthBipsOfValue(Number(paymentInterval), interestRate) / secondsInYear;
}

/* Checks if a value is already rounded to the specified scale.
 * Returns true if rounding down and rounding up produce the same result,
 * indicating no further precision exists beyond the scale.
 */
bool
isRounded(Asset const& asset, Number const& value, std::int32_t scale)
{
    return roundToAsset(asset, value, scale, Number::downward) ==
        roundToAsset(asset, value, scale, Number::upward);
}

namespace detail {

void
LoanStateDeltas::nonNegative()
{
    if (principal < beast::zero)
        principal = numZero;
    if (interest < beast::zero)
        interest = numZero;
    if (managementFee < beast::zero)
        managementFee = numZero;
}

/* Computes (1 + periodicRate)^paymentsRemaining for amortization calculations.
 *
 * Equation (5) from XLS-66 spec, Section A-2 Equation Glossary
 */
Number
computeRaisedRate(Number const& periodicRate, std::uint32_t paymentsRemaining)
{
    return power(1 + periodicRate, paymentsRemaining);
}

/* Computes the payment factor used in standard amortization formulas.
 * This factor converts principal to periodic payment amount.
 *
 * Equation (6) from XLS-66 spec, Section A-2 Equation Glossary
 */
Number
computePaymentFactor(Number const& periodicRate, std::uint32_t paymentsRemaining)
{
    if (paymentsRemaining == 0)
        return numZero;

    // For zero interest, payment factor is simply 1/paymentsRemaining
    if (periodicRate == beast::zero)
        return Number{1} / paymentsRemaining;

    Number const raisedRate = computeRaisedRate(periodicRate, paymentsRemaining);

    return (periodicRate * raisedRate) / (raisedRate - 1);
}

/* Calculates the periodic payment amount using standard amortization formula.
 * For interest-free loans, returns principal divided equally across payments.
 *
 * Equation (7) from XLS-66 spec, Section A-2 Equation Glossary
 */
Number
loanPeriodicPayment(
    Number const& principalOutstanding,
    Number const& periodicRate,
    std::uint32_t paymentsRemaining)
{
    if (principalOutstanding == 0 || paymentsRemaining == 0)
        return 0;

    // Interest-free loans: equal principal payments
    if (periodicRate == beast::zero)
        return principalOutstanding / paymentsRemaining;

    return principalOutstanding * computePaymentFactor(periodicRate, paymentsRemaining);
}

/* Reverse-calculates principal from periodic payment amount.
 * Used to determine theoretical principal at any point in the schedule.
 *
 * Equation (10) from XLS-66 spec, Section A-2 Equation Glossary
 */
Number
loanPrincipalFromPeriodicPayment(
    Number const& periodicPayment,
    Number const& periodicRate,
    std::uint32_t paymentsRemaining)
{
    if (paymentsRemaining == 0)
        return numZero;

    if (periodicRate == 0)
        return periodicPayment * paymentsRemaining;

    return periodicPayment / computePaymentFactor(periodicRate, paymentsRemaining);
}

/*
 * Computes the interest and management fee parts from interest amount.
 *
 * Equation (33) from XLS-66 spec, Section A-2 Equation Glossary
 */
std::pair<Number, Number>
computeInterestAndFeeParts(
    Asset const& asset,
    Number const& interest,
    TenthBips16 managementFeeRate,
    std::int32_t loanScale)
{
    auto const fee = computeManagementFee(asset, interest, managementFeeRate, loanScale);

    return std::make_pair(interest - fee, fee);
}

/* Calculates penalty interest accrued on overdue payments.
 * Returns 0 if payment is not late.
 *
 * Equation (16) from XLS-66 spec, Section A-2 Equation Glossary
 */
Number
loanLatePaymentInterest(
    Number const& principalOutstanding,
    TenthBips32 lateInterestRate,
    NetClock::time_point parentCloseTime,
    std::uint32_t nextPaymentDueDate)
{
    if (principalOutstanding == beast::zero)
        return numZero;

    if (lateInterestRate == TenthBips32{0})
        return numZero;

    auto const now = parentCloseTime.time_since_epoch().count();

    // If the payment is not late by any amount of time, then there's no late
    // interest
    if (now <= nextPaymentDueDate)
        return 0;

    // Equation (3) from XLS-66 spec, Section A-2 Equation Glossary
    auto const secondsOverdue = now - nextPaymentDueDate;

    auto const rate = loanPeriodicRate(lateInterestRate, secondsOverdue);

    return principalOutstanding * rate;
}

/* Calculates interest accrued since the last payment based on time elapsed.
 * Returns 0 if loan is paid ahead of schedule.
 *
 * Equation (27) from XLS-66 spec, Section A-2 Equation Glossary
 */
Number
loanAccruedInterest(
    Number const& principalOutstanding,
    Number const& periodicRate,
    NetClock::time_point parentCloseTime,
    std::uint32_t startDate,
    std::uint32_t prevPaymentDate,
    std::uint32_t paymentInterval)
{
    if (periodicRate == beast::zero)
        return numZero;

    if (paymentInterval == 0)
        return numZero;

    auto const lastPaymentDate = std::max(prevPaymentDate, startDate);
    auto const now = parentCloseTime.time_since_epoch().count();

    // If the loan has been paid ahead, then "lastPaymentDate" is in the future,
    // and no interest has accrued.
    if (now <= lastPaymentDate)
        return numZero;

    // Equation (4) from XLS-66 spec, Section A-2 Equation Glossary
    auto const secondsSinceLastPayment = now - lastPaymentDate;

    // Division is more likely to introduce rounding errors, which will then get
    // amplified by multiplication. Therefore, we first multiply, and only then
    // divide.
    return principalOutstanding * periodicRate * secondsSinceLastPayment / paymentInterval;
}

/* Applies a payment to the loan state and returns the breakdown of amounts
 * paid.
 *
 * This is the core function that updates the Loan ledger object fields based on
 * a computed payment.

 * The function is templated to work with both direct Number/uint32_t values
 * (for testing/simulation) and ValueProxy types (for actual ledger updates).
 */
template <class NumberProxy, class UInt32Proxy, class UInt32OptionalProxy>
LoanPaymentParts
doPayment(
    ExtendedPaymentComponents const& payment,
    NumberProxy& totalValueOutstandingProxy,
    NumberProxy& principalOutstandingProxy,
    NumberProxy& managementFeeOutstandingProxy,
    UInt32Proxy& paymentRemainingProxy,
    UInt32Proxy& prevPaymentDateProxy,
    UInt32OptionalProxy& nextDueDateProxy,
    std::uint32_t paymentInterval)
{
    XRPL_ASSERT_PARTS(nextDueDateProxy, "xrpl::detail::doPayment", "Next due date proxy set");

    if (payment.specialCase == PaymentSpecialCase::final)
    {
        XRPL_ASSERT_PARTS(
            principalOutstandingProxy == payment.trackedPrincipalDelta,
            "xrpl::detail::doPayment",
            "Full principal payment");
        XRPL_ASSERT_PARTS(
            totalValueOutstandingProxy == payment.trackedValueDelta,
            "xrpl::detail::doPayment",
            "Full value payment");
        XRPL_ASSERT_PARTS(
            managementFeeOutstandingProxy == payment.trackedManagementFeeDelta,
            "xrpl::detail::doPayment",
            "Full management fee payment");

        // Mark the loan as complete
        paymentRemainingProxy = 0;

        // Record when the final payment was made
        prevPaymentDateProxy = *nextDueDateProxy;

        // Clear the next due date. Setting it to 0 causes
        // it to be removed from the Loan ledger object, saving space.
        nextDueDateProxy = 0;

        // Zero out all tracked loan balances to mark the loan as paid off.
        // These will be removed from the Loan object since they're default
        // values.
        principalOutstandingProxy = 0;
        totalValueOutstandingProxy = 0;
        managementFeeOutstandingProxy = 0;
    }
    else
    {
        // For regular payments (not overpayments), advance the payment schedule
        if (payment.specialCase != PaymentSpecialCase::extra)
        {
            paymentRemainingProxy -= 1;

            prevPaymentDateProxy = nextDueDateProxy;
            nextDueDateProxy += paymentInterval;
        }
        XRPL_ASSERT_PARTS(
            principalOutstandingProxy > payment.trackedPrincipalDelta,
            "xrpl::detail::doPayment",
            "Partial principal payment");
        XRPL_ASSERT_PARTS(
            totalValueOutstandingProxy > payment.trackedValueDelta,
            "xrpl::detail::doPayment",
            "Partial value payment");
        // Management fees are expected to be relatively small, and could get to
        // zero before the loan is paid off
        XRPL_ASSERT_PARTS(
            managementFeeOutstandingProxy >= payment.trackedManagementFeeDelta,
            "xrpl::detail::doPayment",
            "Valid management fee");

        // Apply the payment deltas to reduce the outstanding balances
        principalOutstandingProxy -= payment.trackedPrincipalDelta;
        totalValueOutstandingProxy -= payment.trackedValueDelta;
        managementFeeOutstandingProxy -= payment.trackedManagementFeeDelta;
    }

    // Principal can never exceed total value (principal is part of total value)
    XRPL_ASSERT_PARTS(
        // Use an explicit cast because the template parameter can be
        // ValueProxy<Number> or Number
        static_cast<Number>(principalOutstandingProxy) <=
            static_cast<Number>(totalValueOutstandingProxy),
        "xrpl::detail::doPayment",
        "principal does not exceed total");

    XRPL_ASSERT_PARTS(
        // Use an explicit cast because the template parameter can be
        // ValueProxy<Number> or Number
        static_cast<Number>(managementFeeOutstandingProxy) >= beast::zero,
        "xrpl::detail::doPayment",
        "fee outstanding stays valid");

    return LoanPaymentParts{
        // Principal paid is straightforward - it's the tracked delta
        .principalPaid = payment.trackedPrincipalDelta,

        // Interest paid combines:
        // 1. Tracked interest from the amortization schedule
        //    (derived from the tracked deltas)
        // 2. Untracked interest (e.g., late payment penalties)
        .interestPaid = payment.trackedInterestPart() + payment.untrackedInterest,

        // Value change represents how the loan's total value changed beyond
        // normal amortization.
        .valueChange = payment.untrackedInterest,

        // Fee paid combines:
        // 1. Tracked management fees from the amortization schedule
        // 2. Untracked fees (e.g., late payment fees, service fees)
        .feePaid = payment.trackedManagementFeeDelta + payment.untrackedManagementFee};
}

/* Simulates an overpayment to validate it won't break the loan's amortization.
 *
 * When a borrower pays more than the scheduled amount, the loan needs to be
 * re-amortized with a lower principal. This function performs that calculation
 * in a "sandbox" using temporary variables, allowing the caller to validate
 * the result before committing changes to the actual ledger.
 *
 * The function preserves accumulated rounding errors across the re-amortization
 * to ensure the loan state remains consistent with its payment history.
 */
Expected<std::pair<LoanPaymentParts, LoanProperties>, TER>
tryOverpayment(
    Asset const& asset,
    std::int32_t loanScale,
    ExtendedPaymentComponents const& overpaymentComponents,
    LoanState const& roundedOldState,
    Number const& periodicPayment,
    Number const& periodicRate,
    std::uint32_t paymentRemaining,
    TenthBips16 const managementFeeRate,
    beast::Journal j)
{
    // Calculate what the loan state SHOULD be theoretically (at full precision)
    auto const theoreticalState = computeTheoreticalLoanState(
        periodicPayment, periodicRate, paymentRemaining, managementFeeRate);

    // Calculate the accumulated rounding errors. These need to be preserved
    // across the re-amortization to maintain consistency with the loan's
    // payment history. Without preserving these errors, the loan could end
    // up with a different total value than what the borrower has actually paid.
    auto const errors = roundedOldState - theoreticalState;

    // Compute the new principal by applying the overpayment to the theoretical
    // principal. Use max with 0 to ensure we never go negative.
    auto const newTheoreticalPrincipal = std::max(
        theoreticalState.principalOutstanding - overpaymentComponents.trackedPrincipalDelta,
        Number{0});

    // Compute new loan properties based on the reduced principal. This
    // recalculates the periodic payment, total value, and management fees
    // for the remaining payment schedule.
    auto newLoanProperties = computeLoanProperties(
        asset,
        newTheoreticalPrincipal,
        periodicRate,
        paymentRemaining,
        managementFeeRate,
        loanScale);

    JLOG(j.debug()) << "new periodic payment: " << newLoanProperties.periodicPayment
                    << ", new total value: " << newLoanProperties.loanState.valueOutstanding
                    << ", first payment principal: " << newLoanProperties.firstPaymentPrincipal;

    // Calculate what the new loan state should be with the new periodic payment
    // including rounding errors
    auto const newTheoreticalState =
        computeTheoreticalLoanState(
            newLoanProperties.periodicPayment, periodicRate, paymentRemaining, managementFeeRate) +
        errors;

    JLOG(j.debug()) << "new theoretical value: " << newTheoreticalState.valueOutstanding
                    << ", principal: " << newTheoreticalState.principalOutstanding
                    << ", interest gross: " << newTheoreticalState.interestOutstanding();

    // Update the loan state variables with the new values that include the
    // preserved rounding errors. This ensures the loan's tracked state remains
    // consistent with its payment history.
    auto const principalOutstanding = std::clamp(
        roundToAsset(asset, newTheoreticalState.principalOutstanding, loanScale, Number::upward),
        numZero,
        roundedOldState.principalOutstanding);
    auto const totalValueOutstanding = std::clamp(
        roundToAsset(
            asset,
            principalOutstanding + newTheoreticalState.interestOutstanding(),
            loanScale,
            Number::upward),
        numZero,
        roundedOldState.valueOutstanding);
    auto const managementFeeOutstanding = std::clamp(
        roundToAsset(asset, newTheoreticalState.managementFeeDue, loanScale),
        numZero,
        roundedOldState.managementFeeDue);

    auto const roundedNewState =
        constructLoanState(totalValueOutstanding, principalOutstanding, managementFeeOutstanding);

    // Update newLoanProperties so that checkLoanGuards can make an accurate
    // evaluation.
    newLoanProperties.loanState = roundedNewState;

    JLOG(j.debug()) << "new rounded value: " << roundedNewState.valueOutstanding
                    << ", principal: " << roundedNewState.principalOutstanding
                    << ", interest gross: " << roundedNewState.interestOutstanding();

    // check that the loan is still valid
    if (auto const ter = checkLoanGuards(
            asset,
            principalOutstanding,
            // The loan may have been created with interest, but for
            // small interest amounts, that may have already been paid
            // off. Check what's still outstanding. This should
            // guarantee that the interest checks pass.
            roundedNewState.interestOutstanding() != beast::zero,
            paymentRemaining,
            newLoanProperties,
            j))
    {
        JLOG(j.warn()) << "Principal overpayment would cause the loan to be in "
                          "an invalid state. Ignore the overpayment";

        return Unexpected(tesSUCCESS);
    }

    // Validate that all computed properties are reasonable. These checks should
    // never fail under normal circumstances, but we validate defensively.
    if (newLoanProperties.periodicPayment <= 0 ||
        newLoanProperties.loanState.valueOutstanding <= 0 ||
        newLoanProperties.loanState.managementFeeDue < 0)
    {
        // LCOV_EXCL_START
        JLOG(j.warn()) << "Overpayment not allowed: Computed loan "
                          "properties are invalid. Does "
                          "not compute. TotalValueOutstanding: "
                       << newLoanProperties.loanState.valueOutstanding
                       << ", PeriodicPayment : " << newLoanProperties.periodicPayment
                       << ", ManagementFeeOwedToBroker: "
                       << newLoanProperties.loanState.managementFeeDue;
        return Unexpected(tesSUCCESS);
        // LCOV_EXCL_STOP
    }

    auto const deltas = roundedOldState - roundedNewState;

    // The change in loan management fee is equal to the change between the old
    // and the new outstanding management fees
    XRPL_ASSERT_PARTS(
        deltas.managementFee == roundedOldState.managementFeeDue - managementFeeOutstanding,
        "xrpl::detail::tryOverpayment",
        "no fee change");

    // Calculate how the loan's value changed due to the overpayment.
    // This should be negative (value decreased) or zero. A principal
    // overpayment should never increase the loan's value.
    // The value change is derived from the reduction in interest due to
    // the lower principal.
    // We do not consider the change in management fee here, since
    // management fees are excluded from the valueOutstanding.
    auto const valueChange = -deltas.interest;
    if (valueChange > 0)
    {
        JLOG(j.warn()) << "Principal overpayment would increase the value of "
                          "the loan. Ignore the overpayment";
        return Unexpected(tesSUCCESS);
    }

    return std::make_pair(
        LoanPaymentParts{
            // Principal paid is the reduction in principal outstanding
            .principalPaid = deltas.principal,
            // Interest paid is the reduction in interest due
            .interestPaid = overpaymentComponents.untrackedInterest,
            // Value change includes both the reduction from paying down
            // principal (negative) and any untracked interest penalties
            // (positive, e.g., if the overpayment itself incurs a fee)
            .valueChange = valueChange + overpaymentComponents.untrackedInterest,
            // Fee paid includes both the reduction in tracked management fees
            // and any untracked fees on the overpayment itself
            .feePaid = overpaymentComponents.untrackedManagementFee +
                overpaymentComponents.trackedManagementFeeDelta,
        },
        newLoanProperties);
}

/* Validates and applies an overpayment to the loan state.
 *
 * This function acts as a wrapper around tryOverpayment(), performing the
 * re-amortization calculation in a sandbox (using temporary copies of the
 * loan state), then validating the results before committing them to the
 * actual ledger via the proxy objects.
 *
 * The two-step process (try in sandbox, then commit) ensures that if the
 * overpayment would leave the loan in an invalid state, we can reject it
 * gracefully without corrupting the ledger data.
 */
template <class NumberProxy>
Expected<LoanPaymentParts, TER>
doOverpayment(
    Asset const& asset,
    std::int32_t loanScale,
    ExtendedPaymentComponents const& overpaymentComponents,
    NumberProxy& totalValueOutstandingProxy,
    NumberProxy& principalOutstandingProxy,
    NumberProxy& managementFeeOutstandingProxy,
    NumberProxy& periodicPaymentProxy,
    Number const& periodicRate,
    std::uint32_t const paymentRemaining,
    TenthBips16 const managementFeeRate,
    beast::Journal j)
{
    auto const loanState = constructLoanState(
        totalValueOutstandingProxy, principalOutstandingProxy, managementFeeOutstandingProxy);
    auto const periodicPayment = periodicPaymentProxy;
    JLOG(j.debug()) << "overpayment components:"
                    << ", totalValue before: " << *totalValueOutstandingProxy
                    << ", valueDelta: " << overpaymentComponents.trackedValueDelta
                    << ", principalDelta: " << overpaymentComponents.trackedPrincipalDelta
                    << ", managementFeeDelta: " << overpaymentComponents.trackedManagementFeeDelta
                    << ", interestPart: " << overpaymentComponents.trackedInterestPart()
                    << ", untrackedInterest: " << overpaymentComponents.untrackedInterest
                    << ", totalDue: " << overpaymentComponents.totalDue
                    << ", payments remaining :" << paymentRemaining;

    // Attempt to re-amortize the loan with the overpayment applied.
    // This modifies the temporary copies, leaving the proxies unchanged.
    auto const ret = tryOverpayment(
        asset,
        loanScale,
        overpaymentComponents,
        loanState,
        periodicPayment,
        periodicRate,
        paymentRemaining,
        managementFeeRate,
        j);
    if (!ret)
        return Unexpected(ret.error());

    auto const& [loanPaymentParts, newLoanProperties] = *ret;
    auto const newRoundedLoanState = newLoanProperties.loanState;

    // Safety check: the principal must have decreased. If it didn't (or
    // increased!), something went wrong in the calculation and we should
    // reject the overpayment.
    if (principalOutstandingProxy <= newRoundedLoanState.principalOutstanding)
    {
        // LCOV_EXCL_START
        JLOG(j.warn()) << "Overpayment not allowed: principal "
                       << "outstanding did not decrease. Before: " << *principalOutstandingProxy
                       << ". After: " << newRoundedLoanState.principalOutstanding;
        return Unexpected(tesSUCCESS);
        // LCOV_EXCL_STOP
    }

    // The proxies still hold the original (pre-overpayment) values, which
    // allows us to compute deltas and verify they match what we expect
    // from the overpaymentComponents and loanPaymentParts.

    XRPL_ASSERT_PARTS(
        overpaymentComponents.trackedPrincipalDelta ==
            principalOutstandingProxy - newRoundedLoanState.principalOutstanding,
        "xrpl::detail::doOverpayment",
        "principal change agrees");

    // I'm not 100% sure the following asserts are correct. If in doubt, and
    // everything else works, remove any that cause trouble.

    JLOG(j.debug()) << "valueChange: " << loanPaymentParts.valueChange
                    << ", totalValue before: " << *totalValueOutstandingProxy
                    << ", totalValue after: " << newRoundedLoanState.valueOutstanding
                    << ", totalValue delta: "
                    << (totalValueOutstandingProxy - newRoundedLoanState.valueOutstanding)
                    << ", principalDelta: " << overpaymentComponents.trackedPrincipalDelta
                    << ", principalPaid: " << loanPaymentParts.principalPaid
                    << ", Computed difference: "
                    << overpaymentComponents.trackedPrincipalDelta -
            (totalValueOutstandingProxy - newRoundedLoanState.valueOutstanding);

    XRPL_ASSERT_PARTS(
        loanPaymentParts.valueChange ==
            newRoundedLoanState.valueOutstanding -
                (totalValueOutstandingProxy - overpaymentComponents.trackedPrincipalDelta) +
                overpaymentComponents.trackedInterestPart(),
        "xrpl::detail::doOverpayment",
        "interest paid agrees");

    XRPL_ASSERT_PARTS(
        overpaymentComponents.trackedPrincipalDelta == loanPaymentParts.principalPaid,
        "xrpl::detail::doOverpayment",
        "principal payment matches");

    // All validations passed, so update the proxy objects (which will
    // modify the actual Loan ledger object)
    totalValueOutstandingProxy = newRoundedLoanState.valueOutstanding;
    principalOutstandingProxy = newRoundedLoanState.principalOutstanding;
    managementFeeOutstandingProxy = newRoundedLoanState.managementFeeDue;
    periodicPaymentProxy = newLoanProperties.periodicPayment;

    return loanPaymentParts;
}

/* Computes the payment components for a late payment.
 *
 * A late payment is made after the grace period has expired and includes:
 * 1. All components of a regular periodic payment
 * 2. Late payment penalty interest (accrued since the due date)
 * 3. Late payment fee charged by the broker
 *
 * The late penalty interest increases the loan's total value (the borrower
 * owes more than scheduled), while the regular payment components follow
 * the normal amortization schedule.
 *
 * Implements equation (15) from XLS-66 spec, Section A-2 Equation Glossary
 */
Expected<ExtendedPaymentComponents, TER>
computeLatePayment(
    Asset const& asset,
    ApplyView const& view,
    Number const& principalOutstanding,
    std::int32_t nextDueDate,
    ExtendedPaymentComponents const& periodic,
    TenthBips32 lateInterestRate,
    std::int32_t loanScale,
    Number const& latePaymentFee,
    STAmount const& amount,
    TenthBips16 managementFeeRate,
    beast::Journal j)
{
    // Check if the due date has passed. If not, reject the payment as
    // being too soon
    if (!hasExpired(view, nextDueDate))
        return Unexpected(tecTOO_SOON);

    // Calculate the penalty interest based on how long the payment is overdue.
    auto const latePaymentInterest = loanLatePaymentInterest(
        principalOutstanding, lateInterestRate, view.parentCloseTime(), nextDueDate);

    // Round the late interest and split it between the vault (net interest)
    // and the broker (management fee portion). This lambda ensures we
    // round before splitting to maintain precision.
    auto const [roundedLateInterest, roundedLateManagementFee] = [&]() {
        auto const interest = roundToAsset(asset, latePaymentInterest, loanScale);
        return computeInterestAndFeeParts(asset, interest, managementFeeRate, loanScale);
    }();

    XRPL_ASSERT(roundedLateInterest >= 0, "xrpl::detail::computeLatePayment : valid late interest");
    XRPL_ASSERT_PARTS(
        periodic.specialCase != PaymentSpecialCase::extra,
        "xrpl::detail::computeLatePayment",
        "no extra parts to this payment");

    // Create the late payment components by copying the regular periodic
    // payment and adding the late penalties. We use a lambda to construct
    // this to keep the logic clear. This preserves all the other fields without
    // having to enumerate them.

    ExtendedPaymentComponents const late{
        periodic,
        // Untracked management fee includes:
        // 1. Regular service fee (from periodic.untrackedManagementFee)
        // 2. Late payment fee (fixed penalty)
        // 3. Management fee portion of late interest
        periodic.untrackedManagementFee + latePaymentFee + roundedLateManagementFee,

        // Untracked interest includes:
        // 1. Any untracked interest from the regular payment (usually 0)
        // 2. Late penalty interest (increases loan value)
        // This positive value indicates the loan's value increased due
        // to the late payment.
        periodic.untrackedInterest + roundedLateInterest};

    XRPL_ASSERT_PARTS(
        isRounded(asset, late.totalDue, loanScale),
        "xrpl::detail::computeLatePayment",
        "total due is rounded");

    // Check that the borrower provided enough funds to cover the late payment.
    // The late payment is more expensive than a regular payment due to the
    // penalties.
    if (amount < late.totalDue)
    {
        JLOG(j.warn()) << "Late loan payment amount is insufficient. Due: " << late.totalDue
                       << ", paid: " << amount;
        return Unexpected(tecINSUFFICIENT_PAYMENT);
    }

    return late;
}

/* Computes payment components for paying off a loan early (before final
 * payment).
 *
 * A full payment closes the loan immediately, paying off all outstanding
 * balances plus a prepayment penalty and any accrued interest since the last
 * payment. This is different from the final scheduled payment, which has no
 * prepayment penalty.
 *
 * The function calculates:
 * - Accrued interest since last payment (time-based)
 * - Prepayment penalty (percentage of remaining principal)
 * - Close payment fee (fixed fee for early closure)
 * - All remaining principal and outstanding fees
 *
 * The loan's value may increase or decrease depending on whether the prepayment
 * penalty exceeds the scheduled interest that would have been paid.
 *
 * Implements equation (26) from XLS-66 spec, Section A-2 Equation Glossary
 */
Expected<ExtendedPaymentComponents, TER>
computeFullPayment(
    Asset const& asset,
    ApplyView& view,
    Number const& principalOutstanding,
    Number const& managementFeeOutstanding,
    Number const& periodicPayment,
    std::uint32_t paymentRemaining,
    std::uint32_t prevPaymentDate,
    std::uint32_t const startDate,
    std::uint32_t const paymentInterval,
    TenthBips32 const closeInterestRate,
    std::int32_t loanScale,
    Number const& totalInterestOutstanding,
    Number const& periodicRate,
    Number const& closePaymentFee,
    STAmount const& amount,
    TenthBips16 managementFeeRate,
    beast::Journal j)
{
    // Full payment must be made before the final scheduled payment.
    if (paymentRemaining <= 1)
    {
        // If this is the last payment, it has to be a regular payment
        JLOG(j.warn()) << "Last payment cannot be a full payment.";
        return Unexpected(tecKILLED);
    }

    // Calculate the theoretical principal based on the payment schedule.
    // This theoretical (unrounded) value is used to compute interest and
    // penalties accurately.
    Number const theoreticalPrincipalOutstanding =
        loanPrincipalFromPeriodicPayment(periodicPayment, periodicRate, paymentRemaining);

    // Full payment interest includes both accrued interest (time since last
    // payment) and prepayment penalty (for closing early).
    auto const fullPaymentInterest = computeFullPaymentInterest(
        theoreticalPrincipalOutstanding,
        periodicRate,
        view.parentCloseTime(),
        paymentInterval,
        prevPaymentDate,
        startDate,
        closeInterestRate);

    // Split the full payment interest into net interest (to vault) and
    // management fee (to broker), applying proper rounding.
    auto const [roundedFullInterest, roundedFullManagementFee] = [&]() {
        auto const interest = roundToAsset(asset, fullPaymentInterest, loanScale, Number::downward);
        return computeInterestAndFeeParts(asset, interest, managementFeeRate, loanScale);
    }();

    ExtendedPaymentComponents const full{
        PaymentComponents{
            // Pay off all tracked outstanding balances: principal, interest,
            // and fees.
            // This marks the loan as complete (final payment).
            .trackedValueDelta =
                principalOutstanding + totalInterestOutstanding + managementFeeOutstanding,
            .trackedPrincipalDelta = principalOutstanding,

            // All outstanding management fees are paid. This zeroes out the
            // tracked fee balance.
            .trackedManagementFeeDelta = managementFeeOutstanding,
            .specialCase = PaymentSpecialCase::final,
        },

        // Untracked management fee includes:
        // 1. Close payment fee (fixed fee for early closure)
        // 2. Management fee on the full payment interest
        // 3. Minus the outstanding tracked fee (already accounted for above)
        // This can be negative because the outstanding fee is subtracted, but
        // it gets combined with trackedManagementFeeDelta in the final
        // accounting.
        closePaymentFee + roundedFullManagementFee - managementFeeOutstanding,

        // Value change represents the difference between what the loan was
        // expected to earn (totalInterestOutstanding) and what it actually
        // earns (roundedFullInterest with prepayment penalty).
        // - Positive: Prepayment penalty exceeds scheduled interest (loan value
        // increases)
        // - Negative: Prepayment penalty is less than scheduled interest (loan
        // value decreases)
        roundedFullInterest - totalInterestOutstanding,
    };

    XRPL_ASSERT_PARTS(
        isRounded(asset, full.totalDue, loanScale),
        "xrpl::detail::computeFullPayment",
        "total due is rounded");

    JLOG(j.trace()) << "computeFullPayment result: periodicPayment: " << periodicPayment
                    << ", periodicRate: " << periodicRate
                    << ", paymentRemaining: " << paymentRemaining
                    << ", theoreticalPrincipalOutstanding: " << theoreticalPrincipalOutstanding
                    << ", fullPaymentInterest: " << fullPaymentInterest
                    << ", roundedFullInterest: " << roundedFullInterest
                    << ", roundedFullManagementFee: " << roundedFullManagementFee
                    << ", untrackedInterest: " << full.untrackedInterest;

    if (amount < full.totalDue)
    {
        // If the payment is less than the full payment amount, it's not
        // sufficient to be a full payment.
        return Unexpected(tecINSUFFICIENT_PAYMENT);
    }

    return full;
}

Number
PaymentComponents::trackedInterestPart() const
{
    return trackedValueDelta - (trackedPrincipalDelta + trackedManagementFeeDelta);
}

/* Computes the breakdown of a regular periodic payment into principal,
 * interest, and management fee components.
 *
 * This function determines how a single scheduled payment should be split among
 * the three tracked loan components. The calculation accounts for accumulated
 * rounding errors.
 *
 * The algorithm:
 * 1. Calculate what the loan state SHOULD be after this payment (target)
 * 2. Compare current state to target to get deltas
 * 3. Adjust deltas to handle rounding artifacts and edge cases
 * 4. Ensure deltas don't exceed available balances or payment amount
 *
 * Special handling for the final payment: all remaining balances are paid off
 * regardless of the periodic payment amount.
 *
 * Implements the pseudo-code function `compute_payment_due()`.
 */
PaymentComponents
computePaymentComponents(
    Asset const& asset,
    std::int32_t scale,
    Number const& totalValueOutstanding,
    Number const& principalOutstanding,
    Number const& managementFeeOutstanding,
    Number const& periodicPayment,
    Number const& periodicRate,
    std::uint32_t paymentRemaining,
    TenthBips16 managementFeeRate)
{
    XRPL_ASSERT_PARTS(
        isRounded(asset, totalValueOutstanding, scale) &&
            isRounded(asset, principalOutstanding, scale) &&
            isRounded(asset, managementFeeOutstanding, scale),
        "xrpl::detail::computePaymentComponents",
        "Outstanding values are rounded");
    XRPL_ASSERT_PARTS(
        paymentRemaining > 0, "xrpl::detail::computePaymentComponents", "some payments remaining");

    auto const roundedPeriodicPayment = roundPeriodicPayment(asset, periodicPayment, scale);

    // Final payment: pay off everything remaining, ignoring the normal
    // periodic payment amount. This ensures the loan completes cleanly.
    if (paymentRemaining == 1 || totalValueOutstanding <= roundedPeriodicPayment)
    {
        // If there's only one payment left, we need to pay off each of the loan
        // parts.
        return PaymentComponents{
            .trackedValueDelta = totalValueOutstanding,
            .trackedPrincipalDelta = principalOutstanding,
            .trackedManagementFeeDelta = managementFeeOutstanding,
            .specialCase = PaymentSpecialCase::final};
    }

    // Calculate what the loan state SHOULD be after this payment (the target).
    // This is computed at full precision using the theoretical amortization.
    LoanState const trueTarget = computeTheoreticalLoanState(
        periodicPayment, periodicRate, paymentRemaining - 1, managementFeeRate);

    // Round the target to the loan's scale to match how actual loan values
    // are stored.
    LoanState const roundedTarget = LoanState{
        .valueOutstanding = roundToAsset(asset, trueTarget.valueOutstanding, scale),
        .principalOutstanding = roundToAsset(asset, trueTarget.principalOutstanding, scale),
        .interestDue = roundToAsset(asset, trueTarget.interestDue, scale),
        .managementFeeDue = roundToAsset(asset, trueTarget.managementFeeDue, scale)};

    // Get the current actual loan state from the ledger values
    LoanState const currentLedgerState =
        constructLoanState(totalValueOutstanding, principalOutstanding, managementFeeOutstanding);

    // The difference between current and target states gives us the payment
    // components. Any discrepancies from accumulated rounding are captured
    // here.

    LoanStateDeltas deltas = currentLedgerState - roundedTarget;

    // Rounding can occasionally produce negative deltas. Zero them out.
    deltas.nonNegative();

    XRPL_ASSERT_PARTS(
        deltas.principal <= currentLedgerState.principalOutstanding,
        "xrpl::detail::computePaymentComponents",
        "principal delta not greater than outstanding");

    // Cap each component to never exceed what's actually outstanding
    deltas.principal = std::min(deltas.principal, currentLedgerState.principalOutstanding);

    XRPL_ASSERT_PARTS(
        deltas.interest <= currentLedgerState.interestDue,
        "xrpl::detail::computePaymentComponents",
        "interest due delta not greater than outstanding");

    // Cap interest to both the outstanding amount AND what's left of the
    // periodic payment after principal is paid
    deltas.interest = std::min(
        {deltas.interest,
         std::max(numZero, roundedPeriodicPayment - deltas.principal),
         currentLedgerState.interestDue});

    XRPL_ASSERT_PARTS(
        deltas.managementFee <= currentLedgerState.managementFeeDue,
        "xrpl::detail::computePaymentComponents",
        "management fee due delta not greater than outstanding");

    // Cap management fee to both the outstanding amount AND what's left of the
    // periodic payment after principal and interest are paid
    deltas.managementFee = std::min(
        {deltas.managementFee,
         roundedPeriodicPayment - (deltas.principal + deltas.interest),
         currentLedgerState.managementFeeDue});

    // The shortage must never be negative, which indicates that the parts are
    // trying to take more than the whole payment. The excess can be positive,
    // which indicates that we're not going to take the whole payment amount,
    // but if so, it must be small.
    auto takeFrom = [](Number& component, Number& excess) {
        if (excess > beast::zero)
        {
            auto part = std::min(component, excess);
            component -= part;
            excess -= part;
        }
        XRPL_ASSERT_PARTS(
            excess >= beast::zero, "xrpl::detail::computePaymentComponents", "excess non-negative");
    };
    // Helper to reduce deltas when they collectively exceed a limit.
    // Order matters: we prefer to reduce interest first (most flexible),
    // then management fee, then principal (least flexible).
    auto addressExcess = [&takeFrom](LoanStateDeltas& deltas, Number& excess) {
        // This order is based on where errors are the least problematic
        takeFrom(deltas.interest, excess);
        takeFrom(deltas.managementFee, excess);
        takeFrom(deltas.principal, excess);
    };

    // Check if deltas exceed the total outstanding value. This should never
    // happen due to earlier caps, but handle it defensively.
    Number totalOverpayment = deltas.total() - currentLedgerState.valueOutstanding;

    if (totalOverpayment > beast::zero)
    {
        // LCOV_EXCL_START
        UNREACHABLE(
            "xrpl::detail::computePaymentComponents : payment exceeded loan "
            "state");
        addressExcess(deltas, totalOverpayment);
        // LCOV_EXCL_STOP
    }

    // Check if deltas exceed the periodic payment amount. Reduce if needed.
    Number shortage = roundedPeriodicPayment - deltas.total();

    XRPL_ASSERT_PARTS(
        isRounded(asset, shortage, scale),
        "xrpl::detail::computePaymentComponents",
        "shortage is rounded");

    if (shortage < beast::zero)
    {
        // Deltas exceed payment amount - reduce them proportionally
        Number excess = -shortage;
        addressExcess(deltas, excess);
        shortage = -excess;
    }

    // At this point, shortage >= 0 means we're paying less than the full
    // periodic payment (due to rounding or component caps).
    // shortage < 0 would mean we're trying to pay more than allowed (bug).
    XRPL_ASSERT_PARTS(
        shortage >= beast::zero, "xrpl::detail::computePaymentComponents", "no shortage or excess");

    // Final validation that all components are valid
    XRPL_ASSERT_PARTS(
        deltas.total() == deltas.principal + deltas.interest + deltas.managementFee,
        "xrpl::detail::computePaymentComponents",
        "total value adds up");

    XRPL_ASSERT_PARTS(
        deltas.principal >= beast::zero &&
            deltas.principal <= currentLedgerState.principalOutstanding,
        "xrpl::detail::computePaymentComponents",
        "valid principal result");
    XRPL_ASSERT_PARTS(
        deltas.interest >= beast::zero && deltas.interest <= currentLedgerState.interestDue,
        "xrpl::detail::computePaymentComponents",
        "valid interest result");
    XRPL_ASSERT_PARTS(
        deltas.managementFee >= beast::zero &&
            deltas.managementFee <= currentLedgerState.managementFeeDue,
        "xrpl::detail::computePaymentComponents",
        "valid fee result");

    XRPL_ASSERT_PARTS(
        deltas.principal + deltas.interest + deltas.managementFee > beast::zero,
        "xrpl::detail::computePaymentComponents",
        "payment parts add to payment");

    // Final safety clamp to ensure no value exceeds its outstanding balance
    return PaymentComponents{
        .trackedValueDelta =
            std::clamp(deltas.total(), numZero, currentLedgerState.valueOutstanding),
        .trackedPrincipalDelta =
            std::clamp(deltas.principal, numZero, currentLedgerState.principalOutstanding),
        .trackedManagementFeeDelta =
            std::clamp(deltas.managementFee, numZero, currentLedgerState.managementFeeDue),
    };
}

/* Computes payment components for an overpayment scenario.
 *
 * An overpayment occurs when a borrower pays more than the scheduled periodic
 * payment amount. The overpayment is treated as extra principal reduction,
 * but incurs a fee and potentially a penalty interest charge.
 *
 * The calculation (Section 3.2.4.2.3 from XLS-66 spec):
 * 1. Calculate gross penalty interest on the overpayment amount
 * 2. Split the gross interest into net interest and management fee
 * 3. Calculate the penalty fee
 * 4. Determine the principal portion by subtracting the interest (gross) and
 * management fee from the overpayment amount
 *
 * Unlike regular payments which follow the amortization schedule, overpayments
 * apply to principal, reducing the loan balance and future interest costs.
 *
 * Equations (20), (21) and (22) from XLS-66 spec, Section A-2 Equation Glossary
 */
ExtendedPaymentComponents
computeOverpaymentComponents(
    Asset const& asset,
    int32_t const loanScale,
    Number const& overpayment,
    TenthBips32 const overpaymentInterestRate,
    TenthBips32 const overpaymentFeeRate,
    TenthBips16 const managementFeeRate)
{
    XRPL_ASSERT(
        overpayment > 0 && isRounded(asset, overpayment, loanScale),
        "xrpl::detail::computeOverpaymentComponents : valid overpayment "
        "amount");

    // First, deduct the fixed overpayment fee from the total amount.
    // This reduces the effective payment that will be applied to the loan.
    // Equation (22) from XLS-66 spec, Section A-2 Equation Glossary
    Number const overpaymentFee =
        roundToAsset(asset, tenthBipsOfValue(overpayment, overpaymentFeeRate), loanScale);

    // Calculate the penalty interest on the effective payment amount.
    // This interest doesn't follow the normal amortization schedule - it's
    // a one-time charge for paying early.
    // Equation (20) and (21) from XLS-66 spec, Section A-2 Equation Glossary
    auto const [roundedOverpaymentInterest, roundedOverpaymentManagementFee] = [&]() {
        auto const interest =
            roundToAsset(asset, tenthBipsOfValue(overpayment, overpaymentInterestRate), loanScale);
        return detail::computeInterestAndFeeParts(asset, interest, managementFeeRate, loanScale);
    }();

    auto const result = detail::ExtendedPaymentComponents{
        // Build the payment components, after fees and penalty
        // interest are deducted, the remainder goes entirely to principal
        // reduction.
        detail::PaymentComponents{
            .trackedValueDelta = overpayment - overpaymentFee,
            .trackedPrincipalDelta = overpayment - roundedOverpaymentInterest -
                roundedOverpaymentManagementFee - overpaymentFee,
            .trackedManagementFeeDelta = roundedOverpaymentManagementFee,
            .specialCase = detail::PaymentSpecialCase::extra},
        // Untracked management fee is the fixed overpayment fee
        overpaymentFee,
        // Untracked interest is the penalty interest charged for  overpaying.
        // This is positive, representing a one-time cost, but it's typically
        // much smaller than the interest savings from reducing principal.
        // It is equal to the paymentComponents.trackedInterestPart()
        // but is kept separate for clarity.
        roundedOverpaymentInterest};
    XRPL_ASSERT_PARTS(
        result.trackedInterestPart() == roundedOverpaymentInterest,
        "xrpl::detail::computeOverpaymentComponents",
        "valid interest computation");
    return result;
}

}  // namespace detail

detail::LoanStateDeltas
operator-(LoanState const& lhs, LoanState const& rhs)
{
    detail::LoanStateDeltas result{
        .principal = lhs.principalOutstanding - rhs.principalOutstanding,
        .interest = lhs.interestDue - rhs.interestDue,
        .managementFee = lhs.managementFeeDue - rhs.managementFeeDue,
    };

    return result;
}

LoanState
operator-(LoanState const& lhs, detail::LoanStateDeltas const& rhs)
{
    LoanState result{
        .valueOutstanding = lhs.valueOutstanding - rhs.total(),
        .principalOutstanding = lhs.principalOutstanding - rhs.principal,
        .interestDue = lhs.interestDue - rhs.interest,
        .managementFeeDue = lhs.managementFeeDue - rhs.managementFee,
    };

    return result;
}

LoanState
operator+(LoanState const& lhs, detail::LoanStateDeltas const& rhs)
{
    LoanState result{
        .valueOutstanding = lhs.valueOutstanding + rhs.total(),
        .principalOutstanding = lhs.principalOutstanding + rhs.principal,
        .interestDue = lhs.interestDue + rhs.interest,
        .managementFeeDue = lhs.managementFeeDue + rhs.managementFee,
    };

    return result;
}

TER
checkLoanGuards(
    Asset const& vaultAsset,
    Number const& principalRequested,
    bool expectInterest,
    std::uint32_t paymentTotal,
    LoanProperties const& properties,
    beast::Journal j)
{
    auto const totalInterestOutstanding =
        properties.loanState.valueOutstanding - principalRequested;
    // Guard 1: if there is no computed total interest over the life of the
    // loan for a non-zero interest rate, we cannot properly amortize the
    // loan
    if (expectInterest && totalInterestOutstanding <= 0)
    {
        // Unless this is a zero-interest loan, there must be some interest
        // due on the loan, even if it's (measurable) dust
        JLOG(j.warn()) << "Loan for " << principalRequested << " with interest has no interest due";
        return tecPRECISION_LOSS;
    }
    // Guard 1a: If there is any interest computed over the life of the
    // loan, for a zero interest rate, something went sideways.
    if (!expectInterest && totalInterestOutstanding > 0)
    {
        // LCOV_EXCL_START
        JLOG(j.warn()) << "Loan for " << principalRequested << " with no interest has interest due";
        return tecINTERNAL;
        // LCOV_EXCL_STOP
    }

    // Guard 2: if the principal portion of the first periodic payment is
    // too small to be accurately represented with the given rounding mode,
    // raise an error
    if (properties.firstPaymentPrincipal <= 0)
    {
        // Check that some true (unrounded) principal is paid each period.
        // Since the first payment pays the least principal, if it's good,
        // they'll all be good. Note that the outstanding principal is
        // rounded, and may not change right away.
        JLOG(j.warn()) << "Loan is unable to pay principal.";
        return tecPRECISION_LOSS;
    }

    // Guard 3: If the periodic payment is so small that it can't even be
    // rounded to a representable value, then the loan can't be paid. Also,
    // avoids dividing by 0.
    auto const roundedPayment =
        roundPeriodicPayment(vaultAsset, properties.periodicPayment, properties.loanScale);
    if (roundedPayment == beast::zero)
    {
        JLOG(j.warn()) << "Loan Periodic payment (" << properties.periodicPayment
                       << ") rounds to 0. ";
        return tecPRECISION_LOSS;
    }

    // Guard 4: if the rounded periodic payment is large enough that the
    // loan can't be amortized in the specified number of payments, raise an
    // error
    {
        NumberRoundModeGuard mg(Number::upward);

        if (std::int64_t const computedPayments{
                properties.loanState.valueOutstanding / roundedPayment};
            computedPayments != paymentTotal)
        {
            JLOG(j.warn()) << "Loan Periodic payment (" << properties.periodicPayment
                           << ") rounding (" << roundedPayment << ") on a total value of "
                           << properties.loanState.valueOutstanding
                           << " can not complete the loan in the specified "
                              "number of payments ("
                           << computedPayments << " != " << paymentTotal << ")";
            return tecPRECISION_LOSS;
        }
    }
    return tesSUCCESS;
}

/*
 * This function calculates the full payment interest accrued since the last
 * payment, plus any prepayment penalty.
 *
 * Equations (27) and (28) from XLS-66 spec, Section A-2 Equation Glossary
 */
Number
computeFullPaymentInterest(
    Number const& theoreticalPrincipalOutstanding,
    Number const& periodicRate,
    NetClock::time_point parentCloseTime,
    std::uint32_t paymentInterval,
    std::uint32_t prevPaymentDate,
    std::uint32_t startDate,
    TenthBips32 closeInterestRate)
{
    auto const accruedInterest = detail::loanAccruedInterest(
        theoreticalPrincipalOutstanding,
        periodicRate,
        parentCloseTime,
        startDate,
        prevPaymentDate,
        paymentInterval);
    XRPL_ASSERT(
        accruedInterest >= 0,
        "xrpl::detail::computeFullPaymentInterest : valid accrued "
        "interest");

    // Equation (28) from XLS-66 spec, Section A-2 Equation Glossary
    auto const prepaymentPenalty = closeInterestRate == beast::zero
        ? Number{}
        : tenthBipsOfValue(theoreticalPrincipalOutstanding, closeInterestRate);

    XRPL_ASSERT(
        prepaymentPenalty >= 0,
        "xrpl::detail::computeFullPaymentInterest : valid prepayment "
        "interest");

    // Part of equation (27) from XLS-66 spec, Section A-2 Equation Glossary
    return accruedInterest + prepaymentPenalty;
}

/* Calculates the theoretical loan state at maximum precision for a given point
 * in the amortization schedule.
 *
 * This function computes what the loan's outstanding balances should be based
 * on the periodic payment amount and number of payments remaining,
 * without considering any rounding that may have been applied to the actual
 * Loan object's state. This "theoretical" (unrounded) state is used as a target
 * for computing payment components and validating that the loan's tracked state
 * hasn't drifted too far from the theoretical values.
 *
 * The theoretical state serves several purposes:
 * 1. Computing the expected payment breakdown (principal, interest, fees)
 * 2. Detecting and correcting rounding errors that accumulate over time
 * 3. Validating that overpayments are calculated correctly
 * 4. Ensuring the loan will be fully paid off at the end of its term
 *
 * If paymentRemaining is 0, returns a fully zeroed-out LoanState,
 *       representing a completely paid-off loan.
 *
 * Implements the `calculate_true_loan_state` function from the XLS-66 spec
 * section 3.2.4.4 Transaction Pseudo-code
 */
LoanState
computeTheoreticalLoanState(
    Number const& periodicPayment,
    Number const& periodicRate,
    std::uint32_t const paymentRemaining,
    TenthBips32 const managementFeeRate)
{
    if (paymentRemaining == 0)
    {
        return LoanState{
            .valueOutstanding = 0,
            .principalOutstanding = 0,
            .interestDue = 0,
            .managementFeeDue = 0};
    }

    // Equation (30) from XLS-66 spec, Section A-2 Equation Glossary
    Number const totalValueOutstanding = periodicPayment * paymentRemaining;

    Number const principalOutstanding =
        detail::loanPrincipalFromPeriodicPayment(periodicPayment, periodicRate, paymentRemaining);

    // Equation (31) from XLS-66 spec, Section A-2 Equation Glossary
    Number const interestOutstandingGross = totalValueOutstanding - principalOutstanding;

    // Equation (32) from XLS-66 spec, Section A-2 Equation Glossary
    Number const managementFeeOutstanding =
        tenthBipsOfValue(interestOutstandingGross, managementFeeRate);

    // Equation (33) from XLS-66 spec, Section A-2 Equation Glossary
    Number const interestOutstandingNet = interestOutstandingGross - managementFeeOutstanding;

    return LoanState{
        .valueOutstanding = totalValueOutstanding,
        .principalOutstanding = principalOutstanding,
        .interestDue = interestOutstandingNet,
        .managementFeeDue = managementFeeOutstanding,
    };
};

/* Constructs a LoanState from rounded Loan ledger object values.
 *
 * This function creates a LoanState structure from the three tracked values
 * stored in a Loan ledger object. Unlike calculateTheoreticalLoanState(), which
 * computes theoretical unrounded values, this function works with values
 * that have already been rounded to the loan's scale.
 *
 * The key difference from calculateTheoreticalLoanState():
 * - calculateTheoreticalLoanState: Computes theoretical values at full
 * precision
 * - constructRoundedLoanState: Builds state from actual rounded ledger values
 *
 * The interestDue field is derived from the other three values rather than
 * stored directly, since it can be calculated as:
 *   interestDue = totalValueOutstanding - principalOutstanding -
 * managementFeeOutstanding
 *
 * This ensures consistency across the codebase and prevents copy-paste errors
 * when creating LoanState objects from Loan ledger data.
 */
LoanState
constructLoanState(
    Number const& totalValueOutstanding,
    Number const& principalOutstanding,
    Number const& managementFeeOutstanding)
{
    // This implementation is pretty trivial, but ensures the calculations
    // are consistent everywhere, and reduces copy/paste errors.
    return LoanState{
        .valueOutstanding = totalValueOutstanding,
        .principalOutstanding = principalOutstanding,
        .interestDue = totalValueOutstanding - principalOutstanding - managementFeeOutstanding,
        .managementFeeDue = managementFeeOutstanding};
}

LoanState
constructRoundedLoanState(SLE::const_ref loan)
{
    return constructLoanState(
        loan->at(sfTotalValueOutstanding),
        loan->at(sfPrincipalOutstanding),
        loan->at(sfManagementFeeOutstanding));
}

/*
 * This function calculates the fee owed to the broker based on the asset,
 * value, and management fee rate.
 *
 * Equation (32) from XLS-66 spec, Section A-2 Equation Glossary
 */
Number
computeManagementFee(
    Asset const& asset,
    Number const& value,
    TenthBips32 managementFeeRate,
    std::int32_t scale)
{
    return roundToAsset(asset, tenthBipsOfValue(value, managementFeeRate), scale, Number::downward);
}

/*
 * Given the loan parameters, compute the derived properties of the loan.
 *
 * Pulls together several formulas from the XLS-66 spec, which are noted at each
 * step, plus the concepts from 3.2.4.3 Conceptual Loan Value. They are used for
 * to check some of the conditions in 3.2.1.5 Failure Conditions for the LoanSet
 * transaction.
 */
LoanProperties
computeLoanProperties(
    Asset const& asset,
    Number const& principalOutstanding,
    TenthBips32 interestRate,
    std::uint32_t paymentInterval,
    std::uint32_t paymentsRemaining,
    TenthBips32 managementFeeRate,
    std::int32_t minimumScale)
{
    auto const periodicRate = loanPeriodicRate(interestRate, paymentInterval);
    XRPL_ASSERT(interestRate == 0 || periodicRate > 0, "xrpl::computeLoanProperties : valid rate");
    return computeLoanProperties(
        asset,
        principalOutstanding,
        periodicRate,
        paymentsRemaining,
        managementFeeRate,
        minimumScale);
}

/*
 * Given the loan parameters, compute the derived properties of the loan.
 *
 * Pulls together several formulas from the XLS-66 spec, which are noted at each
 * step, plus the concepts from 3.2.4.3 Conceptual Loan Value. They are used for
 * to check some of the conditions in 3.2.1.5 Failure Conditions for the LoanSet
 * transaction.
 */
LoanProperties
computeLoanProperties(
    Asset const& asset,
    Number const& principalOutstanding,
    Number const& periodicRate,
    std::uint32_t paymentsRemaining,
    TenthBips32 managementFeeRate,
    std::int32_t minimumScale)
{
    auto const periodicPayment =
        detail::loanPeriodicPayment(principalOutstanding, periodicRate, paymentsRemaining);

    auto const [totalValueOutstanding, loanScale] = [&]() {
        // only round up if there should be interest
        NumberRoundModeGuard mg(periodicRate == 0 ? Number::to_nearest : Number::upward);
        // Use STAmount's internal rounding instead of roundToAsset, because
        // we're going to use this result to determine the scale for all the
        // other rounding.

        // Equation (30) from XLS-66 spec, Section A-2 Equation Glossary
        STAmount amount{asset, periodicPayment * paymentsRemaining};

        // Base the loan scale on the total value, since that's going to be
        // the biggest number involved (barring unusual parameters for late,
        // full, or over payments)
        auto const loanScale = std::max(minimumScale, amount.exponent());
        XRPL_ASSERT_PARTS(
            (amount.integral() && loanScale == 0) ||
                (!amount.integral() && loanScale >= static_cast<Number>(amount).exponent()),
            "xrpl::computeLoanProperties",
            "loanScale value fits expectations");

        // We may need to truncate the total value because of the minimum
        // scale
        amount = roundToAsset(asset, amount, loanScale);

        return std::make_pair(amount, loanScale);
    }();

    // Since we just figured out the loan scale, we haven't been able to
    // validate that the principal fits in it, so to allow this function to
    // succeed, round it here, and let the caller do the validation.
    auto const roundedPrincipalOutstanding =
        roundToAsset(asset, principalOutstanding, loanScale, Number::to_nearest);

    // Equation (31) from XLS-66 spec, Section A-2 Equation Glossary
    auto const totalInterestOutstanding = totalValueOutstanding - roundedPrincipalOutstanding;
    auto const feeOwedToBroker =
        computeManagementFee(asset, totalInterestOutstanding, managementFeeRate, loanScale);

    // Compute the principal part of the first payment. This is needed
    // because the principal part may be rounded down to zero, which
    // would prevent the principal from ever being paid down.
    auto const firstPaymentPrincipal = [&]() {
        // Compute the parts for the first payment. Ensure that the
        // principal payment will actually change the principal.
        auto const startingState = computeTheoreticalLoanState(
            periodicPayment, periodicRate, paymentsRemaining, managementFeeRate);

        auto const firstPaymentState = computeTheoreticalLoanState(
            periodicPayment, periodicRate, paymentsRemaining - 1, managementFeeRate);

        // The unrounded principal part needs to be large enough to affect
        // the principal. What to do if not is left to the caller
        return startingState.principalOutstanding - firstPaymentState.principalOutstanding;
    }();

    return LoanProperties{
        .periodicPayment = periodicPayment,
        .loanState =
            constructLoanState(totalValueOutstanding, roundedPrincipalOutstanding, feeOwedToBroker),
        .loanScale = loanScale,
        .firstPaymentPrincipal = firstPaymentPrincipal,
    };
}

/*
 * This is the main function to make a loan payment.
 * This function handles regular, late, full, and overpayments.
 * It is an implementation of the make_payment function from the XLS-66
 * spec. Section 3.2.4.4
 */
Expected<LoanPaymentParts, TER>
loanMakePayment(
    Asset const& asset,
    ApplyView& view,
    SLE::ref loan,
    SLE::const_ref brokerSle,
    STAmount const& amount,
    LoanPaymentType const paymentType,
    beast::Journal j)
{
    using namespace Lending;

    auto principalOutstandingProxy = loan->at(sfPrincipalOutstanding);
    auto paymentRemainingProxy = loan->at(sfPaymentRemaining);

    if (paymentRemainingProxy == 0 || principalOutstandingProxy == 0)
    {
        // Loan complete this is already checked in LoanPay::preclaim()
        // LCOV_EXCL_START
        JLOG(j.warn()) << "Loan is already paid off.";
        return Unexpected(tecKILLED);
        // LCOV_EXCL_STOP
    }

    auto totalValueOutstandingProxy = loan->at(sfTotalValueOutstanding);
    auto managementFeeOutstandingProxy = loan->at(sfManagementFeeOutstanding);

    // Next payment due date must be set unless the loan is complete
    auto nextDueDateProxy = loan->at(sfNextPaymentDueDate);
    if (*nextDueDateProxy == 0)
    {
        JLOG(j.warn()) << "Loan next payment due date is not set.";
        return Unexpected(tecINTERNAL);
    }

    std::int32_t const loanScale = loan->at(sfLoanScale);

    TenthBips32 const interestRate{loan->at(sfInterestRate)};

    Number const serviceFee = loan->at(sfLoanServiceFee);
    TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)};

    Number const periodicPayment = loan->at(sfPeriodicPayment);

    auto prevPaymentDateProxy = loan->at(sfPreviousPaymentDueDate);
    std::uint32_t const startDate = loan->at(sfStartDate);

    std::uint32_t const paymentInterval = loan->at(sfPaymentInterval);

    // Compute the periodic rate that will be used for calculations
    // throughout
    Number const periodicRate = loanPeriodicRate(interestRate, paymentInterval);
    XRPL_ASSERT(interestRate == 0 || periodicRate > 0, "xrpl::loanMakePayment : valid rate");

    XRPL_ASSERT(*totalValueOutstandingProxy > 0, "xrpl::loanMakePayment : valid total value");

    view.update(loan);

    // -------------------------------------------------------------
    // A late payment not flagged as late overrides all other options.
    if (paymentType != LoanPaymentType::late && hasExpired(view, nextDueDateProxy))
    {
        // If the payment is late, and the late flag was not set, it's not
        // valid
        JLOG(j.warn()) << "Loan payment is overdue. Use the tfLoanLatePayment "
                          "transaction "
                          "flag to make a late payment. Loan was created on "
                       << startDate << ", prev payment due date is " << prevPaymentDateProxy
                       << ", next payment due date is " << nextDueDateProxy << ", ledger time is "
                       << view.parentCloseTime().time_since_epoch().count();
        return Unexpected(tecEXPIRED);
    }

    // -------------------------------------------------------------
    // full payment handling
    if (paymentType == LoanPaymentType::full)
    {
        TenthBips32 const closeInterestRate{loan->at(sfCloseInterestRate)};
        Number const closePaymentFee = roundToAsset(asset, loan->at(sfClosePaymentFee), loanScale);

        LoanState const roundedLoanState = constructLoanState(
            totalValueOutstandingProxy, principalOutstandingProxy, managementFeeOutstandingProxy);

        auto const fullPaymentComponents = detail::computeFullPayment(
            asset,
            view,
            principalOutstandingProxy,
            managementFeeOutstandingProxy,
            periodicPayment,
            paymentRemainingProxy,
            prevPaymentDateProxy,
            startDate,
            paymentInterval,
            closeInterestRate,
            loanScale,
            roundedLoanState.interestDue,
            periodicRate,
            closePaymentFee,
            amount,
            managementFeeRate,
            j);

        if (fullPaymentComponents.has_value())
        {
            return doPayment(
                *fullPaymentComponents,
                totalValueOutstandingProxy,
                principalOutstandingProxy,
                managementFeeOutstandingProxy,
                paymentRemainingProxy,
                prevPaymentDateProxy,
                nextDueDateProxy,
                paymentInterval);
        }

        if (fullPaymentComponents.error())
        {
            // error() will be the TER returned if a payment is not made. It
            // will only evaluate to true if it's unsuccessful. Otherwise,
            // tesSUCCESS means nothing was done, so continue.
            return Unexpected(fullPaymentComponents.error());
        }

        // LCOV_EXCL_START
        UNREACHABLE("xrpl::loanMakePayment : invalid full payment result");
        JLOG(j.error()) << "Full payment computation failed unexpectedly.";
        return Unexpected(tecINTERNAL);
        // LCOV_EXCL_STOP
    }

    // -------------------------------------------------------------
    // compute the periodic payment info that will be needed whether the
    // payment is late or regular
    detail::ExtendedPaymentComponents periodic{
        detail::computePaymentComponents(
            asset,
            loanScale,
            totalValueOutstandingProxy,
            principalOutstandingProxy,
            managementFeeOutstandingProxy,
            periodicPayment,
            periodicRate,
            paymentRemainingProxy,
            managementFeeRate),
        serviceFee};
    XRPL_ASSERT_PARTS(
        periodic.trackedPrincipalDelta >= 0,
        "xrpl::loanMakePayment",
        "regular payment valid principal");

    // -------------------------------------------------------------
    // late payment handling
    if (paymentType == LoanPaymentType::late)
    {
        TenthBips32 const lateInterestRate{loan->at(sfLateInterestRate)};
        Number const latePaymentFee = loan->at(sfLatePaymentFee);

        auto const latePaymentComponents = detail::computeLatePayment(
            asset,
            view,
            principalOutstandingProxy,
            nextDueDateProxy,
            periodic,
            lateInterestRate,
            loanScale,
            latePaymentFee,
            amount,
            managementFeeRate,
            j);

        if (latePaymentComponents.has_value())
        {
            return doPayment(
                *latePaymentComponents,
                totalValueOutstandingProxy,
                principalOutstandingProxy,
                managementFeeOutstandingProxy,
                paymentRemainingProxy,
                prevPaymentDateProxy,
                nextDueDateProxy,
                paymentInterval);
        }

        if (latePaymentComponents.error())
        {
            // error() will be the TER returned if a payment is not made. It
            // will only evaluate to true if it's unsuccessful.
            return Unexpected(latePaymentComponents.error());
        }

        // LCOV_EXCL_START
        UNREACHABLE("xrpl::loanMakePayment : invalid late payment result");
        JLOG(j.error()) << "Late payment computation failed unexpectedly.";
        return Unexpected(tecINTERNAL);
        // LCOV_EXCL_STOP
    }

    // -------------------------------------------------------------
    // regular periodic payment handling

    XRPL_ASSERT_PARTS(
        paymentType == LoanPaymentType::regular || paymentType == LoanPaymentType::overpayment,
        "xrpl::loanMakePayment",
        "regular payment type");

    // Keep a running total of the actual parts paid
    LoanPaymentParts totalParts;
    Number totalPaid;
    std::size_t numPayments = 0;

    while ((amount >= (totalPaid + periodic.totalDue)) && paymentRemainingProxy > 0 &&
           numPayments < loanMaximumPaymentsPerTransaction)
    {
        // Try to make more payments
        XRPL_ASSERT_PARTS(
            periodic.trackedPrincipalDelta >= 0,
            "xrpl::loanMakePayment",
            "payment pays non-negative principal");

        totalPaid += periodic.totalDue;
        totalParts += detail::doPayment(
            periodic,
            totalValueOutstandingProxy,
            principalOutstandingProxy,
            managementFeeOutstandingProxy,
            paymentRemainingProxy,
            prevPaymentDateProxy,
            nextDueDateProxy,
            paymentInterval);
        ++numPayments;

        XRPL_ASSERT_PARTS(
            (periodic.specialCase == detail::PaymentSpecialCase::final) ==
                (paymentRemainingProxy == 0),
            "xrpl::loanMakePayment",
            "final payment is the final payment");

        // Don't compute the next payment if this was the last payment
        if (periodic.specialCase == detail::PaymentSpecialCase::final)
            break;

        periodic = detail::ExtendedPaymentComponents{
            detail::computePaymentComponents(
                asset,
                loanScale,
                totalValueOutstandingProxy,
                principalOutstandingProxy,
                managementFeeOutstandingProxy,
                periodicPayment,
                periodicRate,
                paymentRemainingProxy,
                managementFeeRate),
            serviceFee};
    }

    if (numPayments == 0)
    {
        JLOG(j.warn()) << "Regular loan payment amount is insufficient. Due: " << periodic.totalDue
                       << ", paid: " << amount;
        return Unexpected(tecINSUFFICIENT_PAYMENT);
    }

    XRPL_ASSERT_PARTS(
        totalParts.principalPaid + totalParts.interestPaid + totalParts.feePaid == totalPaid,
        "xrpl::loanMakePayment",
        "payment parts add up");
    XRPL_ASSERT_PARTS(totalParts.valueChange == 0, "xrpl::loanMakePayment", "no value change");

    // -------------------------------------------------------------
    // overpayment handling
    if (paymentType == LoanPaymentType::overpayment && loan->isFlag(lsfLoanOverpayment) &&
        paymentRemainingProxy > 0 && totalPaid < amount &&
        numPayments < loanMaximumPaymentsPerTransaction)
    {
        TenthBips32 const overpaymentInterestRate{loan->at(sfOverpaymentInterestRate)};
        TenthBips32 const overpaymentFeeRate{loan->at(sfOverpaymentFee)};

        // It shouldn't be possible for the overpayment to be greater than
        // totalValueOutstanding, because that would have been processed as
        // another normal payment. But cap it just in case.
        Number const overpayment = std::min(amount - totalPaid, *totalValueOutstandingProxy);

        detail::ExtendedPaymentComponents const overpaymentComponents =
            detail::computeOverpaymentComponents(
                asset,
                loanScale,
                overpayment,
                overpaymentInterestRate,
                overpaymentFeeRate,
                managementFeeRate);

        // Don't process an overpayment if the whole amount (or more!)
        // gets eaten by fees and interest.
        if (overpaymentComponents.trackedPrincipalDelta > 0)
        {
            XRPL_ASSERT_PARTS(
                overpaymentComponents.untrackedInterest >= beast::zero,
                "xrpl::loanMakePayment",
                "overpayment penalty did not reduce value of loan");
            // Can't just use `periodicPayment` here, because it might
            // change
            auto periodicPaymentProxy = loan->at(sfPeriodicPayment);
            if (auto const overResult = detail::doOverpayment(
                    asset,
                    loanScale,
                    overpaymentComponents,
                    totalValueOutstandingProxy,
                    principalOutstandingProxy,
                    managementFeeOutstandingProxy,
                    periodicPaymentProxy,
                    periodicRate,
                    paymentRemainingProxy,
                    managementFeeRate,
                    j))
            {
                totalParts += *overResult;
            }
            else if (overResult.error())
            {
                // error() will be the TER returned if a payment is not
                // made. It will only evaluate to true if it's unsuccessful.
                // Otherwise, tesSUCCESS means nothing was done, so
                // continue.
                return Unexpected(overResult.error());
            }
        }
    }

    // Check the final results are rounded, to double-check that the
    // intermediate steps were rounded.
    XRPL_ASSERT(
        isRounded(asset, totalParts.principalPaid, loanScale) &&
            totalParts.principalPaid >= beast::zero,
        "xrpl::loanMakePayment : total principal paid is valid");
    XRPL_ASSERT(
        isRounded(asset, totalParts.interestPaid, loanScale) &&
            totalParts.interestPaid >= beast::zero,
        "xrpl::loanMakePayment : total interest paid is valid");
    XRPL_ASSERT(
        isRounded(asset, totalParts.valueChange, loanScale),
        "xrpl::loanMakePayment : loan value change is valid");
    XRPL_ASSERT(
        isRounded(asset, totalParts.feePaid, loanScale) && totalParts.feePaid >= beast::zero,
        "xrpl::loanMakePayment : fee paid is valid");
    return totalParts;
}
}  // namespace xrpl
