module ThinkingSphinx
  # Fields - holding the string data which Sphinx indexes for your searches.
  # This class isn't really useful to you unless you're hacking around with the
  # internals of Thinking Sphinx - but hey, don't let that stop you.
  #
  # One key thing to remember - if you're using the field manually to
  # generate SQL statements, you'll need to set the base model, and all the
  # associations. Which can get messy. Use Index.link!, it really helps.
  # 
  class Field
    attr_accessor :alias, :columns, :sortable, :associations, :model, :infixes, :prefixes
    
    # To create a new field, you'll need to pass in either a single Column
    # or an array of them, and some (optional) options. The columns are
    # references to the data that will make up the field.
    #
    # Valid options are:
    # - :as       => :alias_name 
    # - :sortable => true
    # - :infixes  => true
    # - :prefixes => true
    #
    # Alias is only required in three circumstances: when there's
    # another attribute or field with the same name, when the column name is
    # 'id', or when there's more than one column.
    # 
    # Sortable defaults to false - but is quite useful when set to true, as
    # it creates an attribute with the same string value (which Sphinx converts
    # to an integer value), which can be sorted by. Thinking Sphinx is smart
    # enough to realise that when you specify fields in sort statements, you
    # mean their respective attributes.
    # 
    # If you have partial matching enabled (ie: enable_star), then you can
    # specify certain fields to have their prefixes and infixes indexed. Keep
    # in mind, though, that Sphinx's default is _all_ fields - so once you
    # highlight a particular field, no other fields in the index will have
    # these partial indexes.
    #
    # Here's some examples:
    #
    #   Field.new(
    #     Column.new(:name)
    #   )
    #
    #   Field.new(
    #     [Column.new(:first_name), Column.new(:last_name)],
    #     :as => :name, :sortable => true
    #   )
    # 
    #   Field.new(
    #     [Column.new(:posts, :subject), Column.new(:posts, :content)],
    #     :as => :posts, :prefixes => true
    #   )
    # 
    def initialize(columns, options = {})
      @columns      = Array(columns)
      @associations = {}

      raise "Cannot define a field with no columns. Maybe you are trying to index a field with a reserved name (id, name). You can fix this error by using a symbol rather than a bare name (:id instead of id)." if @columns.empty? || @columns.any? { |column| !column.respond_to?(:__stack) }
      
      @alias        = options[:as]
      @sortable     = options[:sortable] || false
      @infixes      = options[:infixes]  || false
      @prefixes     = options[:prefixes] || false
    end
    
    # Get the part of the SELECT clause related to this field. Don't forget
    # to set your model and associations first though.
    #
    # This will concatenate strings if there's more than one data source or
    # multiple data values (has_many or has_and_belongs_to_many associations).
    # 
    def to_select_sql
      clause = @columns.collect { |column|
        column_with_prefix(column)
      }.join(', ')
      
      clause = concatenate(clause) if concat_ws?
      clause = group_concatenate(clause) if is_many?
      
      "#{cast_to_string clause } AS #{quote_column(unique_name)}"
    end
    
    # Get the part of the GROUP BY clause related to this field - if one is
    # needed. If not, all you'll get back is nil. The latter will happen if
    # there's multiple data values (read: a has_many or has_and_belongs_to_many
    # association).
    #
    def to_group_sql
      case
      when is_many?, ThinkingSphinx.use_group_by_shortcut?
        nil
      else
        @columns.collect { |column|
          column_with_prefix(column)
        }
      end
    end
    
    # Returns the unique name of the field - which is either the alias of
    # the field, or the name of the only column - if there is only one. If
    # there isn't, there should be an alias. Else things probably won't work.
    # Consider yourself warned.
    #
    def unique_name
      if @columns.length == 1
        @alias || @columns.first.__name
      else
        @alias
      end
    end
    
    private
    
    def concatenate(clause)
      case @model.connection.class.name
      when "ActiveRecord::ConnectionAdapters::MysqlAdapter"
        "CONCAT_WS(' ', #{clause})"
      when "ActiveRecord::ConnectionAdapters::PostgreSQLAdapter"
        clause.split(', ').collect { |field|
          "COALESCE(#{field}, '')"
        }.join(" || ' ' || ")
      else
        clause
      end
    end
    
    def group_concatenate(clause)
      case @model.connection.class.name
      when "ActiveRecord::ConnectionAdapters::MysqlAdapter"
        "GROUP_CONCAT(#{clause} SEPARATOR ' ')"
      when "ActiveRecord::ConnectionAdapters::PostgreSQLAdapter"
        "array_to_string(array_accum(#{clause}), ' ')"
      else
        clause
      end
    end
    
    def cast_to_string(clause)
      case @model.connection.class.name
      when "ActiveRecord::ConnectionAdapters::MysqlAdapter"
        "CAST(#{clause} AS CHAR)"
      when "ActiveRecord::ConnectionAdapters::PostgreSQLAdapter"
        clause
      else
        clause
      end
    end
    
    def quote_column(column)
      @model.connection.quote_column_name(column)
    end
    
    # Indication of whether the columns should be concatenated with a space
    # between each value. True if there's either multiple sources or multiple
    # associations.
    # 
    def concat_ws?
      @columns.length > 1 || multiple_associations?
    end
    
    # Checks the association tree for each column - if they're all the same,
    # returns false.
    # 
    def multiple_sources?
      first = associations[@columns.first]
      
      !@columns.all? { |col| associations[col] == first }
    end
    
    # Checks whether any column requires multiple associations (which only
    # happens for polymorphic situations).
    # 
    def multiple_associations?
      associations.any? { |col,assocs| assocs.length > 1 }
    end
    
    # Builds a column reference tied to the appropriate associations. This
    # dives into the associations hash and their corresponding joins to
    # figure out how to correctly reference a column in SQL.
    #
    def column_with_prefix(column)
      if column.is_string?
        column.__name
      elsif associations[column].empty?
        "#{@model.quoted_table_name}.#{quote_column(column.__name)}"
      else
        associations[column].collect { |assoc|
          assoc.has_column?(column.__name) ?
          "#{@model.connection.quote_table_name(assoc.join.aliased_table_name)}" + 
          ".#{quote_column(column.__name)}" :
          nil
        }.compact.join(', ')
      end
    end
    
    # Could there be more than one value related to the parent record? If so,
    # then this will return true. If not, false. It's that simple.
    #
    def is_many?
      associations.values.flatten.any? { |assoc| assoc.is_many? }
    end
    
    def is_string?
      columns.all? { |col| col.is_string? }
    end
  end
end