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, :groupings,
      :delta_object, :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   = []
      @groupings    = []
      @options      = {}
      @delta_object = nil
      
      initialize_from_builder(&block) if block_given?
    end
    
    def name
      self.class.name(@model)
    end
    
    def self.name(model)
      model.name.underscore.tr(':/\\', '_')
    end
    
    def to_riddle_for_core(offset, index)
      add_internal_attributes_and_facets
      link!
      
      source = Riddle::Configuration::SQLSource.new(
        "#{name}_core_#{index}", adapter.sphinx_identifier
      )
      
      set_source_database_settings  source
      set_source_attributes         source, offset
      set_source_sql                source, offset
      set_source_settings           source
      
      source
    end
    
    def to_riddle_for_delta(offset, index)
      add_internal_attributes_and_facets
      link!
      
      source = Riddle::Configuration::SQLSource.new(
        "#{name}_delta_#{index}", adapter.sphinx_identifier
      )
      source.parent = "#{name}_core_#{index}"
      
      set_source_database_settings  source
      set_source_attributes         source, offset
      set_source_sql                source, offset, true
      
      source
    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
    
    # Flag to indicate whether this index has a corresponding delta index.
    #
    def delta?
      !@delta_object.nil?
    end
    
    def adapter
      @adapter ||= @model.sphinx_database_adapter
    end
        
    def prefix_fields
      @fields.select { |field| field.prefixes }
    end
    
    def infix_fields
      @fields.select { |field| field.infixes }
    end
        
    def index_options
      all_index_options = ThinkingSphinx::Configuration.instance.index_options.clone
      @options.keys.select { |key|
        ThinkingSphinx::Configuration::IndexOptions.include?(key.to_s)
      }.each { |key| all_index_options[key.to_sym] = @options[key] }
      all_index_options
    end
        
    def quote_column(column)
      @model.connection.quote_column_name(column)
    end
    
    private
    
    def utf8?
      self.index_options[:charset_type] == "utf-8"
    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
      
      set_model = Proc.new { |item| item.model = @model }
      
      @fields       = builder.fields &set_model
      @attributes   = builder.attributes.each &set_model
      @conditions   = builder.conditions
      @groupings    = builder.groupings
      @delta_object = ThinkingSphinx::Deltas.parse self, builder.properties
      @options      = builder.properties
      
      is_faceted = Proc.new { |item| item.faceted }
      add_facet  = Proc.new { |item| @model.sphinx_facets << item.to_facet }
      
      @model.sphinx_facets ||= []
      @fields.select(    &is_faceted).each &add_facet
      @attributes.select(&is_faceted).each &add_facet
      
      # We want to make sure that if the database doesn't exist, then Thinking
      # Sphinx doesn't mind when running non-TS tasks (like db:create, db:drop
      # and db:migrate). It's a bit hacky, but I can't think of a better way.
    rescue StandardError => err
      case err.class.name
      when "Mysql::Error", "Java::JavaSql::SQLException", "ActiveRecord::StatementInvalid"
        return
      else
        raise err
      end
    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 if attrib.include_as_association?
        }.compact.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

    def crc_column
      if @model.column_names.include?(@model.inheritance_column)
        adapter.cast_to_unsigned(adapter.convert_nulls(
          adapter.crc(adapter.quote_with_table(@model.inheritance_column), true),
          @model.to_crc32
        ))
      else
        @model.to_crc32.to_s
      end
    end
    
    def add_internal_attributes_and_facets
      add_internal_attribute :sphinx_internal_id, :integer, @model.primary_key.to_sym
      add_internal_attribute :class_crc,          :integer, crc_column, true
      add_internal_attribute :subclass_crcs,      :multi,   subclasses_to_s
      add_internal_attribute :sphinx_deleted,     :integer, "0"
      
      add_internal_facet :class_crc
    end
    
    def add_internal_attribute(name, type, contents, facet = false)
      return unless attribute_by_alias(name).nil?
      
      @attributes << Attribute.new(
        FauxColumn.new(contents),
        :type   => type,
        :as     => name,
        :facet  => facet,
        :admin  => true
      )
    end
    
    def add_internal_facet(name)
      return unless facet_by_alias(name).nil?
      
      @model.sphinx_facets << ClassFacet.new(attribute_by_alias(name))
    end
    
    def attribute_by_alias(attr_alias)
      @attributes.detect { |attrib| attrib.alias == attr_alias }
    end
    
    def facet_by_alias(name)
      @model.sphinx_facets.detect { |facet| facet.name == name }
    end
    
    def subclasses_to_s
      "'" + (@model.send(:subclasses).collect { |klass|
        klass.to_crc32.to_s
      } << @model.to_crc32.to_s).join(",") + "'"
    end
        
    def set_source_database_settings(source)
      config = @model.connection.instance_variable_get(:@config)
      
      source.sql_host = config[:host]           || "localhost"
      source.sql_user = config[:username]       || config[:user] || ""
      source.sql_pass = (config[:password].to_s || "").gsub('#', '\#')
      source.sql_db   = config[:database]
      source.sql_port = config[:port]
      source.sql_sock = config[:socket]
    end
    
    def set_source_attributes(source, offset = nil)
      attributes.each do |attrib|
        source.send(attrib.type_to_config) << attrib.config_value(offset)
      end
    end
    
    def set_source_sql(source, offset, delta = false)
      source.sql_query        = to_sql(:offset => offset, :delta => delta).gsub(/\n/, ' ')
      source.sql_query_range  = to_sql_query_range(:delta => delta)
      source.sql_query_info   = to_sql_query_info(offset)
      
      source.sql_query_pre += send(!delta ? :sql_query_pre_for_core : :sql_query_pre_for_delta)
      
      if @options[:group_concat_max_len]
        source.sql_query_pre << "SET SESSION group_concat_max_len = #{@options[:group_concat_max_len]}"
      end
      
      source.sql_query_pre += [adapter.utf8_query_pre].compact if utf8?
    end
    
    def set_source_settings(source)
      ThinkingSphinx::Configuration.instance.source_options.each do |key, value|
        source.send("#{key}=".to_sym, value)
      end
      
      @options.each do |key, value|
        source.send("#{key}=".to_sym, value) if ThinkingSphinx::Configuration::SourceOptions.include?(key.to_s) && !value.nil?
      end
    end
    
    def sql_query_pre_for_core
      if self.delta? && !@delta_object.reset_query(@model).blank?
        [@delta_object.reset_query(@model)]
      else
        []
      end
    end
    
    def sql_query_pre_for_delta
      [""]
    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? && !@delta_object.clause(@model, options[:delta]).blank?
        where_clause << " AND #{@delta_object.clause(@model, options[:delta])}"
      end
      unless @conditions.empty?
        where_clause << " AND " << @conditions.join(" AND ")
      end
      
      internal_groupings = []
      if @model.column_names.include?(@model.inheritance_column)
         internal_groupings << "#{@model.quoted_table_name}.#{quote_column(@model.inheritance_column)}"
      end
      
      unique_id_expr = ThinkingSphinx.unique_id_expression(options[:offset])
      
      sql = <<-SQL
SELECT #{ (
  ["#{@model.quoted_table_name}.#{quote_column(@model.primary_key)} #{unique_id_expr} AS #{quote_column(@model.primary_key)} "] + 
  @fields.collect { |field| field.to_select_sql } +
  @attributes.collect { |attribute| attribute.to_select_sql }
).compact.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 +
  @groupings + internal_groupings
).join(", ") }
      SQL
      
      sql += " ORDER BY NULL" if adapter.sphinx_identifier == "mysql"
      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(offset)
      "SELECT * FROM #{@model.quoted_table_name} WHERE " +
      " #{quote_column(@model.primary_key)} = (($id - #{offset}) / #{ThinkingSphinx.indexed_models.size})"
    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 = adapter.convert_nulls(
        "MIN(#{quote_column(@model.primary_key)})", 1
      )
      max_statement = adapter.convert_nulls(
        "MAX(#{quote_column(@model.primary_key)})", 1
      )
      
      sql = "SELECT #{min_statement}, #{max_statement} " +
            "FROM #{@model.quoted_table_name} "
      if self.delta? && !@delta_object.clause(@model, options[:delta]).blank?
        sql << "WHERE #{@delta_object.clause(@model, options[:delta])}"
      end
      
      sql
    end
  end
end
