<?php
declare(strict_types=1);

require_once "KLogger.php";
$UtilLogger = new KLogger(STRIPE_LOG_FILE, KLogger::DEBUG);

/*
 * GetInvoiceIDFromPaymentIntent
 *
 * As of the Stripe "basil" release the Charge and PaymentIntent objects no longer
 * carry an `invoice` field, and the Invoice object no longer carries `charge` or
 * `payment_intent`.  The link now lives in the standalone InvoicePayment object.
 *
 * Stripe creates a default InvoicePayment when the invoice is FINALIZED — before
 * any charge is attempted — so this lookup resolves for failed charges too.  The
 * InvoicePayment status stays 'open' until the invoice is actually paid.
 *
 * Returns the in_ id, or '' if the PaymentIntent is a standalone (non-invoice) charge.
 */
function GetInvoiceIDFromPaymentIntent(string $PaymentIntentID): string
{
    global $UtilLogger;

    if (trim($PaymentIntentID) === '') {
        return '';
    }

    require_once STRIPE_INIT_LOCATION;
    $myStripeClient = new \Stripe\StripeClient(STRIPE_SECRET_KEY);

    try {
        $InvoicePayments = $myStripeClient->invoicePayments->all([
            'payment' => ['type' => 'payment_intent', 'payment_intent' => $PaymentIntentID],
            'limit'   => 1,
        ]);

        if (empty($InvoicePayments->data)) {
            $UtilLogger->LogDebug("GetInvoiceIDFromPaymentIntent: no invoice_payment for $PaymentIntentID — standalone charge");
            return '';
        }

        $InvoicePayment = $InvoicePayments->data[0];
        $Invoice        = $InvoicePayment->invoice;
        $InvoiceID      = is_string($Invoice) ? $Invoice : (string) ($Invoice->id ?? '');

        $UtilLogger->LogDebug("GetInvoiceIDFromPaymentIntent: pi=$PaymentIntentID | invoice_payment={$InvoicePayment->id} | status={$InvoicePayment->status} | invoice=$InvoiceID");

        return $InvoiceID;

    } catch (\Exception $ex) {
        $UtilLogger->LogError("GetInvoiceIDFromPaymentIntent error (pi=$PaymentIntentID): " . $ex->getMessage());
        return '';
    }
}


/*
 * ResolveInvoiceChargeDate
 *
 * StripeInvoices.ChargeDate means: the latest moment this invoice either was,
 * or will be, attempted.  Three candidates, whichever is latest wins:
 *
 *   1. $AttemptedAt  - the attempt that triggered the current event, supplied
 *                      by the caller (payment_failed / payment_succeeded).
 *   2. paid_at       - when the invoice actually settled.
 *   3. next_payment_attempt - a retry Stripe has already scheduled.  Being in
 *                      the future, this outranks anything already attempted.
 *
 * When Stripe stops retrying, next_payment_attempt goes away and the last real
 * attempt stands — which is the case dunning depends on.
 *
 * Returns 'Y-m-d H:i:s', or null so PDO binds a real SQL NULL.  Never returns
 * the string "null", which MySQL silently coerces to a zero date in a datetime
 * column and which broke every `datediff(curdate(), ChargeDate)` comparison.
 *
 * Reads fields with ?? rather than property_exists(): $InvoiceObject is a
 * \Stripe\StripeObject that serves its fields from an internal _values array
 * through __get, so property_exists() returns false for every one of them.
 */
function ResolveInvoiceChargeDate(object $InvoiceObject, ?string $AttemptedAt = null): ?string
{
    $Candidates = [];

    if ($AttemptedAt !== null && $AttemptedAt !== '' && $AttemptedAt !== 'null') {
        $AttemptedTs = strtotime($AttemptedAt);
        if ($AttemptedTs !== false && $AttemptedTs > 0) {
            $Candidates[] = $AttemptedTs;
        }
    }

    $PaidAt = $InvoiceObject->status_transitions->paid_at ?? null;
    if (is_numeric($PaidAt) && (int) $PaidAt > 0) {
        $Candidates[] = (int) $PaidAt;
    }

    $NextAttempt = $InvoiceObject->next_payment_attempt ?? null;
    if (is_numeric($NextAttempt) && (int) $NextAttempt > 0) {
        $Candidates[] = (int) $NextAttempt;
    }

    if (empty($Candidates)) {
        return null;
    }

    return date('Y-m-d H:i:s', max($Candidates));
}


/*
 * ResolveInvoiceStatus
 *
 * Maps a Stripe invoice onto the InvoiceStatus vocabulary stored in StripeInvoices:
 *
 *   'free'     - nothing was owed
 *   'new'      - no payment has been attempted yet
 *   'paid'     - the invoice settled
 *   'failed'   - payment was attempted and did not succeed
 *   'forgiven' - Stripe stopped trying: marked uncollectible, or voided
 *
 * Derived entirely from amount_due, status_transitions and attempted. The older approach
 * read Invoice->paid and Invoice->forgiven, both of which Stripe removed — leaving every
 * attempted invoice to fall through to 'failed', including ones that had been paid.
 *
 * Order matters twice over:
 *   - amount_due is checked first, matching Dunning4 and GetStripeRevenue. Stripe
 *     auto-settles a zero-amount invoice, so without this it would report as 'paid'.
 *   - paid is checked before forgiven, because an invoice can be marked uncollectible
 *     and then paid afterwards, and settlement should win.
 *
 * An absent amount_due is not treated as zero — that would mislabel a real invoice
 * 'free' and hide it from every collection query.
 *
 * NOTE: currently called only by tools/repair_invoice_chargedates.php. The live event
 * handlers still use the older inline block; this function is the intended replacement
 * once that change is approved, and lives here so the two cannot drift apart.
 */
function ResolveInvoiceStatus(object $InvoiceObject): string
{
    $AmountDue = $InvoiceObject->amount_due ?? null;
    if (is_numeric($AmountDue) && (int) $AmountDue === 0) {
        return 'free';
    }

    $Transitions = $InvoiceObject->status_transitions ?? null;

    $PaidAt   = $Transitions->paid_at                 ?? null;
    $UncollAt = $Transitions->marked_uncollectible_at ?? null;
    $VoidedAt = $Transitions->voided_at               ?? null;

    if ($PaidAt !== null) {
        return 'paid';
    }
    if ($UncollAt !== null || $VoidedAt !== null) {
        return 'forgiven';
    }
    if (empty($InvoiceObject->attempted)) {
        return 'new';
    }
    return 'failed';
}


/*
 * GetInvoicePaymentAttempt
 *
 * Reverse of GetInvoiceIDFromPaymentIntent — walks the invoice's default
 * InvoicePayment to recover the pi_ that Stripe used to attempt payment, then
 * follows that to the ch_ and the decline reason.  Use this on
 * invoice.payment_failed / invoice.payment_succeeded, where the Invoice object
 * no longer exposes `charge` or `payment_intent` directly.
 *
 * On a failure that never produced a Charge (declined at the PaymentIntent
 * level), $ChargeID stays '' but $FailureMessage is still populated from
 * PaymentIntent->last_payment_error.
 *
 * $CardType/$CardBrand/$CardLastFour describe the card that was actually
 * attempted, taken from last_payment_error->payment_method.  That is more
 * reliable than the customer's current default, which may since have changed.
 *
 * Returns the InvoicePayment status ('open', 'paid', 'canceled') or '' if none found.
 */
function GetInvoicePaymentAttempt(
    string $InvoiceID,
    string &$PaymentIntentID,
    string &$ChargeID,
    string &$FailureMessage,
    string &$CardType,
    string &$CardBrand,
    string &$CardLastFour
): string {
    global $UtilLogger;

    $PaymentIntentID = '';
    $ChargeID        = '';
    $FailureMessage  = '';
    $CardType        = '';
    $CardBrand       = '';
    $CardLastFour    = '';

    if (trim($InvoiceID) === '') {
        return '';
    }

    require_once STRIPE_INIT_LOCATION;
    $myStripeClient = new \Stripe\StripeClient(STRIPE_SECRET_KEY);

    try {
        $InvoicePayments = $myStripeClient->invoicePayments->all(['invoice' => $InvoiceID]);

        // Prefer the default InvoicePayment; Stripe keeps it synced to amount_remaining.
        $Chosen = null;
        foreach ($InvoicePayments->data as $InvoicePayment) {
            if (!empty($InvoicePayment->is_default)) {
                $Chosen = $InvoicePayment;
                break;
            }
            if ($Chosen === null) {
                $Chosen = $InvoicePayment;
            }
        }

        if ($Chosen === null) {
            $UtilLogger->LogDebug("GetInvoicePaymentAttempt: no invoice_payment for $InvoiceID");
            return '';
        }

        $PaymentIntent   = $Chosen->payment->payment_intent ?? null;
        $PaymentIntentID = is_string($PaymentIntent) ? $PaymentIntent : (string) ($PaymentIntent->id ?? '');

        if ($PaymentIntentID !== '') {
            // Expand the declined payment method so we can name the card for the customer.
            $Pi = $myStripeClient->paymentIntents->retrieve(
                $PaymentIntentID,
                ['expand' => ['last_payment_error.payment_method']]
            );
            $LatestCharge = $Pi->latest_charge ?? null;
            $ChargeID     = is_string($LatestCharge) ? $LatestCharge : (string) ($LatestCharge->id ?? '');

            if (!empty($Pi->last_payment_error)) {
                $LastError      = $Pi->last_payment_error;
                $FailureMessage = (string) ($LastError->message
                    ?? $LastError->decline_code
                    ?? $LastError->code
                    ?? '');

                $DeclinedMethod = $LastError->payment_method ?? null;
                if ($DeclinedMethod !== null && !is_string($DeclinedMethod) && ($DeclinedMethod->type ?? '') === 'card') {
                    $Card       = $DeclinedMethod->card;
                    $WalletType = !empty($Card->wallet) ? $Card->wallet->type : '';
                    $CardType   = match ($WalletType) {
                        'apple_pay'  => 'ApplePay',
                        'google_pay' => 'GooglePay',
                        default      => !empty($Card->funding) ? $Card->funding : 'credit',
                    };
                    $CardBrand    = (string) ($Card->brand ?? '');
                    $CardLastFour = (string) ($Card->last4 ?? '');
                } elseif ($DeclinedMethod !== null && !is_string($DeclinedMethod)) {
                    $CardType = (string) ($DeclinedMethod->type ?? '');
                }
            }
        }

        $UtilLogger->LogDebug("GetInvoicePaymentAttempt: invoice=$InvoiceID | invoice_payment={$Chosen->id} | status={$Chosen->status} | pi=$PaymentIntentID | charge=$ChargeID | card=$CardBrand $CardLastFour | error=$FailureMessage");

        return (string) $Chosen->status;

    } catch (\Exception $ex) {
        $UtilLogger->LogError("GetInvoicePaymentAttempt error (invoice=$InvoiceID): " . $ex->getMessage());
        return '';
    }
}


function GetPaymentMethodFromCharge(
    \Stripe\Charge $ChargeObject,
    string         &$CardType,
    string         &$CardBrand,
    string         &$CardLastFour,
    int            &$CardExpMonth,
    int            &$CardExpYear
): string {
    global $UtilLogger;

    $CardType     = '';
    $CardBrand    = '';
    $CardLastFour = '';
    $CardExpMonth = 0;
    $CardExpYear  = 0;

    $ChargeID = !empty($ChargeObject->id) ? (string) $ChargeObject->id : '(unknown)';

    try {
        // 1. Modern: payment_method_details (embedded on all recent charges)
        if (!empty($ChargeObject->payment_method_details)) {
            $pmd  = $ChargeObject->payment_method_details;
            $type = $pmd->type;
            $UtilLogger->LogDebug("GetPaymentMethodFromCharge: source=payment_method_details | charge=$ChargeID | type=$type");

            if ($type === 'card') {
                $card       = $pmd->card;
                $walletType = !empty($card->wallet) ? $card->wallet->type : '';
                $CardType   = match ($walletType) {
                    'apple_pay'  => 'ApplePay',
                    'google_pay' => 'GooglePay',
                    default      => !empty($card->funding) ? $card->funding : 'credit',
                };
                $CardBrand    = $card->brand;
                $CardLastFour = $card->last4;
                $CardExpMonth = (int) $card->exp_month;
                $CardExpYear  = (int) $card->exp_year;

            } elseif ($type === 'ach_debit' || $type === 'us_bank_account') {
                $CardType = 'ACH';
            } elseif ($type === 'amazon_pay') {
                $CardType = 'AmazonPay';
            } else {
                $CardType = $type;
            }

            return $CardType;
        }

        // 2. Legacy: charge->source (older charges before payment_method_details existed)
        if (!empty($ChargeObject->source)) {
            $source    = $ChargeObject->source;
            $sourceObj = !empty($source->object) ? (string) $source->object : '';
            $UtilLogger->LogDebug("GetPaymentMethodFromCharge: source=charge.source | charge=$ChargeID | object=$sourceObj");

            if ($sourceObj === 'card') {
                // Direct legacy Card object
                $UtilLogger->LogDebug("GetPaymentMethodFromCharge: id_type=card_ (legacy Card on charge.source) | charge=$ChargeID | id={$source->id}");
                $CardType     = !empty($source->funding) ? $source->funding : 'credit';
                $CardBrand    = $source->brand;
                $CardLastFour = $source->last4;
                $CardExpMonth = (int) $source->exp_month;
                $CardExpYear  = (int) $source->exp_year;

            } elseif ($sourceObj === 'source') {
                // Legacy Source object — card details are nested under source->card
                $sourceType = !empty($source->type) ? (string) $source->type : '';
                $UtilLogger->LogDebug("GetPaymentMethodFromCharge: id_type=src_ (legacy Source on charge.source) | charge=$ChargeID | id={$source->id} | type=$sourceType");

                if ($sourceType === 'card') {
                    $card         = $source->card;
                    $CardType     = !empty($card->funding) ? $card->funding : 'credit';
                    $CardBrand    = $card->brand;
                    $CardLastFour = $card->last4;
                    $CardExpMonth = (int) $card->exp_month;
                    $CardExpYear  = (int) $card->exp_year;
                } elseif ($sourceType === 'ach_debit' || $sourceType === 'ach_credit_transfer') {
                    $CardType = 'ACH';
                } else {
                    $CardType = $sourceType;
                }

            } elseif ($sourceObj === 'bank_account') {
                $UtilLogger->LogDebug("GetPaymentMethodFromCharge: id_type=ba_ (legacy BankAccount on charge.source) | charge=$ChargeID | id={$source->id}");
                $CardType = 'ACH';
            }
        }

    } catch (\Exception $ex) {
        $UtilLogger->LogError("GetPaymentMethodFromCharge error (charge=$ChargeID): " . $ex->getMessage());
        while ($ex->getPrevious() !== null) {
            $ex = $ex->getPrevious();
            $UtilLogger->LogError("  Inner error: " . $ex->getMessage());
        }
        return '';
    }

    return $CardType;
}
?>
