/*
 * 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 freemarker.ext.beans;

import static freemarker.template.Configuration.*;

import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.function.Supplier;
import java.util.regex.Pattern;

import org.junit.Test;

import freemarker.template.Configuration;
import freemarker.template.DefaultObjectWrapper;
import freemarker.template.ObjectWrapper;
import freemarker.template.SimpleHash;
import freemarker.template.TemplateException;
import freemarker.test.TemplateTest;

public class TestZeroArgumentNonVoidMethodPolicy extends TemplateTest {
    @Override
    protected Configuration createConfiguration() throws Exception {
        Configuration cfg = super.createConfiguration();
        // Don't use default, as then the object wrapper is a shared static mutable object:
        cfg.setIncompatibleImprovements(Configuration.VERSION_2_3_32);
        cfg.setAPIBuiltinEnabled(true);
        return cfg;
    }

    @Test
    public void testDefaultWithHighIncompatibleImprovements() throws TemplateException, IOException {
        for (boolean cacheTopLevelVars : List.of(true, false)){
            setupDataModel(
                    () -> new DefaultObjectWrapper(VERSION_2_3_33),
                    cacheTopLevelVars);
            assertRecIsBothPropertyAndMethod();
            assertNrcIsMethodOnly();
        }
    }

    @Test
    public void testDefaultWithLowIncompatibleImprovements() throws TemplateException, IOException {
        for (boolean cacheTopLevelVars : List.of(true, false)) {
            setupDataModel(
                    () -> new DefaultObjectWrapper(VERSION_2_3_32),
                    cacheTopLevelVars);
            assertRecIsMethodOnly();
            assertNrcIsMethodOnly();
        }
    }

    @Test
    public void testDefaultWithLowIncompatibleImprovements2() throws TemplateException, IOException {
        for (boolean cacheTopLevelVars : List.of(true, false)) {
            setupDataModel(
                    () -> {
                        DefaultObjectWrapper beansWrapper = new DefaultObjectWrapper(VERSION_2_3_32);
                        beansWrapper.setRecordZeroArgumentNonVoidMethodPolicy(
                                ZeroArgumentNonVoidMethodPolicy.BOTH_METHOD_AND_PROPERTY_UNLESS_BEAN_PROPERTY_READ_METHOD);
                        return beansWrapper;
                    },
                    cacheTopLevelVars);
            assertRecIsBothPropertyAndMethod();
            assertNrcIsMethodOnly();
        }
    }

    @Test
    public void testDefaultWithRecordsPropertyOnly() throws TemplateException, IOException {
        for (boolean cacheTopLevelVars : List.of(true, false)) {
            setupDataModel(
                    () -> {
                        DefaultObjectWrapper beansWrapper = new DefaultObjectWrapper(VERSION_2_3_32);
                        beansWrapper.setRecordZeroArgumentNonVoidMethodPolicy(
                                ZeroArgumentNonVoidMethodPolicy.PROPERTY_ONLY_UNLESS_BEAN_PROPERTY_READ_METHOD);
                        return beansWrapper;
                    },
                    cacheTopLevelVars);
            assertRecIsPropertyOnly();
            assertNrcIsMethodOnly();
        }
    }

    @Test
    public void testDefaultWithRecordsPropertyOnly2() throws TemplateException, IOException {
        for (boolean cacheTopLevelVars : List.of(true, false)) {
            setupDataModel(
                    () -> {
                        DefaultObjectWrapper beansWrapper = new DefaultObjectWrapper(VERSION_2_3_33);
                        beansWrapper.setRecordZeroArgumentNonVoidMethodPolicy(
                                ZeroArgumentNonVoidMethodPolicy.PROPERTY_ONLY_UNLESS_BEAN_PROPERTY_READ_METHOD);
                        return beansWrapper;
                    },
                    cacheTopLevelVars);
            assertRecIsPropertyOnly();
            assertNrcIsMethodOnly();
        }
    }

    @Test
    public void testDefaultWithDefaultPropertyOnly() throws TemplateException, IOException {
        for (boolean cacheTopLevelVars : List.of(true, false)) {
            setupDataModel(
                    () -> {
                        DefaultObjectWrapper beansWrapper = new DefaultObjectWrapper(VERSION_2_3_32);
                        beansWrapper.setDefaultZeroArgumentNonVoidMethodPolicy(
                                ZeroArgumentNonVoidMethodPolicy.PROPERTY_ONLY_UNLESS_BEAN_PROPERTY_READ_METHOD);
                        return beansWrapper;
                    },
                    cacheTopLevelVars);
            assertRecIsMethodOnly();
            assertNrcIsPropertyOnly();
        }
    }

    @Test
    public void testDefaultWithBothPropertyAndMethod() throws TemplateException, IOException {
        for (boolean cacheTopLevelVars : List.of(true, false)) {
            setupDataModel(
                    () -> {
                        DefaultObjectWrapper beansWrapper = new DefaultObjectWrapper(VERSION_2_3_33);
                        beansWrapper.setDefaultZeroArgumentNonVoidMethodPolicy(
                                ZeroArgumentNonVoidMethodPolicy.BOTH_METHOD_AND_PROPERTY_UNLESS_BEAN_PROPERTY_READ_METHOD);
                        return beansWrapper;
                    },
                    cacheTopLevelVars);
            assertRecIsBothPropertyAndMethod();
            assertNrcIsBothPropertyAndMethod();
        }
    }

    @Test
    public void testSettings() throws TemplateException, IOException {
            getConfiguration().setSetting(
                    "objectWrapper",
                    "DefaultObjectWrapper(2.3.33, "
                            + "defaultZeroArgumentNonVoidMethodPolicy="
                            + "freemarker.ext.beans.ZeroArgumentNonVoidMethodPolicy"
                            + ".BOTH_METHOD_AND_PROPERTY_UNLESS_BEAN_PROPERTY_READ_METHOD)");
            setupDataModel(() -> getConfiguration().getObjectWrapper(), false);
            assertRecIsBothPropertyAndMethod();
            assertNrcIsBothPropertyAndMethod();
    }

    private void setupDataModel(Supplier<? extends ObjectWrapper> objectWrapperSupplier, boolean cacheTopLevelVars) {
        ObjectWrapper objectWrapper = objectWrapperSupplier.get();
        getConfiguration().setObjectWrapper(objectWrapper);

        setDataModel(cacheTopLevelVars ? new SimpleHash(objectWrapper) : new HashMap<>());

        addToDataModel("rec", new TestRecord(1, "S"));
        addToDataModel("nrc", new TestDefault());
    }

    private void assertRecIsBothPropertyAndMethod() throws IOException, TemplateException {
        for (TemplateModifications tempMods : TemplateModifications.values()) {
            assertOutput(modifyTemplate("${rec.x}", tempMods), "1");
            assertOutput(modifyTemplate("${rec.x()}", tempMods), "1");
            assertOutput(modifyTemplate("${rec.s}", tempMods), "S");
            assertOutput(modifyTemplate("${rec.s()}", tempMods), "S");
            assertOutput(modifyTemplate("${rec.y}", tempMods), "2");
            assertOutput(modifyTemplate("${rec.y()}", tempMods), "2");
            assertOutput(modifyTemplate("${rec.tenX}", tempMods), "10");
            assertOutput(modifyTemplate("${rec.tenX()}", tempMods), "10");
        }
        assertRecPolicyIndependentMembers();
    }

    private void assertRecIsMethodOnly() throws IOException, TemplateException {
        for (TemplateModifications tempMods : TemplateModifications.values()) {
            assertErrorContains(modifyTemplate("${rec.x}", tempMods), "SimpleMethodModel");
            assertOutput(modifyTemplate("${rec.x()}", tempMods), "1");
            assertErrorContains(modifyTemplate("${rec.s}", tempMods), "SimpleMethodModel");
            assertOutput(modifyTemplate("${rec.s()}", tempMods), "S");
            assertErrorContains(modifyTemplate("${rec.y}", tempMods), "SimpleMethodModel");
            assertOutput(modifyTemplate("${rec.y()}", tempMods), "2");
            assertErrorContains(modifyTemplate("${rec.tenX}", tempMods), "SimpleMethodModel");
            assertOutput(modifyTemplate("${rec.tenX()}", tempMods), "10");
        }
        assertRecPolicyIndependentMembers();
    }

    private void assertRecIsPropertyOnly() throws IOException, TemplateException {
        for (TemplateModifications tempMods : TemplateModifications.values()) {
            assertOutput(modifyTemplate("${rec.x}", tempMods), "1");
            assertErrorContains(modifyTemplate("${rec.x()}", tempMods), "SimpleNumber", "must not be called as a method");
            assertOutput(modifyTemplate("${rec.s}", tempMods), "S");
            assertErrorContains(modifyTemplate("${rec.s()}", tempMods), "SimpleScalar");
            assertOutput(modifyTemplate("${rec.y}", tempMods), "2");
            assertErrorContains(modifyTemplate("${rec.y()}", tempMods), "SimpleNumber");
            assertOutput(modifyTemplate("${rec.tenX}", tempMods), "10");
            assertErrorContains(modifyTemplate("${rec.tenX()}", tempMods), "SimpleNumber");
        }
        assertRecPolicyIndependentMembers();
    }

    private void assertRecPolicyIndependentMembers() throws IOException, TemplateException {
        for (TemplateModifications tempMods : TemplateModifications.values()) {
            assertOutput(modifyTemplate("${rec.z}", tempMods), "3");
            assertErrorContains(modifyTemplate("${rec.z()}", tempMods), "SimpleNumber");
            assertOutput(modifyTemplate("${rec.getZ()}", tempMods), "3");
            assertOutput(modifyTemplate("${rec.xTimes(5)}", tempMods), "5");
            assertErrorContains(modifyTemplate("${rec.xTimes}", tempMods), "SimpleMethodModel");
            assertOutput(modifyTemplate("${rec.voidMethod()}", tempMods), "");
            assertErrorContains(modifyTemplate("${rec.voidMethod}", tempMods), "SimpleMethodModel");
        }
    }

    private void assertNrcIsMethodOnly() throws IOException, TemplateException {
        for (TemplateModifications tempMods : TemplateModifications.values()) {
            assertErrorContains(modifyTemplate("${nrc.x}", tempMods), "SimpleMethodModel");
            assertOutput(modifyTemplate("${nrc.x()}", tempMods), "1");
            assertErrorContains(modifyTemplate("${nrc.y}", tempMods), "SimpleMethodModel");
            assertOutput(modifyTemplate("${nrc.y()}", tempMods), "2");
            assertErrorContains(modifyTemplate("${nrc.tenX}", tempMods), "SimpleMethodModel");
            assertOutput(modifyTemplate("${nrc.tenX()}", tempMods), "10");
        }
        assertNrcPolicyIndependentMembers();
    }

    private void assertNrcIsBothPropertyAndMethod() throws IOException, TemplateException {
        for (TemplateModifications tempMods : TemplateModifications.values()) {
            assertOutput(modifyTemplate("${nrc.x}", tempMods), "1");
            assertOutput(modifyTemplate("${nrc.x()}", tempMods), "1");
            assertOutput(modifyTemplate("${nrc.y}", tempMods), "2");
            assertOutput(modifyTemplate("${nrc.y()}", tempMods), "2");
            assertOutput(modifyTemplate("${nrc.tenX}", tempMods), "10");
            assertOutput(modifyTemplate("${nrc.tenX()}", tempMods), "10");
        }
        assertNrcPolicyIndependentMembers();
    }

    private void assertNrcIsPropertyOnly() throws IOException, TemplateException {
        for (TemplateModifications tempMods : TemplateModifications.values()) {
            assertOutput(modifyTemplate("${nrc.x}", tempMods), "1");
            assertErrorContains(modifyTemplate("${nrc.x()}", tempMods), "SimpleNumber", "must not be called as a method");
            assertOutput(modifyTemplate("${nrc.y}", tempMods), "2");
            assertErrorContains(modifyTemplate("${nrc.y()}", tempMods), "SimpleNumber");
            assertOutput(modifyTemplate("${nrc.tenX}", tempMods), "10");
            assertErrorContains(modifyTemplate("${nrc.tenX()}", tempMods), "SimpleNumber");
        }
        assertNrcPolicyIndependentMembers();
    }

    private void assertNrcPolicyIndependentMembers() throws IOException, TemplateException {
        for (TemplateModifications tempMods : TemplateModifications.values()) {
            assertOutput(modifyTemplate("${nrc.z}", tempMods), "3");
            assertErrorContains(modifyTemplate("${nrc.z()}", tempMods), "SimpleNumber");
            assertOutput(modifyTemplate("${nrc.getZ()}", tempMods), "3");
            assertOutput(modifyTemplate("${nrc.xTimes(5)}", tempMods), "5");
            assertErrorContains(modifyTemplate("${nrc.xTimes}", tempMods), "SimpleMethodModel");
            assertOutput(modifyTemplate("${nrc.voidMethod()}", tempMods), "");
            assertErrorContains(modifyTemplate("${nrc.voidMethod}", tempMods), "SimpleMethodModel");
        }
    }

    public interface TestInterface {
        int y();

        /**
         * Defines a real JavaBeans property, "z", so the {@link ZeroArgumentNonVoidMethodPolicy} shouldn't affect this
         */
        int getZ();
    }

    /**
     * Defines record component readers for "x" and "s", and some other non-record-component methods that are still
     * potentially exposed as if there were properties.
     */
    public record TestRecord(int x, String s) implements TestInterface {
        @Override
        public int y() {
            return 2;
        }

        @Override
        public int getZ() {
            return 3;
        }

        public int tenX() {
            return x * 10;
        }

        /**
         * Has an argument, so this never should be exposed as property.
         */
        public int xTimes(int m) {
            return x * m;
        }

        /**
         * Has a void return type, so this never should be exposed as property.
         */
        public void voidMethod() {
            // do nothing
        }
    }

    public static class TestDefault implements TestInterface {
        public int x() {
            return 1;
        }

        @Override
        public int y() {
            return 2;
        }

        @Override
        public int getZ() {
            return 3;
        }

        public int tenX() {
            return x() * 10;
        }

        public int xTimes(int m) {
            return x() * m;
        }

        /**
         * Has a void return type, so this never should be exposed as property.
         */
        public void voidMethod() {
            // do nothing
        }
    }

    private static final Pattern DOT_TO_SQUARE_BRACKETS_REPLACEMENT_PATTERN = Pattern.compile("\\.(\\w+)");
    
    private static String modifyTemplate(String s, TemplateModifications tempMods) {
        if (tempMods.useApi) {
            s = s.replace(".", "?api.");
        }
        if (tempMods.doToSquareBrackets) {
            s = DOT_TO_SQUARE_BRACKETS_REPLACEMENT_PATTERN.matcher(s).replaceFirst(key -> "['" + key.group(1) + "']");
        }
        return s;
    }

    enum TemplateModifications {
        DOT(true, false), SQUARE_BRACKETS(false, false),
        API_DOT(true, true), API_SQUARE_BRACKETS(false, true);

        private final boolean doToSquareBrackets;
        private final boolean useApi;

        TemplateModifications(boolean doToSquareBrackets, boolean useApi) {
            this.doToSquareBrackets = doToSquareBrackets;
            this.useApi = useApi;
        }
    }


}
