<?php

namespace SilverStripe\Security\Tests;

use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\Security\Group;
use SilverStripe\Security\InheritedPermissions;
use SilverStripe\Security\Member;
use SilverStripe\Security\PermissionChecker;
use SilverStripe\Security\Tests\InheritedPermissionsTest\TestPermissionNode;
use SilverStripe\Security\Tests\InheritedPermissionsTest\TestDefaultPermissionChecker;
use SilverStripe\Security\Tests\InheritedPermissionsTest\UnstagedNode;
use SilverStripe\Versioned\Versioned;
use Psr\SimpleCache\CacheInterface;
use ReflectionClass;

class InheritedPermissionsTest extends SapphireTest
{
    protected static $fixture_file = 'InheritedPermissionsTest.yml';

    protected static $extra_dataobjects = [
        TestPermissionNode::class,
        UnstagedNode::class,
    ];

    /**
     * @var TestDefaultPermissionChecker
     */
    protected $rootPermissions = null;

    protected function setUp(): void
    {
        $this->rootPermissions = new TestDefaultPermissionChecker();

        // Register root permissions
        $permission1 = InheritedPermissions::create(TestPermissionNode::class)
            ->setGlobalEditPermissions(['TEST_NODE_ACCESS'])
            ->setDefaultPermissions($this->rootPermissions);
        Injector::inst()->registerService(
            $permission1,
            PermissionChecker::class . '.testpermissions'
        );

        // Reset root permission
        $permission2 = InheritedPermissions::create(UnstagedNode::class)
            ->setGlobalEditPermissions(['TEST_NODE_ACCESS'])
            ->setDefaultPermissions($this->rootPermissions);
        Injector::inst()->registerService(
            $permission2,
            PermissionChecker::class . '.unstagedpermissions'
        );

        parent::setUp();

        $permission1->clearCache();
        $permission2->clearCache();
    }

    protected function tearDown(): void
    {
        Injector::inst()->unregisterNamedObject(PermissionChecker::class . '.testpermissions');
        Injector::inst()->unregisterNamedObject(PermissionChecker::class . '.unstagedpermissions');
        $this->rootPermissions = null;
        parent::tearDown();
    }

    public function testEditPermissions()
    {
        $editor = $this->objFromFixture(Member::class, 'editor');
        $freddie = $this->objFromFixture(Member::class, 'oneFileFreddie');

        $about = $this->objFromFixture(TestPermissionNode::class, 'about');
        $aboutStaff = $this->objFromFixture(TestPermissionNode::class, 'about-staff');
        $history = $this->objFromFixture(TestPermissionNode::class, 'history');
        $products = $this->objFromFixture(TestPermissionNode::class, 'products');
        $product1 = $this->objFromFixture(TestPermissionNode::class, 'products-product1');
        $product4 = $this->objFromFixture(TestPermissionNode::class, 'products-product4');
        $freddiesFile = $this->objFromFixture(TestPermissionNode::class, 'freddies-file');

        // Test logged out users cannot edit
        Member::actAs(null, function () use ($aboutStaff, $freddiesFile) {
            $this->assertFalse($aboutStaff->canEdit());
            $this->assertFalse($freddiesFile->canEdit());
        });

        // Can't edit a page that is locked to admins
        $this->assertFalse($about->canEdit($editor));

        // Can edit a page that is locked to editors
        $this->assertTrue($products->canEdit($editor));

        // Can edit a child of that page that inherits
        $this->assertTrue($product1->canEdit($editor));

        // Can't edit a child of that page that has its permissions overridden
        $this->assertFalse($product4->canEdit($editor));

        // Test that root node respects root permissions
        $this->assertTrue($history->canEdit($editor));

        // Test that only Freddie can edit Freddie's file
        $this->assertFalse($freddiesFile->canEdit($editor));
        $this->assertTrue($freddiesFile->canEdit($freddie));

        TestPermissionNode::getInheritedPermissions()->clearCache();
        $this->rootPermissions->setCanEdit(false);

        // With root edit false, permissions are now denied for CanEditType = Inherit
        $this->assertFalse($history->canEdit($editor));
    }

    public function testDeletePermissions()
    {
        $editor = $this->objFromFixture(Member::class, 'editor');
        $freddie = $this->objFromFixture(Member::class, 'oneFileFreddie');

        $about = $this->objFromFixture(TestPermissionNode::class, 'about');
        $aboutStaff = $this->objFromFixture(TestPermissionNode::class, 'about-staff');
        $history = $this->objFromFixture(TestPermissionNode::class, 'history');
        $products = $this->objFromFixture(TestPermissionNode::class, 'products');
        $product1 = $this->objFromFixture(TestPermissionNode::class, 'products-product1');
        $product4 = $this->objFromFixture(TestPermissionNode::class, 'products-product4');
        $freddiesFile = $this->objFromFixture(TestPermissionNode::class, 'freddies-file');

        // Test logged out users cannot edit
        Member::actAs(null, function () use ($aboutStaff, $freddiesFile) {
            $this->assertFalse($aboutStaff->canDelete());
            $this->assertFalse($freddiesFile->canDelete());
        });

        // Can't edit a page that is locked to admins
        $this->assertFalse($about->canDelete($editor));

        // Can't delete a page if a child (product4) is un-deletable
        $this->assertFalse($products->canDelete($editor));

        // Can edit a child of that page that inherits
        $this->assertTrue($product1->canDelete($editor));

        // Can't edit a child of that page that has its permissions overridden
        $this->assertFalse($product4->canDelete($editor));

        // Test that root node respects root permissions
        $this->assertTrue($history->canDelete($editor));

        // Test that only Freddie can delete Freddie's file
        $this->assertFalse($freddiesFile->canDelete($editor));
        $this->assertTrue($freddiesFile->canDelete($freddie));

        TestPermissionNode::getInheritedPermissions()->clearCache();
        $this->rootPermissions->setCanEdit(false);

        // With root edit false, permissions are now denied for CanEditType = Inherit
        $this->assertFalse($history->canDelete($editor));
    }

    public function testViewPermissions()
    {
        $history = $this->objFromFixture(TestPermissionNode::class, 'history');
        $contact = $this->objFromFixture(TestPermissionNode::class, 'contact');
        $contactForm = $this->objFromFixture(TestPermissionNode::class, 'contact-form');
        $secret = $this->objFromFixture(TestPermissionNode::class, 'secret');
        $secretNested = $this->objFromFixture(TestPermissionNode::class, 'secret-nested');
        $protected = $this->objFromFixture(TestPermissionNode::class, 'protected');
        $protectedChild = $this->objFromFixture(TestPermissionNode::class, 'protected-child');
        $restricted = $this->objFromFixture(TestPermissionNode::class, 'restricted-page');
        $freddiesFile = $this->objFromFixture(TestPermissionNode::class, 'freddies-file');

        $editor = $this->objFromFixture(Member::class, 'editor');
        $admin = $this->objFromFixture(Member::class, 'admin');
        $freddie = $this->objFromFixture(Member::class, 'oneFileFreddie');

        // Not logged in user can only access Inherit or Anyone pages
        Member::actAs(
            null,
            function () use ($protectedChild, $secretNested, $protected, $secret, $history, $contact, $contactForm, $freddiesFile) {
                $this->assertTrue($history->canView());
                $this->assertTrue($contact->canView());
                $this->assertTrue($contactForm->canView());
                // Protected
                $this->assertFalse($secret->canView());
                $this->assertFalse($secretNested->canView());
                $this->assertFalse($protected->canView());
                $this->assertFalse($protectedChild->canView());
                $this->assertFalse($freddiesFile->canView());
            }
        );

        // Editor can view pages restricted to logged in users
        $this->assertTrue($secret->canView($editor));
        $this->assertTrue($secretNested->canView($editor));

        // Cannot read admin-only pages
        $this->assertFalse($protected->canView($editor));
        $this->assertFalse($protectedChild->canView($editor));

        // Check root permissions
        $this->assertTrue($history->canView($editor));

        // Test that only Freddie can view Freddie's file
        $this->assertFalse($freddiesFile->canView($editor));
        $this->assertTrue($freddiesFile->canView($freddie));

        TestPermissionNode::getInheritedPermissions()->clearCache();
        $this->rootPermissions->setCanView(false);

        $this->assertFalse($history->canView($editor));

        // Ensure admins can view everything, even if only a certain group is allowed to view it
        $this->assertTrue($restricted->canView($admin));
    }

    public function testUnstagedViewPermissions()
    {
        $history = $this->objFromFixture(UnstagedNode::class, 'history');
        $contact = $this->objFromFixture(UnstagedNode::class, 'contact');
        $contactForm = $this->objFromFixture(UnstagedNode::class, 'contact-form');
        $secret = $this->objFromFixture(UnstagedNode::class, 'secret');
        $secretNested = $this->objFromFixture(UnstagedNode::class, 'secret-nested');
        $protected = $this->objFromFixture(UnstagedNode::class, 'protected');
        $protectedChild = $this->objFromFixture(UnstagedNode::class, 'protected-child');
        $freddiesFile = $this->objFromFixture(UnstagedNode::class, 'freddies-file');

        $editor = $this->objFromFixture(Member::class, 'editor');
        $freddie = $this->objFromFixture(Member::class, 'oneFileFreddie');


        // Not logged in user can only access Inherit or Anyone pages
        Member::actAs(
            null,
            function () use ($protectedChild, $secretNested, $protected, $secret, $history, $contact, $contactForm, $freddiesFile) {
                $this->assertTrue($history->canView());
                $this->assertTrue($contact->canView());
                $this->assertTrue($contactForm->canView());
                // Protected
                $this->assertFalse($secret->canView());
                $this->assertFalse($secretNested->canView());
                $this->assertFalse($protected->canView());
                $this->assertFalse($protectedChild->canView());
                $this->assertFalse($freddiesFile->canView());
            }
        );

        // Editor can view pages restricted to logged in users
        $this->assertTrue($secret->canView($editor));
        $this->assertTrue($secretNested->canView($editor));

        // Cannot read admin-only pages
        $this->assertFalse($protected->canView($editor));
        $this->assertFalse($protectedChild->canView($editor));

        // Check root permissions
        $this->assertTrue($history->canView($editor));

        // Test that only Freddie can view Freddie's file
        $this->assertFalse($freddiesFile->canView($editor));
        $this->assertTrue($freddiesFile->canView($freddie));

        UnstagedNode::getInheritedPermissions()->clearCache();
        $this->rootPermissions->setCanView(false);

        $this->assertFalse($history->canView($editor));
    }

    /**
     * Test that draft permissions deny unrestricted live permissions
     */
    public function testRestrictedDraftUnrestrictedLive()
    {
        Versioned::set_stage(Versioned::DRAFT);

        // Should be editable by non-admin editor
        /** @var TestPermissionNode $products */
        $products = $this->objFromFixture(TestPermissionNode::class, 'products');
        /** @var TestPermissionNode $products1 */
        $products1 = $this->objFromFixture(TestPermissionNode::class, 'products-product1');
        $editor = $this->objFromFixture(Member::class, 'editor');

        // Ensure the editor can edit
        $this->assertTrue($products->canEdit($editor));
        $this->assertTrue($products1->canEdit($editor));

        // Write current version to live
        $products->writeToStage(Versioned::LIVE);
        $products1->writeToStage(Versioned::LIVE);

        // Draft version restrict to admins
        $products->EditorGroups()->setByIDList([
            $this->idFromFixture(Group::class, 'admins')
        ]);
        $products->write();

        // Ensure editor can no longer edit
        TestPermissionNode::getInheritedPermissions()->clearCache();
        $this->assertFalse($products->canEdit($editor));
        $this->assertFalse($products1->canEdit($editor));
    }

    /**
     * Test that draft permissions permit access over live permissions
     */
    public function testUnrestrictedDraftOverridesLive()
    {
        Versioned::set_stage(Versioned::DRAFT);

        // Should be editable by non-admin editor
        /** @var TestPermissionNode $about */
        $about = $this->objFromFixture(TestPermissionNode::class, 'about');
        /** @var TestPermissionNode $aboutStaff */
        $aboutStaff = $this->objFromFixture(TestPermissionNode::class, 'about-staff');
        $editor = $this->objFromFixture(Member::class, 'editor');

        // Ensure the editor can't edit
        $this->assertFalse($about->canEdit($editor));
        $this->assertFalse($aboutStaff->canEdit($editor));

        // Write current version to live
        $about->writeToStage(Versioned::LIVE);
        $aboutStaff->writeToStage(Versioned::LIVE);

        // Unrestrict draft
        $about->CanEditType = InheritedPermissions::LOGGED_IN_USERS;
        $about->write();

        // Ensure editor can no longer edit
        TestPermissionNode::getInheritedPermissions()->clearCache();
        $this->assertTrue($about->canEdit($editor));
        $this->assertTrue($aboutStaff->canEdit($editor));
    }

    /**
     * Ensure that flipping parent / child relationship on live doesn't
     * cause infinite loop
     */
    public function testMobiusHierarchy()
    {
        Versioned::set_stage(Versioned::DRAFT);

        /** @var TestPermissionNode $history */
        $history = $this->objFromFixture(TestPermissionNode::class, 'history');
        /** @var TestPermissionNode $historyGallery */
        $historyGallery = $this->objFromFixture(TestPermissionNode::class, 'history-gallery');

        // Publish current state to live
        $history->writeToStage(Versioned::LIVE);
        $historyGallery->writeToStage(Versioned::LIVE);

        // Flip relation
        $historyGallery->ParentID = 0;
        $historyGallery->write();
        $history->ParentID = $historyGallery->ID;
        $history->write();

        // Test viewability (not logged in users)
        Member::actAs(null, function () use ($history, $historyGallery) {
            $this->assertTrue($history->canView());
            $this->assertTrue($historyGallery->canView());
        });

        // Change permission on draft root and ensure it affects both
        $historyGallery->CanViewType = InheritedPermissions::LOGGED_IN_USERS;
        $historyGallery->write();
        TestPermissionNode::getInheritedPermissions()->clearCache();

        // Test viewability (not logged in users)
        Member::actAs(null, function () use ($history, $historyGallery) {
            $this->assertFalse($historyGallery->canView());
            $this->assertFalse($history->canView());
        });
    }

    public function testPermissionsPersistCache()
    {
        /* @var CacheInterface $cache */
        $cache = Injector::inst()->create(CacheInterface::class . '.InheritedPermissions');
        $cache->clear();

        $member = $this->objFromFixture(Member::class, 'editor');

        /** @var TestPermissionNode $history */
        $history = $this->objFromFixture(TestPermissionNode::class, 'history');
        /** @var TestPermissionNode $historyGallery */
        $historyGallery = $this->objFromFixture(TestPermissionNode::class, 'history-gallery');
        $permissionChecker = new InheritedPermissions(TestPermissionNode::class, $cache);

        $viewKey = $this->generateCacheKey($permissionChecker, InheritedPermissions::VIEW, $member->ID);
        $editKey = $this->generateCacheKey($permissionChecker, InheritedPermissions::EDIT, $member->ID);

        $this->assertNull($cache->get($editKey));
        $this->assertNull($cache->get($viewKey));

        $permissionChecker->canEditMultiple([$history->ID, $historyGallery->ID], $member);
        $this->assertNull($cache->get($editKey));
        $this->assertNull($cache->get($viewKey));

        unset($permissionChecker);
        $this->assertTrue(is_array($cache->get($editKey)));
        $this->assertNull($cache->get($viewKey));
        $this->assertArrayHasKey($history->ID, $cache->get($editKey));
        $this->assertArrayHasKey($historyGallery->ID, $cache->get($editKey));

        $permissionChecker = new InheritedPermissions(TestPermissionNode::class, $cache);
        $permissionChecker->canViewMultiple([$history->ID], $member);
        $this->assertNotNull($cache->get($editKey));
        $this->assertNull($cache->get($viewKey));

        unset($permissionChecker);
        $this->assertTrue(is_array($cache->get($viewKey)));
        $this->assertTrue(is_array($cache->get($editKey)));
        $this->assertArrayHasKey($history->ID, $cache->get($viewKey));
        $this->assertArrayNotHasKey($historyGallery->ID, $cache->get($viewKey));
    }

    public function testPermissionsFlushCache()
    {
        /* @var CacheInterface $cache */
        $cache = Injector::inst()->create(CacheInterface::class . '.InheritedPermissions');
        $cache->clear();

        $permissionChecker = new InheritedPermissions(TestPermissionNode::class, $cache);
        $member1 = $this->objFromFixture(Member::class, 'editor');
        $member2 = $this->objFromFixture(Member::class, 'admin');
        $editKey1 = $this->generateCacheKey($permissionChecker, InheritedPermissions::EDIT, $member1->ID);
        $editKey2 = $this->generateCacheKey($permissionChecker, InheritedPermissions::EDIT, $member2->ID);
        $viewKey1 = $this->generateCacheKey($permissionChecker, InheritedPermissions::VIEW, $member1->ID);
        $viewKey2 = $this->generateCacheKey($permissionChecker, InheritedPermissions::VIEW, $member2->ID);

        foreach ([$editKey1, $editKey2, $viewKey1, $viewKey2] as $key) {
            $this->assertNull($cache->get($key));
        }

        /** @var TestPermissionNode $history */
        $history = $this->objFromFixture(TestPermissionNode::class, 'history');
        /** @var TestPermissionNode $historyGallery */
        $historyGallery = $this->objFromFixture(TestPermissionNode::class, 'history-gallery');

        $permissionChecker->canEditMultiple([$history->ID, $historyGallery->ID], $member1);
        $permissionChecker->canViewMultiple([$history->ID, $historyGallery->ID], $member1);
        $permissionChecker->canEditMultiple([$history->ID, $historyGallery->ID], $member2);
        $permissionChecker->canViewMultiple([$history->ID, $historyGallery->ID], $member2);

        unset($permissionChecker);

        foreach ([$editKey1, $editKey2, $viewKey1, $viewKey2] as $key) {
            $this->assertNotNull($cache->get($key));
        }
        $permissionChecker = new InheritedPermissions(TestPermissionNode::class, $cache);

        // Non existent ID
        $permissionChecker->flushMemberCache('dummy');
        foreach ([$editKey1, $editKey2, $viewKey1, $viewKey2] as $key) {
            $this->assertNotNull($cache->get($key));
        }

        // Precision strike
        $permissionChecker->flushMemberCache([$member1->ID]);
        // Member1 should be clear
        $this->assertNull($cache->get($editKey1));
        $this->assertNull($cache->get($viewKey1));
        // Member 2 is unaffected
        $this->assertNotNull($cache->get($editKey2));
        $this->assertNotNull($cache->get($viewKey2));

        // Nuclear
        $permissionChecker->flushMemberCache();
        foreach ([$editKey1, $editKey2, $viewKey1, $viewKey2] as $key) {
            $this->assertNull($cache->get($key));
        }
    }

    protected function generateCacheKey(InheritedPermissions $inst, $type, $memberID)
    {
        $reflection = new ReflectionClass(InheritedPermissions::class);
        $method = $reflection->getMethod('generateCacheKey');
        $method->setAccessible(true);

        return $method->invokeArgs($inst, [$type, $memberID]);
    }
}