<?php
/**
 * MembersBasket Trait.
 * @version 13/11/16
 * @author Martins Fridenbergs <martins.fridenbergs@mtcmedia.co.uk>
 */

namespace Mtc\Modules\Members\Classes;

use App\Events\MemberCreatedEvent;
use App\Events\MemberUpdatedEvent;
use Basket;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Schema;
use Mtc\Modules\Members\Models\Member;
use Mtc\Plugins\NewsletterSignup\Classes\Newsletter;
use Mtc\Shop\Assessment\Assessment;

/**
 * MembersBasket Trait.
 * Implements the functionality that bridges member and basket
 *
 * @version 13/11/16
 * @author Martins Fridenbergs <martins.fridenbergs@mtcmedia.co.uk>
 */
trait MembersBasket
{
    /**
     * Merge the pre-login (guest) session basket into this member's stored
     * basket at sign-in, so nothing the visitor added is lost.
     *
     * Called from Authenticator::login() on the email/password path only, where
     * the session provably belongs to the person who just authenticated.
     * Object/impersonation logins (Clinic, Wisebee, admin login-as) must NOT
     * call this: their session belongs to the acting user, not the member being
     * logged in, so folding it in would leak one patient's basket into another.
     *
     *  - No guest basket, or one owned by another member -> no-op. (An empty
     *    guest basket is not special-cased: there is nothing to fold, but its
     *    empty shell is still adopted or discarded like any other so no orphan
     *    is left behind.)
     *  - MEMBERS_BASKET disabled -> adopt the guest basket wholesale, matching
     *    the historic login behaviour (there is no stored member basket to
     *    merge against on those deployments).
     *  - Member has no usable stored basket (none, paid, or order-bearing) ->
     *    adopt the guest basket wholesale. Adoption is non-destructive, so it is
     *    safe even when the guest basket itself has an order against it.
     *  - Both baskets have items and neither is order-bearing -> fold the guest
     *    lines into the member's stored basket (the canonical survivor) and
     *    discard the emptied guest basket so no orphan is left behind.
     *
     * A basket with any order against it (paid or unpaid) is mid/post-checkout
     * and is never mutated or discarded: doing so would silently change a
     * pending order's contents or orphan it from its basket.
     *
     * @param int $guestBasketId The pre-login $_SESSION['basket_id'].
     */
    public function reconcileBasketOnLogin(int $guestBasketId): void
    {
        $guestBasket = $guestBasketId > 0
            ? \Mtc\Shop\Basket::query()->find($guestBasketId)
            : null;

        // Only touch a guest basket that exists and is either unowned or already
        // this member's -- never another member's, so a shared device cannot
        // leak baskets between patients.
        $guestIsOurs = !empty($guestBasket)
            && (empty($guestBasket->member) || (int) $guestBasket->member === (int) $this->id);

        if (!$guestIsOurs) {
            return;
        }

        // Prescription-safety guard (partial): never attach a prescription
        // basket to a patient whose session did not complete the assessment and
        // who does not already own it. Rejection is deliberately whole-basket:
        // one unsafe prescription line means nothing merges, including
        // legitimate lines alongside it. Filtering out just the unsafe line was
        // considered and rejected -- the fold discards the guest basket
        // afterwards, so a skipped line would either be deleted with it
        // (destroying another patient's basket line) or left behind in an
        // orphaned basket. Bailing out is non-destructive: the visitor keeps
        // their session basket exactly as it was, it just never attaches to
        // this member. See guestPrescriptionsBelongToThisSession() for the
        // guard's scope and the shared-terminal case it deliberately does not
        // cover.
        if (!$this->guestPrescriptionsBelongToThisSession($guestBasket)) {
            return;
        }

        // On deployments without member baskets there is nothing to merge
        // against; preserve the historic behaviour of adopting the guest session
        // basket outright (this is what the login path did before this change).
        if (MEMBERS_BASKET !== true) {
            $this->setBasketDetails($guestBasketId);
            $_SESSION['basket_id'] = $guestBasketId;
            return;
        }

        // The member's stored basket is a usable merge target only when it still
        // exists and is not tied to an order. A paid basket is never reopened,
        // and a basket with any order (paid or unpaid) is mid/post-checkout: if
        // we folded into it, a pending order would re-snapshot and silently gain
        // the guest's line.
        $memberBasketId = (int) $this->basket_id;
        $memberBasketUsable = $memberBasketId > 0
            && $memberBasketId !== $guestBasketId
            && \Mtc\Shop\Basket::query()->whereKey($memberBasketId)->exists()
            && !Basket::isPaid($memberBasketId)
            && !\Mtc\Shop\Order::query()->where('basket_id', $memberBasketId)->exists();

        if (!$memberBasketUsable) {
            // Nothing safe to merge against: adopt the guest basket as-is.
            // setBasketDetails() stamps ownership, points members.basket_id at it
            // and copies the member's address/info onto the basket. Adoption is
            // non-destructive, so the guest's items are kept even if that basket
            // has an order of its own.
            $this->setBasketDetails($guestBasketId);
            $_SESSION['basket_id'] = $guestBasketId;
            return;
        }

        // Both baskets carry items and the member basket is a safe target. We can
        // only fold-and-discard when the guest basket has no order of its own,
        // because deleting a basket an order points at would orphan that order.
        // When the guest basket is itself mid-checkout, leave both baskets intact
        // (the member's stored basket stays active; the guest basket and its
        // order remain recoverable) rather than risk corrupting either.
        if (\Mtc\Shop\Order::query()->where('basket_id', $guestBasketId)->exists()) {
            return;
        }

        // Fold the guest lines into the member's stored basket (the canonical
        // survivor) and discard the emptied guest basket, in one transaction so a
        // mid-merge failure cannot leave a duplicated or half-merged state.
        DB::transaction(function () use ($guestBasket, $memberBasketId) {
            $this->mergeGuestBasketInto($guestBasket, $memberBasketId);
        });

        // Stamp ownership and prefill the member's billing/delivery details onto
        // the surviving basket (the adopt path does this too), then re-point the
        // session at it. setBasketDetails() also resets and reloads the session
        // basket so the merged contents are re-read.
        $this->setBasketDetails($memberBasketId);
        $_SESSION['basket_id'] = $memberBasketId;
        unset($_SESSION['basket']);
    }

    /**
     * Fold every line from the guest basket into the member's basket, then
     * discard the emptied guest basket.
     *
     * Line identity is (item_id, size, assessment_id, bundle_id): a guest line
     * matching an existing member line is combined into it, otherwise it is
     * added as a distinct line. Including assessment_id keeps a prescription
     * (assessment-bearing) line from ever being collapsed onto a different line,
     * and including bundle_id keeps a bundle line distinct from a matching
     * standalone line, so neither an assessment nor a bundle grouping is lost to
     * de-duplication.
     *
     * Quantities are only summed for non-prescription lines (capped at
     * MAX_QUANTITY_SIZE). Two lines that share an assessment are the same
     * prescription, so the authorised quantity is kept rather than doubled. A
     * subscription/refill request (and its chosen cadence) on either line is
     * preserved.
     *
     * @param \Mtc\Shop\Basket $guestBasket
     * @param int              $memberBasketId
     */
    private function mergeGuestBasketInto(\Mtc\Shop\Basket $guestBasket, int $memberBasketId): void
    {
        $guestItems = \Mtc\Shop\Basket\Item::query()
            ->where('basket_id', $guestBasket->id)
            ->get();

        // Load the member's existing lines once, keyed by line identity, so the
        // fold does not run a lookup per guest line.
        $memberItems = \Mtc\Shop\Basket\Item::query()
            ->where('basket_id', $memberBasketId)
            ->get()
            ->keyBy(fn ($item) => $this->basketLineKey($item));

        foreach ($guestItems as $line) {
            $existing = $memberItems->get($this->basketLineKey($line));

            if (!empty($existing)) {
                // A shared non-prescription line combines quantities (capped).
                // A shared prescription line keeps the assessment's authorised
                // quantity rather than doubling it.
                if ((int) $line->assessment_id === 0) {
                    $existing->quantity = $this->cappedQuantity(
                        (int) $existing->quantity + (int) $line->quantity
                    );
                }
                // Carry across a subscription / refill request from the guest
                // line so the patient's recurring supply -- and the cadence they
                // chose -- is not silently lost.
                if (!empty($line->has_requested_subscription)) {
                    $existing->has_requested_subscription = $line->has_requested_subscription;
                    if ($this->basketItemsHaveSubscriptionPeriod()) {
                        $existing->has_requested_subscription_period = $line->has_requested_subscription_period;
                    }
                }
                if (empty($existing->refill_date) && !empty($line->refill_date)) {
                    $existing->refill_date = $line->refill_date;
                }
                $existing->save();
                continue;
            }

            $attributes = [
                'basket_id'                  => $memberBasketId,
                'item_id'                    => $line->item_id,
                'bundle_id'                  => $line->bundle_id,
                'assessment_id'              => (int) $line->assessment_id,
                'size'                       => (string) $line->size,
                'quantity'                   => $this->cappedQuantity((int) $line->quantity),
                'recommended_quantity'       => $line->recommended_quantity ?? 1,
                'PLU'                        => (string) $line->PLU,
                'sizeid'                     => (int) $line->sizeid,
                'refill_date'                => $line->refill_date,
                'has_requested_subscription' => $line->has_requested_subscription,
            ];
            if ($this->basketItemsHaveSubscriptionPeriod()) {
                $attributes['has_requested_subscription_period'] = $line->has_requested_subscription_period;
            }

            $newItem = \Mtc\Shop\Basket\Item::query()->create($attributes);
            $memberItems->put($this->basketLineKey($newItem), $newItem);
        }

        $this->discardGuestBasket($guestBasket);
    }

    /**
     * Identity of a basket line when folding baskets together: same product,
     * size, assessment and bundle. Assessment keeps a prescription line
     * distinct; bundle keeps a bundle line distinct from a matching standalone
     * line so bundle groupings survive the merge.
     *
     * @param \Mtc\Shop\Basket\Item $item
     * @return string
     */
    private function basketLineKey($item): string
    {
        return $item->item_id
            . '|' . $item->size
            . '|' . (int) $item->assessment_id
            . '|' . (int) $item->bundle_id;
    }

    /**
     * Cap a basket line quantity at MAX_QUANTITY_SIZE, matching the limit the
     * rest of the shop enforces on Add_Item. Centralised here so the merge path
     * does not drift from that rule.
     *
     * @param int $quantity
     * @return int
     */
    private function cappedQuantity(int $quantity): int
    {
        return min(MAX_QUANTITY_SIZE, $quantity);
    }

    /**
     * Whether basket_items carries the has_requested_subscription_period
     * column. It is added by the mtc_pharmacy_subscriptions plugin migration,
     * not the base install, so a deployment without that plugin must not have
     * the merge write to it (an unknown-column error would abort the fold).
     * Checked once per request; reading the attribute on a line that lacks the
     * column is safe (Eloquent returns null), only writes need the guard.
     *
     * @return bool
     */
    private function basketItemsHaveSubscriptionPeriod(): bool
    {
        static $hasColumn = null;
        if ($hasColumn === null) {
            $hasColumn = Schema::hasColumn('basket_items', 'has_requested_subscription_period');
        }

        return $hasColumn;
    }

    /**
     * Whether the abandonment-recovery bookkeeping tables exist. They ship with
     * the base install migrations, but a fold transaction that throws on a
     * deployment lacking them would be rolled back wholesale and swallowed by
     * the best-effort catch in Authenticator::login(), silently skipping the
     * merge on every login. Checked once per request, like
     * basketItemsHaveSubscriptionPeriod().
     *
     * @return bool
     */
    private function basketRecoveryTablesExist(): bool
    {
        static $hasTables = null;
        if ($hasTables === null) {
            $hasTables = Schema::hasTable('basket_recovery_logs')
                && Schema::hasTable('basket_recovery_sent_emails');
        }

        return $hasTables;
    }

    /**
     * Prescription-safety guard for the sign-in basket handover.
     *
     * A prescription (assessment-bearing) line may only be brought across to a
     * member when this session actually completed that assessment (it is in the
     * session's assessment list, the same source linkAssessmentsFromSession()
     * acts on) or the member already owns it. This stops a stale or
     * cross-session prescription basket from being attached to the wrong
     * patient. One failing line fails the whole basket -- the caller then
     * leaves the entire guest basket unattached, deliberately (see the call
     * site in reconcileBasketOnLogin() for why filtering is not done).
     *
     * LIMITATION: this does NOT close the same-session shared-terminal case,
     * where the browser session still holds a previous visitor's completed
     * assessment (both the basket line and linkAssessmentsFromSession() would
     * then legitimately look like this session's own). Closing that needs a
     * session-identity control and is tracked as separate work.
     *
     * @param \Mtc\Shop\Basket $guestBasket
     * @return bool True when the basket's prescription lines are safe to bring across.
     */
    private function guestPrescriptionsBelongToThisSession(\Mtc\Shop\Basket $guestBasket): bool
    {
        $assessmentIds = $guestBasket->items()
            ->where('assessment_id', '>', 0)
            ->pluck('assessment_id')
            ->map(fn ($id) => (int) $id)
            ->unique()
            ->all();

        if (empty($assessmentIds)) {
            return true;
        }

        $sessionAssessmentIds = $this->sessionAssessmentIds();

        foreach ($assessmentIds as $assessmentId) {
            if (in_array($assessmentId, $sessionAssessmentIds, true)) {
                continue;
            }

            $ownedByThisMember = Assessment::query()
                ->whereKey($assessmentId)
                ->where('member_id', (int) $this->id)
                ->exists();

            if (!$ownedByThisMember) {
                return false;
            }
        }

        return true;
    }

    /**
     * The assessment ids the current session has completed, mirroring the
     * sources linkAssessmentsFromSession() reads.
     *
     * @return int[]
     */
    private function sessionAssessmentIds(): array
    {
        $ids = [];
        if (!empty($_SESSION['assessments'])) {
            $ids = (array) $_SESSION['assessments'];
        }
        if (!empty($_SESSION['category_assessments'])) {
            $ids = array_merge(array_values((array) $_SESSION['category_assessments']), $ids);
        }

        return array_map('intval', $ids);
    }

    /**
     * Remove the guest basket once its lines have been folded into the member
     * basket, so it is not left behind as an unowned orphan. The foldability
     * guard has already ensured no order references this basket, so it is safe
     * to delete outright.
     *
     * @param \Mtc\Shop\Basket $guestBasket
     */
    private function discardGuestBasket(\Mtc\Shop\Basket $guestBasket): void
    {
        $guestBasket->items()->delete();
        $guestBasket->info()->delete();
        $guestBasket->address()->delete();

        // Abandonment-recovery bookkeeping also keys on basket_id, so clear it
        // or a guest basket that already triggered recovery emails leaves rows
        // pointing at a deleted basket. Deleted via the tables rather than the
        // BasketRecovery module models, so the merge does not depend on that
        // module being present, and guarded by a table check (mirroring the
        // has_requested_subscription_period column guard) so a deployment
        // without the recovery tables cannot throw here and roll back the whole
        // fold. The basket_recovery table itself holds campaign definitions,
        // not per-basket rows, so it is not touched.
        if ($this->basketRecoveryTablesExist()) {
            DB::table('basket_recovery_logs')->where('basket_id', $guestBasket->id)->delete();
            DB::table('basket_recovery_sent_emails')->where('basket_id', $guestBasket->id)->delete();
        }

        $guestBasket->delete();
    }

    /**
     * Method will link a basket to this member
     *
     * @param Member $member Current member
     * @param int $basket_id
     */
    private function setMemberBasket(Member $member, $basket_id) {
        if (MEMBERS_BASKET === true) {
            Member::where('id', $member->id)
                ->update([
                    'basket_id' => $basket_id
                ]);
        }
    }

    /**
     * Unlink basket from member.
     */
    public static function unsetMemberBasket($member_id) {
        Member::where('id', $member_id)
            ->update([
                'basket_id' => 0
            ]);
        if (isset($_SESSION['basket_id'])) {
            unset($_SESSION['basket_id']);
        }
    }

    /**
     * Handle user registration on basket page
     *
     * @access public
     * @param Member $member current user
     * @param Basket $basket current users basket
     * @return array errors experienced
     */
    public function registerFromBasket(Member $member, Basket $basket, $password)
    {
        // Check if member already doesn't exist
        if ($member->where('email', $basket->info['email'])->count() > 0) {
            return [
                'errors' => [
                    'email' => 'The Email address given has already been used for a member account. 
                        Click the SIGN IN button above to sign in',
                ],
                'error_field' => 'info',
            ];
        }

        if (empty($password)) {
            return [
                'errors' => 'Please enter the password',
                'error_field' => 'cardholder_password',
            ];
        }

        // Validate password
        if (!MemberManager::sufficientPasswordStrength($password)) {
            return [
                'errors' => 'Please enter a password of at least 8 characters using letters as well as numbers.',
                'error_field' => 'cardholder_password',
            ];
        }

        // Register member
        $member = Member::query()->create([
            'email' => $basket->info['email'],
            'contact_no' => $basket->info['contact_no'],
            'first_login' => new Carbon(),
            'phone_prefix' => $basket->info['phone_prefix'],
            'dob' => $basket->info['dob'],
            'doctor_surgery' => $_SESSION['health_profile']['doctor_surgery'] ?? '',
            'gender' => $basket->address['billing']['gender'],
        ]);

        $member->setMedications($_SESSION['health_profile']['medications'] ?? []);
        $member->setAllergies($_SESSION['health_profile']['allergies'] ?? []);
        $member->setConditions($_SESSION['health_profile']['conditions'] ?? []);
        $member->save();

        $member->setPassword($password);
        $member->save();
        $member->addresses()->create($basket->address['billing']);

        $member->setMemberBasket($member, $basket->id);
        $basket->Set_Member($member->id);

        Event::dispatch(MemberCreatedEvent::class, new MemberCreatedEvent($member));

        Auth::login($basket->info['email'], $password);

        return [];
    }

    /**
     * Updates member details from basket
     *
     * @param Member $member
     * @param Basket $basket
     * @return void
     */
    public function updateFromBasket(Member $member, Basket $basket): void
    {
        $oldMember = clone $member;
        $oldMember->load('addresses');
        // Update member addresses
        foreach (['billing', 'shipping'] as $type) {
            $address = $member->addresses()->firstOrNew(['type' => $type]);
            $address->fill($basket->address[$type]);
            $address->save();
        }

        // Update member prefix
        $member->phone_prefix = $basket->info['phone_prefix'];
        $member->dob = $basket->info['dob'];
        $member->gender = $basket->address['billing']['gender'];

        if ($member->emptyPassword()) {
            if (!MemberManager::sufficientPasswordStrength($_POST['cardholder_password'])) {
                $errors['cardholder_password'] = 'Please enter a password of at least 8 characters using letters as well as numbers.';
            } else {
                $member->setPassword($_POST['cardholder_password']);
            }
        }

        if (!empty($basket->info['newsletter'])) {
            Newsletter::signUp($basket->info['email'], [
                'first_name' => $basket->address['billing']['firstname'],
                'last_name' => $basket->address['billing']['lastname'],
                'skip_double_opt_in' => true,
            ]);
        } else {
            Newsletter::unsubscribe($basket->info['email']);
        }

        $member->save();
        Event::dispatch(MemberUpdatedEvent::class, new MemberUpdatedEvent($oldMember, $member));
    }
}
