/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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.apache.struts2.interceptor.parameter;

import org.aopalliance.intercept.Joinpoint;
import org.aopalliance.intercept.MethodInterceptor;
import org.apache.commons.lang3.ClassUtils;
import org.apache.struts2.ActionContext;
import org.apache.struts2.ModelDriven;
import org.apache.struts2.StubValueStack;
import org.apache.struts2.dispatcher.HttpParameters;
import org.apache.struts2.dispatcher.Parameter;
import org.apache.struts2.ognl.DefaultOgnlBeanInfoCacheFactory;
import org.apache.struts2.ognl.DefaultOgnlExpressionCacheFactory;
import org.apache.struts2.ognl.OgnlUtil;
import org.apache.struts2.ognl.StrutsOgnlGuard;
import org.apache.struts2.ognl.StrutsProxyCacheFactory;
import org.apache.struts2.ognl.ThreadAllowlist;
import org.apache.struts2.util.StrutsProxyService;
import org.apache.struts2.security.AcceptedPatternsChecker.IsAccepted;
import org.apache.struts2.security.ExcludedPatternsChecker.IsExcluded;
import org.apache.struts2.security.NotExcludedAcceptedPatternsChecker;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.springframework.aop.framework.ProxyFactory;

import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import static org.apache.struts2.ognl.OgnlCacheFactory.CacheType.LRU;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

public class StrutsParameterAnnotationTest {

    private ParametersInterceptor parametersInterceptor;

    private ThreadAllowlist threadAllowlist;

    @Before
    public void setUp() throws Exception {
        parametersInterceptor = new ParametersInterceptor();
        parametersInterceptor.setRequireAnnotations(Boolean.TRUE.toString());

        threadAllowlist = new ThreadAllowlist();
        parametersInterceptor.setThreadAllowlist(threadAllowlist);

        var ognlUtil = new OgnlUtil(
                new DefaultOgnlExpressionCacheFactory<>(String.valueOf(1000), LRU.toString()),
                new DefaultOgnlBeanInfoCacheFactory<>(String.valueOf(1000), LRU.toString()),
                new StrutsOgnlGuard());
        parametersInterceptor.setOgnlUtil(ognlUtil);

        var proxyService = new StrutsProxyService(new StrutsProxyCacheFactory<>("1000", "basic"));
        parametersInterceptor.setProxyService(proxyService);

        NotExcludedAcceptedPatternsChecker checker = mock(NotExcludedAcceptedPatternsChecker.class);
        when(checker.isAccepted(anyString())).thenReturn(IsAccepted.yes(""));
        when(checker.isExcluded(anyString())).thenReturn(IsExcluded.no(Set.of()));
        parametersInterceptor.setAcceptedPatterns(checker);
        parametersInterceptor.setExcludedPatterns(checker);
    }

    @After
    public void tearDown() throws Exception {
        threadAllowlist.clearAllowlist();
        ActionContext.clear();
    }

    private void testParameter(Object action, String paramName, boolean shouldContain) {
        Map<String, String[]> requestParamMap = new HashMap<>();
        requestParamMap.put(paramName, new String[]{"value"});
        HttpParameters httpParameters = HttpParameters.create(requestParamMap).build();

        Map<String, Parameter> acceptedParameters = parametersInterceptor.toAcceptableParameters(httpParameters, action);

        if (shouldContain) {
            assertThat(acceptedParameters).containsOnlyKeys(paramName);
        } else {
            assertThat(acceptedParameters).isEmpty();
            assertThat(threadAllowlist.getAllowlist()).isEmpty();
        }
    }

    private Set<Class<?>> getParentClasses(Class<?>... clazzes) {
        Set<Class<?>> set = new HashSet<>();
        for (Class<?> clazz : clazzes) {
            set.add(clazz);
            set.addAll(ClassUtils.getAllSuperclasses(clazz));
            set.addAll(ClassUtils.getAllInterfaces(clazz));
        }
        return set;
    }

    /**
     * Private String field cannot be injected even when annotated.
     */
    @Test
    public void privateStrAnnotated() {
        testParameter(new FieldAction(), "privateStr", false);
    }

    /**
     * Public String field can be injected when annotated.
     */
    @Test
    public void publicStrAnnotated() {
        testParameter(new FieldAction(), "publicStr", true);
        assertThat(threadAllowlist.getAllowlist()).isEmpty();
    }

    /**
     * Public String field cannot be injected when not annotated.
     */
    @Test
    public void publicStrNotAnnotated() {
        testParameter(new FieldAction(), "publicStrNotAnnotated", false);
    }

    /**
     * Private Pojo field cannot be injected even when annotated with the appropriate depth.
     */
    @Test
    public void privatePojoAnnotated() {
        testParameter(new FieldAction(), "privatePojo.key", false);
    }

    /**
     * Public Pojo field cannot be injected when annotated with depth zero.
     */
    @Test
    public void publicPojoDepthZero() {
        testParameter(new FieldAction(), "publicPojoDepthZero.key", false);
    }

    /**
     * Public Pojo field can be injected when annotated with depth one.
     */
    @Test
    public void publicPojoDepthOne() {
        testParameter(new FieldAction(), "publicPojoDepthOne.key", true);
        assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(Pojo.class));
    }

    /**
     * Public Pojo field can be injected when annotated with depth one, using the square bracket syntax.
     */
    @Test
    public void publicPojoDepthOne_sqrBracket() {
        testParameter(new FieldAction(), "publicPojoDepthOne['key']", true);
        assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(Pojo.class));
    }

    /**
     * Public Pojo field can be injected when annotated with depth one, using the bracket syntax.
     */
    @Test
    public void publicPojoDepthOne_bracket() {
        testParameter(new FieldAction(), "publicPojoDepthOne('key')", true);
        assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(Pojo.class));
    }

    /**
     * Public Pojo field can be injected when annotated with a depth greater than required.
     */
    @Test
    public void publicPojoDepthTwo() {
        testParameter(new FieldAction(), "publicPojoDepthTwo.key", true);
        assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(Pojo.class));
    }

    /**
     * Public Pojo field cannot be injected two levels when only annotated with depth one.
     */
    @Test
    public void publicNestedPojoDepthOne() {
        testParameter(new FieldAction(), "publicPojoDepthOne.key.key", false);
    }

    /**
     * Public Pojo field can be injected two levels when annotated with depth two.
     */
    @Test
    public void publicNestedPojoDepthTwo() {
        testParameter(new FieldAction(), "publicPojoDepthTwo.key.key", true);
        assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(Pojo.class));
    }

    /**
     * Public Pojo field can be injected two levels when annotated with depth two, using the square bracket syntax.
     */
    @Test
    public void publicNestedPojoDepthTwo_sqrBracket() {
        testParameter(new FieldAction(), "publicPojoDepthTwo['key']['key']", true);
        assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(Pojo.class));
    }

    /**
     * Public Pojo field can be injected two levels when annotated with depth two, using the bracket syntax.
     */
    @Test
    public void publicNestedPojoDepthTwo_bracket() {
        testParameter(new FieldAction(), "publicPojoDepthTwo('key')('key')", true);
        assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(Pojo.class));
    }

    /**
     * Private String setting method cannot be injected even when annotated.
     */
    @Test
    public void privateStrAnnotatedMethod() {
        testParameter(new MethodAction(), "privateStr", false);
    }

    /**
     * Public String setting method can be injected when annotated.
     */
    @Test
    public void publicStrAnnotatedMethod() {
        testParameter(new MethodAction(), "publicStr", true);
        assertThat(threadAllowlist.getAllowlist()).isEmpty();
    }

    /**
     * Public String setting method cannot be injected when not annotated.
     */
    @Test
    public void publicStrNotAnnotatedMethod() {
        testParameter(new MethodAction(), "publicStrNotAnnotated", false);
    }

    /**
     * Private Pojo returning method cannot be injected even when annotated with the appropriate depth.
     */
    @Test
    public void privatePojoAnnotatedMethod() {
        testParameter(new MethodAction(), "privatePojo.key", false);
    }

    /**
     * Public Pojo returning method cannot be injected when annotated with depth zero.
     */
    @Test
    public void publicPojoDepthZeroMethod() {
        testParameter(new MethodAction(), "publicPojoDepthZero.key", false);
    }

    /**
     * Public Pojo returning method can be injected when annotated with depth one.
     */
    @Test
    public void publicPojoDepthOneMethod() {
        testParameter(new MethodAction(), "publicPojoDepthOne.key", true);
        assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(Pojo.class));
    }

    /**
     * Public Pojo returning method cannot be injected two levels when only annotated with depth one.
     */
    @Test
    public void publicNestedPojoDepthOneMethod() {
        testParameter(new MethodAction(), "publicPojoDepthOne.key.key", false);
    }

    /**
     * Public Pojo returning method can be injected when annotated with a depth greater than required.
     */
    @Test
    public void publicPojoDepthTwoMethod() {
        testParameter(new MethodAction(), "publicPojoDepthTwo.key", true);
        assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(Pojo.class));
    }

    /**
     * Public Pojo returning method can be injected two levels when annotated with depth two.
     */
    @Test
    public void publicNestedPojoDepthTwoMethod() {
        testParameter(new MethodAction(), "publicPojoDepthTwo.key.key", true);
        assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(Pojo.class));
    }

    /**
     * Public list of Pojo field cannot be injected when annotated with depth one.
     */
    @Test
    public void publicPojoListDepthOne() {
        testParameter(new FieldAction(), "publicPojoListDepthOne[0].key", false);
    }

    /**
     * Public list of Pojo field can be injected when annotated with depth two.
     */
    @Test
    public void publicPojoListDepthTwo() {
        testParameter(new FieldAction(), "publicPojoListDepthTwo[0].key", true);
        assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(List.class, Pojo.class));
    }

    /**
     * Public list of Pojo returning method cannot be injected when annotated with depth one.
     */
    @Test
    public void publicPojoListDepthOneMethod() {
        testParameter(new MethodAction(), "publicPojoListDepthOne[0].key", false);
    }

    /**
     * Public list of Pojo returning method can be injected when annotated with depth two.
     */
    @Test
    public void publicPojoListDepthTwoMethod() {
        testParameter(new MethodAction(), "publicPojoListDepthTwo[0].key", true);
        assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(List.class, Pojo.class));
    }

    /**
     * Public map of Pojo field can be injected when annotated with depth two.
     */
    @Test
    public void publicPojoMapDepthTwo() {
        testParameter(new FieldAction(), "publicPojoMapDepthTwo['a'].key", true);
        assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(Map.class, String.class, Pojo.class));
    }

    /**
     * Public map of Pojo returning method can be injected when annotated with depth two.
     */
    @Test
    public void publicPojoMapDepthTwoMethod() {
        testParameter(new MethodAction(), "publicPojoMapDepthTwo['a'].key", true);
        assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(Map.class, String.class, Pojo.class));
    }

    /**
     * Public String field can be injected even when not annotated, if transition mode is enabled.
     */
    @Test
    public void publicStrNotAnnotated_transitionMode() {
        parametersInterceptor.setRequireAnnotationsTransitionMode(Boolean.TRUE.toString());
        testParameter(new FieldAction(), "publicStrNotAnnotated", true);
    }

    /**
     * Public String setting method can be injected even when not annotated, if transition mode is enabled.
     */
    @Test
    public void publicStrNotAnnotatedMethod_transitionMode() {
        parametersInterceptor.setRequireAnnotationsTransitionMode(Boolean.TRUE.toString());
        testParameter(new MethodAction(), "publicStrNotAnnotated", true);
    }

    /**
     * Models of ModelDriven actions can be injected without any annotations on the Action.
     */
    @Test
    public void publicModelPojo() {
        var action = new ModelAction();

        // Emulate ModelDrivenInterceptor running previously
        var valueStack = new StubValueStack();
        valueStack.push(action.getModel());
        ActionContext.of().withValueStack(valueStack).bind();

        testParameter(action, "name", true);
        testParameter(action, "name.nested", true);
    }

    /**
     * Models of ModelDriven actions can be injected without any annotations on the Action, even when the Action is
     * proxied.
     */
    @Test
    public void publicModelPojo_proxied() {
        var proxyFactory = new ProxyFactory(new ModelAction());
        proxyFactory.setProxyTargetClass(true);
        proxyFactory.addAdvice((MethodInterceptor) Joinpoint::proceed);
        var proxiedAction = (ModelAction) proxyFactory.getProxy();

        // Emulate ModelDrivenInterceptor running previously
        var valueStack = new StubValueStack();
        valueStack.push(proxiedAction.getModel());
        ActionContext.of().withValueStack(valueStack).bind();

        testParameter(proxiedAction, "name", true);
        testParameter(proxiedAction, "name.nested", true);
    }

    public static class FieldAction {
        @StrutsParameter
        private String privateStr;

        @StrutsParameter
        public String publicStr;

        public String publicStrNotAnnotated;

        @StrutsParameter(depth = 1)
        private Pojo privatePojo;

        @StrutsParameter
        public Pojo publicPojoDepthZero;

        @StrutsParameter(depth = 1)
        public Pojo publicPojoDepthOne;

        @StrutsParameter(depth = 2)
        public Pojo publicPojoDepthTwo;

        @StrutsParameter(depth = 1)
        public List<Pojo> publicPojoListDepthOne;

        @StrutsParameter(depth = 2)
        public List<Pojo> publicPojoListDepthTwo;

        @StrutsParameter(depth = 2)
        public Map<String, Pojo> publicPojoMapDepthTwo;
    }

    public static class MethodAction {

        @StrutsParameter
        private void setPrivateStr(String str) {
        }

        @StrutsParameter
        public void setPublicStr(String str) {
        }

        public void setPublicStrNotAnnotated(String str) {
        }

        @StrutsParameter(depth = 1)
        private Pojo getPrivatePojo() {
            return null;
        }

        @StrutsParameter
        public Pojo getPublicPojoDepthZero() {
            return null;
        }

        @StrutsParameter
        public void setPublicPojoDepthZero() {
        }

        @StrutsParameter(depth = 1)
        public Pojo getPublicPojoDepthOne() {
            return null;
        }

        @StrutsParameter(depth = 2)
        public Pojo getPublicPojoDepthTwo() {
            return null;
        }

        @StrutsParameter(depth = 1)
        public List<Pojo> getPublicPojoListDepthOne() {
            return null;
        }

        @StrutsParameter(depth = 2)
        public List<Pojo> getPublicPojoListDepthTwo() {
            return null;
        }

        @StrutsParameter(depth = 2)
        public Map<String, Pojo> getPublicPojoMapDepthTwo() {
            return null;
        }
    }

    public static class ModelAction implements ModelDriven<Pojo> {

        @Override
        public Pojo getModel() {
            return new Pojo();
        }
    }

    public static class Pojo {
    }
}
