/*
 * 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.geode.internal.cache.tx;

import java.util.Collection;
import java.util.HashMap;
import java.util.Map;

import org.apache.geode.CancelException;
import org.apache.geode.cache.CacheClosedException;
import org.apache.geode.cache.EntryNotFoundException;
import org.apache.geode.cache.Region;
import org.apache.geode.cache.Region.Entry;
import org.apache.geode.cache.TransactionDataNodeHasDepartedException;
import org.apache.geode.cache.TransactionDataNotColocatedException;
import org.apache.geode.cache.TransactionDataRebalancedException;
import org.apache.geode.cache.TransactionException;
import org.apache.geode.distributed.internal.membership.InternalDistributedMember;
import org.apache.geode.internal.cache.BucketNotFoundException;
import org.apache.geode.internal.cache.ColocationHelper;
import org.apache.geode.internal.cache.DataLocationException;
import org.apache.geode.internal.cache.DistributedPutAllOperation;
import org.apache.geode.internal.cache.DistributedRemoveAllOperation;
import org.apache.geode.internal.cache.EntryEventImpl;
import org.apache.geode.internal.cache.ForceReattemptException;
import org.apache.geode.internal.cache.InternalRegion;
import org.apache.geode.internal.cache.KeyInfo;
import org.apache.geode.internal.cache.PartitionedRegion;
import org.apache.geode.internal.cache.PartitionedRegion.RetryTimeKeeper;
import org.apache.geode.internal.cache.PrimaryBucketException;
import org.apache.geode.internal.cache.PutAllPartialResultException;
import org.apache.geode.internal.cache.PutAllPartialResultException.PutAllPartialResult;
import org.apache.geode.internal.cache.TXStateStub;
import org.apache.geode.internal.cache.partitioned.PutAllPRMessage;
import org.apache.geode.internal.cache.partitioned.RemoveAllPRMessage;
import org.apache.geode.internal.cache.tier.sockets.ClientProxyMembershipID;
import org.apache.geode.internal.cache.tier.sockets.VersionedObjectList;
import org.apache.geode.internal.offheap.annotations.Released;

public class PartitionedTXRegionStub extends AbstractPeerTXRegionStub {

  /**
   * tracks bucketIds of transactional operations so as to distinguish between
   * TransactionDataNotColocated and TransactionDataRebalanced exceptions. Map rather than set, as a
   * HashSet is backed by a HashMap. (avoids one "new" call).
   */
  private final Map<Integer, Boolean> buckets = new HashMap<>();

  private final PartitionedRegion region;

  public PartitionedTXRegionStub(TXStateStub txstate, PartitionedRegion r) {
    super(txstate);
    region = r;
  }

  public Map<Integer, Boolean> getBuckets() {
    return buckets;
  }

  @Override
  public void destroyExistingEntry(EntryEventImpl event, boolean cacheWrite,
      Object expectedOldValue) {
    PartitionedRegion pr = (PartitionedRegion) event.getRegion();
    try {
      pr.destroyRemotely(state.getTarget(), event.getKeyInfo().getBucketId(), event,
          expectedOldValue);
    } catch (PrimaryBucketException e) {
      RuntimeException re = getTransactionException(event.getKeyInfo(), e);
      re.initCause(e);
      throw re;
    } catch (ForceReattemptException e) {
      RuntimeException re;
      if (isBucketNotFoundException(e)) {
        re = new TransactionDataRebalancedException(
            "Transactional data moved, due to rebalancing.");
      } else {
        re = new TransactionDataNodeHasDepartedException(
            String.format(
                "Transaction data node %s has departed. To proceed, rollback this transaction and begin a new one.",
                state.getTarget()));
      }
      re.initCause(e);
      waitToRetry();
      throw re;
    }
    trackBucketForTx(event.getKeyInfo());
  }


  RuntimeException getTransactionException(KeyInfo keyInfo, Throwable cause) {
    region.getCancelCriterion().checkCancelInProgress(cause); // fixes bug 44567
    Throwable ex = cause;
    while (ex != null) {
      if (ex instanceof CacheClosedException) {
        return new TransactionDataNodeHasDepartedException(ex.getMessage());
      }
      ex = ex.getCause();
    }

    if (isKeyInNonColocatedBucket(keyInfo)) {
      return new TransactionDataNotColocatedException(
          String.format("Key %s is not colocated with transaction",
              keyInfo.getKey()));
    }
    ex = cause;
    while (ex != null) {
      if (ex instanceof PrimaryBucketException || ex instanceof BucketNotFoundException) {
        return new TransactionDataRebalancedException(
            "Transactional data moved, due to rebalancing.");
      }
      ex = ex.getCause();
    }
    return new TransactionDataNodeHasDepartedException(cause.getLocalizedMessage());
  }

  // is this key in a different bucket from all the existing buckets
  // of the underlying PR or its colocated PRs touched by the transaction.
  private boolean isKeyInNonColocatedBucket(KeyInfo keyInfo) {
    Map<Region<?, ?>, TXRegionStub> regionStubs = state.getRegionStubs();
    Collection<PartitionedRegion> colcatedRegions = ColocationHelper
        .getAllColocationRegions(region).values();
    // get all colocated region buckets touched in the transaction
    for (PartitionedRegion colcatedRegion : colcatedRegions) {
      PartitionedTXRegionStub regionStub =
          (PartitionedTXRegionStub) regionStubs.get(colcatedRegion);
      if (regionStub != null) {
        buckets.putAll(regionStub.getBuckets());
      }
    }
    return keyInfo != null && !buckets.isEmpty() && !buckets.containsKey(keyInfo.getBucketId());
  }


  /**
   * wait to retry after getting a ForceReattemptException
   */
  void waitToRetry() {
    // this is what PR operations do. The 2000ms is not used
    (new RetryTimeKeeper(2000)).waitForBucketsRecovery();
  }



  @Override
  public Entry getEntry(KeyInfo keyInfo, boolean allowTombstones) {
    try {
      Entry e = region.getEntryRemotely((InternalDistributedMember) state.getTarget(),
          keyInfo.getBucketId(), keyInfo.getKey(), false, allowTombstones);
      trackBucketForTx(keyInfo);
      return e;
    } catch (EntryNotFoundException enfe) {
      return null;
    } catch (PrimaryBucketException e) {
      RuntimeException re = getTransactionException(keyInfo, e);
      re.initCause(e);
      throw re;
    } catch (ForceReattemptException e) {
      RuntimeException re;
      if (isBucketNotFoundException(e)) {
        re = new TransactionDataRebalancedException(
            "Transactional data moved, due to rebalancing.");
      } else {
        re = new TransactionDataNodeHasDepartedException(
            String.format(
                "Transaction data node %s has departed. To proceed, rollback this transaction and begin a new one.",
                state.getTarget()));
      }
      re.initCause(e);
      waitToRetry();
      throw re;
    }
  }


  void trackBucketForTx(KeyInfo keyInfo) {
    if (region.getCache().getLogger().fineEnabled()) {
      region.getCache().getLogger()
          .fine("adding bucket:" + keyInfo.getBucketId() + " for tx:" + state.getTransactionId());
    }
    if (keyInfo.getBucketId() >= 0) {
      buckets.put(keyInfo.getBucketId(), Boolean.TRUE);
    }
  }


  @Override
  public void invalidateExistingEntry(EntryEventImpl event, boolean invokeCallbacks,
      boolean forceNewEntry) {
    PartitionedRegion pr = (PartitionedRegion) event.getRegion();
    try {
      pr.invalidateRemotely(state.getTarget(), event.getKeyInfo().getBucketId(), event);
    } catch (PrimaryBucketException e) {
      RuntimeException re = getTransactionException(event.getKeyInfo(), e);
      re.initCause(e);
      throw re;
    } catch (ForceReattemptException e) {
      RuntimeException re;
      if (isBucketNotFoundException(e)) {
        re = new TransactionDataRebalancedException(
            "Transactional data moved, due to rebalancing.");
      } else {
        re = new TransactionDataNodeHasDepartedException(
            String.format(
                "Transaction data node %s has departed. To proceed, rollback this transaction and begin a new one.",
                state.getTarget()));
      }
      re.initCause(e);
      waitToRetry();
      throw re;
    }
    trackBucketForTx(event.getKeyInfo());
  }


  @Override
  public boolean containsKey(KeyInfo keyInfo) {
    try {
      boolean retVal = region.containsKeyRemotely((InternalDistributedMember) state.getTarget(),
          keyInfo.getBucketId(), keyInfo.getKey());
      trackBucketForTx(keyInfo);
      return retVal;
    } catch (PrimaryBucketException e) {
      RuntimeException re = getTransactionException(keyInfo, e);
      re.initCause(e);
      throw re;
    } catch (ForceReattemptException e) {
      if (isBucketNotFoundException(e)) {
        RuntimeException re = getTransactionException(keyInfo, e);
        re.initCause(e);
        throw re;
      }
      waitToRetry();
      RuntimeException re = new TransactionDataNodeHasDepartedException(
          String.format(
              "Transaction data node %s has departed. To proceed, rollback this transaction and begin a new one.",
              state.getTarget()));
      re.initCause(e);
      throw re;
    }
  }


  /**
   * @return true if the cause of the FRE is a BucketNotFoundException
   */
  boolean isBucketNotFoundException(ForceReattemptException e) {
    ForceReattemptException fre = e;
    while (fre.getCause() != null && fre.getCause() instanceof ForceReattemptException) {
      fre = (ForceReattemptException) fre.getCause();
    }
    return fre instanceof BucketNotFoundException;
  }


  @Override
  public boolean containsValueForKey(KeyInfo keyInfo) {
    try {
      boolean retVal = region.containsValueForKeyRemotely(
          (InternalDistributedMember) state.getTarget(), keyInfo.getBucketId(), keyInfo.getKey());
      trackBucketForTx(keyInfo);
      return retVal;
    } catch (PrimaryBucketException e) {
      RuntimeException re = getTransactionException(keyInfo, e);
      re.initCause(e);
      throw re;
    } catch (ForceReattemptException e) {
      if (isBucketNotFoundException(e)) {
        RuntimeException re = getTransactionException(keyInfo, e);
        re.initCause(e);
        throw re;
      }
      waitToRetry();
      RuntimeException re = new TransactionDataNodeHasDepartedException(
          String.format(
              "Transaction data node %s has departed. To proceed, rollback this transaction and begin a new one.",
              state.getTarget()));
      re.initCause(e);
      throw re;
    }
  }

  @Override
  public Object findObject(KeyInfo keyInfo, boolean isCreate, boolean generateCallbacks,
      Object value, boolean peferCD, ClientProxyMembershipID requestingClient,
      EntryEventImpl clientEvent) {
    Object retVal = null;
    final Object key = keyInfo.getKey();
    final Object callbackArgument = keyInfo.getCallbackArg();
    try {
      retVal =
          region.getRemotely((InternalDistributedMember) state.getTarget(), keyInfo.getBucketId(),
              key, callbackArgument, peferCD, requestingClient, clientEvent, false);
    } catch (PrimaryBucketException e) {
      RuntimeException re = getTransactionException(keyInfo, e);
      re.initCause(e);
      throw re;
    } catch (ForceReattemptException e) {
      if (isBucketNotFoundException(e)) {
        RuntimeException re = getTransactionException(keyInfo, e);
        re.initCause(e);
        throw re;
      }
      waitToRetry();
      RuntimeException re = getTransactionException(keyInfo, e);
      re.initCause(e);
      throw re;
    }
    trackBucketForTx(keyInfo);
    return retVal;
  }


  @Override
  public Object getEntryForIterator(KeyInfo keyInfo, boolean allowTombstones) {
    InternalDistributedMember primary = region.getBucketPrimary(keyInfo.getBucketId());
    if (primary.equals(state.getTarget())) {
      return getEntry(keyInfo, allowTombstones);
    } else {
      return region.getSharedDataView().getEntry(keyInfo, region, allowTombstones);
    }
  }


  @Override
  public boolean putEntry(EntryEventImpl event, boolean ifNew, boolean ifOld,
      Object expectedOldValue, boolean requireOldValue, long lastModified,
      boolean overwriteDestroyed) {
    boolean retVal = false;
    final InternalRegion r = event.getRegion();
    PartitionedRegion pr = (PartitionedRegion) r;
    try {
      retVal =
          pr.putRemotely(state.getTarget(), event, ifNew, ifOld, expectedOldValue, requireOldValue);
    } catch (PrimaryBucketException e) {
      RuntimeException re = getTransactionException(event.getKeyInfo(), e);
      re.initCause(e);
      throw re;
    } catch (ForceReattemptException e) {
      waitToRetry();
      RuntimeException re = getTransactionException(event.getKeyInfo(), e);
      re.initCause(e);
      throw re;
    }
    trackBucketForTx(event.getKeyInfo());
    return retVal;
  }

  /**
   * Create PutAllPRMsgs for each bucket, and send them.
   *
   * @param putallO DistributedPutAllOperation object.
   */
  @Override
  public void postPutAll(DistributedPutAllOperation putallO, VersionedObjectList successfulPuts,
      InternalRegion r) throws TransactionException {
    if (r.getCache().isCacheAtShutdownAll()) {
      throw r.getCache().getCacheClosedException("Cache is shutting down");
    }

    PartitionedRegion pr = (PartitionedRegion) r;
    final long startTime = pr.prStats.getTime();
    // build all the msgs by bucketid
    HashMap prMsgMap = putallO.createPRMessages();
    PutAllPartialResult partialKeys = new PutAllPartialResult(putallO.putAllDataSize);

    successfulPuts.clear(); // this is rebuilt by this method
    for (final Object o : prMsgMap.entrySet()) {
      Map.Entry mapEntry = (Map.Entry) o;
      Integer bucketId = (Integer) mapEntry.getKey();
      PutAllPRMessage prMsg = (PutAllPRMessage) mapEntry.getValue();
      pr.checkReadiness();
      try {
        VersionedObjectList versions = sendMsgByBucket(bucketId, prMsg, pr);
        // prMsg.saveKeySet(partialKeys);
        partialKeys.addKeysAndVersions(versions);
        successfulPuts.addAll(versions);
      } catch (PutAllPartialResultException pre) {
        // sendMsgByBucket applied partial keys
        partialKeys.consolidate(pre.getResult());
      } catch (Exception ex) {
        // If failed at other exception
        @Released
        EntryEventImpl firstEvent = prMsg.getFirstEvent(pr);
        try {
          partialKeys.saveFailedKey(firstEvent.getKey(), ex);
        } finally {
          firstEvent.release();
        }
      }
    }
    pr.prStats.endPutAll(startTime);

    if (partialKeys.hasFailure()) {
      pr.getCache().getLogger().info(String.format("Region %s putAll: %s",
          pr.getFullPath(), partialKeys));
      if (putallO.isBridgeOperation()) {
        if (partialKeys.getFailure() instanceof CancelException) {
          throw (CancelException) partialKeys.getFailure();
        } else {
          throw new PutAllPartialResultException(partialKeys);
        }
      } else {
        if (partialKeys.getFailure() instanceof RuntimeException) {
          throw (RuntimeException) partialKeys.getFailure();
        } else {
          throw new RuntimeException(partialKeys.getFailure());
        }
      }
    }
  }

  @Override
  public void postRemoveAll(DistributedRemoveAllOperation op, VersionedObjectList successfulOps,
      InternalRegion r) {
    if (r.getCache().isCacheAtShutdownAll()) {
      throw r.getCache().getCacheClosedException("Cache is shutting down");
    }

    PartitionedRegion pr = (PartitionedRegion) r;
    final long startTime = pr.prStats.getTime();
    // build all the msgs by bucketid
    HashMap<Integer, RemoveAllPRMessage> prMsgMap = op.createPRMessages();
    PutAllPartialResult partialKeys = new PutAllPartialResult(op.removeAllDataSize);

    successfulOps.clear(); // this is rebuilt by this method
    for (final Map.Entry<Integer, RemoveAllPRMessage> mapEntry : prMsgMap.entrySet()) {
      Integer bucketId = mapEntry.getKey();
      RemoveAllPRMessage prMsg = mapEntry.getValue();
      pr.checkReadiness();
      try {
        VersionedObjectList versions = sendMsgByBucket(bucketId, prMsg, pr);
        // prMsg.saveKeySet(partialKeys);
        partialKeys.addKeysAndVersions(versions);
        successfulOps.addAll(versions);
      } catch (PutAllPartialResultException pre) {
        // sendMsgByBucket applied partial keys
        partialKeys.consolidate(pre.getResult());
      } catch (Exception ex) {
        // If failed at other exception
        @Released
        EntryEventImpl firstEvent = prMsg.getFirstEvent(pr);
        try {
          partialKeys.saveFailedKey(firstEvent.getKey(), ex);
        } finally {
          firstEvent.release();
        }
      }
    }
    pr.prStats.endRemoveAll(startTime);

    if (partialKeys.hasFailure()) {
      pr.getCache().getLogger().info(String.format("Region %s removeAll: %s",
          pr.getFullPath(), partialKeys));
      if (op.isBridgeOperation()) {
        if (partialKeys.getFailure() instanceof CancelException) {
          throw (CancelException) partialKeys.getFailure();
        } else {
          throw new PutAllPartialResultException(partialKeys);
        }
      } else {
        if (partialKeys.getFailure() instanceof RuntimeException) {
          throw (RuntimeException) partialKeys.getFailure();
        } else {
          throw new RuntimeException(partialKeys.getFailure());
        }
      }
    }

  }


  /*
   * If failed after retries, it will throw PartitionedRegionStorageException, no need for return
   * value
   */
  private VersionedObjectList sendMsgByBucket(final Integer bucketId, PutAllPRMessage prMsg,
      PartitionedRegion pr) {
    // retry the put remotely until it finds the right node managing the bucket
    InternalDistributedMember currentTarget =
        pr.getOrCreateNodeForBucketWrite(bucketId, null);
    if (!currentTarget.equals(state.getTarget())) {
      @Released
      EntryEventImpl firstEvent = prMsg.getFirstEvent(pr);
      try {
        throw new TransactionDataNotColocatedException(
            String.format("Key %s is not colocated with transaction",
                firstEvent.getKey()));
      } finally {
        firstEvent.release();
      }
    }
    try {
      return pr.tryToSendOnePutAllMessage(prMsg, currentTarget);
    } catch (ForceReattemptException prce) {
      pr.checkReadiness();
      throw new TransactionDataNotColocatedException(prce.getMessage());
    } catch (PrimaryBucketException notPrimary) {
      RuntimeException re = new TransactionDataRebalancedException(
          "Transactional data moved, due to rebalancing.", notPrimary);
      throw re;
    } catch (DataLocationException dle) {
      throw new TransactionException(dle);
    }
  }

  /*
   * If failed after retries, it will throw PartitionedRegionStorageException, no need for return
   * value
   */
  private VersionedObjectList sendMsgByBucket(final Integer bucketId, RemoveAllPRMessage prMsg,
      PartitionedRegion pr) {
    // retry the put remotely until it finds the right node managing the bucket
    InternalDistributedMember currentTarget =
        pr.getOrCreateNodeForBucketWrite(bucketId, null);
    if (!currentTarget.equals(state.getTarget())) {
      @Released
      EntryEventImpl firstEvent = prMsg.getFirstEvent(pr);
      try {
        throw new TransactionDataNotColocatedException(
            String.format("Key %s is not colocated with transaction",
                firstEvent.getKey()));
      } finally {
        firstEvent.release();
      }
    }
    try {
      return pr.tryToSendOneRemoveAllMessage(prMsg, currentTarget);
    } catch (ForceReattemptException prce) {
      pr.checkReadiness();
      throw new TransactionDataNotColocatedException(prce.getMessage());
    } catch (PrimaryBucketException notPrimary) {
      RuntimeException re = new TransactionDataRebalancedException(
          "Transactional data moved, due to rebalancing.", notPrimary);
      throw re;
    } catch (DataLocationException dle) {
      throw new TransactionException(dle);
    }
  }


  @Override
  public void cleanup() {}

  @Override
  protected InternalRegion getRegion() {
    return region;
  }

}
