<?php

namespace Give\Donations\Repositories;

use Give\DonationForms\Models\DonationForm;
use Give\Donations\Actions\GeneratePurchaseKey;
use Give\Donations\Models\Donation;
use Give\Donations\ValueObjects\DonationMetaKeys;
use Give\Donations\ValueObjects\DonationMode;
use Give\Donations\ValueObjects\DonationStatus;
use Give\Framework\Database\DB;
use Give\Framework\Exceptions\Primitives\Exception;
use Give\Framework\Exceptions\Primitives\InvalidArgumentException;
use Give\Framework\FieldsAPI\Field;
use Give\Framework\Models\ModelQueryBuilder;
use Give\Framework\QueryBuilder\QueryBuilder;
use Give\Framework\Receipts\DonationReceipt;
use Give\Framework\Receipts\DonationReceiptBuilder;
use Give\Framework\Support\Facades\DateTime\Temporal;
use Give\Helpers\Call;
use Give\Helpers\Hooks;
use Give\Log\Log;

/**
 * @since 2.20.0 update amount type, fee recovered, and exchange rate
 * @since 2.19.6
 */
class DonationRepository
{
    /**
     * @var DonationNotesRepository
     */
    public $notes;

    /**
     * @since 2.21.0
     */
    public function __construct()
    {
        $this->notes = give(DonationNotesRepository::class);
    }

    /**
     * @since 2.19.6
     *
     * @var string[]
     */
    private $requiredDonationProperties = [
        'formId',
        'status',
        'gatewayId',
        'amount',
        'donorId',
        'firstName',
        'type',
        'email',
    ];

    /**
     * Get Donation By ID
     *
     * @since 2.19.6
     *
     * @return Donation|null
     */
    public function getById(int $donationId)
    {
        return $this->prepareQuery()
            ->where('ID', $donationId)
            ->get();
    }

    /**
     * @since 2.21.0
     * @return Donation|null
     */
    public function getByGatewayTransactionId($gatewayTransactionId)
    {
        return $this->queryByGatewayTransactionId($gatewayTransactionId)->get();
    }

    /**
     * @since 3.16.0
     */
    public function getTotalDonationCountByGatewayTransactionId($gatewayTransactionId): int
    {
        return $this->queryByGatewayTransactionId($gatewayTransactionId)->count();
    }

    /**
     * @since 2.21.0
     */
    public function queryByGatewayTransactionId($gatewayTransactionId): ModelQueryBuilder
    {
        return $this->prepareQuery()
            ->where('post_type', 'give_payment')
            ->where('ID', function (QueryBuilder $builder) use ($gatewayTransactionId) {
                $builder
                    ->select('donation_id')
                    ->from('give_donationmeta')
                    ->where('meta_key', DonationMetaKeys::GATEWAY_TRANSACTION_ID()->getValue())
                    ->where('meta_value', $gatewayTransactionId);
            });
    }

    /**
     * @since 2.19.6
     *
     * @param  int  $donationId
     * @return ModelQueryBuilder<Donation>
     */
    public function queryById(int $donationId): ModelQueryBuilder
    {
        return $this->prepareQuery()
            ->where('ID', $donationId);
    }

    /**
     * @since 2.19.6
     *
     * @return Donation[]|null
     */
    public function getBySubscriptionId(int $subscriptionId)
    {
        return $this->queryBySubscriptionId($subscriptionId)->getAll();
    }

    /**
     * @since 2.19.6
     *
     * @param  int  $subscriptionId
     * @return ModelQueryBuilder<Donation>
     */
    public function queryBySubscriptionId(int $subscriptionId): ModelQueryBuilder
    {
        $initialDonationId = give()->subscriptions->getInitialDonationId($subscriptionId);

        $renewals = $this->prepareQuery()
            ->where('post_type', 'give_payment')
            ->where('post_status', 'give_subscription')
            ->whereIn('ID', function (QueryBuilder $builder) use ($subscriptionId) {
                $builder
                    ->select('donation_id')
                    ->from('give_donationmeta')
                    ->where('meta_key', DonationMetaKeys::SUBSCRIPTION_ID)
                    ->where('meta_value', $subscriptionId);
            });

        return $renewals->orWhere('ID', $initialDonationId)->orderBy('post_date', 'DESC');
    }

    /**
     * @since 2.19.6
     *
     * @param  int  $donorId
     * @return ModelQueryBuilder<Donation>
     */
    public function queryByDonorId(int $donorId): ModelQueryBuilder
    {
        return $this->prepareQuery()
            ->where('post_type', 'give_payment')
            ->whereIn('ID', function (QueryBuilder $builder) use ($donorId) {
                $builder
                    ->select('donation_id')
                    ->from('give_donationmeta')
                    ->where('meta_key', DonationMetaKeys::DONOR_ID)
                    ->where('meta_value', $donorId);
            })
            ->orderBy('post_date', 'DESC');
    }

    /**
     * @since 4.0.0
     */
    public function getByReceiptId(string $receiptId): ?Donation
    {
        return $this->queryByReceiptId($receiptId)->get();
    }

    /**
     * @since 4.0.0
     */
    public function queryByReceiptId(string $receiptId): ModelQueryBuilder
    {
        return $this->prepareQuery()
            ->where('post_type', 'give_payment')
            ->whereIn('ID', function (QueryBuilder $builder) use ($receiptId) {
                $builder
                    ->select('donation_id')
                    ->from('give_donationmeta')
                    ->where('meta_key', DonationMetaKeys::PURCHASE_KEY)
                    ->where('meta_value', $receiptId);
            });
    }

    /**
     * @since 4.8.1 Moved campaignId assignment logic to getCoreDonationMetaForDatabase method to eliminate code duplication.
     * @since 3.20.0 store meta using native WP functions
     * @since 2.23.0 retrieve the post_parent instead of relying on parentId property
     * @since 2.21.0 replace actions with givewp_donation_creating and givewp_donation_created
     * @since 2.20.0 mutate model and return void
     * @since 2.19.6
     *
     * @return void
     * @throws Exception|InvalidArgumentException
     */
    public function insert(Donation $donation)
    {
        $this->validateDonation($donation);

        Hooks::doAction('givewp_donation_creating', $donation);

        $dateCreated = Temporal::withoutMicroseconds($donation->createdAt ?: Temporal::getCurrentDateTime());
        $dateCreatedFormatted = Temporal::getFormattedDateTime($dateCreated);
        $dateUpdated = $donation->updatedAt ?? $dateCreated;
        $dateUpdatedFormatted = Temporal::getFormattedDateTime($dateUpdated);

        DB::query('START TRANSACTION');

        try {
            DB::table('posts')
                ->insert([
                    'post_date' => $dateCreatedFormatted,
                    'post_date_gmt' => get_gmt_from_date($dateCreatedFormatted),
                    'post_modified' => $dateUpdatedFormatted,
                    'post_modified_gmt' => get_gmt_from_date($dateUpdatedFormatted),
                    'post_status' => $this->getPersistedDonationStatus($donation)->getValue(),
                    'post_type' => 'give_payment',
                    'post_parent' => $this->deriveLegacyDonationParentId($donation),
                ]);

            $donationId = DB::last_insert_id();

            $donationMeta = $this->getCoreDonationMetaForDatabase($donation);

            foreach ($donationMeta as $metaKey => $metaValue) {
                give()->payment_meta->add_meta($donationId, $metaKey, $metaValue);
            }
        } catch (Exception $exception) {
            DB::query('ROLLBACK');

            Log::error('Failed creating a donation', compact('donation'));

            throw new $exception('Failed creating a donation');
        }

        DB::query('COMMIT');

        $donation->id = $donationId;

        $donation->createdAt = $dateCreated;
        $donation->updatedAt = $dateUpdated;

        if (!isset($donation->formTitle)) {
            $donation->formTitle = $this->getFormTitle($donation->formId);
        }

        if (!isset($donation->purchaseKey)) {
            $donation->purchaseKey = $donationMeta[DonationMetaKeys::PURCHASE_KEY];
        }

        Hooks::doAction('givewp_donation_created', $donation);
    }

    /**
     * @since 4.6.0 Turn createdAt property updatable
     * @since 2.23.1 Use give_update_meta() method to update entries on give_donationmeta table
     * @since 2.23.0 retrieve the post_parent instead of relying on parentId property
     * @since 2.21.0 replace actions with givewp_donation_updating and givewp_donation_updated
     * @since 2.20.0 return void
     * @since 2.19.6
     *
     * @return void
     * @throws Exception|InvalidArgumentException
     */
    public function update(Donation $donation)
    {
        $this->validateDonation($donation);

        Hooks::doAction('givewp_donation_updating', $donation);

        $dateCreated = Temporal::withoutMicroseconds($donation->createdAt);
        $dateCreatedFormatted = Temporal::getFormattedDateTime($dateCreated);
        $now = Temporal::withoutMicroseconds(Temporal::getCurrentDateTime());
        $nowFormatted = Temporal::getFormattedDateTime($now);

        DB::query('START TRANSACTION');

        try {
            DB::table('posts')
                ->where('ID', $donation->id)
                ->update([
                    'post_date' => $dateCreatedFormatted,
                    'post_date_gmt' => get_gmt_from_date($dateCreatedFormatted),
                    'post_modified' => $nowFormatted,
                    'post_modified_gmt' => get_gmt_from_date($nowFormatted),
                    'post_status' => $this->getPersistedDonationStatus($donation)->getValue(),
                    'post_type' => 'give_payment',
                    'post_parent' => $this->deriveLegacyDonationParentId($donation),
                ]);

            foreach ($this->getCoreDonationMetaForDatabase($donation) as $metaKey => $metaValue) {
                give()->payment_meta->update_meta($donation->id, $metaKey, $metaValue);
            }
        } catch (Exception $exception) {
            DB::query('ROLLBACK');

            Log::error('Failed updating a donation', compact('donation'));

            throw new $exception('Failed updating a donation');
        }

        $donation->updatedAt = $now;

        DB::query('COMMIT');

        Hooks::doAction('givewp_donation_updated', $donation);
    }

    /**
     * @since 2.21.0 replace actions with givewp_donation_deleting and givewp_donation_deleted
     * @since 2.20.0 consolidate meta deletion into a single query
     * @since 2.19.6
     *
     * @throws Exception
     */
    public function delete(Donation $donation): bool
    {
        DB::query('START TRANSACTION');

        Hooks::doAction('givewp_donation_deleting', $donation);

        try {
            DB::table('posts')
                ->where('id', $donation->id)
                ->delete();

            DB::table('give_donationmeta')
                ->where('donation_id', $donation->id)
                ->delete();
        } catch (Exception $exception) {
            DB::query('ROLLBACK');

            Log::error('Failed deleting a donation', compact('donation'));

            throw new $exception('Failed deleting a donation');
        }

        DB::query('COMMIT');

        Hooks::doAction('givewp_donation_deleted', $donation);

        return true;
    }

     /**
     * @since 4.6.0
     *
     * @throws Exception
     */
    public function trash(Donation $donation): bool
    {
        DB::query('START TRANSACTION');

        Hooks::doAction('givewp_donation_trashing', $donation);

        try {
            $previousStatus = DB::table('posts')
                ->where('ID', $donation->id)
                ->value('post_status');

            give()->payment_meta->update_meta($donation->id, '_wp_trash_meta_status', $previousStatus);
            give()->payment_meta->update_meta($donation->id, '_wp_trash_meta_time', time());

            DB::table('posts')
                ->where('ID', $donation->id)
                ->update(['post_status' => 'trash']);
        } catch (Exception $exception) {
            DB::query('ROLLBACK');

            Log::error('Failed trashing a donation', compact('donation'));

            throw new $exception('Failed trashing a donation');
        }

        DB::query('COMMIT');

        Hooks::doAction('givewp_donation_trashed', $donation);

        return true;
    }

    /**
     * @since 4.12.0
     *
     * @throws Exception
     */
    public function unTrash(Donation $donation): bool
    {
        DB::query('START TRANSACTION');

        Hooks::doAction('givewp_donation_untrashing', $donation);

        try {
            $previousStatus = give()->payment_meta->get_meta($donation->id, '_wp_trash_meta_status', true);

            // If no previous status was saved, default to 'publish' (completed)
            if (empty($previousStatus)) {
                $previousStatus = DonationStatus::COMPLETE;
            }

            DB::table('posts')
                ->where('id', $donation->id)
                ->update(['post_status' => $previousStatus]);
        } catch (Exception $exception) {
            DB::query('ROLLBACK');

            Log::error('Failed untrashing a donation', compact('donation'));

            throw new $exception('Failed untrashing a donation');
        }

        DB::query('COMMIT');

        Hooks::doAction('givewp_donation_untrashed', $donation);

        return true;
    }

    /**
     * @since 4.8.1 Consolidated campaignId assignment logic from insert method to eliminate duplication and ensure donation model is updated.
     * @since 4.0.0 added campaignId
     * @since 3.9.0 Added meta for phone property
     * @since 3.2.0 added meta for honorific property
     * @since 2.20.0 update amount to use new type, and add currency and exchange rate
     * @since 2.19.6
     */
    private function getCoreDonationMetaForDatabase(Donation $donation): array
    {
        $meta = [
            DonationMetaKeys::GATEWAY_TRANSACTION_ID => $donation->gatewayTransactionId,
            DonationMetaKeys::AMOUNT => give_sanitize_amount_for_db(
                $donation->amount->formatToDecimal(),
                ['currency' => $donation->amount->getCurrency()]
            ),
            DonationMetaKeys::CURRENCY => $donation->amount->getCurrency()->getCode(),
            DonationMetaKeys::EXCHANGE_RATE => $donation->exchangeRate,
            DonationMetaKeys::GATEWAY => $donation->gatewayId,
            DonationMetaKeys::DONOR_ID => $donation->donorId,
            DonationMetaKeys::FIRST_NAME => $donation->firstName,
            DonationMetaKeys::LAST_NAME => $donation->lastName,
            DonationMetaKeys::EMAIL => $donation->email,
            DonationMetaKeys::PHONE => $donation->phone,
            DonationMetaKeys::FORM_ID => $donation->formId,
            DonationMetaKeys::FORM_TITLE => $donation->formTitle ?? $this->getFormTitle($donation->formId),
            DonationMetaKeys::MODE => isset($donation->mode) ?
                $donation->mode->getValue() :
                $this->getDefaultDonationMode()->getValue(),
            DonationMetaKeys::PURCHASE_KEY => $donation->purchaseKey ?? Call::invoke(
                    GeneratePurchaseKey::class,
                    $donation->email
                ),
            DonationMetaKeys::DONOR_IP => $donation->donorIp ?? give_get_ip(),
            DonationMetaKeys::LEVEL_ID => $donation->levelId,
            DonationMetaKeys::ANONYMOUS => (int)$donation->anonymous,
            DonationMetaKeys::CAMPAIGN_ID => $donation->campaignId,
        ];

        // If the donation is not associated with a campaign, try to find the campaign ID by the form ID
        if (!$donation->campaignId) {
            $campaign = give()->campaigns->getByFormId($donation->formId);
            $donation->campaignId = $campaign ? $campaign->id : null;
        }

        if ($donation->campaignId !== null) {
            $meta[DonationMetaKeys::CAMPAIGN_ID] = $donation->campaignId;
        }

        if ($donation->feeAmountRecovered !== null) {
            $meta[DonationMetaKeys::FEE_AMOUNT_RECOVERED] = $donation->feeAmountRecovered->formatToDecimal();
        }

        if ($donation->billingAddress !== null) {
            $meta[DonationMetaKeys::BILLING_COUNTRY] = $donation->billingAddress->country;
            $meta[DonationMetaKeys::BILLING_ADDRESS2] = $donation->billingAddress->address2;
            $meta[DonationMetaKeys::BILLING_CITY] = $donation->billingAddress->city;
            $meta[DonationMetaKeys::BILLING_ADDRESS1] = $donation->billingAddress->address1;
            $meta[DonationMetaKeys::BILLING_STATE] = $donation->billingAddress->state;
            $meta[DonationMetaKeys::BILLING_ZIP] = $donation->billingAddress->zip;
        }

        if (isset($donation->subscriptionId)) {
            $meta[DonationMetaKeys::SUBSCRIPTION_ID] = $donation->subscriptionId;
        }

        if ($donation->type->isSubscription()) {
            $meta[DonationMetaKeys::SUBSCRIPTION_INITIAL_DONATION] = 1;
            $meta[DonationMetaKeys::IS_RECURRING] = 1;
        }

        if ($donation->company !== null) {
            $meta[DonationMetaKeys::COMPANY] = $donation->company;
        }

        if ($donation->comment !== null) {
            $meta[DonationMetaKeys::COMMENT] = $donation->comment;
        }

        if ($donation->honorific !== null) {
            $meta[DonationMetaKeys::HONORIFIC] = $donation->honorific;
        }

        return $meta;
    }

    /**
     * @since 2.19.6
     *
     * @return int|null
     */
    public function getSequentialId(int $donationId)
    {
        $query = DB::table('give_sequential_ordering')->where('payment_id', $donationId)->get();

        if (!$query) {
            return null;
        }

        return (int)$query->id;
    }

    /**
     * @since 2.19.6
     *
     * @return void
     */
    private function validateDonation(Donation $donation)
    {
        foreach ($this->requiredDonationProperties as $key) {
            if (!isset($donation->$key)) {
                throw new InvalidArgumentException("'$key' is required.");
            }
        }

        if ( $donation->subscriptionId && $donation->type->isSingle()) {
            throw new InvalidArgumentException('Subscription ID can only be set for recurring donations.');
        }

        if ( !$donation->subscriptionId && ( $donation->type->isRenewal() || $donation->type->isSubscription() ) ) {
            throw new InvalidArgumentException('Subscription ID is required for recurring donations.');
        }

        if (!$donation->donor) {
            throw new InvalidArgumentException("Invalid donorId, Donor does not exist");
        }
    }

    /**
     * Provides the donation status with consideration for the donation type. If a donation is a renewal type and its
     * status is "complete", then the status will return "renewal". In the future, "renewal" will no longer be a valid
     * status, at which point this function will be unnecessary.
     *
     * New renewal donations moving forward should set the type as "renewal" and the status as "complete".
     *
     * @since 2.23.0
     */
    private function getPersistedDonationStatus(Donation $donation): DonationStatus {
        if ( $donation->status->isComplete() && $donation->type->isRenewal() ) {
            return DonationStatus::RENEWAL();
        }

        return $donation->status;
    }

    /**
     * @since 2.19.6
     */
    private function getDefaultDonationMode(): DonationMode
    {
        $mode = give_is_test_mode() ? 'test' : 'live';

        return new DonationMode($mode);
    }

    /**
     * We're moving away from using the parent_id column for donations, but we still need to support it for now as
     * legacy code still relies on it. It is only stored and should never be used in the model.
     *
     * @since 2.23.0
     */
    private function deriveLegacyDonationParentId(Donation $donation): int
    {
        return $donation->type->isRenewal() ? give()->subscriptions->getInitialDonationId($donation->subscriptionId) : 0;
    }

    /**
     * @since 2.19.6
     */
    public function getFormTitle(int $formId): string
    {
        $form = DB::table('posts')
            ->where('id', $formId)
            ->get();

        if (!$form) {
            return '';
        }

        return $form->post_title;
    }

    /**
     * @since 2.23.0 no longer retrieve the post_parent from the database as parentId is deprecated
     *
     * @return ModelQueryBuilder<Donation>
     */
    public function prepareQuery(): ModelQueryBuilder
    {
        $builder = new ModelQueryBuilder(Donation::class);

        return $builder->from('posts')
            ->select(
                ['ID', 'id'],
                ['post_date', 'createdAt'],
                ['post_modified', 'updatedAt'],
                ['post_status', 'status']
            )
            ->attachMeta(
                'give_donationmeta',
                'ID',
                'donation_id',
                ...DonationMetaKeys::getColumnsForAttachMetaQuery()
            )
            ->where('post_type', 'give_payment');
    }

    /**
     * @since 2.19.6
     */
    public function getTotalDonationCountByDonorId(int $donorId): int
    {
        return DB::table('posts')
            ->where('post_type', 'give_payment')
            ->whereIn('ID', function (QueryBuilder $builder) use ($donorId) {
                $builder
                    ->select('donation_id')
                    ->from('give_donationmeta')
                    ->where('meta_key', DonationMetaKeys::DONOR_ID)
                    ->where('meta_value', $donorId);
            })
            ->count();
    }

    /**
     * @since 2.19.6
     *
     * @return array|bool|null
     */
    public function getAllDonationIdsByDonorId(int $donorId)
    {
        return array_column(
            DB::table('give_donationmeta')
                ->select('donation_id')
                ->where('meta_key', DonationMetaKeys::DONOR_ID)
                ->where('meta_value', $donorId)
                ->getAll(),
            'donation_id'
        );
    }

    /**
     * @since 2.23.1 Fixed order by property, see https://github.com/impress-org/givewp/pull/6559
     * @since 2.21.2
     *
     * @return Donation|null
     */
    public function getFirstDonation() {
        return $this->prepareQuery()
            ->limit(1)
            ->orderBy('post_date', 'ASC')
            ->get();
    }

    /**
     * @since 2.23.1 Fixed order by property, see https://github.com/impress-org/givewp/pull/6559
     * @since 2.21.2
     *
     * @return Donation|null
     */
    public function getLatestDonation() {
        return $this->prepareQuery()
            ->limit(1)
            ->orderBy('post_date', 'DESC')
            ->get();
    }

    /**
     * @since 4.3.0
     */
    public function getConfirmationPageReceipt(Donation $donation): DonationReceipt
    {
        $receipt = new DonationReceipt($donation);

        return (new DonationReceiptBuilder($receipt))->toConfirmationPage();
    }
}
