#! /usr/bin/env ruby
#
# Copyright (C) 2002  Yoshinori K. Okuji <okuji@enbug.org>
#
# You may redistribute it and/or modify it under the same term as Ruby.

# Cache manager based on the LRU algorithm.
class Cache

  CACHE_OBJECT = Struct.new('CacheObject', :content, :size, :atime)
  CACHE_VERSION = '0.3'

  include Enumerable
  
  def self.version
    CACHE_VERSION
  end

  # initialize(max_obj_size = nil, max_size = nil, max_num = nil,
  #            expiration = nil, &hook)
  # initialize(hash, &hook)
  def initialize(*args, &hook)
    if args.size == 1 and args[0].kind_of?(Hash)
      @max_obj_size = @max_size = @max_num = @expiration = nil
      args[0].each do |k, v|
	k = k.intern if k.respond_to?(:intern)
	case k
	when :max_obj_size
	  @max_obj_size = v
	when :max_size
	  @max_size = v
	when :max_num
	  @max_num = v
	when :expiration
	  @expiration = v
	end
      end
    else
      @max_obj_size, @max_size, @max_num, @expiration = args
    end

    # Sanity checks.
    if @max_obj_size and @max_size and @max_obj_size > @max_size
      raise ArgumentError, "max_obj_size exceeds max_size (#{@max_obj_size} > #{@max_size})"
    end
    if @max_obj_size and @max_obj_size <= 0
      raise ArgumentError, "invalid max_obj_size `#{@max_obj_size}'"
    end
    if @max_size and @max_size <= 0
      raise ArgumentError, "invalid max_size `#{@max_size}'"
    end
    if @max_num and @max_num <= 0
      raise ArgumentError, "invalid max_num `#{@max_num}'"
    end
    if @expiration and @expiration <= 0
      raise ArgumentError, "invalid expiration `#{@expiration}'"
    end
    
    @hook = hook
    
    @objs = {}
    @size = 0
    @list = []
    
    @hits = 0
    @misses = 0
  end

  attr_reader :max_obj_size, :max_size, :max_num, :expiration

  def cached?(key)
    @objs.include?(key)
  end
  alias :include? :cached?
  alias :member? :cached?
  alias :key? :cached?
  alias :has_key? :cached?

  def cached_value?(val)
    self.each_value do |v|
      return true if v == val
    end
    false
  end
  alias :has_value? :cached_value?
  alias :value? :cached_value?

  def index(val)
    self.each_pair do |k,v|
      return k if v == val
    end
    nil
  end

  def keys
    @objs.keys
  end

  def length
    @objs.length
  end
  alias :size :length

  def to_hash
    @objs.dup
  end

  def values
    @objs.collect {|key, obj| obj.content}
  end
  
  def invalidate(key)
    obj = @objs[key]
    if obj
      if @hook
	@hook.call(key, obj.content)
      end
      @size -= obj.size
      @objs.delete(key)
      @list.each_index do |i|
	if @list[i] == key
	  @list.delete_at(i)
	  break
	end
      end
    elsif block_given?
      return yield(key)
    end
    obj.content
  end
  alias :delete :invalidate

  def invalidate_all()
    if @hook
      @objs.each do |key, obj|
	@hook.call(key, obj)
      end
    end

    @objs.clear
    @list.clear
    @size = 0
  end
  alias :clear :invalidate_all
  
  def expire()
    if @expiration
      now = Time.now.to_i
      @list.each_index do |i|
	key = @list[i]
	
	break unless @objs[key].atime + @expiration <= now
	self.invalidate(key)
      end
    end
#    GC.start
  end
	
  def [](key)
    self.expire()
    
    unless @objs.include?(key)
      @misses += 1
      return nil
    end
    
    obj = @objs[key]
    obj.atime = Time.now.to_i

    @list.each_index do |i|
      if @list[i] == key
	@list.delete_at(i)
	break
      end
    end
    @list.push(key)

    @hits += 1
    obj.content
  end
  
  def []=(key, obj)
    self.expire()
    
    if self.cached?(key)
      self.invalidate(key)
    end

    size = obj.to_s.size
    if @max_obj_size and @max_obj_size < size
      if $DEBUG
	$stderr.puts("warning: `#{obj.inspect}' isn't cached because its size exceeds #{@max_obj_size}")
      end
      return obj
    end
    if @max_obj_size.nil? and @max_size and @max_size < size
      if $DEBUG
	$stderr.puts("warning: `#{obj.inspect}' isn't cached because its size exceeds #{@max_size}")
      end
      return obj
    end
      
    if @max_num and @max_num == @list.size
      self.invalidate(@list.first)
    end

    @size += size
    if @max_size
      while @max_size < @size
	self.invalidate(@list.first)
      end
    end

    @objs[key] = CACHE_OBJECT.new(obj, size, Time.now.to_i)
    @list.push(key)

    obj
  end

  def store(key, value)
    self[key] = value
  end

  def each_pair
    @objs.each do |key, obj|
      yield key, obj.content
    end
    self
  end
  alias :each :each_pair

  def each_key
    @objs.each_key do |key|
      yield key
    end
    self
  end

  def each_value
    @objs.each_value do |obj|
      yield obj.content
    end
    self
  end

  def empty?
    @objs.empty?
  end

  def fetch(key, default = nil)
    val = self[key]
    if val.nil?
      if default
	val = self[key] = default
      elsif block_given?
	val = self[key] = yield(key)
      else
	raise IndexError, "invalid key `#{key}'"
      end
    end
    val
  end
  
  # The total size of cached objects, the number of cached objects,
  # the number of cache hits, and the number of cache misses.
  def statistics()
    [@size, @list.size, @hits, @misses]
  end
end

# Run a test, if executed.
if __FILE__ == $0
  cache = Cache.new(100 * 1024, 100 * 1024 * 1024, 256, 1)
  1000.times do
    key = rand(1000)
    cache[key] = key.to_s
  end
  1000.times do
    key = rand(1000)
    puts cache[key]
  end
  sleep 1
  1000.times do
    key = rand(1000)
    puts cache[key]
  end
  
  stat = cache.statistics()
  hits = stat[2]
  misses = stat[3]
  ratio = hits.to_f / (hits + misses)
  
  puts "Total size:\t#{stat[0]}"
  puts "Number:\t\t#{stat[1]}"
  puts "Hit ratio:\t#{ratio * 100}% (#{hits} / #{hits + misses})"
end
