module Picky
  module Query
    # This is a query token. Together with other tokens it makes up a query.
    #
    # It remembers the original form, and and a normalized form.
    #
    # It also knows whether it needs to look for similarity (bla~),
    # or whether it is a partial (bla*).
    #
    class Token
      mattr_reader :no_partial_character
      mattr_reader :partial_character
      mattr_accessor :no_partial
      mattr_accessor :partial

      mattr_reader :no_similar_character
      mattr_reader :similar_character
      mattr_accessor :no_similar
      mattr_accessor :similar

      mattr_reader :qualifier_text_delimiter
      mattr_reader :qualifiers_delimiter
      mattr_accessor :qualifier_text_splitter
      mattr_accessor :qualifiers_splitter

      mattr_accessor :range_character

      mattr_accessor :illegals

      attr_accessor :text, :original
      attr_writer :similar, :predefined_categories

      forward :blank?, to: :@text

      # Normal initializer.
      #
      # Note:
      # Use this if you do not want a normalized token.
      #
      # TODO Throw away @predefined_categories?
      #
      def initialize(text, original = nil, categories = nil)
        @text     = text
        @original = original
        @predefined_categories = categories
      end

      # Returns a qualified and normalized token.
      #
      # Note:
      # Use this in the search engine if you need a qualified
      # and normalized token. I.e. one prepared for a search.
      #
      def self.processed(text, original = nil)
        new(text, original).process
      end

      def self.redefine_illegals
        # NOTE: By default, both no similar and no partial are ".
        #
        self.illegals = /[#{@no_similar_character}#{@similar_character}#{@no_partial_character}#{@partial_character}]/
      end

      def process
        qualify
        similarize
        partialize
        rangify
        remove_illegals
        self
      end

      # Symbolizes this token's text.
      #
      # Note:
      # Call externally when Picky operates in Symbols mode.
      #
      def symbolize!
        @text = @text.to_sym
      end

      # Translates this token's qualifiers into actual categories.
      #
      # Note: If this is not done, there is no mapping.
      # Note: predefined is an Array of mapped categories.
      #
      # TODO Do we really need to set the predefined categories on the token?
      #
      def predefined_categories(mapper = nil)
        @predefined_categories || mapper && extract_predefined(mapper)
      end

      def extract_predefined(mapper)
        user_qualified = categorize_with mapper, @qualifiers
        mapper.restrict user_qualified
      end

      def categorize_with(mapper, qualifiers)
        qualifiers&.map do |qualifier|
          mapper.map qualifier
        end&.compact
      end

      # Selects the bundle to be used.
      #
      def select_bundle(exact, partial)
        @partial ? partial : exact
      end

      # Generates a reused stem.
      #
      # Caches a stem for a tokenizer.
      #
      def stem(tokenizer)
        if stem?
          @stems ||= {}
          @stems[tokenizer] ||= tokenizer.stem(@text)
        else
          @text
        end
      end

      def stem?
        @text !~ self.class.no_partial
      end

      # Partial is a conditional setter.
      #
      # It is only settable if it hasn't been set yet.
      #
      def partial=(partial)
        @partial = partial if @partial.nil?
      end

      # A token is partial? only if it not similar
      # and is partial.
      #
      # It can't be similar and partial at the same time.
      #
      # Note: @partial is calculated at processing time (see Token#process).
      #
      def partial?
        # Was: !@similar && @partial
        @partial
      end

      # If the text ends with *, partialize it. If with ",
      # non-partialize it.
      #
      # The last one wins.
      # So "hello*" will not be partially searched.
      # So "hello"* will be partially searched.
      #
      def partialize
        # A token is partial? only if it not similar
        # and is partial.
        #
        # It can't be similar and partial at the same time.
        #
        self.partial = false or return if @similar
        self.partial = false or return if @text =~ self.class.no_partial

        self.partial = true if @text =~ self.class.partial
      end

      # Define a character which stops a token from
      # being a partial token, even if it is the last token.
      #
      # Default is '"'.
      #
      # This is used in a regexp (%r{#{char}\z}) for String#=~,
      # so escape the character.
      #
      # Example:
      #   Picky::Query::Token.no_partial_character = '?'
      #   try.search("tes?") # Won't find "test".
      #
      def self.no_partial_character=(character)
        @no_partial_character = character
        self.no_partial = /#{Regexp.escape(character)}\z/
        redefine_illegals
      end
      self.no_partial_character = '"'

      # Define a character which makes a token a partial token.
      #
      # Default is '*'.
      #
      # This is used in a regexp (%r{#{char}\z}) for String#=~,
      # so escape the character.
      #
      # Example:
      #   Picky::Query::Token.partial_character = '?'
      #   try.search("tes?") # Will find "test".
      #
      def self.partial_character=(character)
        @partial_character = character
        self.partial = /#{Regexp.escape(character)}\z/
        redefine_illegals
      end
      self.partial_character = '*'

      # If the text ends with ~ similarize it. If with ", don't.
      #
      # The latter wins.
      #
      def similarize
        self.similar = false or return if @text =~ self.class.no_similar

        self.similar = true if @text =~ self.class.similar
      end

      # Define a character which stops a token from
      # being a similar token, even if it is the last token.
      #
      # Default is '"'.
      #
      # This is used in a regexp (%r{#{char}\z}) for String#=~,
      # so escape the character.
      #
      # Example:
      #   Picky::Query::Token.no_similar_character = '?'
      #   try.search("tost?") # Won't find "test".
      #
      def self.no_similar_character=(character)
        @no_similar_character = character
        self.no_similar = /#{Regexp.escape(character)}\z/
        redefine_illegals
      end
      self.no_similar_character = '"'

      # Define a character which makes a token a similar token.
      #
      # Default is '~'.
      #
      # This is used in a regexp (%r{#{char}\z}) for String#=~,
      # so escape the character.
      #
      # Example:
      #   Picky::Query::Token.similar_character = '?'
      #   try.search("tost?") # Will find "test".
      #
      def self.similar_character=(character)
        @similar_character = character
        self.similar = /#{Regexp.escape(character)}\z/
        redefine_illegals
      end
      self.similar_character = '~'

      # Define a character which makes a token a range token.
      #
      # Default is '…'.
      #
      # Example:
      #   Picky::Query::Token.range_character = "-"
      #   try.search("year:2000-2008") # Will find results in a range.
      #
      self.range_character = '…'

      def rangify
        @range = @text.split(self.class.range_character, 2) if @text.include?(self.class.range_character)
      end

      attr_reader :range

      # Is this a "similar" character?
      #
      def similar?
        @similar
      end

      # Normalizes this token's text.
      #
      def remove_illegals
        # NOTE: unless @text.blank? was removed.
        #
        @text.gsub! self.class.illegals, EMPTY_STRING unless @text == EMPTY_STRING
      end

      # Return all possible combinations.
      #
      # This checks if it needs to also search through similar
      # tokens, if for example, the token is one with ~.
      # If yes, it puts together all solutions.
      #
      def possible_combinations(categories)
        similar? ? categories.similar_possible_for(self) : categories.possible_for(self)
      end

      # If the Token has weight for the given category,
      # it will return a new combination for the tuple
      # (self, category, weight).
      #
      def combination_for(category)
        weight = category.weight self
        weight && Query::Combination.new(self, category, weight)
      end

      # Returns all similar tokens for the token.
      #
      def similar_tokens_for(category)
        similars = category.similar self
        similars.map do |similar|
          # The array describes all possible categories. There is only one here.
          #
          self.class.new similar, similar, [category]
        end
      end

      # Splits text into a qualifier and text.
      #
      def qualify
        @qualifiers, @text = self.class.qualifier_text_splitter.single(@text)
        @qualifiers = self.class.qualifiers_splitter.multi(@qualifiers) if @qualifiers
      end

      # Define a regexp which separates the qualifier
      # from the search text.
      #
      # Default is /:/.
      #
      # Example:
      #   Picky::Query::Token.qualifier_text_delimiter = /\?/
      #   try.search("text1?hello text2?world").ids.should == [1]
      #
      def self.qualifier_text_delimiter=(character)
        @qualifier_text_delimiter = character
        self.qualifier_text_splitter = Splitter.new(@qualifier_text_delimiter)
      end
      self.qualifier_text_delimiter = /:/

      # Define a regexp which separates the qualifiers
      # (before the search text).
      #
      # Default is /,/.
      #
      # Example:
      #   Picky::Query::Token.qualifiers_delimiter = /|/
      #   try.search("text1|text2:hello").ids.should == [1]
      #
      def self.qualifiers_delimiter=(character)
        @qualifiers_delimiter = character
        self.qualifiers_splitter = Splitter.new(@qualifiers_delimiter)
      end
      self.qualifiers_delimiter = /,/

      # Returns the qualifiers as an array.
      #
      # Example:
      #   token.qualifiers # => ['title', 'author']
      #   token.qualifiers # => []
      #
      # Note: Internally, qualifiers are nil if there are none.
      #
      def qualifiers
        @qualifiers || []
      end

      # Returns the token in the form
      #   ['original:Text', 'processedtext']
      #
      def to_result
        [@original, @text]
      end

      # Internal identifier.
      #
      # Note: Used in many backends.
      #
      def identifier
        "#{similar? ? :similarity : :inverted}:#{@text}"
      end

      # If the originals & the text are the same, they are the same.
      #
      def ==(other)
        original == other.original && text == other.text
      end

      # Displays the text and the qualifiers.
      #
      # e.g. name:meier
      #
      def to_s
        "#{self.class}(#{[@text, (@qualifiers.inspect unless @qualifiers.blank?)].compact.join(', ')})"
      end
    end
  end
end
