content: /*
   *  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.persistence.jdo.datanucleus.metamodel.facets.entity;
  
  import java.lang.reflect.Method;
  import java.util.List;
  import java.util.Map;
  import java.util.Optional;
  import java.util.concurrent.ConcurrentHashMap;
  import java.util.function.Supplier;
  
  import javax.jdo.FetchGroup;
  import javax.jdo.JDOHelper;
  import javax.jdo.PersistenceManager;
  
  import jakarta.inject.Inject;
  
  import org.datanucleus.api.jdo.JDOQuery;
  import org.datanucleus.enhancement.Persistable;
  import org.datanucleus.store.rdbms.RDBMSPropertyNames;
  
  import org.jspecify.annotations.Nullable;
  
  import org.apache.causeway.applib.query.AllInstancesQuery;
  import org.apache.causeway.applib.query.NamedQuery;
  import org.apache.causeway.applib.query.Query;
  import org.apache.causeway.applib.services.bookmark.Bookmark;
  import org.apache.causeway.applib.services.exceprecog.Category;
  import org.apache.causeway.applib.services.exceprecog.ExceptionRecognizerService;
  import org.apache.causeway.applib.services.repository.EntityState;
  import org.apache.causeway.applib.services.xactn.TransactionService;
  import org.apache.causeway.applib.services.xactn.TransactionalProcessor;
  import org.apache.causeway.commons.collections.Can;
  import org.apache.causeway.commons.internal.assertions._Assert;
  import org.apache.causeway.commons.internal.base._NullSafe;
  import org.apache.causeway.commons.internal.collections._Maps;
  import org.apache.causeway.commons.internal.exceptions._Exceptions;
  import org.apache.causeway.core.config.beans.CausewayBeanMetaData.PersistenceStack;
  import org.apache.causeway.core.metamodel.facetapi.FacetAbstract;
  import org.apache.causeway.core.metamodel.facetapi.FacetHolder;
  import org.apache.causeway.core.metamodel.facets.object.entity.EntityFacet;
  import org.apache.causeway.core.metamodel.facets.object.entity.EntityOrmMetadata;
  import org.apache.causeway.core.metamodel.object.ManagedObject;
  import org.apache.causeway.core.metamodel.objectmanager.ObjectManager;
  import org.apache.causeway.core.metamodel.services.idstringifier.IdStringifierLookupService;
  import org.apache.causeway.core.metamodel.services.objectlifecycle.ObjectLifecyclePublisher;
  import org.apache.causeway.persistence.jdo.datanucleus.entities.DnEntityStateProvider;
  import org.apache.causeway.persistence.jdo.datanucleus.entities.DnOidStoreAndRecoverHelper;
  import org.apache.causeway.persistence.jdo.datanucleus.entities.DnStateManagerForCauseway;
  import org.apache.causeway.persistence.jdo.metamodel.facets.object.persistencecapable.JdoPersistenceCapableFacetFactory;
  import org.apache.causeway.persistence.jdo.provider.entities.JdoFacetContext;
  import org.apache.causeway.persistence.jdo.spring.integration.TransactionAwarePersistenceManagerFactoryProxy;
  
  import lombok.AccessLevel;
  import lombok.Getter;
  import org.jspecify.annotations.NonNull;
  import lombok.experimental.Accessors;
  import lombok.extern.log4j.Log4j2;
  
  /**
   * @apiNote Does not have its own (exclusive) FacetFactory, but is installed via
   * {@link JdoPersistenceCapableFacetFactory} by means of dependency inversion using
   * {@link JdoFacetContext}
   */
  @Log4j2
  public class JdoEntityFacet
  extends FacetAbstract
  implements EntityFacet {
  
      // self managed injections via constructor ...
      @Inject private TransactionAwarePersistenceManagerFactoryProxy pmf;
      @Inject private TransactionService txService;
      @Inject private ObjectManager objectManager;
      @Inject private ExceptionRecognizerService exceptionRecognizerService;
      @Inject private JdoFacetContext jdoFacetContext;
      @Inject private ObjectLifecyclePublisher objectLifecyclePublisher;
  
      @Getter(value = AccessLevel.PROTECTED) @Accessors(fluent = true)
      @Inject private IdStringifierLookupService idStringifierLookupService;
  
      private final Class<?> entityClass;
  
      // lazily looks up the primaryKeyTypeFor (needs a PersistenceManager)
      @Getter(lazy=true, value = AccessLevel.PROTECTED) @Accessors(fluent = true)
      private final PrimaryKeyType<?> primaryKeyTypeForDecoding = idStringifierLookupService()
              .primaryKeyTypeFor(entityClass, getOrmMetadata().primaryKeyClass());
  
      // lazily looks up the ORM metadata (needs a PersistenceManager)
      @Getter(lazy=true)
      private final EntityOrmMetadata ormMetadata =
              _MetadataUtil.ormMetadataFor(getPersistenceManager(), entityClass);
  
      public JdoEntityFacet(
              final FacetHolder holder, final Class<?> entityClass) {
          super(EntityFacet.class, holder);
          this.entityClass = entityClass;
  
          _Assert.assertTrue(isPersistableType(entityClass),
                  ()->String.format("JdoEntityFacet initialized with type '%s' "
                          + "that is not Persistable (JDO)", entityClass));
  
          // resolve injection points on self
          getServiceInjector().injectServicesInto(this);
      }
  
      @Override
      public PersistenceStack getPersistenceStack() {
          return PersistenceStack.JDO;
      }
  
      /* OPTIMIZATION
       * even though the IdStringifierLookupService already holds a lookup map,
       * for performance reasons,
       * we define another one local to the context of the associated entity class */
      private final Map<Class<?>, PrimaryKeyType<?>> primaryKeyTypesForEncoding = new ConcurrentHashMap<>();
      private final PrimaryKeyType<?> primaryKeyTypeForEncoding(final @NonNull Object oid) {
          var actualPrimaryKeyClass = oid.getClass();
          var primaryKeyType = primaryKeyTypesForEncoding.computeIfAbsent(actualPrimaryKeyClass, key->
              idStringifierLookupService()
                  .primaryKeyTypeFor(entityClass, actualPrimaryKeyClass));
          return primaryKeyType;
      }
  
      @Override
      public boolean isInjectionPointsResolved(final Object pojo) {
          if(pojo instanceof Persistable) {
              DnStateManagerForCauseway.extractFrom((Persistable) pojo)
              .map(DnStateManagerForCauseway::injectServicesIfNotAlready)
              .orElse(false);
          }
          return pojo==null;
      }
  
      @Override
      public Optional<String> identifierFor(final Object pojo) {
  
          var entityState = getEntityState(pojo);
  
          if (!entityState.hasOid()) {
              return Optional.empty();
          }
  
          if(entityState.isHollow()) {
              /* for previously attached objects that have become hollow,
               * the OID can be looked up in DnStateManagerForHollow,
               * that simply acts as a holder of OID. */
              return DnOidStoreAndRecoverHelper.forEntity((Persistable)pojo).recoverOid();
          }
  
          var pm = getPersistenceManager();
          var primaryKey = pm.getObjectId(pojo);
  
          _Assert.assertNotNull(primaryKey, ()->
              String.format("failed to get OID even though entity is attached %s", pojo.getClass().getName()));
  
          return identifierForDnPrimaryKey(primaryKey);
      }
  
      public Optional<String> identifierForDnPrimaryKey(final @Nullable Object primaryKey) {
          var idIfAny = Optional.ofNullable(primaryKey)
                  .map(pk->
                      primaryKeyTypeForEncoding(pk).enstringWithCast(pk));
          return idIfAny;
      }
  
      @Override
      public Bookmark validateBookmark(final @NonNull Bookmark bookmark) {
          _Assert.assertNotNull(primaryKeyTypeForDecoding().destring(bookmark.identifier()));
          return bookmark;
      }
  
      @Override
      public Optional<Object> fetchByBookmark(final @NonNull Bookmark bookmark) {
  
          log.debug("fetchEntity; bookmark={}", bookmark);
  
          Object entityPojo;
          try {
  
              var persistenceManager = getPersistenceManager();
              var primaryKey = primaryKeyTypeForDecoding().destring(bookmark.identifier());
  
              var fetchPlan = persistenceManager.getFetchPlan();
              fetchPlan.addGroup(FetchGroup.DEFAULT);
              entityPojo = persistenceManager.getObjectById(entityClass, primaryKey);
  
          } catch (final RuntimeException e) {
  
              var recognition = exceptionRecognizerService.recognize(e);
              if(recognition.isPresent()) {
                  if(recognition.get().category() == Category.NOT_FOUND) {
                      return Optional.empty();
                  }
              }
  
              throw e;
          }
  
          return Optional.ofNullable(entityPojo);
      }
  
      @Override
      public Can<ManagedObject> fetchByQuery(final Query<?> query) {
  
          if (log.isDebugEnabled()) {
              log.debug("about to execute Query: {}", query.getDescription());
          }
  
          var range = query.getRange();
  
          if(query instanceof AllInstancesQuery) {
  
              var queryFindAllInstances = (AllInstancesQuery<?>) query;
              var queryEntityType = queryFindAllInstances.getResultType();
  
              // guard against misuse
              _Assert.assertTypeIsInstanceOf(queryEntityType, entityClass);
  
              var persistenceManager = getPersistenceManager();
  
              var typedQuery = persistenceManager.newJDOQLTypedQuery(queryEntityType);
              typedQuery.extension(RDBMSPropertyNames.PROPERTY_RDBMS_QUERY_MULTIVALUED_FETCH, "none");
  
              if(!range.isUnconstrained()) {
                  typedQuery.range(range.getStart(), range.getEnd());
              }
  
              var resultList = fetchWithinTransaction(typedQuery::executeList);
  
              if(range.hasLimit()) {
                  _Assert.assertTrue(resultList.size()<=range.getLimit());
              }
  
              return resultList;
  
          } else if(query instanceof NamedQuery) {
  
              var applibNamedQuery = (NamedQuery<?>) query;
              var queryResultType = applibNamedQuery.getResultType();
  
              var persistenceManager = getPersistenceManager();
  
              var namedParams = _Maps.<String, Object>newHashMap();
              var namedQuery = persistenceManager.newNamedQuery(queryResultType, applibNamedQuery.getName())
                      .setNamedParameters(namedParams);
  
              namedQuery.extension(RDBMSPropertyNames.PROPERTY_RDBMS_QUERY_MULTIVALUED_FETCH, "none");
  
              if(!range.isUnconstrained()) {
                  namedQuery.range(range.getStart(), range.getEnd());
              }
  
              // inject services into query params; not sure if required (might be redundant)
              {
                  var injector = getServiceInjector();
  
                  applibNamedQuery
                  .getParametersByName()
                  .values()
                  .forEach(injector::injectServicesInto);
              }
  
              applibNamedQuery
                  .getParametersByName()
                  .forEach(namedParams::put);
  
              Supplier<List<?>> executeMethod = hasResultPhrase(namedQuery)
                      ? namedQuery::executeResultList     // eg SELECT DISTINCT this.paymentMethod FROM IncomingInvoice WHERE ...
                      : namedQuery::executeList;          // eg SELECT FROM IncomingInvoice WHERE ...
              var resultList = fetchWithinTransaction(executeMethod);
  
              if(range.hasLimit()) {
                  _Assert.assertTrue(resultList.size()<=range.getLimit());
              }
  
              return resultList;
          }
  
          throw _Exceptions.unsupportedOperation("query type %s (%s) not supported by this persistence implementation",
                  query.getClass(),
                  query.getDescription());
      }
  
      private static boolean hasResultPhrase(final javax.jdo.Query<?> namedQuery) {
          if (namedQuery instanceof JDOQuery) {
              JDOQuery<?> jdoQuery = (JDOQuery<?>) namedQuery;
              return jdoQuery.getInternalQuery().getResult() != null;
          }
          return false;
      }
  
      @Override
      public void persist(final Object pojo) {
          // guard against misuse
          _Assert.assertNullableObjectIsInstanceOf(pojo, entityClass);
  
          if(pojo==null
                  || DnEntityStateProvider.entityState(pojo).isAttached()) {
              return; // nothing to do
          }
  
          var pm = getPersistenceManager();
  
          log.debug("about to persist entity {}", pojo);
  
          getTransactionalProcessor()
              .runWithinCurrentTransactionElseCreateNew(()->pm.makePersistent(pojo))
              .ifFailureFail();
      }
  
      @Override @Nullable
      public <T> T refresh(final @Nullable T pojo) {
          if(pojo==null) return pojo; // nothing to do
  
          // guard against misuse
          _Assert.assertNullableObjectIsInstanceOf(pojo, entityClass);
  
          var pm = getPersistenceManager();
  
          log.debug("about to refresh entity {}", pojo);
  
          getTransactionalProcessor()
              .runWithinCurrentTransactionElseCreateNew(()->pm.refresh(pojo))
              .ifFailureFail();
  
          return pojo;
      }
  
      @Override
      public void delete(final Object pojo) {
          if(pojo==null) {
              return; // nothing to do
          }
  
          // guard against misuse
          _Assert.assertNullableObjectIsInstanceOf(pojo, entityClass);
  
          if (!DnEntityStateProvider.entityState(pojo).hasOid()) {
              throw _Exceptions.illegalArgument("can only delete an entity with an OID");
          }
  
          var pm = getPersistenceManager();
  
          log.debug("about to delete entity {}", pojo);
  
          getTransactionalProcessor()
              .runWithinCurrentTransactionElseCreateNew(()->pm.deletePersistent(pojo))
              .ifFailureFail();
      }
  
      @Override
      public EntityState getEntityState(final Object pojo) {
          return DnEntityStateProvider.entityState(pojo);
      }
  
      @Override
      public Object versionOf(final Object pojo) {
          if (getEntityState(pojo).isAttached()) {
              return JDOHelper.getVersion(pojo);
          }
          return null;
      }
  
      @Override
      public <T> T detach(final T pojo) {
          return getPersistenceManager().detachCopy(pojo);
      }
  
      // -- HELPER
  
      private static boolean isPersistableType(final Class<?> type) {
          return Persistable.class.isAssignableFrom(type);
      }
  
      @Override
      public boolean isProxyEnhancement(final Method method) {
          return jdoFacetContext.isMethodProvidedByEnhancement(method);
      }
  
      // -- DEPENDENCIES
  
      private PersistenceManager getPersistenceManager() {
          return pmf.getPersistenceManagerFactory().getPersistenceManager();
      }
  
      private TransactionalProcessor getTransactionalProcessor() {
          return txService;
      }
  
      // -- HELPER
  
      private Can<ManagedObject> fetchWithinTransaction(final Supplier<List<?>> fetcher) {
          return getTransactionalProcessor().callWithinCurrentTransactionElseCreateNew(
                  ()->_NullSafe.stream(fetcher.get())
                      .map(fetchedObject->adapt(objectLifecyclePublisher, fetchedObject))
                      .collect(Can.toCan()))
                  .ifFailureFail()
                  .getValue().orElseThrow();
      }
  
      private ManagedObject adapt(
              final ObjectLifecyclePublisher objectLifecyclePublisher,
              final Object fetchedObject) {
          // handles lifecycle callbacks and injects services
  
          // ought not to be necessary, however for some queries it seems that the
          // lifecycle listener is not called
          if(fetchedObject instanceof Persistable) {
              // an entity
              var entity = objectManager.adapt(fetchedObject);
              objectLifecyclePublisher.onPostLoad(entity);
              return entity;
          } else {
              // a value type
              return objectManager.adapt(fetchedObject);
          }
      }
  
  }
  