<?php

namespace Give\Donors\Repositories;

use Exception;
use Give\Donations\ValueObjects\DonationMetaKeys;
use Give\Donors\Exceptions\FailedDonorUpdateException;
use Give\Donors\Models\Donor;
use Give\Donors\Models\DonorModelQueryBuilder;
use Give\Donors\ValueObjects\DonorMetaKeys;
use Give\Donors\ValueObjects\DonorType;
use Give\Framework\Database\DB;
use Give\Framework\Exceptions\Primitives\InvalidArgumentException;
use Give\Framework\Support\Facades\DateTime\Temporal;
use Give\Helpers\Hooks;
use Give\Log\Log;

/**
 * @since 2.19.6
 */
class DonorRepository
{
    /**
     * @since 4.4.0
     *
     * @var DonorNotesRepository
     */
    public $notes;

    /**
     * @var string[]
     */
    private $requiredDonorProperties = [
        'name',
        'firstName',
        'lastName',
        'email',
    ];

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

    /**
     * Query Donor By ID
     *
     * @since 2.24.0 replace ModelQueryBuilder with DonorModelQueryBuilder
     * @since 2.19.6
     *
     * @return DonorModelQueryBuilder<Donor>
     */
    public function queryById(int $donorId): DonorModelQueryBuilder
    {
        return $this->prepareQuery()
            ->where('id', $donorId);
    }

    /**
     * Get Donor By ID
     *
     * @since 2.19.6
     *
     * @return Donor|null
     */
    public function getById(int $donorId)
    {
        return $this->queryById($donorId)->get();
    }

    /**
     * Get Donor By WP User ID
     *
     * @since 2.19.6
     *
     * @return Donor|null
     */
    public function getByWpUserId(int $userId)
    {
        // user_id can technically be 0 so make sure to return null
        if (!$userId) {
            return null;
        }

        return $this->prepareQuery()
            ->where('user_id', $userId)
            ->get();
    }

    /**
     * @since 2.19.6
     *
     * @return array|bool
     */
    public function getAdditionalEmails(int $donorId)
    {
        $additionalEmails = DB::table('give_donormeta')
            ->select(['meta_value', 'email'])
            ->where('meta_key', DonorMetaKeys::ADDITIONAL_EMAILS)
            ->where('donor_id', $donorId)
            ->getAll();

        if (!$additionalEmails) {
            return null;
        }

        return array_column($additionalEmails, 'email');
    }

    /**
     * @since 3.20.0 store meta using native WP functions
     * @since 3.7.0 Add support to "phone" property
     * @since 2.24.0 add support for $donor->totalAmountDonated and $donor->totalNumberOfDonation
     * @since 2.21.0 add actions givewp_donor_creating and givewp_donor_created
     * @since 2.20.0 mutate model and return void
     * @since 2.19.6
     *
     * @return void
     * @throws Exception
     */
    public function insert(Donor $donor)
    {
        $this->validateDonor($donor);

        Hooks::doAction('givewp_donor_creating', $donor);

        $dateCreated = Temporal::withoutMicroseconds($donor->createdAt ?: Temporal::getCurrentDateTime());

        DB::query('START TRANSACTION');

        $args = [
            'date_created' => Temporal::getFormattedDateTime($dateCreated),
            'user_id' => $donor->userId ?? 0,
            'email' => $donor->email,
            'name' => $donor->name,
        ];

        if (isset($donor->phone)) {
            $args['phone'] = $donor->phone;
        }

        if (isset($donor->totalAmountDonated)) {
            $args['purchase_value'] = $donor->totalAmountDonated->formatToDecimal();
        }

        if (isset($donor->totalNumberOfDonations)) {
            $args['purchase_count'] = $donor->totalNumberOfDonations;
        }

        try {
            DB::table('give_donors')
                ->insert($args);

            $donorId = DB::last_insert_id();

            foreach ($this->getCoreDonorMeta($donor) as $metaKey => $metaValue) {
                give()->donor_meta->add_meta($donorId, $metaKey, $metaValue);
            }

            if (isset($donor->additionalEmails)) {
                foreach ($donor->additionalEmails as $additionalEmail) {
                    give()->donor_meta->add_meta($donorId, DonorMetaKeys::ADDITIONAL_EMAILS, $additionalEmail);
                }
            }

            if (isset($donor->addresses)) {
                $this->updateAddresses($donor, $donorId);
            }
        } catch (Exception $exception) {
            DB::query('ROLLBACK');

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

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

        DB::query('COMMIT');

        $donor->id = $donorId;
        $donor->createdAt = $dateCreated;

        Hooks::doAction('givewp_donor_created', $donor);
    }

    /**
     * @since 4.4.0 Add support for addresses
     * @since 3.7.0 Add support to "phone" property
     * @since 2.24.0 add support for $donor->totalAmountDonated and $donor->totalNumberOfDonation
     * @since 2.23.1 use give()->donor_meta to update meta so data is upserted
     * @since 2.21.0 add actions givewp_donor_updating and givewp_donor_updated
     * @since 2.20.0 return void
     * @since 2.19.6
     *
     * @return void
     * @throws Exception
     */
    public function update(Donor $donor)
    {
        $this->validateDonor($donor);

        Hooks::doAction('givewp_donor_updating', $donor);

        DB::query('START TRANSACTION');

        $args = [
            'user_id' => $donor->userId,
            'email' => $donor->email,
            'phone' => $donor->phone,
            'name' => $donor->name,
        ];

        if (isset($donor->totalAmountDonated) && $donor->isDirty('totalAmountDonated')) {
            $args['purchase_value'] = $donor->totalAmountDonated->formatToDecimal();
        }

        if (isset($donor->totalNumberOfDonations) && $donor->isDirty('totalNumberOfDonations')) {
            $args['purchase_count'] = $donor->totalNumberOfDonations;
        }

        try {
            DB::table('give_donors')
                ->where('id', $donor->id)
                ->update($args);

            foreach ($this->getCoreDonorMeta($donor) as $metaKey => $metaValue) {
                give()->donor_meta->update_meta($donor->id, $metaKey, $metaValue);
            }

            if (isset($donor->additionalEmails) && $donor->isDirty('additionalEmails')) {
                $this->updateAdditionalEmails($donor);
            }

            if (isset($donor->addresses) && $donor->isDirty('addresses')) {
                $this->updateAddresses($donor, $donor->id);
            }
        } catch (Exception $exception) {
            DB::query('ROLLBACK');

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

            throw new FailedDonorUpdateException($donor, 0, $exception);
        }

        DB::query('COMMIT');

        Hooks::doAction('givewp_donor_updated', $donor);
    }

    /**
     * @since 2.19.6
     *
     * @throws Exception
     */
    public function updateLegacyColumns(int $donorId, array $columns): bool
    {
        DB::query('START TRANSACTION');

        foreach (Donor::propertyKeys() as $key) {
            if (array_key_exists($key, $columns)) {
                throw new InvalidArgumentException("'$key' is not a legacy column.");
            }
        }

        try {
            DB::table('give_donors')
                ->where('id', $donorId)
                ->update($columns);
        } catch (Exception $exception) {
            DB::query('ROLLBACK');

            Log::error('Failed updating a donor', compact('donorId', 'columns'));

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

        DB::query('COMMIT');

        return true;
    }

    /**
     *
     * @since 2.21.0 add actions givewp_donor_deleting and givewp_donor_deleted
     * @since 2.20.0 consolidate meta deletion into a single query
     * @since 2.19.6
     *
     * @throws Exception
     */
    public function delete(Donor $donor): bool
    {
        DB::query('START TRANSACTION');

        Hooks::doAction('givewp_donor_deleting', $donor);

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

            DB::table('give_donormeta')
                ->where('donor_id', $donor->id)
                ->delete();
        } catch (Exception $exception) {
            DB::query('ROLLBACK');

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

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

        DB::query('COMMIT');

        Hooks::doAction('givewp_donor_deleted', $donor);

        return true;
    }

    /**
     * @since 4.4.0 Add avatarId and company to core donor meta
     * @since 2.19.6
     */
    private function getCoreDonorMeta(Donor $donor): array
    {
        return [
            DonorMetaKeys::FIRST_NAME => $donor->firstName,
            DonorMetaKeys::LAST_NAME => $donor->lastName,
            DonorMetaKeys::PREFIX => $donor->prefix ?? null,
            DonorMetaKeys::AVATAR_ID => $donor->avatarId ?? null,
            DonorMetaKeys::COMPANY => $donor->company ?? null,
        ];
    }

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

    /**
     * @since 2.21.1 optimize query by skipping prepareQuery until found
     * @since 2.19.6
     *
     * @return Donor|null
     */
    public function getByEmail(string $email)
    {
        $queryByPrimaryEmail = DB::table('give_donors')
            ->select(
                'id',
                'email'
            )
            ->where('email', $email)
            ->get();

        if ($queryByPrimaryEmail) {
            return $this->queryById($queryByPrimaryEmail->id)->get();
        }

        return $this->getByAdditionalEmail($email);
    }

    /**
     * @since 2.19.6
     *
     * @return Donor|null
     */
    public function getByAdditionalEmail(string $email)
    {
        $donorMetaObject = DB::table('give_donormeta')
            ->select(['donor_id', 'id'])
            ->where('meta_key', DonorMetaKeys::ADDITIONAL_EMAILS)
            ->where('meta_value', $email)
            ->get();

        if (!$donorMetaObject) {
            return null;
        }

        return $this->getById($donorMetaObject->id);
    }

    /**
     * @since 3.7.0 Add support to "phone" property
     * @since 2.24.0 replace ModelQueryBuilder with DonorModelQueryBuilder
     * @since 2.19.6
     *
     * @return DonorModelQueryBuilder<Donor>
     */
    public function prepareQuery(): DonorModelQueryBuilder
    {
        $builder = new DonorModelQueryBuilder(Donor::class);

        return $builder->from('give_donors')
            ->select(
                'id',
                ['user_id', 'userId'],
                'email',
                'phone',
                'name',
                ['purchase_value', 'totalAmountDonated'],
                ['purchase_count', 'totalNumberOfDonations'],
                ['payment_ids', 'paymentIds'],
                ['date_created', 'createdAt'],
                'token',
                ['verify_key', 'verifyKey'],
                ['verify_throttle', 'verifyThrottle']
            )
            ->attachMeta(
                'give_donormeta',
                'ID',
                'donor_id',
                ...DonorMetaKeys::getColumnsForAttachMetaQueryWithoutExtraMetadata()
            );
    }

    /**
     * Additional emails are assigned to the same additional_email meta key.
     * In order to update them we need to delete and re-insert.
     *
     * @since 4.4.0 Remove all additional emails and re-insert only the new ones
     * @since 3.20.0 store meta using native WP functions
     * @since 2.19.6
     *
     * @return void
     */
    private function updateAdditionalEmails(Donor $donor)
    {
        DB::table('give_donormeta')
            ->where('donor_id', $donor->id)
            ->where('meta_key', DonorMetaKeys::ADDITIONAL_EMAILS)
            ->delete();

        foreach ($donor->additionalEmails as $additionalEmail) {
            give()->donor_meta->add_meta($donor->id, DonorMetaKeys::ADDITIONAL_EMAILS, $additionalEmail);
        }
    }

    /**
     * Addresses are stored as indexed meta keys.
     * In order to update them we need to delete all address-related meta keys and re-insert.
     *
     * @since 4.4.0
     */
    private function updateAddresses(Donor $donor, ?int $donorId): void
    {
        $id = $donorId ?? $donor->id;
        $prefix = DB::prefix('give_donormeta');

        $addressMetaKeys = [
            DonorMetaKeys::ADDRESS_LINE1,
            DonorMetaKeys::ADDRESS_LINE2,
            DonorMetaKeys::ADDRESS_CITY,
            DonorMetaKeys::ADDRESS_STATE,
            DonorMetaKeys::ADDRESS_COUNTRY,
            DonorMetaKeys::ADDRESS_ZIP,
        ];

        $likeConditions = implode(' OR ', array_fill(0, count($addressMetaKeys), 'meta_key LIKE %s'));
        $likeValues = array_map(function($key) { return $key . '*'; }, $addressMetaKeys);

        $sql = DB::prepare(
            "DELETE FROM {$prefix}
                WHERE donor_id = %d
                AND ({$likeConditions})",
            array_merge([$id], $likeValues)
        );

        try {
            DB::query( str_replace('*', '%', $sql));
        } catch (Exception $e) {
            Log::error('Failed deleting donor addresses', compact('donor', 'id', 'sql'));
        }

        foreach ($donor->addresses as $index => $address) {
            give()->donor_meta->add_meta($id, DonorMetaKeys::ADDRESS_LINE1 . $index, $address->address1);
            give()->donor_meta->add_meta($id, DonorMetaKeys::ADDRESS_LINE2 . $index, $address->address2);
            give()->donor_meta->add_meta($id, DonorMetaKeys::ADDRESS_CITY . $index, $address->city);
            give()->donor_meta->add_meta($id, DonorMetaKeys::ADDRESS_STATE . $index, $address->state);
            give()->donor_meta->add_meta($id, DonorMetaKeys::ADDRESS_COUNTRY . $index, $address->country);
            give()->donor_meta->add_meta($id, DonorMetaKeys::ADDRESS_ZIP . $index, $address->zip);
        }
    }

    /**
     * @since 4.12.0 filter by status
     * @since 2.20.0
     *
     * @return string|null
     */
    public function getDonorLatestDonationDate(int $donorId)
    {
        $donation = DB::table('posts')
            ->select('post_date')
            ->leftJoin('give_donationmeta', 'ID', 'donation_id')
            ->where('post_type', 'give_payment')
            ->where('meta_key', DonationMetaKeys::DONOR_ID)
            ->where('meta_value', $donorId)
            ->whereIn('post_status', ['publish', 'give_subscription'])
            ->orderBy('CAST(post_date AS DATETIME)', 'DESC')
            ->limit(1)
            ->get();

        if ($donation) {
            return $donation->post_date;
        }

        return null;
    }

    /**
     * @since 2.24.0 change return to DonorType
     * @since 2.20.0
     *
     * @return DonorType|null
     */
    public function getDonorType(int $donorId)
    {
        $donor = DB::table('give_donors')
            ->select(
                'id',
                ['purchase_count', 'donationCount'],
                ['payment_ids', 'paymentIds']
            )
            ->where('id', $donorId)
            ->get();

        if (!$donor) {
            return null;
        }

        if (!$donor->donationCount) {
            return DonorType::NEW();
        }

        // Donation IDs
        $ids = strpos($donor->paymentIds, ',')
            ? explode(',', $donor->paymentIds)
            : [$donor->paymentIds];

        // Recurring
        $recurringDonations = DB::table('posts')
            ->leftJoin('give_donationmeta', 'id', 'donation_id')
            ->whereIn('donation_id', $ids)
            ->where('meta_key', DonationMetaKeys::IS_RECURRING)
            ->where('meta_value', '1')
            ->count();

        if ($recurringDonations) {
            return DonorType::SUBSCRIBER();
        }

        if ((int)$donor->donationCount > 1) {
            return DonorType::REPEAT();
        }

        return DonorType::SINGLE();
    }

    /**
     * @since 2.20.0
     */
    public function getDonorsCount(): int
    {
        return DB::table('give_donors')->count();
    }
}
