/*
 *  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.causeway.testing.archtestsupport.applib.classrules;

import java.lang.annotation.Annotation;
import java.util.Arrays;
import java.util.Objects;

import jakarta.inject.Inject;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Transient;
import jakarta.persistence.Version;

import com.tngtech.archunit.base.DescribedPredicate;
import com.tngtech.archunit.core.domain.JavaAnnotation;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaModifier;
import com.tngtech.archunit.lang.ArchCondition;
import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.lang.ConditionEvents;
import com.tngtech.archunit.lang.SimpleConditionEvent;
import com.tngtech.archunit.lang.syntax.elements.ClassesShouldConjunction;

import static com.tngtech.archunit.base.DescribedPredicate.not;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.fields;

import org.apache.causeway.applib.annotation.DomainObject;
import org.apache.causeway.persistence.jpa.applib.integration.CausewayEntityListener;

import static org.apache.causeway.testing.archtestsupport.applib.classrules.CommonPredicates.annotationOf;
import static org.apache.causeway.testing.archtestsupport.applib.classrules.CommonPredicates.haveNoArgProtectedConstructor;
import static org.apache.causeway.testing.archtestsupport.applib.classrules.CommonPredicates.ofAnEnum;

import lombok.experimental.UtilityClass;

/**
 * A library of architecture tests to ensure coding conventions are followed for classes annotated with
 * the JPA {@link Entity} annotation.
 *
 * @since 2.0 {@index}
 */
@UtilityClass
public class ArchitectureJpaRules {

    /**
     * This rule requires that classes annotated with the JPA {@link Entity} annotation must also be
     * annotated with the Apache Causeway {@link DomainObject} annotation specifying that its
     * {@link DomainObject#nature() nature} is an {@link org.apache.causeway.applib.annotation.Nature#ENTITY entity}.
     */
    public static ArchRule every_jpa_Entity_must_be_annotated_with_DomainObject_nature_of_ENTITY() {
        return classes()
                .that().areAnnotatedWith(Entity.class)
                .should().beAnnotatedWith(CommonPredicates.DomainObject_nature_ENTITY());
    }

    /**
     * This rule requires that classes annotated with the JPA {@link Entity} annotation must also be
     * annotated with the Apache Causeway {@link jakarta.xml.bind.annotation.adapters.XmlJavaTypeAdapter} annotation
     * with a value of {@link org.apache.causeway.applib.jaxb.PersistentEntityAdapter}<code>.class</code>.
     *
     * <p>
     * Tnis is so that entities can be transparently referenced from XML-style view models.
     * </p>
     */
    public static ArchRule every_jpa_Entity_must_be_annotated_with_XmlJavaAdapter_of_PersistentEntityAdapter() {
        return classes()
                .that().areAnnotatedWith(Entity.class)
                .should().beAnnotatedWith(CommonPredicates.XmlJavaTypeAdapter_value_PersistentEntityAdapter());
    }

    /**
     * This rule requires that classes annotated with the JPA {@link Entity} annotation must also be
     * annotated with the {@link jakarta.persistence.EntityListeners} annotation that includes
     * a value of <code>org.apache.causeway.persistence.jpa.applib.integration.CausewayEntityListener.class</code>.
     *
     * <p>
     * Tnis is so that entities can be transparently referenced from XML-style view models.
     * </p>
     */
    public static ArchRule every_jpa_Entity_must_be_annotated_as_an_CausewayEntityListener() {
        return classes()
                .that().areAnnotatedWith(Entity.class)
                .should().beAnnotatedWith(EntityListeners_with_CausewayEntityListener());
    }

    static DescribedPredicate<JavaAnnotation<?>> EntityListeners_with_CausewayEntityListener() {
        return new DescribedPredicate<JavaAnnotation<?>>("@EntityListener({CausewayEntityListener.class})") {
            @Override public boolean test(final JavaAnnotation<?> javaAnnotation) {
                if (!javaAnnotation.getRawType().isAssignableTo(EntityListeners.class)) {
                    return false;
                }
                var properties = javaAnnotation.getProperties();
                var listeners = properties.get("value");
                return listeners instanceof JavaClass[] && containsCausewayEntityListener((JavaClass[]) listeners);
            }

            private boolean containsCausewayEntityListener(final JavaClass[] classes) {
                //noinspection deprecation
                return Arrays.stream(classes)
                        .anyMatch(x -> Objects.equals(x.getFullName(), CausewayEntityListener.class.getName())
                                || x.isAssignableTo(CausewayEntityListener.class));
            }
        };
    }

    /**
     * This rule requires that classes annotated with the JPA {@link Entity} annotation must also be
     * implement {@link Comparable}.
     *
     * <p>
     * This is so that entities have a natural ordering and can safely be added to parented collections of type
     * {@link java.util.SortedSet}.
     * </p>
     */
    public static ArchRule every_jpa_Entity_must_implement_Comparable() {
        return classes()
                .that().areAnnotatedWith(Entity.class)
                .should().implement(Comparable.class);
    }

    /**
     * This rule requires that classes annotated with the JPA {@link Entity} annotation must also be annotated with the
     * JPA {@link Table} annotation which includes {@link Table#uniqueConstraints() uniqueConstraints}.
     *
     * <p>
     * This is so that entities will have an alternative business key in addition to the system-defined surrogate
     * key.
     * </p>
     */
    public static ArchRule every_jpa_Entity_must_be_annotated_as_Table_with_uniqueConstraints() {
        return classes()
                .that().areAnnotatedWith(Entity.class)
                .and(not(areSubtypeEntities()))
                .should().beAnnotatedWith(Table_uniqueConstraints());
    }

    /**
     * This rule requires that classes annotated with the JPA {@link Entity} annotation must also be annotated with the
     * JPA {@link Table} annotation which includes {@link Table#schema()}  schema}.
     *
     * <p>
     * This is so that entity tables are organised into an appropriate structure (ideally mirroring that of the
     * entities).
     * </p>
     */
    public static ArchRule every_jpa_Entity_must_be_annotated_as_Table_with_schema() {
        return classes()
                .that().areAnnotatedWith(Entity.class)
                .should().beAnnotatedWith(Table_schema());
    }

    /**
     * This rule requires that enum fields in classes annotated with the JPA {@link Entity} annotation must also be
     * annotated with the JPA {@link Enumerated} annotation indicating that they should be persisted as
     * {@link jakarta.persistence.EnumType#STRING string}s (rather than ordinal numbers).
     *
     * <p>
     * The rationale here is that a string is (arguably) more stable than an ordinal number, and is certainly easier
     * to work with when querying the database.  The downside is slightly more space to persist the data, and slightly
     * less performant (not that it would be noticeable).
     * </p>
     */
    public static ArchRule every_enum_field_of_jpa_Entity_must_be_annotated_with_Enumerable_STRING() {
        return fields().that()
                .areDeclaredInClassesThat(areEntities())
                .and().haveRawType(ofAnEnum())
                .should().beAnnotatedWith(annotationOf(Enumerated.class, "value",
                        "EnumType.STRING"))
                ;
    }

    /**
     * This rule requires that injected fields in classes annotated with the JPA {@link Entity} annotation must also be
     * annotated with JPA {@link Transient} annotation.
     *
     * <p>
     * The rationale here is that injected services are managed by the runtime and are not/cannot be persisted.
     * </p>
     */
    public static ArchRule every_injected_field_of_jpa_Entity_must_be_annotated_with_Transient() {
        return fields().that()
                .areDeclaredInClassesThat(areEntities())
                .and().areAnnotatedWith(Inject.class)
                .should().beAnnotatedWith(Transient.class)
                ;
    }

    /**
     * This rule requires that classes annotated with the JPA {@link Entity} annotation must contain an <code>id</code>
     * field that is itself annotated with {@link Id}.
     *
     * <p>
     * This is part of the standard contract for JPA entities.
     * </p>
     */
    public static ArchRule every_jpa_Entity_must_have_an_id_field() {
        return everyJpa_Entity_must_have_a_field_named_and_annotated("id", Id.class);
    }

    /**
     * This rule requires that classes annotated with the JPA {@link Entity} annotation must contain a
     * <code>version</code> field that is itself annotated with {@link jakarta.persistence.Version}.
     *
     * <p>
     * This is good practice for JPA entities to implement optimistic locking
     * </p>
     */
    public static ArchRule every_jpa_Entity_must_have_a_version_field() {
        return everyJpa_Entity_must_have_a_field_named_and_annotated("version", Version.class);
    }

    static ClassesShouldConjunction everyJpa_Entity_must_have_a_field_named_and_annotated(
            final String fieldName,
            final Class<? extends Annotation> annotationClass) {
        final String fieldAnnotation = annotationClass.getSimpleName();
        return classes().that()
                .areAnnotatedWith(Entity.class)
                .should(new ArchCondition<JavaClass>(
                        String.format("have a field named '%s' annotated with '@%s'", fieldName, fieldAnnotation)) {
                    @Override public void check(final JavaClass javaClass, final ConditionEvents conditionEvents) {
                        var javaFieldIfAny = javaClass.getAllFields().stream()
                                .filter(x -> Objects.equals(x.getName(), fieldName)).findAny();
                        if(!javaFieldIfAny.isPresent()) {
                            conditionEvents.add(new SimpleConditionEvent(javaClass, false,
                                    String.format("%s does not have a field named '%s'", javaClass.getSimpleName(), fieldName)));
                            return;
                        }
                        var javaField = javaFieldIfAny.get();
                        if(!javaField.isAnnotatedWith(annotationClass)) {
                            conditionEvents.add(new SimpleConditionEvent(javaClass, false,
                                    String.format("%s has field named '%s' but it is not annotated with '@%s'",
                                            javaClass.getSimpleName(), fieldName, fieldAnnotation)));
                        }
                    }
                })
                ;
    }

    static DescribedPredicate<JavaAnnotation<?>> Table_schema() {
        return new DescribedPredicate<JavaAnnotation<?>>("@Table(schema=...)") {
            @Override public boolean test(final JavaAnnotation<?> javaAnnotation) {
                if (!javaAnnotation.getRawType().isAssignableTo(Table.class)) {
                    return false;
                }
                var properties = javaAnnotation.getProperties();
                var schema = properties.get("schema");
                return schema instanceof String &&
                        ((String) schema).length() > 0;
            }
        };
    }

    static DescribedPredicate<JavaClass> areEntities() {
        return new DescribedPredicate<JavaClass>("are entities") {
            @Override
            public boolean test(final JavaClass input) {
                return input.isAnnotatedWith(Entity.class);
            }
        };
    }

    static DescribedPredicate<JavaAnnotation<?>> Table_uniqueConstraints() {
        return new DescribedPredicate<JavaAnnotation<?>>("@Table(uniqueConstraints=...)") {
            @Override public boolean test(final JavaAnnotation<?> javaAnnotation) {
                if (!javaAnnotation.getRawType().isAssignableTo(Table.class)) {
                    return false;
                }
                var properties = javaAnnotation.getProperties();
                var uniqueConstraints = properties.get("uniqueConstraints");
                return uniqueConstraints instanceof JavaAnnotation[] &&
                        ((JavaAnnotation<?>[]) uniqueConstraints).length > 0;
            }
        };
    }

    /**
     * This rule requires that concrete classes annotated with the JPA {@link Entity} annotation have a no-arg
     * constructor with <code>protected</code> visibility.
     *
     * <p>
     * The rationale is to encourage the use of static factory methods.
     * </p>
     */
    public static ArchRule every_jpa_Entity_must_have_protected_no_arg_constructor() {
        return classes()
                .that().areAnnotatedWith(Entity.class)
                .and().doNotHaveModifier(JavaModifier.ABSTRACT)
                .should(haveNoArgProtectedConstructor());
    }

    static DescribedPredicate<? super JavaClass> areSubtypeEntities() {
        return new DescribedPredicate<JavaClass>("are subtype entities ") {
            @Override public boolean test(final JavaClass input) {
                var superclassIfAny = input.getSuperclass();
                if(!superclassIfAny.isPresent()) {
                    return false;
                }
                var superType = superclassIfAny.get();
                var superClass = superType.toErasure();
                var entityIfAny = superClass
                        .tryGetAnnotationOfType(Entity.class);
                return entityIfAny.isPresent();
            }
        };
    }

}
