/*
 * Copyright 2016 Red Hat, Inc. and/or its affiliates
 * and other contributors as indicated by the @author tags.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.keycloak.testsuite.admin;

import org.apache.commons.lang3.RandomStringUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.concurrent.BasicThreadFactory;
import org.hamcrest.Matchers;
import org.junit.Before;
import org.junit.Test;
import org.keycloak.admin.client.resource.ComponentResource;
import org.keycloak.admin.client.resource.ComponentsResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.representations.idm.*;
import org.keycloak.testsuite.components.TestProvider;

import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.not;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;

/**
 * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
 */
public class ComponentsTest extends AbstractAdminTest {

    private ComponentsResource components;

    @Before
    public void before() throws Exception {
        components = adminClient.realm(REALM_NAME).components();
    }

    private volatile CountDownLatch remainingDeleteSubmissions;

    private static final int NUMBER_OF_THREADS = 4;
    private static final int NUMBER_OF_TASKS = NUMBER_OF_THREADS * 5;
    private static final int NUMBER_OF_CHILDREN = 3;

    private void testConcurrency(BiConsumer<ExecutorService, Integer> taskCreator) throws InterruptedException {
        ExecutorService s = Executors.newFixedThreadPool(NUMBER_OF_THREADS,
          new BasicThreadFactory.Builder().daemon(true).uncaughtExceptionHandler((t, e) -> log.error(e.getMessage(), e)).build());
        this.remainingDeleteSubmissions = new CountDownLatch(NUMBER_OF_TASKS);

        for (int i = 0; i < NUMBER_OF_TASKS; i++) {
            taskCreator.accept(s, i);
        }

        try {
            assertTrue("Did not create all components in time", this.remainingDeleteSubmissions.await(100, TimeUnit.SECONDS));
            s.shutdown();
            assertTrue("Did not finish before timeout", s.awaitTermination(100, TimeUnit.SECONDS));
        } finally {
            s.shutdownNow();
        }
    }

    @Test
    public void testConcurrencyWithoutChildren() throws InterruptedException {
        testConcurrency((s, i) -> s.submit(new CreateAndDeleteComponent(s, i)));

//        Data consistency is not guaranteed with concurrent access to entities in map store.
//        For details see https://issues.redhat.com/browse/KEYCLOAK-17586
//        The reason that this test remains here is to test whether it finishes in time (we need to test whether there is no slowness).
//        assertThat(realm.components().query(realm.toRepresentation().getId(), TestProvider.class.getName()), Matchers.hasSize(0));
    }

    @Test
    public void testConcurrencyWithChildren() throws InterruptedException {
        testConcurrency((s, i) -> s.submit(new CreateAndDeleteComponentWithFlatChildren(s, i)));

//        Data consistency is not guaranteed with concurrent access to entities in map store.
//        For details see https://issues.redhat.com/browse/KEYCLOAK-17586
//        The reason that this test remains here is to test whether it finishes in time (we need to test whether there is no slowness).
//        assertThat(realm.components().query(realm.toRepresentation().getId(), TestProvider.class.getName()), Matchers.hasSize(0));
    }

    @Test
    public void testNotDeadlocked() {
        for (int i = 0; i < 50; i++) {
            ComponentRepresentation rep = createComponentRepresentation("test-" + i);
            rep.getConfig().putSingle("required", "required-value");
            createComponent(rep);

            List<ComponentRepresentation> list = realm.components().query(realmId, TestProvider.class.getName());
            assertEquals(i + 1, list.size());
        }
    }

    @Test
    public void testCreateValidation() {
        ComponentRepresentation rep = createComponentRepresentation("mycomponent");

        // Check validation is invoked
        try {
            createComponent(rep);
        } catch (WebApplicationException e) {
            assertError(e.getResponse(), "'Required' is required");
        }

        rep.getConfig().putSingle("required", "Required");
        rep.getConfig().putSingle("number", "invalid");

        // Check validation is invoked
        try {
            createComponent(rep);
        } catch (WebApplicationException e) {
            assertError(e.getResponse(), "'Number' should be a number");
        }
    }

    @Test
    public void testCreateEmptyValues() {
        ComponentRepresentation rep = createComponentRepresentation("mycomponent");

        rep.getConfig().addFirst("required", "foo");
        rep.getConfig().addFirst("val1", "");
        rep.getConfig().put("val2", null);
        rep.getConfig().put("val3", Collections.emptyList());

        String id = createComponent(rep);
        ComponentRepresentation returned = components.component(id).toRepresentation();
        assertEquals( "foo", returned.getSubType());

        assertEquals(1, returned.getConfig().size());
        assertTrue(returned.getConfig().containsKey("required"));
    }

    @Test
    public void testCreateWithoutGivenId() {
        ComponentRepresentation rep = createComponentRepresentation("mycomponent");
        rep.getConfig().addFirst("required", "foo");
        rep.setId(null);

        String id = createComponent(rep);
        assertNotNull(id);
    }

    @Test
    public void testCreateWithGivenId() {
        ComponentRepresentation rep = createComponentRepresentation("mycomponent");
        rep.getConfig().addFirst("required", "foo");
        String componentId = KeycloakModelUtils.generateId();
        rep.setId(componentId);

        String id = createComponent(rep);
        assertEquals(componentId, id);
    }

    @Test
    public void failCreateWithLongName() {
        StringBuilder name = new StringBuilder();

        while (name.length() < 30) {
            name.append("invalid");
        }

        ComponentRepresentation rep = createComponentRepresentation(name.toString());

        rep.getConfig().addFirst("required", "foo");

        ComponentsResource components = realm.components();

        try (Response response = components.add(rep)) {
            if (Response.Status.INTERNAL_SERVER_ERROR.getStatusCode() == response.getStatus()) {
                // using database should fail due to constraint violations
                assertFalse(components.query().stream().map(ComponentRepresentation::getName).anyMatch(name.toString()::equals));
            } else if (Response.Status.CREATED.getStatusCode() == response.getStatus()) {
                // using the map storage should work because there are no constraints
                String id = ApiUtil.getCreatedId(response);
                assertNotNull(components.component(id).toRepresentation());
            } else {
                fail("Unexpected response");
            }
        }
    }

    @Test
    public void testUpdate() {
        ComponentRepresentation rep = createComponentRepresentation("mycomponent");

        rep.getConfig().addFirst("required", "foo");
        rep.getConfig().addFirst("val1", "one");
        rep.getConfig().addFirst("val2", "two");
        rep.getConfig().addFirst("val3", "three");

        String id = createComponent(rep);
        ComponentRepresentation returned = components.component(id).toRepresentation();
        assertEquals(4, returned.getConfig().size());
        assertEquals("foo", returned.getConfig().getFirst("required"));
        assertEquals("one", returned.getConfig().getFirst("val1"));
        assertEquals("two", returned.getConfig().getFirst("val2"));
        assertEquals("three", returned.getConfig().getFirst("val3"));

        // Check value updated
        returned.getConfig().putSingle("val1", "one-updated");

        // send a key / value which not contained in the original component config
        returned.getConfig().putSingle("not-a-config-key", "ten");

        // Check null deletes property
        returned.getConfig().putSingle("val2", null);

        components.component(id).update(returned);

        returned = components.component(id).toRepresentation();
        assertEquals(3, returned.getConfig().size());
        assertEquals("one-updated", returned.getConfig().getFirst("val1"));
        assertFalse(returned.getConfig().containsKey("val2"));

        // Check empty string is deleted
        returned.getConfig().addFirst("val1", "");

        components.component(id).update(returned);

        returned = components.component(id).toRepresentation();
        assertEquals(2, returned.getConfig().size());

        // Check empty list removes property
        returned.getConfig().put("val3", Collections.emptyList());

        components.component(id).update(returned);

        returned = components.component(id).toRepresentation();
        assertEquals(1, returned.getConfig().size());
    }


    @Test
    public void testRename() {
        ComponentRepresentation rep = createComponentRepresentation("mycomponent");
        rep.getConfig().addFirst("required", "foo");

        String id = createComponent(rep);
        ComponentRepresentation returned = components.component(id).toRepresentation();
        assertEquals("mycomponent", returned.getName());

        rep.setName("myupdatedcomponent");

        components.component(id).update(rep);

        returned = components.component(id).toRepresentation();
        assertEquals("myupdatedcomponent", returned.getName());
    }

    @Test
    public void testSecretConfig() throws Exception {
        ComponentRepresentation rep = createComponentRepresentation("mycomponent");
        rep.getConfig().addFirst("secret", "some secret value!!");
        rep.getConfig().addFirst("required", "some required value");

        String id = createComponent(rep);

        // Check secret value is not returned
        ComponentRepresentation returned = components.component(id).toRepresentation();
        assertEquals(ComponentRepresentation.SECRET_VALUE, returned.getConfig().getFirst("secret"));

        // Check secret not leaked in admin events
        AdminEventRepresentation event = testingClient.testing().pollAdminEvent();
        assertFalse(event.getRepresentation().contains("some secret value!!"));
        assertTrue(event.getRepresentation().contains(ComponentRepresentation.SECRET_VALUE));

        Map<String, TestProvider.DetailsRepresentation> details = testingClient.testing(REALM_NAME).getTestComponentDetails();

        // Check value is set correctly
        assertEquals("some secret value!!", details.get("mycomponent").getConfig().get("secret").get(0));

        returned.getConfig().putSingle("priority", "200");
        components.component(id).update(returned);

        ComponentRepresentation returned2 = components.component(id).toRepresentation();
        assertEquals(ComponentRepresentation.SECRET_VALUE, returned2.getConfig().getFirst("secret"));

        // Check secret not leaked in admin events
        event = testingClient.testing().pollAdminEvent();
        assertThat(event.getRepresentation(), not(containsString("some secret value!!")));
        assertThat(event.getRepresentation(), containsString(ComponentRepresentation.SECRET_VALUE));

        // Check secret value is not set to '*********'
        details = testingClient.testing(REALM_NAME).getTestComponentDetails();
        assertEquals("some secret value!!", details.get("mycomponent").getConfig().get("secret").get(0));

        returned2.getConfig().putSingle("secret", "updated secret value!!");
        components.component(id).update(returned2);

        // Check secret value is updated
        details = testingClient.testing(REALM_NAME).getTestComponentDetails();
        assertEquals("updated secret value!!", details.get("mycomponent").getConfig().get("secret").get(0));

        ComponentRepresentation returned3 = components.query().stream().filter(c -> c.getId().equals(returned2.getId())).findFirst().get();
        assertEquals(ComponentRepresentation.SECRET_VALUE, returned3.getConfig().getFirst("secret"));


        returned2.getConfig().putSingle("secret", "${vault.value}");
        components.component(id).update(returned2);

        // Check secret value is updated
        details = testingClient.testing(REALM_NAME).getTestComponentDetails();
        assertThat(details.get("mycomponent").getConfig().get("secret"), contains("${vault.value}"));

        ComponentRepresentation returned4 = components.query().stream().filter(c -> c.getId().equals(returned2.getId())).findFirst().get();
        assertThat(returned4.getConfig().get("secret"), contains("${vault.value}"));
    }

    @Test
    public void testCreateLongValue() {
        ComponentRepresentation rep = createComponentRepresentation("mycomponent");

        final String randomLongString = RandomStringUtils.random(5000, true, true);

        rep.getConfig().putSingle("required", "Required");
        rep.getConfig().putSingle("val1", randomLongString);

        String id = createComponent(rep);
        ComponentRepresentation returned = components.component(id).toRepresentation();

        assertThat(returned.getConfig().size(), equalTo(2));
        assertNotNull(returned.getConfig().getFirst("val1"));
        assertThat(returned.getConfig().getFirst("val1"), equalTo(randomLongString));
    }

    @Test
    public void testLongValueInComponentConfigAscii() throws Exception {
        ComponentRepresentation rep = createComponentRepresentation("mycomponent");
        String value = StringUtils.repeat("0123456789", 400);  // 4000 8-bit characters

        rep.getConfig().addFirst("required", "foo");
        rep.getConfig().addFirst("val1", value);

        String id = createComponent(rep);

        ComponentRepresentation returned = components.component(id).toRepresentation();
        assertEquals(value, returned.getConfig().getFirst("val1"));
    }

    @Test
    public void testLongValueInComponentConfigExtLatin() throws Exception {
        ComponentRepresentation rep = createComponentRepresentation("mycomponent");
        String value = StringUtils.repeat("ěščřžýíŮÍÁ", 400);  // 4000 Unicode extended-Latin characters

        rep.getConfig().addFirst("required", "foo");
        rep.getConfig().addFirst("val1", value);

        String id = createComponent(rep);

        ComponentRepresentation returned = components.component(id).toRepresentation();
        assertEquals(value, returned.getConfig().getFirst("val1"));
    }

    private java.lang.String createComponent(ComponentRepresentation rep) {
        return createComponent(realm, rep);
    }

    private String createComponent(RealmResource realm, ComponentRepresentation rep) {
        Response response = null;
        try {
            ComponentsResource components = realm.components();
            response = components.add(rep);
            String id = ApiUtil.getCreatedId(response);
            getCleanup(realm.toRepresentation().getRealm()).addComponentId(id);
            return id;
        } finally {
            if (response != null) {
                response.bufferEntity();
                response.close();
            }
        }
    }

    private void assertError(Response response, String error) {
        if (!response.hasEntity()) {
            fail("No error message set");
        }

        ErrorRepresentation errorRepresentation = response.readEntity(ErrorRepresentation.class);
        assertEquals(error, errorRepresentation.getErrorMessage());
    }

    private ComponentRepresentation createComponentRepresentation(String name) {
        ComponentRepresentation rep = new ComponentRepresentation();
        rep.setName(name);
        rep.setParentId(realmId);
        rep.setProviderId("test");
        rep.setProviderType(TestProvider.class.getName());
        rep.setSubType("foo");

        MultivaluedHashMap<String, String> config = new MultivaluedHashMap<>();
        rep.setConfig(config);
        return rep;
    }

    private class CreateComponent implements Runnable {

        protected final ExecutorService s;
        protected final int i;
        protected final RealmResource realm;

        public CreateComponent(ExecutorService s, int i, RealmResource realm) {
            this.s = s;
            this.i = i;
            this.realm = realm;
        }

        public CreateComponent(ExecutorService s, int i) {
            this(s, i, ComponentsTest.this.realm);
        }

        @Override
        public void run() {
            log.debugf("Started for i=%d ", i);
            ComponentRepresentation rep = createComponentRepresentation("test-" + i);
            rep.getConfig().putSingle("required", "required-value");
            rep.setParentId(this.realm.toRepresentation().getId());

            String id = createComponent(this.realm, rep);
            assertThat(id, Matchers.notNullValue());

            createChildren(id);

            log.debugf("Finished: i=%d, id=%s", i, id);

            scheduleDeleteComponent(id);
            remainingDeleteSubmissions.countDown();
        }

        protected void scheduleDeleteComponent(String id) {
        }

        protected void createChildren(String id) {
        }
    }

    private class CreateAndDeleteComponent extends CreateComponent {

        public CreateAndDeleteComponent(ExecutorService s, int i) {
            super(s, i);
        }

        @Override
        protected void scheduleDeleteComponent(String id) {
            s.submit(new DeleteComponent(id));
        }
    }

    private class CreateComponentWithFlatChildren extends CreateComponent {

        public CreateComponentWithFlatChildren(ExecutorService s, int i, RealmResource realm) {
            super(s, i, realm);
        }

        public CreateComponentWithFlatChildren(ExecutorService s, int i) {
            super(s, i);
        }

        @Override
        protected void createChildren(String id) {
            for (int j = 0; j < NUMBER_OF_CHILDREN; j ++) {
                ComponentRepresentation rep = createComponentRepresentation("test-" + i + ":" + j);
                rep.setParentId(id);
                rep.getConfig().putSingle("required", "required-value");

                assertThat(createComponent(this.realm, rep), Matchers.notNullValue());
            }
        }

    }

    private class CreateAndDeleteComponentWithFlatChildren extends CreateAndDeleteComponent {

        public CreateAndDeleteComponentWithFlatChildren(ExecutorService s, int i) {
            super(s, i);
        }

        @Override
        protected void createChildren(String id) {
            for (int j = 0; j < NUMBER_OF_CHILDREN; j ++) {
                ComponentRepresentation rep = createComponentRepresentation("test-" + i + ":" + j);
                rep.setParentId(id);
                rep.getConfig().putSingle("required", "required-value");

                assertThat(createComponent(this.realm, rep), Matchers.notNullValue());
            }
        }

    }

    private class DeleteComponent implements Runnable {

        private final String id;

        public DeleteComponent(String id) {
            this.id = id;
        }

        @Override
        public void run() {
            log.debugf("Started, id=%s", id);

            ComponentResource c = realm.components().component(id);
            assertThat(c.toRepresentation(), Matchers.notNullValue());
            c.remove();

            log.debugf("Finished, id=%s", id);
        }
    }

}
