/*
 * Copyright (c) 2014, 2021 Oracle and/or its affiliates. All rights reserved.
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License v. 2.0 which is available at
 * http://www.eclipse.org/legal/epl-2.0,
 * or the Eclipse Distribution License v. 1.0 which is available at
 * http://www.eclipse.org/org/documents/edl-v10.php.
 *
 * SPDX-License-Identifier: EPL-2.0 OR BSD-3-Clause
 */

// Contributors:
//     Marcel Valovy - 2.6 - initial implementation
package org.eclipse.persistence.testing.jaxb.beanvalidation;

import com.sun.tools.xjc.Driver;

import org.eclipse.persistence.jaxb.compiler.Generator;

import javax.tools.Diagnostic;
import jakarta.validation.Valid;
import jakarta.validation.constraints.AssertFalse;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Future;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Past;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

import org.eclipse.persistence.jaxb.javamodel.reflection.JavaModelImpl;
import org.eclipse.persistence.jaxb.javamodel.reflection.JavaModelInputImpl;

import javax.tools.DiagnosticCollector;
import javax.tools.JavaCompiler;
import javax.tools.JavaFileObject;
import javax.tools.StandardJavaFileManager;
import javax.tools.ToolProvider;
import jakarta.xml.bind.annotation.XmlAttribute;
import jakarta.xml.bind.annotation.XmlElement;
import jakarta.xml.bind.annotation.XmlValue;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.OutputStreamWriter;
import java.lang.annotation.Annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Field;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.StringTokenizer;
import javax.tools.StandardLocation;

import static org.eclipse.persistence.testing.jaxb.beanvalidation.ContentComparator.equalsXML;

/**
 * Tests generation of JAXB Facets and Bean Validation annotations during Schemagen and XJC, respectively.
 *
 * @author Marcel Valovy - marcel.valovy@oracle.com
 */
public class BeanValidationBindingsTestCase extends junit.framework.TestCase {

    private static final String GOLDEN_FILE_RESOURCE_PATH = "org/eclipse/persistence/testing/jaxb/beanvalidation/sgen_xjc/golden_file.xsd";
    private static String PATH_TO_SCHEMA_DIRECTORY;
    private static String GOLDEN_FILE_PATH;
    private static String RICH_SCHEMA_PATH;
    private static String CUSTOMIZED_SCHEMA_PATH;
    private static String GENERATED_SCHEMA_PATH;
    private static String TARGET_PATH;
    private static final int DIRS_TO_ROOT = GOLDEN_FILE_RESOURCE_PATH.length() - GOLDEN_FILE_RESOURCE_PATH.replace("/", "").length();

    public BeanValidationBindingsTestCase() {
        try {
            //context class loader and already exists file path is needed to run this test case from within IDE like Eclipse
            PATH_TO_SCHEMA_DIRECTORY = new File(Thread.currentThread().getContextClassLoader().getResource(GOLDEN_FILE_RESOURCE_PATH).toURI()).getParentFile().getAbsolutePath();
            GOLDEN_FILE_PATH = PATH_TO_SCHEMA_DIRECTORY + "/golden_file.xsd";
            RICH_SCHEMA_PATH = PATH_TO_SCHEMA_DIRECTORY + "/rich_schema.xsd";
            CUSTOMIZED_SCHEMA_PATH = PATH_TO_SCHEMA_DIRECTORY + "/customized_schema.xsd";
            GENERATED_SCHEMA_PATH = PATH_TO_SCHEMA_DIRECTORY + "/schema1.xsd";

            File alreadyExistsFile = new File(Thread.currentThread().getContextClassLoader().getResource(GOLDEN_FILE_RESOURCE_PATH).toURI());
            for (int i = 0; i < DIRS_TO_ROOT + 1; i++) {
                alreadyExistsFile = alreadyExistsFile.getParentFile();
            }

            TARGET_PATH = alreadyExistsFile.getAbsolutePath();
        } catch (URISyntaxException e) {
            fail(String.format("Unable to initialize %s test case", BeanValidationBindingsTestCase.class.getName()));
        }
    }

    // Handles error case where no BV annotations would be generated on class fields and would still pass the equality test.
    boolean annotationsGenerated;

    private String pkg;

    @Override
    public void tearDown() throws Exception {
        assertTrue(deleteDir(new File(pkg)));
        //noinspection ResultOfMethodCallIgnored
        new File(GENERATED_SCHEMA_PATH).delete();
    }

    /**
     * A fancy test demonstrating (and testing) that it is possible to perform
     * a round-trip in a lucky case where the XML Schema is identical to the
     * original golden file.
     * In practice, the XJC/Schemagen transformations guarantee only equality
     * of post round-trip schema with the original, not identity.
     * This test should still pass even if changes are done to our code.
     */
    public void testGoldenFileIdentity() throws Throwable {
        pkg = "gf";

        roundTrip(GOLDEN_FILE_PATH, pkg);
        assertTrue(equalsXML(new File(GOLDEN_FILE_PATH), new File(GENERATED_SCHEMA_PATH)));
    }

    /**
     * Tests equality of the original schema and schema after round-trip, with
     * bean validation annotations and facets.
     * Also tests equality of generated Java classes from the original schema
     * and from the schema after round-trip.
     */
    public void testEqualitySchemaAndJava() throws Throwable {
        pkg = "rs";

        Class<?>[] cTenured = roundTrip(RICH_SCHEMA_PATH, pkg);

        xjcGenerateJavaSources(GENERATED_SCHEMA_PATH); // Regenerate the sources.
        compileGeneratedSources(createCompileList(pkg));
        Class<?>[] cYoung = loadCompiledClasses(createLoadList(pkg));

        assertTrue(equalsClasses(cTenured, cYoung));
    }

    /**
     * Tests customizations, i.e. facet customizations + custom facets, i.e.
     * Future, Past, AssertTrue, AssertFalse.
     */
    public void testFacetCustomizationsAndCustomFacets() throws Throwable {
        pkg = "cs";

        xjcGenerateJavaSourcesWithCustomizations(CUSTOMIZED_SCHEMA_PATH);
        compileGeneratedSources(createCompileList(pkg));
        Class<?> custom = loadCompiledClasses(createLoadList(pkg))[0];

        Field generic = custom.getDeclaredField("generic");
        Size s = generic.getAnnotation(Size.class);
        assertEquals(s.message(), "Hello, world!");
        assertEquals(s.groups()[0], BindingTeam.class);
        assertEquals(s.groups()[1], RocketTeam.class);
        assertEquals(s.max(), 4);

        Pattern p1 = generic.getAnnotation(Pattern.List.class).value()[0];
        Pattern p2 = generic.getAnnotation(Pattern.List.class).value()[1];
        assertEquals(p1.message(), p2.message());
        assertTrue(Arrays.equals(p1.groups(), p2.groups()));
        assertEquals(p1.message(), "Hello.");
        assertEquals(p1.groups()[0], Object.class);
        assertEquals(p1.regexp(), "10");

        Future f = generic.getAnnotation(Future.class);
        assertEquals(f.message(), "Welcome to the Future!");
        assertEquals(f.groups()[0], BindingTeam.class);

        Past p = generic.getAnnotation(Past.class);
        assertEquals(p.message(), "Farewell from the ancestors.");
        assertEquals(p.groups()[0], Ancestors.class);

        AssertTrue at = generic.getAnnotation(AssertTrue.class);
        assertEquals(at.message(), "True fan of the team!");
        assertEquals(at.groups()[0], BindingTeam.class);

        AssertFalse af = generic.getAnnotation(AssertFalse.class);
        assertEquals(af.message(), "false");
        assertEquals(af.groups()[0], Object.class);

        // To test a user custom annotation.
        CustomAnnotation xmlKey = generic.getAnnotation(CustomAnnotation.class);
        assertNotNull(xmlKey);
    }

    /**
     * Tests that the XJC detects all facets and generates their respective
     * annotations correctly.
     */
    public void testAllFacetsAndAnnotations() throws Throwable {
        pkg = "rs";

        Class<?>[] c = roundTrip(RICH_SCHEMA_PATH, pkg);
        Class<?> Main = c[0];
        Class<?> Numbers = c[1];
        Class<?> NumberWithHiddenValueAttribute = c[2];
        Class<?> Strings = c[4];

        XmlElement xmlElement;
        Size size;
        DecimalMax decimalMax;
        DecimalMin decimalMin;
        Pattern.List patternList;
        Pattern pattern;

        /* Main.class */
        Field numbers = Main.getDeclaredField("numbers");
        assertNotNull(numbers.getAnnotation(Valid.class));
        assertNotNull(numbers.getAnnotation(NotNull.class));
        xmlElement = numbers.getAnnotation(XmlElement.class);
        assertFalse(xmlElement.nillable());

        Field strings = Main.getDeclaredField("strings");
        size = strings.getAnnotation(Size.class);
        assertTrue(size.min() == 1 && size.max() == 2);
        assertNotNull(strings.getAnnotation(Valid.class));
        assertNotNull(strings.getAnnotation(NotNull.class));
        xmlElement = strings.getAnnotation(XmlElement.class);
        assertFalse(xmlElement.nillable());

        Field unsignedByte = Main.getDeclaredField("unsignedByte");
        decimalMax = unsignedByte.getAnnotation(DecimalMax.class);
        assertEquals(decimalMax.value(), "255");
        assertTrue(decimalMax.inclusive());
        decimalMin = unsignedByte.getAnnotation(DecimalMin.class);
        assertEquals(decimalMin.value(), "0");
        assertTrue(decimalMin.inclusive());

        Field byteArray = Main.getDeclaredField("byteArray");
        size = byteArray.getAnnotation(Size.class);
        assertTrue(size.max() == 18);

        Field someCollection = Main.getDeclaredField("someCollection");
        size = someCollection.getAnnotation(Size.class);
        assertTrue(size.min() == 1);
        assertNotNull(someCollection.getAnnotation(Valid.class));
        assertNotNull(someCollection.getAnnotation(NotNull.class));

        Field optionalElement = Main.getDeclaredField("optionalElement");
        size = optionalElement.getAnnotation(Size.class);
        assertTrue(size.min() == 0);
        assertNotNull(optionalElement.getAnnotation(Valid.class));
        assertNull(optionalElement.getAnnotation(NotNull.class));

        Field optionalElementWithSimpleType = Main.getDeclaredField("optionalElementWithSimpleType");
        assertNull(optionalElementWithSimpleType.getAnnotation(NotNull.class));
        decimalMin = optionalElementWithSimpleType.getAnnotation(DecimalMin.class);
        assertEquals(decimalMin.value(), "1");
        decimalMax = optionalElementWithSimpleType.getAnnotation(DecimalMax.class);
        assertEquals(decimalMax.value(), "31");

        /* Numbers.class */
        Field minInclusive = Numbers.getDeclaredField("minInclusive");
        decimalMin = minInclusive.getAnnotation(DecimalMin.class);
        assertEquals(decimalMin.value(), "1000");
        assertTrue(decimalMin.inclusive());

        Field maxInclusive = Numbers.getDeclaredField("maxInclusive");
        decimalMax = maxInclusive.getAnnotation(DecimalMax.class);
        assertEquals(decimalMax.value(), "1000");
        assertTrue(decimalMax.inclusive());

        Field minExclusive = Numbers.getDeclaredField("minExclusive");
        decimalMin = minExclusive.getAnnotation(DecimalMin.class);
        assertEquals(decimalMin.value(), "0");
        assertFalse(decimalMin.inclusive());

        Field maxExclusive = Numbers.getDeclaredField("maxExclusive");
        decimalMax = maxExclusive.getAnnotation(DecimalMax.class);
        assertEquals(decimalMax.value(), "1000");
        assertFalse(decimalMax.inclusive());

        Field minMaxExclusive = Numbers.getDeclaredField("minMaxExclusive");
        decimalMax = minMaxExclusive.getAnnotation(DecimalMax.class);
        assertEquals(decimalMax.value(), "9223372");
        assertFalse(decimalMax.inclusive());
        decimalMin = minMaxExclusive.getAnnotation(DecimalMin.class);
        assertEquals(decimalMin.value(), "0");
        assertFalse(decimalMin.inclusive());

        /* NumberWithHiddenValueAttribute.class */
        Field value = NumberWithHiddenValueAttribute.getDeclaredField("value");
        assertNotNull(value.getAnnotation(XmlValue.class));
        size = value.getAnnotation(Size.class);
        assertTrue(size.min() == 1 && size.max() == 5);

        Field code = NumberWithHiddenValueAttribute.getDeclaredField("code");
        assertNotNull(code.getAnnotation(NotNull.class));

        Field whatNumber = NumberWithHiddenValueAttribute.getDeclaredField("whatNumber");
        XmlAttribute xmlAttribute = whatNumber.getAnnotation(XmlAttribute.class);
        assertTrue(xmlAttribute.required());

        /* Strings.class */
        Field regexShorthands = Strings.getDeclaredField("regexShorthands");
        patternList = regexShorthands.getAnnotation(Pattern.List.class);
        Pattern[] patterns = patternList.value();
        assertEquals("[:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD]", patterns[0].regexp());
        assertEquals("[^:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD]", patterns[1].regexp());
        assertEquals("[-.0-9:A-Z_a-z\\u00B7\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u203F\\u2040\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD]", patterns[2].regexp());
        assertEquals("[^-.0-9:A-Z_a-z\\u00B7\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u203F\\u2040\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD]", patterns[3].regexp());
        assertEquals("\\p{Nd}", patterns[4].regexp());
        assertEquals("\\P{Nd}", patterns[5].regexp());
        assertEquals("[\\u0009-\\u000D\\u0020\\u0085\\u00A0\\u1680\\u180E\\u2000-\\u200A\\u2028\\u2029\\u202F\\u205F\\u3000]", patterns[6].regexp());
        assertEquals("[^\\u0009-\\u000D\\u0020\\u0085\\u00A0\\u1680\\u180E\\u2000-\\u200A\\u2028\\u2029\\u202F\\u205F\\u3000]", patterns[7].regexp());
        assertEquals("[\\u0009\\u0020\\u00A0\\u1680\\u180E\\u2000-\\u200A\\u202F\\u205F\\u3000]", patterns[8].regexp());
        assertEquals("[^\\u0009\\u0020\\u00A0\\u1680\\u180E\\u2000\\u2001-\\u200A\\u202F\\u205F\\u3000]", patterns[9].regexp());
        assertEquals("[^\\u000A-\\u000D\\u0085\\u2028\\u2029]", patterns[10].regexp());
        assertEquals("(?:(?>\\u000D\\u000A)|[\\u000A\\u000B\\u000C\\u000D\\u0085\\u2028\\u2029])", patterns[11].regexp());

        Field idType = Strings.getDeclaredField("idType");
        pattern = idType.getAnnotation(Pattern.class);
        assertEquals(pattern.regexp(), "[[:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD]-[:]][[-.0-9:A-Z_a-z\\u00B7\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u203F\\u2040\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD]-[:]]*");
        assertTrue(idType.getAnnotation(Size.class).max() == 100);

        Field genericString = Strings.getDeclaredField("genericString");
        assertTrue(genericString.getAnnotation(Size.class).min() == 0);
        assertTrue(genericString.getAnnotation(Size.class).max() == 1024);

        Field maxLength = Strings.getDeclaredField("maxLength");
        assertTrue(maxLength.getAnnotation(Size.class).max() == 1024);

        Field minLength = Strings.getDeclaredField("minLength");
        assertTrue(minLength.getAnnotation(Size.class).min() == 0);
    }

    private Class<?>[] roundTrip(String schemaPath, String pkg) throws Throwable {
        xjcGenerateJavaSources(schemaPath);
        compileGeneratedSources(createCompileList(pkg));
        Class<?>[] classes = loadCompiledClasses(createLoadList(pkg));
        generateSchema(classes); // Generates resource/PATH_TO_SCHEMA_DIRECTORY/schema1.xsd.
        return classes;
    }

    public void xjcGenerateJavaSources(String schemaPath) throws Exception {
        Driver.run(new String[] { schemaPath, "-XBeanVal" }, System.out, System.out);
    }

    public void xjcGenerateJavaSourcesWithCustomizations(String schemaPath) throws Exception {
        Driver.run(new String[] { schemaPath, "-extension", "-XBeanVal" }, System.out, System.out);
    }

    private void generateSchema(Class<?>[] classes) throws FileNotFoundException {
        JavaModelImpl javaModel = new JavaModelImpl(Thread.currentThread().getContextClassLoader());
        JavaModelInputImpl modelInput = new JavaModelInputImpl(classes, javaModel);
        modelInput.setFacets(true);
        Generator gen = new Generator(modelInput);
        gen.generateSchemaFiles(PATH_TO_SCHEMA_DIRECTORY, null);
    }

    private void compileGeneratedSources(File... compileList) throws Exception {
        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
        DiagnosticCollector<? super JavaFileObject> diag = new DiagnosticCollector<>();
        StandardJavaFileManager fm = compiler.getStandardFileManager(diag, null, null);
        List<File> pathElements = getPath("jdk.module.path");
        if (!pathElements.isEmpty()) {
            //running on module-path:
            fm.setLocation(StandardLocation.MODULE_PATH, pathElements);
            pathElements = getPath("java.class.path");
            if (!pathElements.isEmpty()) {
                fm.setLocation(StandardLocation.CLASS_PATH, pathElements);
            }
        } else {
            //on cp - move everythig to mp
            pathElements = getPath("java.class.path");
            if (!pathElements.isEmpty()) {
                fm.setLocation(StandardLocation.MODULE_PATH, pathElements);
            }
        }
        fm.setLocation(StandardLocation.CLASS_OUTPUT, Arrays.asList(new File(TARGET_PATH)));
        Iterable<? extends JavaFileObject> compilationUnits = fm.getJavaFileObjectsFromFiles(Arrays.asList(compileList));
        List<String> options = new ArrayList<>();
        JavaCompiler.CompilationTask task = compiler.getTask(new OutputStreamWriter(System.out), fm, diag, options,
                null, compilationUnits);

        if (!task.call()) {
            for (Diagnostic diagnostic : diag.getDiagnostics()) {
                System.out.format("Error on line %d in %s\n", diagnostic.getLineNumber(), diagnostic);
            }
            fail("Compilation of generated classes failed. See the diagnostics output.");
        }
    }

    private List<File> getPath(String property) {
        List<File> cp = new ArrayList<>();
        String value = System.getProperty(property, "").trim();
        if (!value.isEmpty()) {
            StringTokenizer st = new StringTokenizer(value, File.pathSeparator);
            while (st.hasMoreTokens()) {
                cp.add(new File(st.nextToken()));
            }
        }
        return cp;
    }

    private File[] createCompileList(String pkg) {
        return "cs".equals(pkg)
                ? new File[]{new File(pkg + "/Custom.java"),
                    new File(pkg + "/ObjectFactory.java"),
                    new File(pkg + "/package-info.java")}
                : new File[]{new File(pkg + "/Main.java"),
                    new File(pkg + "/Numbers.java"),
                    new File(pkg + "/NumberWithHiddenValueAttribute.java"),
                    new File(pkg + "/ObjectFactory.java"),
                    new File(pkg + "/package-info.java"),
                    new File(pkg + "/Strings.java")};
    }

    private Class<?>[] loadCompiledClasses(String... loadList) throws Throwable {
        ClassLoader cl = new URLClassLoader(new URL[] {new File(TARGET_PATH).toURI().toURL()},
                Thread.currentThread().getContextClassLoader());
        Class<?>[] loadedClasses = new Class<?>[loadList.length];
        for (int i = 0; i < loadedClasses.length; i++)
            loadedClasses[i] = cl.loadClass(loadList[i]);
        return loadedClasses;
    }

    private String[] createLoadList(String pkg) throws ClassNotFoundException {
        return "cs".equals(pkg)
                ? new String[]{pkg + ".Custom", pkg + ".ObjectFactory" }
                : new String[]{pkg + ".Main", pkg + ".Numbers",
                pkg + ".NumberWithHiddenValueAttribute", pkg + ".ObjectFactory",
                pkg + ".Strings"};
    }

    private boolean equalsClasses(Class<?>[] cTenured, Class<?>[] cYoung) {
        if (cTenured.length != cYoung.length) return false;
        for (int i = 0; i < cTenured.length; i++)
            if (!equalsAnnotations(cTenured[i], cYoung[i])) return false;
        return true;
    }

    private boolean equalsAnnotations(Class<?> c1, Class<?> c2) {
        Field[] f1 = c1.getDeclaredFields();
        Field[] f2 = c2.getDeclaredFields();
        if (f1.length != f2.length) return false;
        for (int i = 0; i<f2.length; i++) {
            Annotation[] a1 = f1[i].getDeclaredAnnotations();
            Annotation[] a2 = f2[i].getDeclaredAnnotations();
            if (!Arrays.equals(a1, a2)) return false;
            // Returns false if no BV annotations were generated.
            if (!annotationsGenerated)
                for (Annotation a : a1)
                    if (a.annotationType().equals(NotNull.class)) {
                        annotationsGenerated = true;
                        break;
                    }
        }
        return annotationsGenerated;
    }

    private static boolean deleteDir(File file) {
        if (file.isDirectory()) {
            String[] children = file.list();
            for (String child : children)
                deleteDir(new File(file, child));
            return file.delete();
        } else
        // The directory is now empty so delete it
        return file.delete();
    }

    public interface BindingTeam{}
    public @interface RocketTeam{}
    public abstract class Ancestors{}
    @Target({ ElementType.FIELD})
    @Retention(RetentionPolicy.RUNTIME)
    public @interface CustomAnnotation {
        String value();
    }

}
