/*
 * arcus-spring - Arcus as a caching provider for the Spring Cache Abstraction
 * Copyright 2011-2014 NAVER Corp.
 *
 * Licensed 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 com.navercorp.arcus.spring.cache;

import com.navercorp.arcus.spring.concurrent.DefaultKeyLockProvider;
import com.navercorp.arcus.spring.concurrent.KeyLockProvider;
import net.spy.memcached.ArcusClientPool;
import net.spy.memcached.internal.OperationFuture;
import net.spy.memcached.transcoders.Transcoder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.cache.Cache;
import org.springframework.cache.support.SimpleValueWrapper;
import org.springframework.util.Assert;
import org.springframework.util.DigestUtils;

import java.util.concurrent.Callable;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;

/**
 * 스프링 Cache의 Arcus 구현체.
 * <p>
 * Arcus의 key 구조는 prefix:subkey 입니다. prefix는 사용자가 그룹으로 생성하고자 하는 subkey들의 집합이며
 * ArcusCache에서는 서비스 또는 빌드 단계 등의 구분을 위해 serviceId + name으로 정의합니다. serviceCode
 * 속성과 name는 반드시 설정되어야 합니다.
 * <p>
 * <pre class="code">
 * <p>
 * <bean id="operationTranscoderA" class="net.spy.memcached.transcoders.SerializingTranscoder">
 * <property name="charset" value="UTF-8" />
 * <property name="compressionThreshold" value="400" />
 * </bean>
 * <p>
 * <bean id="operationTranscoderB" class="net.spy.memcached.transcoders.SerializingTranscoder">
 * <property name="charset" value="UTF-8" />
 * <property name="compressionThreshold" value="1024" />
 * </bean>
 * <p>
 * <bean id="arcusCacheManager" class="org.springframework.cache.support.SimpleCacheManager">
 * <property name="caches">
 * <list>
 * <bean p:name="member" p:timeoutMilliSeconds="500" parent="defaultArcusCache" p:operationTranscoder-ref="operationTranscoderA" />
 * <bean p:name="memberList" p:expireSeconds="3000"	parent="defaultArcusCache" p:operationTranscoder-ref="operationTranscoderB" />
 * </list>
 * </property>
 * </bean>
 * <p>
 * <bean id="defaultArcusCache" class="com.navercorp.arcus.spring.cache.ArcusCache"
 * p:arcusClient-ref="arcusClient" p:timeoutMilliSeconds="500"
 * p:expireSeconds="3000" abstract="true" serviceId="beta-" />
 * <p>
 * </pre>
 * <p>
 * 이렇게 설정했을때, 캐시의 키 값으로 생성되는 값은 <span>beta-member:메서드 매개변수로 만든 문자열</span>이 됩니다.
 */
@SuppressWarnings("DeprecatedIsStillUsed")
public class ArcusCache implements Cache, InitializingBean {

  private Logger logger = LoggerFactory.getLogger(this.getClass());

  private String name;
  private String prefix;
  private String serviceId;
  private int expireSeconds;
  private long timeoutMilliSeconds = 300L;
  private ArcusClientPool arcusClient;
  @Deprecated
  private boolean wantToGetException;
  private Transcoder<Object> operationTranscoder;
  private KeyLockProvider keyLockProvider = new DefaultKeyLockProvider();

  @Override
  public String getName() {
    return this.name;
  }

  @Override
  public Object getNativeCache() {
    return this.arcusClient;
  }

  @Override
  public ValueWrapper get(Object key) {
    Object value = null;
    String cacheKey = null;
    try {
      cacheKey = createArcusKey(key);
      logger.debug("getting value by key: {}", cacheKey);

      Future<Object> future;

      // operation transcoder can't be null.
      if (operationTranscoder != null) {
        future = arcusClient.asyncGet(cacheKey, operationTranscoder);
      } else {
        future = arcusClient.asyncGet(cacheKey);
      }

      value = future.get(timeoutMilliSeconds, TimeUnit.MILLISECONDS);
    } catch (Exception e) {
      logger.debug(e.getMessage());
      if (wantToGetException) {
        throw new RuntimeException(e);
      }
    }
    return (value != null ? new SimpleValueWrapper(value) : null);
  }

  @SuppressWarnings("unchecked")
  @Override
  public <T> T get(Object key, Class<T> type) {
    try {
      Object value = getValue(createArcusKey(key));
      if (value != null && type != null && !type.isInstance(value)) {
        throw new IllegalStateException("Cached value is not of required type [" + type.getName() + "]: " + value);
      }
      return (T) value;
    } catch (Exception e) {
      logger.debug(e.getMessage());
      throw toRuntimeException(e);
    }
  }

  @SuppressWarnings("unchecked")
  @Override
  public <T> T get(Object key, Callable<T> valueLoader) {
    String arcusKey = createArcusKey(key);
    Object value;
    try {
      value = getValue(arcusKey);
      if (value != null) {
        return (T) value;
      }
    } catch (Exception e) {
      logger.debug(e.getMessage());
      throw toRuntimeException(e);
    }

    try {
      acquireWriteLockOnKey(arcusKey);
      value = getValue(arcusKey);
      if (value != null) {
        return (T) value;
      } else {
        value = valueLoader.call();
        putValue(arcusKey, value);
        return (T) value;
      }
    } catch (Exception e) {
      logger.debug(e.getMessage());
      throw toRuntimeException(e);
    } finally {
      releaseWriteLockOnKey(arcusKey);
    }
  }

  @Override
  public void put(final Object key, final Object value) {
    try {
      String cacheKey = createArcusKey(key);
      logger.debug("trying to put key: {}, value: {}", cacheKey,
              value != null ? value.getClass().getName() : null);

      if (value == null) {
        logger.info("arcus cannot put NULL value. key: {}, value: {}",
                key.toString(), value);
        return;
      }

      Future<Boolean> future;

      if (operationTranscoder != null) {
        future = arcusClient.set(cacheKey, expireSeconds, value,
                operationTranscoder);
      } else {
        future = arcusClient.set(cacheKey, expireSeconds, value);
      }

      boolean success = future.get(timeoutMilliSeconds,
              TimeUnit.MILLISECONDS);

      if (logger.isDebugEnabled() && !success) {
        logger.debug("failed to put a key: {}, value: {}",
                key.toString(), value);
      }
    } catch (Exception e) {
      logger.info("error: {}, with value: {}", e.getMessage(), value);
      if (wantToGetException) {
        throw new RuntimeException(e);
      }
    }
  }

  /**
   * @param key key
   * @param value value
   * @return 지정된 키에 대한 캐시 아이템이 존재하지 않았으며 지정된 값을 캐시에 저장하였다면 null을 리턴,
   * 캐시 아이템이 이미 존재하였다면 지정된 키에 대한 값을 불러와 리턴한다. 값을 불러올 때 비원자적으로
   * 수행되기 때문에 중간에 다른 캐시 연산 수행으로 인하여 새로운 값이 리턴 될 수 있으며 혹은 캐시 만료로 인해
   * ValueWrapper의 내부 value가 null이 되어 리턴될 수 있다.
   */
  @Override
  public ValueWrapper putIfAbsent(Object key, Object value) {
    try {
      String arcusKey = createArcusKey(key);
      logger.debug("trying to add key: {}, value: {}", arcusKey,
              value != null ? value.getClass().getName() : null);

      if (value == null) {
        throw new IllegalArgumentException("arcus cannot add NULL value. key: " +
                arcusKey);
      }

      Future<Boolean> future;

      if (operationTranscoder != null) {
        future = arcusClient.add(arcusKey, expireSeconds, value,
                operationTranscoder);
      } else {
        future = arcusClient.add(arcusKey, expireSeconds, value);
      }

      boolean added = future.get(timeoutMilliSeconds,
              TimeUnit.MILLISECONDS);

      return added ? null : new SimpleValueWrapper(getValue(arcusKey)); // FIXME: maybe returned with a different value.
    } catch (Exception e) {
      logger.debug(e.getMessage());
      throw toRuntimeException(e);
    }
  }

  @Override
  public void evict(final Object key) {
    try {
      String cacheKey = createArcusKey(key);
      if (logger.isDebugEnabled()) {
        logger.debug("evicting a key: {}", cacheKey);
      }

      Future<Boolean> future = arcusClient.delete(cacheKey);

      boolean success = future.get(timeoutMilliSeconds,
              TimeUnit.MILLISECONDS);

      if (logger.isDebugEnabled() && !success) {
        logger.debug("failed to evivt a key: {}", key.toString());
      }
    } catch (Exception e) {
      logger.info(e.getMessage());
      if (wantToGetException) {
        throw new RuntimeException(e);
      }
    }
  }

  @Override
  public void clear() {
    try {
      String prefixName = (prefix != null) ? prefix : name;
      if (logger.isDebugEnabled()) {
        logger.debug("evicting every key that uses the name: {}",
                prefixName);
      }

      OperationFuture<Boolean> future = arcusClient.flush(serviceId
              + prefixName);

      boolean success = future.get(timeoutMilliSeconds,
              TimeUnit.MILLISECONDS);

      if (logger.isDebugEnabled() && !success) {
        logger.debug(
                "failed to evicting every key that uses the name: {}",
                prefixName);
      }
    } catch (Exception e) {
      logger.info(e.getMessage());
      if (wantToGetException) {
        throw new RuntimeException(e);
      }
    }
  }

  /**
   * serviceId, prefix, name 값을 사용하여 아커스 키를 생성합니다. serviceId는 필수값이며, prefix 또는
   * name 둘 중에 하나가 반드시 있어야 합니다. name과 prefix값이 모두 있다면 prefix 값을 사용합니다.
   * <p>
   * 키 생성 로직은 다음과 같습니다.
   * <p>
   * serviceId + (prefix | name) + ":" + key.toString();
   * <p>
   * 만약 전체 키의 길이가 250자를 넘을 경우에는 key.toString() 대신 그 값을 MD5로 압축한 값을 사용합니다.
   *
   * @param key
   * @return
   */
  public String createArcusKey(final Object key) {
    Assert.notNull(key);
    String keyString, arcusKey;

    if (key instanceof ArcusStringKey) {
      keyString = ((ArcusStringKey) key).getStringKey().replace(' ', '_') +
              String.valueOf(((ArcusStringKey) key).getHash());
    } else if (key instanceof Integer) {
      keyString = key.toString();
    } else {
      keyString = key.toString();
      int hash = ArcusStringKey.light_hash(keyString);
      keyString = keyString.replace(' ', '_') + String.valueOf(hash);
    }

    arcusKey = serviceId + name + ":" + keyString;
    if (this.prefix != null) {
      arcusKey = serviceId + prefix + ":" + keyString;
    }
    if (arcusKey.length() > 250) {
      String digestedString = DigestUtils.md5DigestAsHex(keyString
              .getBytes());
      arcusKey = serviceId + name + ":" + digestedString;
      if (this.prefix != null) {
        arcusKey = serviceId + prefix + ":" + digestedString;
      }
    }
    return arcusKey;
  }

  public void setName(String name) {
    this.name = name;
  }

  public void setExpireSeconds(int expireSeconds) {
    this.expireSeconds = expireSeconds;
  }

  public void setTimeoutMilliSeconds(long timeoutMilliseconds) {
    this.timeoutMilliSeconds = timeoutMilliseconds;
  }

  public void setArcusClient(ArcusClientPool arcusClient) {
    this.arcusClient = arcusClient;
  }

  @Override
  public void afterPropertiesSet() throws Exception {
    if (name == null && prefix == null) {
      throw new IllegalArgumentException(
              "ArcusCache's 'name' or 'prefix' property must have a value.");
    }
    Assert.notNull(serviceId,
            "ArcusCache's serviceId property must have a value.");
  }

  public String getServiceId() {
    return serviceId;
  }

  public void setServiceId(String serviceId) {
    this.serviceId = serviceId;
  }

  @Deprecated
  public boolean isWantToGetException() {
    return wantToGetException;
  }

  @Deprecated
  public void setWantToGetException(boolean wantToGetException) {
    this.wantToGetException = wantToGetException;
  }

  public int getExpireSeconds() {
    return expireSeconds;
  }

  public long getTimeoutMilliSeconds() {
    return timeoutMilliSeconds;
  }

  public ArcusClientPool getArcusClient() {
    return arcusClient;
  }

  public Transcoder<Object> getOperationTranscoder() {
    return operationTranscoder;
  }

  public void setOperationTranscoder(Transcoder<Object> operationTranscoder) {
    this.operationTranscoder = operationTranscoder;
  }

  public String getPrefix() {
    return prefix;
  }

  public void setPrefix(String prefix) {
    this.prefix = prefix;
  }

  public KeyLockProvider getKeyLockProvider() {
    return keyLockProvider;
  }

  public void setKeyLockProvider(KeyLockProvider keyLockProvider) {
    this.keyLockProvider = keyLockProvider;
  }

  private void acquireWriteLockOnKey(String arcusKey) {
    keyLockProvider.getLockForKey(arcusKey).writeLock().lock();
  }

  private void releaseWriteLockOnKey(String arcusKey) {
    keyLockProvider.getLockForKey(arcusKey).writeLock().unlock();
  }

  private RuntimeException toRuntimeException(Exception e) {
    if (e instanceof RuntimeException) {
      return (RuntimeException) e;
    } else {
      return new RuntimeException(e);
    }
  }

  private ValueWrapper toValueWrapper(Object value) {
    return (value != null ? new SimpleValueWrapper(value) : null);
  }

  private Object getValue(String arcusKey) throws Exception {
    logger.debug("getting value by key: {}", arcusKey);

    Future<Object> future;

    // operation transcoder can't be null.
    if (operationTranscoder != null) {
      future = arcusClient.asyncGet(arcusKey, operationTranscoder);
    } else {
      future = arcusClient.asyncGet(arcusKey);
    }

    return future.get(timeoutMilliSeconds, TimeUnit.MILLISECONDS);
  }

  private void putValue(String arcusKey, Object value) throws Exception {
    logger.debug("trying to put key: {}, value: {}", arcusKey,
            value != null ? value.getClass().getName() : null);

    if (value == null) {
      throw new IllegalArgumentException("arcus cannot put NULL value. key: " +
              arcusKey);
    }

    Future<Boolean> future;

    if (operationTranscoder != null) {
      future = arcusClient.set(arcusKey, expireSeconds, value,
              operationTranscoder);
    } else {
      future = arcusClient.set(arcusKey, expireSeconds, value);
    }

    boolean success = future.get(timeoutMilliSeconds,
            TimeUnit.MILLISECONDS);

    if (logger.isDebugEnabled() && !success) {
      logger.debug("failed to put a key: {}, value: {}",
              arcusKey, value);
    }
  }
}