<?php

namespace Tests\Feature;

use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\DB;
use Mtc\Modules\Members\Classes\Authenticator;
use Mtc\Modules\Members\Models\Member;
use Tests\TestCase;

/**
 * Pins the sign-in basket *merge* behaviour introduced for AL2U-T189
 * (Member::reconcileBasketOnLogin() in modules/Members/Classes/MembersBasket.php,
 * called from Authenticator::login()).
 *
 * When a visitor builds a basket as a guest and then signs in, the guest
 * basket must be folded into the member's stored basket rather than one basket
 * silently winning. Nothing (least of all an assessment-bearing prescription
 * line) may be dropped, duplicated, or attached to the wrong patient.
 *
 * Mirrors MemberEmailHashLoginTest: hits the real docker DB, no transactions,
 * cleans up its own rows in tearDown().
 *
 * Requires the docker DB:
 *     make shell
 *     ./vendor/bin/phpunit --filter=LoginBasketReconciliationTest
 */
class LoginBasketReconciliationTest extends TestCase
{
    /** @var int[] */
    private array $memberIds = [];
    /** @var int[] */
    private array $basketIds = [];
    /** @var int[] */
    private array $assessmentIds = [];
    /** @var int[] */
    private array $orderIds = [];

    protected function setUp(): void
    {
        parent::setUp();

        // The Auth facade resolves 'members_auth', only bound during a web
        // request (modules/Members/hooks.php). The adopt path ends with
        // setBasketDetails(), whose trailing new \Basket() calls
        // Auth::getLoggedInMember().
        App::bind('members_auth', fn () => new Authenticator());

        // Keep the session empty so the trailing new \Basket() resolves to an
        // empty (no-op) basket in CLI rather than picking anything up.
        $_SESSION = [];
    }

    /** Scenario 1: member has no prior basket -> the guest basket is adopted whole. */
    public function test_adopts_the_guest_basket_when_member_has_no_prior_basket(): void
    {
        $this->skipWithoutMembersBasket();

        $member      = $this->createMemberWithBillingAddress(); // basket_id defaults to 0
        $guestBasket = $this->createBasket(['member' => 0]);
        $assessment  = $this->createCompletedGuestAssessment();
        $this->addBasketItem($guestBasket, ['item_id' => 100, 'size' => 'M', 'assessment_id' => $assessment]);
        // This session completed the assessment (the normal guest journey), so
        // the prescription-safety guard allows it across.
        $_SESSION['assessments'] = [$assessment];

        Member::find($member->id)->reconcileBasketOnLogin($guestBasket);

        $this->assertSame(
            $guestBasket,
            (int) DB::table('members')->where('id', $member->id)->value('basket_id'),
            'members.basket_id should point at the adopted guest basket'
        );
        $this->assertSame(
            $member->id,
            (int) DB::table('basket')->where('id', $guestBasket)->value('member'),
            'the adopted guest basket should be stamped with the member'
        );
        $this->assertSame(
            $assessment,
            (int) DB::table('basket_items')->where('basket_id', $guestBasket)->value('assessment_id'),
            'the assessment line must survive the adopt'
        );
    }

    /** Scenario 2: both baskets have items -> all lines survive, guest basket removed. */
    public function test_true_merge_keeps_lines_from_both_baskets_and_leaves_no_orphan(): void
    {
        $this->skipWithoutMembersBasket();

        $member       = $this->createMemberWithBillingAddress();
        $memberBasket = $this->createBasket(['member' => $member->id]);
        $this->addBasketItem($memberBasket, ['item_id' => 101, 'size' => 'M', 'quantity' => 1]);
        $member->basket_id = $memberBasket;
        $member->save();

        $guestBasket = $this->createBasket(['member' => 0]);
        $this->addBasketItem($guestBasket, ['item_id' => 100, 'size' => 'M', 'quantity' => 1]);

        Member::find($member->id)->reconcileBasketOnLogin($guestBasket);

        // The member's stored basket survives as canonical and holds both lines.
        $this->assertSame(
            $memberBasket,
            (int) DB::table('members')->where('id', $member->id)->value('basket_id'),
            'the member basket should remain the canonical basket'
        );
        $items = DB::table('basket_items')->where('basket_id', $memberBasket)->pluck('item_id')->all();
        $this->assertContains(100, array_map('intval', $items), 'guest line A must be folded in');
        $this->assertContains(101, array_map('intval', $items), 'member line B must be retained');

        // No orphan: the emptied guest basket is gone.
        $this->assertNull(
            DB::table('basket')->where('id', $guestBasket)->value('id'),
            'the emptied guest basket must not be left behind'
        );
        $this->assertSame(
            0,
            DB::table('basket_items')->where('basket_id', $guestBasket)->count(),
            'guest basket lines must not be left behind'
        );
    }

    /** Scenario 3: same product/size in both -> quantities sum, capped at MAX_QUANTITY_SIZE. */
    public function test_shared_line_quantities_combine_and_cap(): void
    {
        $this->skipWithoutMembersBasket();

        $member       = $this->createMemberWithBillingAddress();
        $memberBasket = $this->createBasket(['member' => $member->id]);
        $this->addBasketItem($memberBasket, ['item_id' => 100, 'size' => 'M', 'quantity' => 900]);
        $member->basket_id = $memberBasket;
        $member->save();

        $guestBasket = $this->createBasket(['member' => 0]);
        $this->addBasketItem($guestBasket, ['item_id' => 100, 'size' => 'M', 'quantity' => 200]);

        Member::find($member->id)->reconcileBasketOnLogin($guestBasket);

        $lines = DB::table('basket_items')
            ->where('basket_id', $memberBasket)
            ->where('item_id', 100)
            ->where('size', 'M')
            ->get();

        $this->assertCount(1, $lines, 'the shared line must not be duplicated');
        $this->assertSame(
            MAX_QUANTITY_SIZE,
            (int) $lines->first()->quantity,
            '900 + 200 must combine and cap at MAX_QUANTITY_SIZE (' . MAX_QUANTITY_SIZE . ')'
        );
    }

    /** Scenario 4/Q3: same product/size but one line carries an assessment -> kept distinct. */
    public function test_assessment_line_is_not_collapsed_onto_a_plain_line(): void
    {
        $this->skipWithoutMembersBasket();

        $member       = $this->createMemberWithBillingAddress();
        $memberBasket = $this->createBasket(['member' => $member->id]);
        // Member basket has a plain (no assessment) line for the product.
        $this->addBasketItem($memberBasket, ['item_id' => 100, 'size' => 'M', 'assessment_id' => 0, 'quantity' => 1]);
        $member->basket_id = $memberBasket;
        $member->save();

        // Guest basket has the SAME product/size but carrying an assessment.
        $guestBasket = $this->createBasket(['member' => 0]);
        $assessment  = $this->createCompletedGuestAssessment();
        $this->addBasketItem($guestBasket, ['item_id' => 100, 'size' => 'M', 'assessment_id' => $assessment, 'quantity' => 1]);
        // This session completed the assessment, so the guard allows it across.
        $_SESSION['assessments'] = [$assessment];

        Member::find($member->id)->reconcileBasketOnLogin($guestBasket);

        $lines = DB::table('basket_items')
            ->where('basket_id', $memberBasket)
            ->where('item_id', 100)
            ->where('size', 'M')
            ->get();

        $this->assertCount(2, $lines, 'the prescription line must stay distinct from the plain line');
        $this->assertSame(
            1,
            DB::table('basket_items')
                ->where('basket_id', $memberBasket)
                ->where('assessment_id', $assessment)
                ->count(),
            'the assessment line must be present exactly once on the member basket'
        );
    }

    /** Scenario 5: a basket owned by a different member must never be folded in. */
    public function test_does_not_merge_a_basket_owned_by_another_member(): void
    {
        $this->skipWithoutMembersBasket();

        $member       = $this->createMemberWithBillingAddress();
        $memberBasket = $this->createBasket(['member' => $member->id]);
        $this->addBasketItem($memberBasket, ['item_id' => 101, 'size' => 'M']);
        $member->basket_id = $memberBasket;
        $member->save();

        // The session basket is owned by someone else (shared device).
        $other         = $this->createMember();
        $foreignBasket = $this->createBasket(['member' => $other->id]);
        $this->addBasketItem($foreignBasket, ['item_id' => 999, 'size' => 'M']);

        Member::find($member->id)->reconcileBasketOnLogin($foreignBasket);

        // Member basket untouched, foreign basket untouched.
        $this->assertSame(
            $memberBasket,
            (int) DB::table('members')->where('id', $member->id)->value('basket_id')
        );
        $this->assertSame(
            0,
            DB::table('basket_items')->where('basket_id', $memberBasket)->where('item_id', 999)->count(),
            'the foreign line must not leak into the member basket'
        );
        $this->assertSame(
            $other->id,
            (int) DB::table('basket')->where('id', $foreignBasket)->value('member'),
            'the foreign basket must keep its owner'
        );
    }

    /** Scenario 8: a basket already paid against must not be reused or folded in. */
    public function test_does_not_fold_in_a_paid_guest_basket(): void
    {
        $this->skipWithoutMembersBasket();

        $member       = $this->createMemberWithBillingAddress();
        $memberBasket = $this->createBasket(['member' => $member->id]);
        $this->addBasketItem($memberBasket, ['item_id' => 101, 'size' => 'M']);
        $member->basket_id = $memberBasket;
        $member->save();

        $guestBasket = $this->createBasket(['member' => 0]);
        $this->addBasketItem($guestBasket, ['item_id' => 100, 'size' => 'M']);
        $this->createPaidOrderFor($guestBasket);

        Member::find($member->id)->reconcileBasketOnLogin($guestBasket);

        $this->assertSame(
            0,
            DB::table('basket_items')->where('basket_id', $memberBasket)->where('item_id', 100)->count(),
            'a paid guest basket must not be folded into the member basket'
        );
        $this->assertSame(
            $guestBasket,
            (int) DB::table('basket_items')->where('basket_id', $guestBasket)->value('basket_id'),
            'the paid guest basket must be left untouched'
        );
    }

    /**
     * Prescription-safety guard (Q/finding #1, partial): a guest basket carrying
     * a prescription line whose assessment this session did NOT complete (and
     * which the member does not already own) must not be attached to the member
     * on login. Protects against a stale/cross-session prescription basket
     * landing on the wrong patient.
     */
    public function test_prescription_basket_not_completed_by_this_session_is_not_attached(): void
    {
        $this->skipWithoutMembersBasket();

        $member      = $this->createMemberWithBillingAddress(); // no prior basket
        $guestBasket = $this->createBasket(['member' => 0]);
        $assessment  = $this->createCompletedGuestAssessment(); // member_id = 0, someone else's
        $this->addBasketItem($guestBasket, ['item_id' => 100, 'size' => 'M', 'assessment_id' => $assessment]);

        // Crucially, this session did NOT complete that assessment.
        $_SESSION['assessments'] = [];

        Member::find($member->id)->reconcileBasketOnLogin($guestBasket);

        $this->assertSame(
            0,
            (int) DB::table('members')->where('id', $member->id)->value('basket_id'),
            'a prescription basket this session did not complete must not be adopted'
        );
        $this->assertSame(
            0,
            (int) DB::table('basket')->where('id', $guestBasket)->value('member'),
            'the guest basket must not be stamped onto the member'
        );
    }

    /**
     * A member basket carrying an unpaid, in-progress order must not be mutated:
     * folding into it would silently add a line to the pending order when it
     * re-snapshots. The guest basket is adopted instead, leaving the ordered
     * basket intact.
     */
    public function test_member_basket_with_an_unpaid_order_is_left_intact_and_guest_is_adopted(): void
    {
        $this->skipWithoutMembersBasket();

        $member       = $this->createMemberWithBillingAddress();
        $memberBasket = $this->createBasket(['member' => $member->id]);
        $this->addBasketItem($memberBasket, ['item_id' => 101, 'size' => 'M']);
        $member->basket_id = $memberBasket;
        $member->save();
        // The member started (but did not pay for) checkout on their stored basket.
        $this->createUnpaidOrderFor($memberBasket);

        $guestBasket = $this->createBasket(['member' => 0]);
        $this->addBasketItem($guestBasket, ['item_id' => 100, 'size' => 'M']);

        Member::find($member->id)->reconcileBasketOnLogin($guestBasket);

        // The ordered member basket is untouched: the guest line did not leak in.
        $this->assertSame(
            0,
            DB::table('basket_items')->where('basket_id', $memberBasket)->where('item_id', 100)->count(),
            'the guest line must not be folded into a basket that has a pending order'
        );
        // The guest basket is adopted instead, so nothing the visitor added is lost.
        $this->assertSame(
            $guestBasket,
            (int) DB::table('members')->where('id', $member->id)->value('basket_id'),
            'the guest basket should be adopted when the member basket is order-bound'
        );
    }

    /**
     * A guest's chosen subscription cadence (has_requested_subscription_period)
     * must survive being folded into the member basket, or the medicine is
     * supplied on the wrong schedule.
     */
    public function test_subscription_cadence_survives_the_fold(): void
    {
        $this->skipWithoutMembersBasket();

        $member       = $this->createMemberWithBillingAddress();
        $memberBasket = $this->createBasket(['member' => $member->id]);
        $this->addBasketItem($memberBasket, ['item_id' => 101, 'size' => 'M']);
        $member->basket_id = $memberBasket;
        $member->save();

        $guestBasket = $this->createBasket(['member' => 0]);
        $this->addBasketItem($guestBasket, [
            'item_id'                            => 200,
            'size'                               => 'M',
            'has_requested_subscription'         => 1,
            'has_requested_subscription_period'  => '3 months',
        ]);

        Member::find($member->id)->reconcileBasketOnLogin($guestBasket);

        $folded = DB::table('basket_items')
            ->where('basket_id', $memberBasket)
            ->where('item_id', 200)
            ->first();

        $this->assertNotNull($folded, 'the guest subscription line must be folded in');
        $this->assertSame(
            '3 months',
            $folded->has_requested_subscription_period,
            'the chosen subscription cadence must be carried across'
        );
    }

    /**
     * A guest bundle line must stay distinct from a matching standalone member
     * line: collapsing them would break the bundle grouping/discount.
     */
    public function test_bundle_line_is_not_collapsed_onto_a_matching_standalone_line(): void
    {
        $this->skipWithoutMembersBasket();

        $member       = $this->createMemberWithBillingAddress();
        $memberBasket = $this->createBasket(['member' => $member->id]);
        // Standalone line: same product/size, no bundle.
        $this->addBasketItem($memberBasket, ['item_id' => 100, 'size' => 'M', 'quantity' => 1]);
        $member->basket_id = $memberBasket;
        $member->save();

        // Guest line: SAME product/size but part of a bundle.
        $guestBasket = $this->createBasket(['member' => 0]);
        $this->addBasketItem($guestBasket, ['item_id' => 100, 'size' => 'M', 'bundle_id' => 55, 'quantity' => 1]);

        Member::find($member->id)->reconcileBasketOnLogin($guestBasket);

        $lines = DB::table('basket_items')
            ->where('basket_id', $memberBasket)
            ->where('item_id', 100)
            ->where('size', 'M')
            ->get();

        $this->assertCount(2, $lines, 'the bundle line must stay distinct from the standalone line');
        $this->assertSame(
            1,
            DB::table('basket_items')
                ->where('basket_id', $memberBasket)
                ->where('item_id', 100)
                ->where('bundle_id', 55)
                ->count(),
            'the bundle association must be preserved on the folded line'
        );
    }

    /**
     * The fold path must prefill the member's billing address onto the surviving
     * basket, matching the adopt path (otherwise checkout shows an empty
     * billing address that used to be auto-populated).
     */
    public function test_merge_prefills_member_billing_address_onto_surviving_basket(): void
    {
        $this->skipWithoutMembersBasket();

        $member       = $this->createMemberWithBillingAddress();
        $memberBasket = $this->createBasket(['member' => $member->id]);
        $this->addBasketItem($memberBasket, ['item_id' => 101, 'size' => 'M']);
        $member->basket_id = $memberBasket;
        $member->save();

        $guestBasket = $this->createBasket(['member' => 0]);
        $this->addBasketItem($guestBasket, ['item_id' => 100, 'size' => 'M']);

        Member::find($member->id)->reconcileBasketOnLogin($guestBasket);

        // basket_address columns are encrypted at rest (OptionalEncrypted cast),
        // so read through the model to compare the decrypted value.
        $surviving = \Mtc\Shop\Basket::query()->with('billingAddress')->find($memberBasket);

        $this->assertNotNull(
            $surviving->billingAddress,
            'a billing address must be created on the merged basket'
        );
        $this->assertSame(
            '1 Test Street',
            $surviving->billingAddress->address1,
            'the member billing address must be prefilled onto the merged basket'
        );
    }

    /** Scenario 12: abandonment-recovery rows for the discarded guest basket are cleared with it. */
    public function test_fold_clears_recovery_rows_for_the_discarded_guest_basket(): void
    {
        $this->skipWithoutMembersBasket();

        $member       = $this->createMemberWithBillingAddress();
        $memberBasket = $this->createBasket(['member' => $member->id]);
        $this->addBasketItem($memberBasket, ['item_id' => 101, 'size' => 'M']);
        $member->basket_id = $memberBasket;
        $member->save();

        $guestBasket = $this->createBasket(['member' => 0]);
        $this->addBasketItem($guestBasket, ['item_id' => 100, 'size' => 'M']);

        // The guest abandoned long enough to trigger recovery before signing in.
        DB::table('basket_recovery_logs')->insert([
            'basket_recovery_id' => 0,
            'basket_id'          => $guestBasket,
            'action'             => 'email_sent',
            'value'              => '',
            'created_at'         => date('Y-m-d H:i:s'),
            'updated_at'         => date('Y-m-d H:i:s'),
        ]);
        DB::table('basket_recovery_sent_emails')->insert([
            'basket_id'   => $guestBasket,
            'recovery_id' => 0,
        ]);

        Member::find($member->id)->reconcileBasketOnLogin($guestBasket);

        $this->assertNull(
            DB::table('basket')->where('id', $guestBasket)->value('id'),
            'the emptied guest basket must be discarded'
        );
        $this->assertSame(
            0,
            DB::table('basket_recovery_logs')->where('basket_id', $guestBasket)->count(),
            'recovery log rows must not outlive the discarded guest basket'
        );
        $this->assertSame(
            0,
            DB::table('basket_recovery_sent_emails')->where('basket_id', $guestBasket)->count(),
            'recovery sent-email rows must not outlive the discarded guest basket'
        );
    }

    // ---- helpers ----

    private function skipWithoutMembersBasket(): void
    {
        if (!defined('MEMBERS_BASKET') || MEMBERS_BASKET !== true) {
            $this->markTestSkipped('MEMBERS_BASKET is off; basket-on-member persistence is disabled.');
        }
    }

    private function createMember(): Member
    {
        $member = new Member();
        $member->email              = 'basketmerge+' . uniqid() . '@example.com';
        $member->password           = '';
        $member->first_login        = date('Y-m-d H:i:s');
        $member->failed_login_count = 0;
        $member->gender             = 'Male';
        $member->save();
        $this->memberIds[] = $member->id;

        return Member::find($member->id);
    }

    private function createMemberWithBillingAddress(): Member
    {
        $member = $this->createMember();

        // The adopt path calls setBasketDetails(), which reads
        // $this->addressBilling->{firstname,...}, so a billing address must exist.
        $member->addresses()->create([
            'type'      => 'billing',
            'title'     => 'Mr.',
            'firstname' => 'Test',
            'lastname'  => 'User',
            'address1'  => '1 Test Street',
            'address2'  => '',
            'city'      => 'Testville',
            'state'     => '',
            'country'   => 'GB',
            'postcode'  => 'TE1 1ST',
        ]);

        return Member::find($member->id);
    }

    private function createBasket(array $overrides = []): int
    {
        $id = (int) DB::table('basket')->insertGetId(array_merge([
            'member'             => 0,
            'ref'                => '',
            'keywords'           => '',
            'basket_expiry_time' => date('Y-m-d H:i:s'),
            'checkout'           => '',
            'seccode'            => '',
            'flag_type'          => 0,
        ], $overrides));
        $this->basketIds[] = $id;

        // Pre-create basket_info so setBasketDetails() updates rather than
        // inserts (avoids the NOT NULL `message` column on insert).
        DB::table('basket_info')->insert([
            'basket_id'    => $id,
            'email'        => '',
            'contact_no'   => '',
            'message'      => '',
            'phone_prefix' => '+44',
        ]);

        return $id;
    }

    private function addBasketItem(int $basketId, array $overrides = []): int
    {
        return (int) DB::table('basket_items')->insertGetId(array_merge([
            'basket_id'     => $basketId,
            'item_id'       => 0,
            'assessment_id' => 0,
            'size'          => '',
            'quantity'      => 1,
            'PLU'           => '',
            'sizeid'        => 0,
            'refill_date'   => date('Y-m-d'),
        ], $overrides));
    }

    private function createCompletedGuestAssessment(): int
    {
        $id = (int) DB::table('assessments')->insertGetId([
            'member_id'    => 0,
            'form_id'      => 0,
            'item_id'      => 0,
            'is_completed' => 1,
            'created_at'   => date('Y-m-d H:i:s'),
            'updated_at'   => date('Y-m-d H:i:s'),
        ]);
        $this->assessmentIds[] = $id;

        return $id;
    }

    private function createPaidOrderFor(int $basketId): void
    {
        $this->createOrderFor($basketId, 1);
    }

    private function createUnpaidOrderFor(int $basketId): void
    {
        $this->createOrderFor($basketId, 0);
    }

    private function createOrderFor(int $basketId, int $paid): void
    {
        // The `order` table has many NOT NULL columns without defaults, so set
        // a full baseline; the guards only read paid + basket_id. If the schema
        // differs on this site, skip rather than fail the guard assertion.
        try {
            $id = (int) DB::table('order')->insertGetId([
                'paid'               => $paid,
                'basket_id'          => $basketId,
                'member'             => 0,
                'delivery_method_id' => 0,
                'delivery_cost'      => 0,
                'coupon'             => 0,
                'cost_total'         => 0,
                'cost_total_exvat'   => 0,
                'status'             => 0,
                'admin_id'           => 0,
                'coupon_code'        => '',
                'type'               => '',
                'ref'                => '',
                'keywords'           => '',
                'failed'             => 0,
                'notes'              => '',
                'order_ref'          => '',
                'ga_ga'              => '',
                'exported'           => '2000-01-01 00:00:00',
                'checkout'           => '',
                'blacklisted'        => 0,
                'trustpilot_sent'    => '2000-01-01 00:00:00',
                'flag'               => 0,
                'message_id'         => 0,
                'tracking_no'        => '',
                'shipping_label'     => '',
                'rm_exported'        => '2000-01-01 00:00:00',
                'doctor_note'        => '',
                'pharmacist_note'    => '',
                'patient_notes'      => '',
            ]);
            $this->orderIds[] = $id;
        } catch (\Throwable $e) {
            $this->markTestSkipped('Could not create a paid order row on this schema: ' . $e->getMessage());
        }
    }

    protected function tearDown(): void
    {
        if (!empty($this->orderIds)) {
            DB::table('order')->whereIn('id', $this->orderIds)->delete();
        }
        foreach ($this->basketIds as $basketId) {
            DB::table('basket_items')->where('basket_id', $basketId)->delete();
            DB::table('basket_address')->where('basket_id', $basketId)->delete();
            DB::table('basket_info')->where('basket_id', $basketId)->delete();
            DB::table('basket_recovery_logs')->where('basket_id', $basketId)->delete();
            DB::table('basket_recovery_sent_emails')->where('basket_id', $basketId)->delete();
        }
        if (!empty($this->basketIds)) {
            DB::table('basket')->whereIn('id', $this->basketIds)->delete();
        }
        if (!empty($this->assessmentIds)) {
            DB::table('assessments')->whereIn('id', $this->assessmentIds)->delete();
        }
        if (!empty($this->memberIds)) {
            DB::table('members_addresses')->whereIn('member_id', $this->memberIds)->delete();
            DB::table('members')->whereIn('id', $this->memberIds)->delete();
        }

        parent::tearDown();
    }
}
