require 'thinking_sphinx/index/builder'
require 'thinking_sphinx/index/faux_column'

module ThinkingSphinx
  # The Index class is a ruby representation of a Sphinx source (not a Sphinx
  # index - yes, I know it's a little confusing. You'll manage). This is
  # another 'internal' Thinking Sphinx class - if you're using it directly,
  # you either know what you're doing, or messing with things beyond your ken.
  # Enjoy.
  # 
  class Index
    attr_accessor :model, :fields, :attributes, :conditions, :delta, :options
    
    # Create a new index instance by passing in the model it is tied to, and
    # a block to build it with (optional but recommended). For documentation
    # on the syntax for inside the block, the Builder class is what you want.
    #
    # Quick Example:
    #
    #   Index.new(User) do
    #     indexes login, email
    #     
    #     has created_at
    #     
    #     set_property :delta => true
    #   end
    #
    def initialize(model, &block)
      @model        = model
      @associations = {}
      @fields       = []
      @attributes   = []
      @conditions   = []
      @options      = {}
      @delta        = false
      
      initialize_from_builder(&block) if block_given?
    end
    
    def name
      model.name.underscore.tr(':/\\', '_')
    end
    
    def empty?(part = :core)
      config = ThinkingSphinx::Configuration.new
      File.size?("#{config.searchd_file_path}/#{self.name}_#{part}.spa").nil?
    end
    
    def to_config(index, database_conf, charset_type)
      # Set up associations and joins
      link!
      
      attr_sources = attributes.collect { |attrib|
        attrib.to_sphinx_clause
      }.join("\n  ")
      
      db_adapter = case adapter
      when :postgres
        "pgsql"
      when :mysql
        "mysql"
      else
        raise "Unsupported Database Adapter: Sphinx only supports MySQL and PosgreSQL"
      end
      
      config = <<-SOURCE

source #{model.indexes.first.name}_#{index}_core
{
type     = #{db_adapter}
sql_host = #{database_conf[:host] || "localhost"}
sql_user = #{database_conf[:username]}
sql_pass = #{database_conf[:password]}
sql_db   = #{database_conf[:database]}

sql_query_pre    = #{charset_type == "utf-8" && adapter == :mysql ? "SET NAMES utf8" : ""}
#{"sql_query_pre    = SET SESSION group_concat_max_len = #{@options[:group_concat_max_len]}" if @options[:group_concat_max_len]}
sql_query_pre    = #{to_sql_query_pre}
sql_query        = #{to_sql.gsub(/\n/, ' ')}
sql_query_range  = #{to_sql_query_range}
sql_query_info   = #{to_sql_query_info}
#{attr_sources}
}
      SOURCE
      
      if delta?
        config += <<-SOURCE

source #{model.indexes.first.name}_#{index}_delta : #{model.indexes.first.name}_#{index}_core
{
sql_query_pre    = 
sql_query_pre    = #{charset_type == "utf-8" && adapter == :mysql ? "SET NAMES utf8" : ""}
#{"sql_query_pre    = SET SESSION group_concat_max_len = #{@options[:group_concat_max_len]}" if @options[:group_concat_max_len]}
sql_query        = #{to_sql(:delta => true).gsub(/\n/, ' ')}
sql_query_range  = #{to_sql_query_range :delta => true}
}
        SOURCE
      end
      
      config
    end
    
    # Link all the fields and associations to their corresponding
    # associations and joins. This _must_ be called before interrogating
    # the index's fields and associations for anything that may reference
    # their SQL structure.
    # 
    def link!
      base = ::ActiveRecord::Associations::ClassMethods::JoinDependency.new(
        @model, [], nil
      )
      
      @fields.each { |field|
        field.model ||= @model
        field.columns.each { |col|
          field.associations[col] = associations(col.__stack.clone)
          field.associations[col].each { |assoc| assoc.join_to(base) }
        }
      }
      
      @attributes.each { |attribute|
        attribute.model ||= @model
        attribute.columns.each { |col|
          attribute.associations[col] = associations(col.__stack.clone)
          attribute.associations[col].each { |assoc| assoc.join_to(base) }
        }
      }
    end
    
    # Generates the big SQL statement to get the data back for all the fields
    # and attributes, using all the relevant association joins. If you want
    # the version filtered for delta values, send through :delta => true in the
    # options. Won't do much though if the index isn't set up to support a
    # delta sibling.
    # 
    # Examples:
    # 
    #   index.to_sql
    #   index.to_sql(:delta => true)
    #
    def to_sql(options={})
      assocs = all_associations
      
      where_clause = ""
      if self.delta?
        where_clause << " AND #{@model.quoted_table_name}.#{quote_column('delta')}" +" = #{options[:delta] ? db_boolean(true) : db_boolean(false)}"
      end
      unless @conditions.empty?
        where_clause << " AND " << @conditions.join(" AND ")
      end
      
      sql = <<-SQL
SELECT #{ (
  ["#{@model.quoted_table_name}.#{quote_column(@model.primary_key)}"] + 
  @fields.collect { |field| field.to_select_sql } +
  @attributes.collect { |attribute| attribute.to_select_sql }
).join(", ") }
FROM #{ @model.table_name }
  #{ assocs.collect { |assoc| assoc.to_sql }.join(' ') }
WHERE #{@model.quoted_table_name}.#{quote_column(@model.primary_key)} >= $start
  AND #{@model.quoted_table_name}.#{quote_column(@model.primary_key)} <= $end
  #{ where_clause }
GROUP BY #{ (
  ["#{@model.quoted_table_name}.#{quote_column(@model.primary_key)}"] + 
  @fields.collect { |field| field.to_group_sql }.compact +
  @attributes.collect { |attribute| attribute.to_group_sql }.compact
).join(", ") }
      SQL
      
      if @model.connection.class.name == "ActiveRecord::ConnectionAdapters::MysqlAdapter"
        sql += " ORDER BY NULL"
      end
      
      sql
    end
    
    # Simple helper method for the query info SQL - which is a statement that
    # returns the single row for a corresponding id.
    # 
    def to_sql_query_info
      "SELECT * FROM #{@model.quoted_table_name} WHERE " +
      " #{quote_column(@model.primary_key)} = $id"
    end
    
    # Simple helper method for the query range SQL - which is a statement that
    # returns minimum and maximum id values. These can be filtered by delta -
    # so pass in :delta => true to get the delta version of the SQL.
    # 
    def to_sql_query_range(options={})
      min_statement = "MIN(#{quote_column(@model.primary_key)})"
      max_statement = "MAX(#{quote_column(@model.primary_key)})"
      
      # Fix to handle Sphinx PostgreSQL bug (it doesn't like NULLs or 0's)
      if adapter == :postgres
        min_statement = "COALESCE(#{min_statement}, 1)"
        max_statement = "COALESCE(#{max_statement}, 1)"
      end
      
      sql = "SELECT #{min_statement}, #{max_statement} " +
            "FROM #{@model.quoted_table_name} "
      sql << "WHERE #{@model.quoted_table_name}.#{quote_column('delta')} " + 
            "= #{options[:delta] ? db_boolean(true) : db_boolean(false)}" if self.delta?
      sql
    end
    
    # Returns the SQL query to run before a full index - ie: nothing unless the
    # index has a delta, and then it's an update statement to set delta values
    # back to 0.
    #
    def to_sql_query_pre
      self.delta? ? "UPDATE #{@model.quoted_table_name} SET #{quote_column('delta')} = #{db_boolean(false)}" : ""
    end
    
    # Flag to indicate whether this index has a corresponding delta index.
    #
    def delta?
      @delta
    end
    
    def adapter
      @adapter ||= case @model.connection.class.name
      when "ActiveRecord::ConnectionAdapters::MysqlAdapter"
        :mysql
      when "ActiveRecord::ConnectionAdapters::PostgreSQLAdapter"
        :postgres
      else
        raise "Invalid Database Adapter: Sphinx only supports MySQL and PostgreSQL"
      end
    end
    
    def prefix_fields
      @fields.select { |field| field.prefixes }
    end
    
    def infix_fields
      @fields.select { |field| field.infixes }
    end
    
    private
    
    def quote_column(column)
      @model.connection.quote_column_name(column)
    end
    
    # Does all the magic with the block provided to the base #initialize.
    # Creates a new class subclassed from Builder, and evaluates the block
    # on it, then pulls all relevant settings - fields, attributes, conditions,
    # properties - into the new index.
    # 
    # Also creates a CRC attribute for the model.
    # 
    def initialize_from_builder(&block)
      builder = Class.new(Builder)
      builder.setup
      
      builder.instance_eval &block
      
      unless @model.descends_from_active_record?
        stored_class = @model.store_full_sti_class ? @model.name : @model.name.demodulize
        builder.where("#{@model.quoted_table_name}.#{quote_column(@model.inheritance_column)} = '#{stored_class}'")
      end

      @fields     = builder.fields
      @attributes = builder.attributes
      @conditions = builder.conditions
      @delta      = builder.properties[:delta]
      @options    = builder.properties.except(:delta)
      
      @attributes << Attribute.new(
        FauxColumn.new(@model.to_crc32.to_s),
        :type => :integer,
        :as   => :class_crc
      )
      @attributes << Attribute.new(
        FauxColumn.new("0"),
        :type => :integer,
        :as   => :sphinx_deleted
      )
    end
    
    # Returns all associations used amongst all the fields and attributes.
    # This includes all associations between the model and what the actual
    # columns are from.
    # 
    def all_associations
      @all_associations ||= (
        # field associations
        @fields.collect { |field|
          field.associations.values
        }.flatten +
        # attribute associations
        @attributes.collect { |attrib|
          attrib.associations.values
        }.flatten
      ).uniq.collect { |assoc|
        # get ancestors as well as column-level associations
        assoc.ancestors
      }.flatten.uniq
    end
    
    # Gets a stack of associations for a specific path.
    # 
    def associations(path, parent = nil)
      assocs = []
      
      if parent.nil?
        assocs = association(path.shift)
      else
        assocs = parent.children(path.shift)
      end
      
      until path.empty?
        point = path.shift
        assocs = assocs.collect { |assoc|
          assoc.children(point)
        }.flatten
      end
      
      assocs
    end
    
    # Gets the association stack for a specific key.
    # 
    def association(key)
      @associations[key] ||= Association.children(@model, key)
    end

    # Returns the proper boolean value string literal for the
    # current database adapter.
    #
    def db_boolean(val)
      if adapter == :postgres
        val ? 'TRUE' : 'FALSE'
      else
        val ? '1' : '0'
      end
    end
  end
end
