/*
 * 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.json;

import org.apache.struts2.ActionContext;
import org.apache.struts2.mock.MockActionInvocation;
import org.apache.struts2.util.ValueStack;
import org.apache.struts2.junit.StrutsTestCase;
import org.apache.struts2.junit.util.TestUtils;
import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.mock.web.MockServletContext;

import java.util.Calendar;
import java.util.List;
import java.util.Map;

public class JSONInterceptorTest extends StrutsTestCase {
    private MockActionInvocationEx invocation;
    private MockHttpServletRequest request;
    private MockHttpServletResponse response;

    private void setRequestContent(String fileName) throws Exception {
        String content = TestUtils.readContent(JSONInterceptorTest.class.getResource(fileName));
        this.request.setContent(content.getBytes());
    }

    private JSONInterceptor createInterceptor() {
        JSONInterceptor interceptor = new JSONInterceptor();
        interceptor.setContainer(container);
        // Default: allow all parameters (simulates requireAnnotations=false)
        interceptor.setParameterAuthorizer((parameterName, target, action) -> true);
        interceptor.setExcludedPatterns(new org.apache.struts2.security.DefaultExcludedPatternsChecker());
        interceptor.setAcceptedPatterns(new org.apache.struts2.security.DefaultAcceptedPatternsChecker());
        return interceptor;
    }

    public void testBadJSON1() throws Exception {
        tryBadJSON("bad-1.txt");
    }

    public void testBadJSON2() throws Exception {
        tryBadJSON("bad-2.txt");
    }

    public void testBadJSON3() throws Exception {
        tryBadJSON("bad-3.txt");
    }

    public void testBadJSON4() throws Exception {
        tryBadJSON("bad-4.txt");
    }

    public void testBadJSON5() throws Exception {
        tryBadJSON("bad-5.txt");
    }

    public void testBadToTheBoneJSON4() throws Exception {
        tryBadJSON("bad-to-the-bone.txt");
    }

    private void tryBadJSON(String fileName) throws Exception {
        // request
        setRequestContent(fileName);
        this.request.addHeader("Content-Type", "application/json; charset=UTF-8");

        JSONInterceptor interceptor = createInterceptor();
        interceptor.setEnableSMD(true);
        SMDActionTest1 action = new SMDActionTest1();

        this.invocation.setAction(action);

        // JSON is not well formed, throw exception
        try {
            interceptor.intercept(this.invocation);
            fail("Should have thrown an exception");
        } catch (JSONException e) {
            // I can't get JUnit to ignore the exception
            // @Test(expected = JSONException.class)
        }
    }

    public void testSMDDisabledSMD() throws Exception {
        // request
        setRequestContent("smd-3.txt");
        this.request.addHeader("Content-Type", "application/json-rpc");

        JSONInterceptor interceptor = createInterceptor();
        SMDActionTest1 action = new SMDActionTest1();

        this.invocation.setAction(action);

        // SMD was not enabled so invocation must happen
        try {
            interceptor.intercept(this.invocation);
        } catch (JSONException e) {
            fail("Should have not thrown an exception");
        }

    }

    public void testSMDAliasedMethodCall1() throws Exception {
        // request
        setRequestContent("smd-14.txt");
        this.request.addHeader("Content-Type", "application/json-rpc");

        JSONInterceptor interceptor = createInterceptor();
        interceptor.setEnableSMD(true);
        SMDActionTest2 action = new SMDActionTest2();

        this.invocation.setAction(action);

        interceptor.intercept(this.invocation);
        // method was aliased, but was invoked with the regular name
        // so method must not be invoked
        assertFalse(this.invocation.isInvoked());
        assertFalse(action.isDoSomethingInvoked());
    }

    public void testSMDAliasedMethodCall2() throws Exception {
        // request
        setRequestContent("smd-15.txt");
        this.request.addHeader("Content-Type", "application/json-rpc");

        JSONInterceptor interceptor = createInterceptor();
        interceptor.setEnableSMD(true);
        SMDActionTest2 action = new SMDActionTest2();

        this.invocation.setAction(action);

        interceptor.intercept(this.invocation);
        // method was aliased, but was invoked with the aliased name
        // so method must be invoked
        assertFalse(this.invocation.isInvoked());
        assertTrue(action.isDoSomethingInvoked());
    }

    public void testSMDNoMethod() throws Exception {
        // request
        setRequestContent("smd-4.txt");
        this.request.addHeader("Content-Type", "application/json-rpc");

        JSONInterceptor interceptor = createInterceptor();
        interceptor.setEnableSMD(true);
        SMDActionTest1 action = new SMDActionTest1();

        this.invocation.setAction(action);

        // SMD was enabled so invocation must happen

        interceptor.intercept(this.invocation);

        String json = response.getContentAsString();

        String normalizedActual = TestUtils.normalize(json, true);
        String normalizedExpected = TestUtils.normalize(JSONResultTest.class.getResource("smd-13.txt"));
        assertEquals(normalizedExpected, normalizedActual);

        assertFalse(this.invocation.isInvoked());
    }

    public void testSMDMethodWithoutAnnotations() throws Exception {
        // request
        setRequestContent("smd-9.txt");
        this.request.addHeader("Content-Type", "application/json-rpc");

        JSONInterceptor interceptor = createInterceptor();
        interceptor.setEnableSMD(true);
        SMDActionTest1 action = new SMDActionTest1();

        this.invocation.setAction(action);

        // SMD was enabled so invocation must happen
        try {
            interceptor.intercept(this.invocation);
            assertTrue("Exception was expected here!", true);
        } catch (Exception e) {
            // ok
        }
        assertFalse(this.invocation.isInvoked());
    }

    public void testSMDPrimitivesNoResult() throws Exception {
        // request
        setRequestContent("smd-6.txt");
        this.request.addHeader("Content-Type", "application/json-rpc");

        JSONInterceptor interceptor = createInterceptor();
        interceptor.setEnableSMD(true);
        SMDActionTest1 action = new SMDActionTest1();

        this.invocation.setAction(action);

        // can't be invoked
        interceptor.intercept(this.invocation);
        assertFalse(this.invocation.isInvoked());

        // asert values were passed properly
        assertEquals("string", action.getStringParam());
        assertEquals(1, action.getIntParam());
        assertEquals(true, action.isBooleanParam());
        assertEquals('c', action.getCharParam());
        assertEquals(2, action.getLongParam());
        assertEquals(new Float(3.3), action.getFloatParam());
        assertEquals(4.4, action.getDoubleParam());
        assertEquals(5, action.getShortParam());
        assertEquals(6, action.getByteParam());

        String json = response.getContentAsString();

        String normalizedActual = TestUtils.normalize(json, true);
        String normalizedExpected = TestUtils.normalize(JSONResultTest.class.getResource("smd-11.txt"));
        assertEquals(normalizedExpected, normalizedActual);

        assertEquals("application/json;charset=UTF-8", response.getContentType());
    }

    public void testSMDReturnObject() throws Exception {
        // request
        setRequestContent("smd-10.txt");
        this.request.addHeader("Content-Type", "application/json-rpc");

        JSONInterceptor interceptor = createInterceptor();
        interceptor.setEnableSMD(true);
        SMDActionTest2 action = new SMDActionTest2();

        this.invocation.setAction(action);

        // can't be invoked
        interceptor.intercept(this.invocation);
        assertFalse(this.invocation.isInvoked());

        String json = response.getContentAsString();

        String normalizedActual = TestUtils.normalize(json, true);
        String normalizedExpected = TestUtils.normalize(JSONResultTest.class.getResource("smd-12.txt"));
        assertEquals(normalizedExpected, normalizedActual);

        assertEquals("application/json;charset=UTF-8", response.getContentType());
    }

    @SuppressWarnings("unchecked")
    public void testSMDObjectsNoResult() throws Exception {
        // request
        setRequestContent("smd-7.txt");
        this.request.addHeader("Content-Type", "application/json-rpc");

        JSONInterceptor interceptor = createInterceptor();
        interceptor.setEnableSMD(true);
        SMDActionTest1 action = new SMDActionTest1();

        this.invocation.setAction(action);

        // can't be invoked
        interceptor.intercept(this.invocation);
        assertFalse(this.invocation.isInvoked());

        // asert values were passed properly
        Bean bean = action.getBeanParam();
        assertNotNull(bean);
        assertTrue(bean.isBooleanField());
        assertEquals(bean.getStringField(), "test");
        assertEquals(bean.getIntField(), 10);
        assertEquals(bean.getCharField(), 's');
        assertEquals(bean.getDoubleField(), 10.1);
        assertEquals(bean.getByteField(), 3);

        List list = action.getListParam();
        assertNotNull(list);
        assertEquals("str0", list.get(0));
        assertEquals("str1", list.get(1));

        Map map = action.getMapParam();
        assertNotNull(map);
        assertNotNull(map.get("a"));
        assertEquals(new Long(1), map.get("a"));
        assertNotNull(map.get("c"));
        List insideList = (List) map.get("c");
        assertEquals(1.0d, insideList.get(0));
        assertEquals(2.0d, insideList.get(1));

        String json = response.getContentAsString();
        String normalizedActual = TestUtils.normalize(json, true);
        String normalizedExpected = TestUtils.normalize(JSONResultTest.class.getResource("smd-11.txt"));
        assertEquals(normalizedExpected, normalizedActual);

        assertEquals("application/json;charset=UTF-8", response.getContentType());
    }

    @SuppressWarnings({"unchecked", "unchecked"})
    public void testReadEmpty() throws Exception {
        // request
        setRequestContent("json-6.txt");
        this.request.addHeader("Content-Type", "application/json");

        // interceptor
        JSONInterceptor interceptor = createInterceptor();
        TestAction action = new TestAction();

        this.invocation.setAction(action);

        interceptor.intercept(this.invocation);
    }

    @SuppressWarnings({"unchecked", "unchecked"})
    public void test() throws Exception {
        // request
        setRequestContent("json-1.txt");
        this.request.addHeader("Content-Type", "application/json");

        // interceptor
        JSONInterceptor interceptor = createInterceptor();
        TestAction action = new TestAction();

        this.invocation.setAction(action);
        this.invocation.getStack().push(action);

        interceptor.intercept(this.invocation);

        // serialize and compare
        List list = action.getList();

        assertNotNull(list);
        assertEquals(list.size(), 10);

        list = action.getCollection();
        assertNotNull(list);
        assertEquals(list.size(), 3);
        assertEquals(list.get(0), "b");
        assertEquals(list.get(1), 1L);
        list = (List) list.get(2);
        assertNotNull(list);
        assertEquals(list.size(), 2);
        assertEquals(list.get(0), 10L);
        assertEquals(list.get(1), 12L);

        list = action.getCollection2();
        assertNotNull(list);
        assertEquals(list.size(), 1);

        // inside a map any primitive is either: String, Long, Boolean or Double
        Map bean = (Map) list.get(0);

        assertNotNull(bean);
        assertTrue((Boolean) bean.get("booleanField"));
        assertEquals(bean.get("charField"), "s");
        assertEquals(bean.get("doubleField"), 10.1);
        assertEquals(bean.get("floatField"), 1.5);
        assertEquals(bean.get("intField"), 10L);
        assertEquals(bean.get("longField"), 100L);
        assertEquals(bean.get("stringField"), "str");

        bean = (Map) bean.get("objectField");
        assertNotNull(bean);
        assertFalse((Boolean) bean.get("booleanField"));
        assertEquals(bean.get("charField"), "\u0000");
        assertEquals(bean.get("doubleField"), 2.2);
        assertEquals(bean.get("floatField"), 1.1);
        assertEquals(bean.get("intField"), 0L);
        assertEquals(bean.get("longField"), 0L);
        assertEquals(bean.get("stringField"), "  ");

        assertEquals(action.getFoo(), "foo");

        Map map = action.getMap();

        assertNotNull(map);
        assertEquals(map.size(), 2);
        assertEquals(map.get("a"), 1L);
        list = (List) map.get("c");
        assertNotNull(list);
        assertEquals(list.size(), 2);
        assertEquals(list.get(0), 1.0);
        assertEquals(list.get(1), 2.0);

        assertEquals(action.getResult(), null);

        Bean bean2 = action.getBean();

        assertNotNull(bean2);
        assertTrue(bean2.isBooleanField());
        assertEquals(bean2.getStringField(), "test");
        assertEquals(bean2.getIntField(), 10);
        assertEquals(bean2.getCharField(), 's');
        assertEquals(bean2.getDoubleField(), 10.1);
        assertEquals(bean2.getByteField(), 3);

        String[] strArray = action.getArray();

        assertNotNull(strArray);
        assertEquals(strArray.length, 2);
        assertEquals(strArray[0], "str0");
        assertEquals(strArray[1], "str1");

        int[] intArray = action.getIntArray();

        assertNotNull(intArray);
        assertEquals(intArray.length, 2);
        assertEquals(intArray[0], 1);
        assertEquals(intArray[1], 2);

        Bean[] beanArray = action.getBeanArray();

        assertNotNull(beanArray);
        assertNotNull(beanArray[0]);
        assertEquals(beanArray[0].getStringField(), "bean1");
        assertNotNull(beanArray[1]);
        assertEquals(beanArray[1].getStringField(), "bean2");

        Calendar calendar = Calendar.getInstance();
        calendar.setTime(action.getDate());

        assertEquals(calendar.get(Calendar.YEAR), 1999);
        assertEquals(calendar.get(Calendar.MONTH), Calendar.DECEMBER);
        assertEquals(calendar.get(Calendar.DAY_OF_MONTH), 31);
        assertEquals(calendar.get(Calendar.HOUR), 11);
        assertEquals(calendar.get(Calendar.MINUTE), 59);
        assertEquals(calendar.get(Calendar.SECOND), 59);

        calendar.setTime(action.getDate2());
        assertEquals(calendar.get(Calendar.YEAR), 1999);
        assertEquals(calendar.get(Calendar.MONTH), Calendar.DECEMBER);
        assertEquals(calendar.get(Calendar.DAY_OF_MONTH), 31);

        // test desrialize=false
        assertNull(action.getFoo2());
    }

    public void testRoot() throws Exception {
        setRequestContent("json-5.txt");
        this.request.addHeader("Content-Type", "application/json");

        // interceptor
        JSONInterceptor interceptor = createInterceptor();
        interceptor.setRoot("bean");
        TestAction4 action = new TestAction4();

        this.invocation.setAction(action);
        this.invocation.getStack().push(action);

        interceptor.intercept(this.invocation);

        Bean bean2 = action.getBean();

        assertNotNull(bean2);
        assertTrue(bean2.isBooleanField());
        assertEquals(bean2.getStringField(), "test");
        assertEquals(bean2.getIntField(), 10);
        assertEquals(bean2.getCharField(), 's');
        assertEquals(bean2.getDoubleField(), 10.1);
        assertEquals(bean2.getByteField(), 3);
    }

    public void testJSONArray() throws Exception {
        setRequestContent("json-12.txt");
        this.request.addHeader("Content-Type", "application/json");

        // interceptor
        JSONInterceptor interceptor = createInterceptor();
        interceptor.setRoot("beans");
        TestAction5 action = new TestAction5();

        this.invocation.setAction(action);
        this.invocation.getStack().push(action);

        interceptor.intercept(this.invocation);

        List<Bean> beans = action.getBeans();

        assertNotNull(beans);
        assertEquals(1, beans.size());
        assertTrue(beans.get(0).isBooleanField());
        assertEquals(beans.get(0).getStringField(), "test");
        assertEquals(beans.get(0).getIntField(), 10);
        assertEquals(beans.get(0).getCharField(), 's');
        assertEquals(beans.get(0).getDoubleField(), 10.1);
        assertEquals(beans.get(0).getByteField(), 3);
    }

    public void testJSONArray2() throws Exception {
        setRequestContent("json-12.txt");
        this.request.addHeader("Content-Type", "application/json");

        // interceptor
        JSONInterceptor interceptor = createInterceptor();
        interceptor.setRoot("anotherBean.yetAnotherBean.beans");
        TestAction5 action = new TestAction5();

        this.invocation.setAction(action);
        this.invocation.getStack().push(action);

        interceptor.intercept(this.invocation);

        List<Bean> beans = action.getAnotherBean().getYetAnotherBean().getBeans();

        assertNotNull(beans);
        assertEquals(1, beans.size());
        assertTrue(beans.get(0).isBooleanField());
        assertEquals(beans.get(0).getStringField(), "test");
        assertEquals(beans.get(0).getIntField(), 10);
        assertEquals(beans.get(0).getCharField(), 's');
        assertEquals(beans.get(0).getDoubleField(), 10.1);
        assertEquals(beans.get(0).getByteField(), 3);
    }

    public void testMaxLengthExceededThrows() throws Exception {
        // Body is 27 bytes, set maxLength to 10
        this.request.setContent("{\"a\":1, \"b\":2, \"c\":\"hello\"}".getBytes());
        this.request.addHeader("Content-Type", "application/json");

        JSONInterceptor interceptor = createInterceptor();
        interceptor.setMaxLength("10");
        TestAction action = new TestAction();

        this.invocation.setAction(action);

        try {
            interceptor.intercept(this.invocation);
            fail("Should have thrown JSONException for exceeding maxLength");
        } catch (JSONException e) {
            assertTrue(e.getMessage().contains(JSONConstants.JSON_MAX_LENGTH));
        }
    }

    public void testMaxDepthEnforcedThroughInterceptor() throws Exception {
        // Nested 3 levels deep, set maxDepth to 2
        this.request.setContent("{\"a\":{\"b\":{\"c\":1}}}".getBytes());
        this.request.addHeader("Content-Type", "application/json");

        JSONInterceptor interceptor = createInterceptor();
        interceptor.setMaxDepth("2");
        TestAction action = new TestAction();

        this.invocation.setAction(action);

        try {
            interceptor.intercept(this.invocation);
            fail("Should have thrown JSONException for exceeding maxDepth");
        } catch (JSONException e) {
            assertTrue(e.getMessage().contains("maximum allowed depth"));
        }
    }

    public void testParameterAuthorizerRejectsUnauthorizedKeys() throws Exception {
        // JSON body with "foo" and "bar" keys, but authorizer only allows "foo"
        this.request.setContent("{\"foo\":\"allowed\", \"bar\":\"blocked\"}".getBytes());
        this.request.addHeader("Content-Type", "application/json");

        JSONInterceptor interceptor = new JSONInterceptor();
        interceptor.setContainer(container);
        // Only authorize "foo", reject "bar"
        interceptor.setParameterAuthorizer((parameterName, target, action) -> "foo".equals(parameterName));
        TestAction action = new TestAction();

        this.invocation.setAction(action);
        this.invocation.getStack().push(action);

        interceptor.intercept(this.invocation);

        // "foo" should be set, "bar" should NOT be set
        assertEquals("allowed", action.getFoo());
        assertNull(action.getBar());
    }

    public void testNonStringKeysAreSkippedByAuthorizationFilter() throws Exception {
        // Simulate a custom JSON reader producing a Map with a non-String key.
        // The authorizer should skip the entry rather than throw ClassCastException.
        JSONInterceptor interceptor = new JSONInterceptor();
        interceptor.setContainer(container);
        interceptor.setParameterAuthorizer((parameterName, target, action) -> true);

        java.util.Map<Object, Object> mixedKeyMap = new java.util.LinkedHashMap<>();
        mixedKeyMap.put("validKey", "ok");
        mixedKeyMap.put(42, "shouldBeSkipped"); // Integer key, not String

        java.lang.reflect.Method method = JSONInterceptor.class.getDeclaredMethod(
                "filterUnacceptableKeys", java.util.Map.class, Object.class, Object.class);
        method.setAccessible(true);

        // Should not throw ClassCastException
        method.invoke(interceptor, mixedKeyMap, new TestAction(), new TestAction());

        // The non-String key entry should still be present (skipped, not removed)
        assertTrue("non-String-key entry should remain (skipped, not removed)", mixedKeyMap.containsKey(42));
        assertTrue("String-key entry should remain", mixedKeyMap.containsKey("validKey"));
    }

    public void testParameterAuthorizerAllowsAllWhenPermissive() throws Exception {
        // Same JSON body, but authorizer allows all
        this.request.setContent("{\"foo\":\"value1\", \"bar\":\"value2\"}".getBytes());
        this.request.addHeader("Content-Type", "application/json");

        JSONInterceptor interceptor = createInterceptor();
        TestAction action = new TestAction();

        this.invocation.setAction(action);
        this.invocation.getStack().push(action);

        interceptor.intercept(this.invocation);

        // Both should be set
        assertEquals("value1", action.getFoo());
        assertEquals("value2", action.getBar());
    }

    public void testMaxElementsEnforcedThroughInterceptor() throws Exception {
        // JSON object with 5 keys, set maxElements to 3
        this.request.setContent("{\"a\":1, \"b\":2, \"c\":3, \"d\":4, \"e\":5}".getBytes());
        this.request.addHeader("Content-Type", "application/json");

        JSONInterceptor interceptor = createInterceptor();
        interceptor.setMaxElements("3");
        TestAction action = new TestAction();

        this.invocation.setAction(action);

        try {
            interceptor.intercept(this.invocation);
            fail("Should have thrown JSONException for exceeding maxElements");
        } catch (JSONException e) {
            assertTrue(e.getMessage().contains("maximum allowed elements"));
        }
    }

    /**
     * Tests that nested JSON keys are recursively checked by the parameter authorizer.
     * Regression test for lukaszlenart's review: nested @StrutsParameter(depth=N) enforcement.
     */
    public void testNestedJsonKeysRecursivelyFiltered() throws Exception {
        // JSON body with nested object: {"bean": {"stringField": "test", "intField": 42}}
        this.request.setContent("{\"bean\": {\"stringField\": \"test\", \"intField\": 42}}".getBytes());
        this.request.addHeader("Content-Type", "application/json");

        JSONInterceptor interceptor = new JSONInterceptor();
        interceptor.setContainer(container);
        // Authorize "bean" (top-level) and "bean.stringField" (nested) but reject "bean.intField"
        interceptor.setParameterAuthorizer((parameterName, target, action) ->
                "bean".equals(parameterName) || "bean.stringField".equals(parameterName));
        TestAction action = new TestAction();

        this.invocation.setAction(action);
        this.invocation.getStack().push(action);

        interceptor.intercept(this.invocation);

        // bean should exist with stringField set, but intField should be default (0)
        assertNotNull(action.getBean());
        assertEquals("test", action.getBean().getStringField());
        assertEquals(0, action.getBean().getIntField());
    }

    /**
     * Tests that when root resolves to a non-action object (not ModelDriven),
     * annotation checks are still enforced.
     * Regression test for lukaszlenart's review: non-action root bypass.
     */
    public void testNonActionRootObjectStillChecked() throws Exception {
        this.request.setContent("{\"stringField\":\"injected\", \"intField\":99}".getBytes());
        this.request.addHeader("Content-Type", "application/json");

        JSONInterceptor interceptor = new JSONInterceptor();
        interceptor.setContainer(container);
        interceptor.setRoot("bean");
        // Reject all parameters — simulates strict requireAnnotations
        interceptor.setParameterAuthorizer((parameterName, target, action) -> false);
        TestAction4 action = new TestAction4();

        this.invocation.setAction(action);
        this.invocation.getStack().push(action);

        interceptor.intercept(this.invocation);

        // Both fields should remain at defaults since authorizer rejected everything
        Bean bean = action.getBean();
        assertNotNull(bean);
        assertNull(bean.getStringField());
        assertEquals(0, bean.getIntField());
    }

    public void testExcludedNamePatternRejectsKey() throws Exception {
        this.request.setContent("{\"foo\":\"a\", \"bar\":\"b\"}".getBytes());
        this.request.addHeader("Content-Type", "application/json");

        JSONInterceptor interceptor = createInterceptor();
        org.apache.struts2.security.DefaultExcludedPatternsChecker excluded =
                new org.apache.struts2.security.DefaultExcludedPatternsChecker();
        excluded.setExcludedPatterns("bar");
        interceptor.setExcludedPatterns(excluded);
        TestAction action = new TestAction();

        this.invocation.setAction(action);
        this.invocation.getStack().push(action);

        interceptor.intercept(this.invocation);

        assertEquals("a", action.getFoo());
        assertNull(action.getBar());
    }

    public void testAcceptedNamePatternRejectsKey() throws Exception {
        this.request.setContent("{\"foo\":\"a\", \"bar\":\"b\"}".getBytes());
        this.request.addHeader("Content-Type", "application/json");

        JSONInterceptor interceptor = createInterceptor();
        org.apache.struts2.security.DefaultAcceptedPatternsChecker accepted =
                new org.apache.struts2.security.DefaultAcceptedPatternsChecker();
        accepted.setAcceptedPatterns("foo");
        interceptor.setAcceptedPatterns(accepted);
        TestAction action = new TestAction();

        this.invocation.setAction(action);
        this.invocation.getStack().push(action);

        interceptor.intercept(this.invocation);

        assertEquals("a", action.getFoo());
        assertNull(action.getBar());
    }

    public void testExcludedNamePatternRejectsNestedKey() throws Exception {
        this.request.setContent("{\"bean\": {\"stringField\": \"keep\", \"intField\": 42}}".getBytes());
        this.request.addHeader("Content-Type", "application/json");

        JSONInterceptor interceptor = createInterceptor();
        org.apache.struts2.security.DefaultExcludedPatternsChecker excluded =
                new org.apache.struts2.security.DefaultExcludedPatternsChecker();
        excluded.setExcludedPatterns("bean\\.intField");
        interceptor.setExcludedPatterns(excluded);
        TestAction action = new TestAction();

        this.invocation.setAction(action);
        this.invocation.getStack().push(action);

        interceptor.intercept(this.invocation);

        assertNotNull(action.getBean());
        assertEquals("keep", action.getBean().getStringField());
        assertEquals(0, action.getBean().getIntField());
    }

    public void testAcceptedNamePatternRejectsNestedKey() throws Exception {
        this.request.setContent("{\"bean\": {\"stringField\": \"keep\", \"intField\": 42}}".getBytes());
        this.request.addHeader("Content-Type", "application/json");

        JSONInterceptor interceptor = createInterceptor();
        org.apache.struts2.security.DefaultAcceptedPatternsChecker accepted =
                new org.apache.struts2.security.DefaultAcceptedPatternsChecker();
        // Leaf-targeting pattern: matches only the nested leaf "bean.stringField", not the
        // intermediate node "bean". Accepted patterns are evaluated at leaf keys, so the
        // intermediate node is not gated and the matching leaf still populates while the
        // non-matching sibling "bean.intField" is dropped.
        accepted.setAcceptedPatterns("bean\\.stringField");
        interceptor.setAcceptedPatterns(accepted);
        TestAction action = new TestAction();

        this.invocation.setAction(action);
        this.invocation.getStack().push(action);

        interceptor.intercept(this.invocation);

        assertNotNull(action.getBean());
        assertEquals("keep", action.getBean().getStringField());
        assertEquals(0, action.getBean().getIntField());
    }

    public void testAcceptedNamePatternAppliesToListElementPath() throws Exception {
        this.request.setContent("{\"list\": [\"x\", \"y\"]}".getBytes());
        this.request.addHeader("Content-Type", "application/json");

        JSONInterceptor interceptor = createInterceptor();
        org.apache.struts2.security.DefaultAcceptedPatternsChecker accepted =
                new org.apache.struts2.security.DefaultAcceptedPatternsChecker();
        // Matches the container name "list" but not the element path "list[0]". Because accepted
        // patterns are evaluated at the leaf (the scalar element path), the elements are rejected
        // even though the container name matches — mirroring the flat ParametersInterceptor path,
        // which would evaluate "list[0]" and reject it.
        accepted.setAcceptedPatterns("list");
        interceptor.setAcceptedPatterns(accepted);
        TestAction action = new TestAction();

        this.invocation.setAction(action);
        this.invocation.getStack().push(action);

        interceptor.intercept(this.invocation);

        assertTrue(action.getList() == null || action.getList().isEmpty());
    }

    public void testExcludedNamePatternAppliesToListElementPath() throws Exception {
        this.request.setContent("{\"list\": [\"x\", \"y\"]}".getBytes());
        this.request.addHeader("Content-Type", "application/json");

        JSONInterceptor interceptor = createInterceptor();
        org.apache.struts2.security.DefaultExcludedPatternsChecker excluded =
                new org.apache.struts2.security.DefaultExcludedPatternsChecker();
        // Excludes the element path "list[0]" but not the container "list". Scalar list elements are
        // full leaf binding targets, so the per-node checks (here, excluded patterns) are evaluated
        // at the element path — mirroring the flat ParametersInterceptor path, which would exclude
        // "list[0]".
        excluded.setExcludedPatterns("list\\[0\\]");
        interceptor.setExcludedPatterns(excluded);
        TestAction action = new TestAction();

        this.invocation.setAction(action);
        this.invocation.getStack().push(action);

        interceptor.intercept(this.invocation);

        assertTrue(action.getList() == null || action.getList().isEmpty());
    }

    public void testAuthorizationAppliesToListElementPath() throws Exception {
        this.request.setContent("{\"list\": [\"x\", \"y\"]}".getBytes());
        this.request.addHeader("Content-Type", "application/json");

        JSONInterceptor interceptor = createInterceptor();
        // Authorize the container "list" but reject the element path "list[0]". Scalar list elements
        // now pass through @StrutsParameter authorization at their element path, so the elements are
        // rejected even though the container is authorized.
        interceptor.setParameterAuthorizer((parameterName, target, action) -> !"list[0]".equals(parameterName));
        TestAction action = new TestAction();

        this.invocation.setAction(action);
        this.invocation.getStack().push(action);

        interceptor.intercept(this.invocation);

        assertTrue(action.getList() == null || action.getList().isEmpty());
    }

    public void testParameterNameAwareDoesNotRejectIntermediateNode() throws Exception {
        this.request.setContent("{\"bean\": {\"stringField\": \"keep\", \"intField\": 42}}".getBytes());
        this.request.addHeader("Content-Type", "application/json");

        JSONInterceptor interceptor = createInterceptor();
        // The action's acceptableParameterName rejects the intermediate node "bean" but accepts the
        // nested leaves. ParameterNameAware is evaluated at leaf keys, so the subtree is not dropped.
        ParameterAwareTestAction action = new ParameterAwareTestAction();

        this.invocation.setAction(action);
        this.invocation.getStack().push(action);

        interceptor.intercept(this.invocation);

        assertNotNull(action.getBean());
        assertEquals("keep", action.getBean().getStringField());
        assertEquals(42, action.getBean().getIntField());
    }

    public void testExcludedValuePatternRejectsListElement() throws Exception {
        this.request.setContent("{\"list\": [\"good\", \"badvalue\"]}".getBytes());
        this.request.addHeader("Content-Type", "application/json");

        JSONInterceptor interceptor = createInterceptor();
        interceptor.setExcludedValuePatterns("badvalue");
        TestAction action = new TestAction();

        this.invocation.setAction(action);
        this.invocation.getStack().push(action);

        interceptor.intercept(this.invocation);

        assertNotNull(action.getList());
        assertEquals(1, action.getList().size());
        assertEquals("good", action.getList().get(0));
    }

    public void testParamNameMaxLengthRejectsLongKey() throws Exception {
        this.request.setContent("{\"foo\":\"a\"}".getBytes());
        this.request.addHeader("Content-Type", "application/json");

        JSONInterceptor interceptor = createInterceptor();
        interceptor.setParamNameMaxLength(2); // "foo" is length 3, over the limit
        TestAction action = new TestAction();

        this.invocation.setAction(action);
        this.invocation.getStack().push(action);

        interceptor.intercept(this.invocation);

        assertNull(action.getFoo());
    }

    public void testParameterNameAwareRejectsKey() throws Exception {
        this.request.setContent("{\"foo\":\"a\", \"bar\":\"b\"}".getBytes());
        this.request.addHeader("Content-Type", "application/json");

        JSONInterceptor interceptor = createInterceptor();
        ParameterAwareTestAction action = new ParameterAwareTestAction();

        this.invocation.setAction(action);
        this.invocation.getStack().push(action);

        interceptor.intercept(this.invocation);

        assertEquals("a", action.getFoo());
        assertNull(action.getBar());
    }

    public void testParameterValueAwareRejectsValue() throws Exception {
        this.request.setContent("{\"foo\":\"blocked\", \"baz\":\"good\"}".getBytes());
        this.request.addHeader("Content-Type", "application/json");

        JSONInterceptor interceptor = createInterceptor();
        ParameterAwareTestAction action = new ParameterAwareTestAction();

        this.invocation.setAction(action);
        this.invocation.getStack().push(action);

        interceptor.intercept(this.invocation);

        assertNull(action.getFoo());
        assertEquals("good", action.getBaz());
    }

    public void testExcludedValuePatternRejectsValue() throws Exception {
        this.request.setContent("{\"foo\":\"badvalue\", \"bar\":\"okvalue\"}".getBytes());
        this.request.addHeader("Content-Type", "application/json");

        JSONInterceptor interceptor = createInterceptor();
        interceptor.setExcludedValuePatterns("badvalue");
        TestAction action = new TestAction();

        this.invocation.setAction(action);
        this.invocation.getStack().push(action);

        interceptor.intercept(this.invocation);

        assertNull(action.getFoo());
        assertEquals("okvalue", action.getBar());
    }

    public void testAcceptedValuePatternRejectsValue() throws Exception {
        this.request.setContent("{\"foo\":\"allowed\", \"bar\":\"other\"}".getBytes());
        this.request.addHeader("Content-Type", "application/json");

        JSONInterceptor interceptor = createInterceptor();
        interceptor.setAcceptedValuePatterns("allowed");
        TestAction action = new TestAction();

        this.invocation.setAction(action);
        this.invocation.getStack().push(action);

        interceptor.intercept(this.invocation);

        assertEquals("allowed", action.getFoo());
        assertNull(action.getBar());
    }

    public void testExcludePropertiesAppliedToInputWhenEnabled() throws Exception {
        this.request.setContent("{\"foo\":\"a\", \"bar\":\"b\"}".getBytes());
        this.request.addHeader("Content-Type", "application/json");

        JSONInterceptor interceptor = createInterceptor();
        interceptor.setApplyPropertyFiltersToInput(true);
        interceptor.setExcludeProperties("foo");
        TestAction action = new TestAction();

        this.invocation.setAction(action);
        this.invocation.getStack().push(action);

        interceptor.intercept(this.invocation);

        assertNull(action.getFoo());
        assertEquals("b", action.getBar());
    }

    public void testExcludePropertiesNotAppliedToInputByDefault() throws Exception {
        this.request.setContent("{\"foo\":\"a\", \"bar\":\"b\"}".getBytes());
        this.request.addHeader("Content-Type", "application/json");

        JSONInterceptor interceptor = createInterceptor();
        interceptor.setExcludeProperties("foo"); // configured, but flag off
        TestAction action = new TestAction();

        this.invocation.setAction(action);
        this.invocation.getStack().push(action);

        interceptor.intercept(this.invocation);

        assertEquals("a", action.getFoo());
        assertEquals("b", action.getBar());
    }

    public void testObtainsFreshJSONUtilAndReaderPerInvocation() {
        JSONInterceptor interceptor = new JSONInterceptor();
        interceptor.setContainer(container); // StrutsTestCase-provided container

        JSONUtil first = interceptor.getJSONUtil();
        JSONUtil second = interceptor.getJSONUtil();

        assertNotSame("interceptor must obtain a fresh JSONUtil per request", first, second);
        assertNotSame("each fresh JSONUtil must carry its own reader (no shared parse state)",
                first.getReader(), second.getReader());
    }

    /**
     * StrutsJSONWriter keeps per-serialization state in plain instance fields and is not
     * thread-safe, so the response-side protection from WW-5644 now relies entirely on the writer
     * bean being prototype-scoped (a fresh writer per request/result). Guard that scope directly:
     * if it were ever switched to singleton, cross-request response state would leak again.
     */
    public void testObtainsFreshWriterPerAcquisition() {
        JSONWriter first = container.getInstance(JSONWriter.class);
        JSONWriter second = container.getInstance(JSONWriter.class);

        assertNotSame("JSONWriter bean must be prototype-scoped so serialize state is never shared across responses",
                first, second);
    }

    public void testIncludePropertiesAppliedToNestedInputWhenEnabled() throws Exception {
        this.request.setContent("{\"bean\": {\"stringField\": \"keep\", \"intField\": 42}}".getBytes());
        this.request.addHeader("Content-Type", "application/json");

        JSONInterceptor interceptor = createInterceptor();
        interceptor.setApplyPropertyFiltersToInput(true);
        // Include patterns expand across the hierarchy: "bean.stringField" compiles patterns for
        // both the intermediate node "bean" and the leaf "bean.stringField", so the nested leaf
        // populates while the excluded sibling "bean.intField" is dropped.
        interceptor.setIncludeProperties("bean\\.stringField");
        TestAction action = new TestAction();

        this.invocation.setAction(action);
        this.invocation.getStack().push(action);

        interceptor.intercept(this.invocation);

        assertNotNull(action.getBean());
        assertEquals("keep", action.getBean().getStringField());
        assertEquals(0, action.getBean().getIntField());
    }

    @Override
    protected void setUp() throws Exception {
        super.setUp();

        this.request = new MockHttpServletRequest();
        this.response = new MockHttpServletResponse();

        MockServletContext servletContext = new MockServletContext();

        ActionContext context = ActionContext.getContext()
            .withServletRequest(request)
            .withServletResponse(response)
            .withServletContext(servletContext);

        ValueStack stack = context.getValueStack();

        this.invocation = new MockActionInvocationEx();
        this.invocation.setInvocationContext(context);
        this.invocation.setStack(stack);
    }
}

class MockActionInvocationEx extends MockActionInvocation {

    private boolean invoked;

    @Override
    public String invoke() throws Exception {
        this.invoked = true;
        return super.invoke();
    }

    public boolean isInvoked() {
        return this.invoked;
    }

    public void setInvoked(boolean invoked) {
        this.invoked = invoked;
    }

}
