<?php

namespace Tests\Feature;

use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\DB;
use Mtc\Modules\Members\Classes\Auth;
use Mtc\Modules\Members\Classes\Authenticator;
use Mtc\Modules\Members\Models\Member;
use Mtc\Shop\Basket as ShopBasket;
use Tests\TestCase;

/**
 * Integration coverage for member login/logout and the guest <-> member basket
 * association, exercised end to end against the real DB.
 *
 * The hardening branch changed isLoggedIn() to be side-effect free and moved
 * stale-session clearing into getLoggedInMember(). Because the legacy
 * Basket::__construct() resolves the active basket via Auth::getLoggedInMember(),
 * these tests pin that the basket association still behaves correctly, including
 * the awkward cases:
 *
 *   - guest basket taken over on login (setBasketDetails)
 *   - a guest login that already has an old member basket_id stored
 *   - refusing to steal a basket owned by another member
 *   - the basket constructor loading / unlinking a member basket
 *   - a stale member (row deleted) whose session is cleared when the basket
 *     constructor calls getLoggedInMember()
 *   - logout preserving members.basket_id for restore on the next login
 *
 * Requires the docker DB:
 *     make shell
 *     ./vendor/bin/phpunit --filter=MemberBasketAssociationTest
 */
class MemberBasketAssociationTest extends TestCase
{
    private const PASSWORD = 'Secret123!';

    /** @var int[] */
    private array $createdMemberIds = [];

    /** @var int[] */
    private array $createdBasketIds = [];

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

        App::bind('members_auth', fn () => new Authenticator());

        if (session_status() !== PHP_SESSION_ACTIVE) {
            @session_start();
        }

        $this->resetMemberExistsCache();
        $_SESSION = [];
    }

    // ---------------------------------------------------------------------
    // Login / logout basics
    // ---------------------------------------------------------------------

    public function test_member_object_login_sets_session_and_is_logged_in(): void
    {
        $member = $this->createMember();

        // NB: the Member-object branch of login() intentionally has no return
        // value (only the email/password branch returns true) — pre-existing
        // behaviour, unchanged by the hardening branch.
        Auth::login($member);

        $this->assertSame($member->id, $_SESSION['member_id']);
        $this->assertTrue($_SESSION['logged_in']);
        $this->assertTrue(Auth::isLoggedIn());
        $this->assertTrue(Auth::getLoggedInMember()->exists);
    }

    public function test_logout_clears_member_session_but_preserves_stored_basket_id(): void
    {
        $member = $this->createMember();
        $basket = $this->createBasket($member->id);

        // Member has a persisted basket_id (the restore-on-next-login pointer).
        $member->basket_id = $basket->id;
        $member->save();

        Auth::login($member);
        $_SESSION['basket_id'] = $basket->id;
        $_SESSION['order_id']  = 555;

        Auth::logout();

        // Member session gone...
        $this->assertArrayNotHasKey('member_id', $_SESSION);
        $this->assertArrayNotHasKey('logged_in', $_SESSION);
        $this->assertArrayNotHasKey('basket_id', $_SESSION);
        $this->assertArrayNotHasKey('order_id', $_SESSION);
        $this->assertFalse(Auth::isLoggedIn());

        // ...but the durable pointer on the member row survives for next login.
        $this->assertSame($basket->id, (int) DB::table('members')->where('id', $member->id)->value('basket_id'));
    }

    // ---------------------------------------------------------------------
    // Guest basket -> member takeover on login (setBasketDetails)
    // ---------------------------------------------------------------------

    public function test_guest_basket_is_taken_over_by_member_on_login(): void
    {
        $member = $this->createMemberWithBillingAddress();
        $guestBasket = $this->createBasket(0); // unowned guest basket

        $_SESSION['basket_id'] = $guestBasket->id;

        // Full email+password login path, which calls setBasketDetails().
        $this->assertTrue(Auth::login($member->email, self::PASSWORD));

        // The guest basket now belongs to the member...
        $this->assertSame($member->id, (int) DB::table('basket')->where('id', $guestBasket->id)->value('member'));
        // ...and is the member's active stored basket.
        $this->assertSame($guestBasket->id, (int) DB::table('members')->where('id', $member->id)->value('basket_id'));
    }

    public function test_guest_basket_merges_into_members_old_stored_basket_on_login(): void
    {
        $member = $this->createMemberWithBillingAddress();
        $oldBasket   = $this->createBasket($member->id);
        $guestBasket = $this->createBasket(0);

        // Member already had an old stored basket before this session...
        $this->addBasketItem($oldBasket->id, ['item_id' => 201, 'size' => 'M']);
        $member->basket_id = $oldBasket->id;
        $member->save();

        // ...and built up a fresh guest basket this session, then logs in.
        $this->addBasketItem($guestBasket->id, ['item_id' => 202, 'size' => 'M']);
        $_SESSION['basket_id'] = $guestBasket->id;
        $this->assertTrue(Auth::login($member->email, self::PASSWORD));

        // The two baskets are merged (nothing lost) rather than one winning: the
        // member's stored basket survives as canonical and holds both lines.
        $this->assertSame(
            $oldBasket->id,
            (int) DB::table('members')->where('id', $member->id)->value('basket_id'),
            'the member stored basket should remain canonical'
        );
        $items = array_map('intval', DB::table('basket_items')->where('basket_id', $oldBasket->id)->pluck('item_id')->all());
        $this->assertContains(201, $items, 'the member line must be retained');
        $this->assertContains(202, $items, 'the guest line must be folded in');

        // The emptied guest basket is not left behind as an orphan.
        $this->assertNull(DB::table('basket')->where('id', $guestBasket->id)->value('id'));
    }

    public function test_set_basket_details_refuses_to_steal_another_members_basket(): void
    {
        $owner   = $this->createMember();
        $ownerBasket = $this->createBasket($owner->id);

        $intruder = $this->createMemberWithBillingAddress();
        $intruderOriginal = $intruder->basket_id;

        // Intruder tries to adopt a basket owned by someone else.
        $intruder->setBasketDetails($ownerBasket->id);

        // Basket ownership is unchanged, and the intruder did not get it.
        $this->assertSame($owner->id, (int) DB::table('basket')->where('id', $ownerBasket->id)->value('member'));
        $this->assertSame(
            (int) $intruderOriginal,
            (int) DB::table('members')->where('id', $intruder->id)->value('basket_id'),
            'intruder basket_id must not change when takeover is refused'
        );
    }

    // ---------------------------------------------------------------------
    // Basket constructor resolution (uses Auth::getLoggedInMember())
    // ---------------------------------------------------------------------

    public function test_basket_constructor_loads_members_own_basket(): void
    {
        $member = $this->createMember();
        $basket = $this->createBasket($member->id);
        $member->basket_id = $basket->id;
        $member->save();

        Auth::login($member);
        // Simulate a fresh request: no basket in session yet.
        unset($_SESSION['basket_id']);

        $legacy = new \Basket();

        $this->assertSame($basket->id, $legacy->id ?? null);
        $this->assertSame($basket->id, $_SESSION['basket_id'] ?? null);
    }

    public function test_basket_constructor_unlinks_a_basket_owned_by_someone_else(): void
    {
        $member = $this->createMember();
        $other  = $this->createMember();
        $foreignBasket = $this->createBasket($other->id);

        // Member's stored basket_id wrongly points at another member's basket.
        $member->basket_id = $foreignBasket->id;
        $member->save();

        Auth::login($member);
        unset($_SESSION['basket_id']);

        new \Basket();

        // The bad pointer is cleared on the member row.
        $this->assertSame(0, (int) DB::table('members')->where('id', $member->id)->value('basket_id'));
        // The foreign basket still belongs to its real owner.
        $this->assertSame($other->id, (int) DB::table('basket')->where('id', $foreignBasket->id)->value('member'));
    }

    public function test_guest_basket_constructor_still_works_without_a_member(): void
    {
        $guestBasket = $this->createBasket(0);
        $_SESSION['basket_id'] = $guestBasket->id;

        $legacy = new \Basket();

        $this->assertSame($guestBasket->id, $legacy->id ?? null);
        $this->assertFalse(Auth::isLoggedIn());
    }

    // ---------------------------------------------------------------------
    // The awkward one: stale member row + basket constructor
    // ---------------------------------------------------------------------

    public function test_basket_constructor_clears_stale_member_session_without_fatal(): void
    {
        $member = $this->createMember();
        $guestBasket = $this->createBasket(0);

        Auth::login($member);
        $_SESSION['basket_id'] = $guestBasket->id;
        $_SESSION['order_id']  = 777;

        // Member row vanishes while the session is still live.
        $this->deleteMember($member->id);
        $this->resetMemberExistsCache();

        // Constructing a basket resolves the (now stale) logged-in member via
        // getLoggedInMember(), which detects the stale row and logs out. This is
        // the one place a mutation is expected. It must not fatal.
        $legacy = new \Basket();

        // Stale member session is cleared as part of the logout side effect.
        $this->assertArrayNotHasKey('member_id', $_SESSION, 'stale member session cleared');
        $this->assertFalse(Auth::isLoggedIn());

        // logout() also unsets $_SESSION['basket_id'], so the session no longer
        // references the guest basket — the user is downgraded to a clean guest.
        // The guest basket row itself is untouched in the DB (still unowned) and
        // can be re-attached to a real session later.
        $this->assertArrayNotHasKey('basket_id', $_SESSION);
        $this->assertSame(0, (int) DB::table('basket')->where('id', $guestBasket->id)->value('member'));
    }

    public function test_isLoggedIn_does_not_clear_session_on_its_own(): void
    {
        $member = $this->createMember();
        Auth::login($member);
        $_SESSION['basket_id'] = 4321;
        $_SESSION['order_id']  = 99;

        $this->deleteMember($member->id);
        $this->resetMemberExistsCache();

        // Pure read: false, but the session is left untouched.
        $this->assertFalse(Auth::isLoggedIn());
        $this->assertSame($member->id, $_SESSION['member_id'] ?? null);
        $this->assertSame(4321, $_SESSION['basket_id'] ?? null);
        $this->assertSame(99, $_SESSION['order_id'] ?? null);
    }

    // ---------------------------------------------------------------------
    // Helpers
    // ---------------------------------------------------------------------

    private function createMember(): Member
    {
        $member = new Member();
        $member->email              = 'basket+' . uniqid() . '@example.com';
        $member->password           = Auth::hashPassword(self::PASSWORD);
        $member->first_login        = date('Y-m-d H:i:s');
        $member->failed_login_count = 0;
        $member->save();

        $this->createdMemberIds[] = $member->id;

        return $member;
    }

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

        // setBasketDetails() reads $this->addressBilling->firstname, so a member
        // that reaches the takeover path needs a billing address row.
        $member->addressBilling()->create([
            'type'      => 'billing',
            'firstname' => 'Test',
            'lastname'  => 'User',
            'address1'  => '1 Test Street',
            'city'      => 'Testville',
            'postcode'  => 'TE1 1ST',
            'country'   => 'GB',
        ]);

        return $member->fresh();
    }

    private function createBasket(int $memberId): ShopBasket
    {
        $basket = new ShopBasket();
        $basket->member = $memberId;
        $basket->save();

        $this->createdBasketIds[] = $basket->id;

        return $basket;
    }

    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 deleteMember($id): void
    {
        DB::table('members')->where('id', $id)->delete();
        $this->createdMemberIds = array_diff($this->createdMemberIds, [$id]);
    }

    private function resetMemberExistsCache(): void
    {
        $reflection = new \ReflectionProperty(Authenticator::class, 'memberExistsCache');
        $reflection->setAccessible(true);
        $reflection->setValue(null, []);
    }

    protected function tearDown(): void
    {
        if (!empty($this->createdBasketIds)) {
            DB::table('basket_items')->whereIn('basket_id', $this->createdBasketIds)->delete();
            DB::table('basket')->whereIn('id', $this->createdBasketIds)->delete();
        }
        if (!empty($this->createdMemberIds)) {
            DB::table('members_addresses')->whereIn('member_id', $this->createdMemberIds)->delete();
            DB::table('members')->whereIn('id', $this->createdMemberIds)->delete();
        }

        $_SESSION = [];

        parent::tearDown();
    }
}
