module CukeCataloger
  class UniqueTestCaseTagger

    SUB_ID_PATTERN = /^\d+\-\d+$/
    SUB_ID_MATCH_PATTERN = /^\d+\-(\d+)$/


    attr_accessor :tag_location


    def initialize
      @file_line_increases = Hash.new(0)
      @tag_location = :adjacent
    end

    def tag_tests(feature_directory, tag_prefix, explicit_indexes = {})
      warn("This script will potentially rewrite all of your feature files. Please be patient and remember to tip your source control system.")

      @known_id_tags = {}

      set_id_tag(tag_prefix)
      set_test_suite_model(feature_directory)

      @start_indexes = merge_indexes(default_start_indexes(determine_known_ids(feature_directory, tag_prefix)), explicit_indexes)
      @next_index = @start_indexes[:primary]

      # Analysis and output
      @tests.each do |test|
        case
          when test.is_a?(CukeModeler::Scenario)
            process_scenario(test)
          when test.is_a?(CukeModeler::Outline)
            process_outline(test)
          else
            raise("Unknown test type: #{test.class.to_s}")
        end
      end
    end

    def scan_for_tagged_tests(feature_directory, tag_prefix)
      @results = []
      @known_id_tags = {}

      set_id_tag(tag_prefix)
      set_test_suite_model(feature_directory)

      @tests.each do |test|
        add_to_results(test) if has_id_tag?(test)

        if test.is_a?(CukeModeler::Outline)
          test.examples.each do |example|
            if has_id_parameter?(example)
              example_rows_for(example).each do |row|
                add_to_results(row) if has_row_id?(row)
              end
            end
          end
        end
      end

      @results
    end

    def validate_test_ids(feature_directory, tag_prefix)
      @results = []
      @known_id_tags = {}

      set_id_tag(tag_prefix)
      set_test_suite_model(feature_directory)

      @features.each { |feature| validate_feature(feature) }
      @tests.each { |test| validate_test(test) }

      @results
    end

    def determine_known_ids(feature_directory, tag_prefix)
      known_ids = []

      found_tagged_objects = scan_for_tagged_tests(feature_directory, tag_prefix).collect { |result| result[:object] }

      found_tagged_objects.each do |element|
        if element.is_a?(CukeModeler::Row)
          row_id = row_id_for(element)
          known_ids << row_id if well_formed_sub_id?(row_id)
        else
          known_ids << test_id_for(element)
        end
      end

      known_ids
    end


    private


    def set_id_tag(tag_prefix)
      # todo - should include the '@' in this or anchor the pattern to the beginning of the tag
      @tag_prefix = tag_prefix
      #todo -should probably escape these characters
      @tag_pattern = Regexp.new("#{@tag_prefix}\\d+")
    end

    def set_test_suite_model(feature_directory)
      @directory = CukeModeler::Directory.new(feature_directory)
      @model_repo = CQL::Repository.new(@directory)

      @tests = @model_repo.query do
        select :self
        from scenarios, outlines
      end.collect { |result| result[:self] }

      @features = @model_repo.query do
        select :self
        from features
      end.collect { |result| result[:self] }
    end

    def validate_feature(feature)
      check_for_feature_level_test_tag(feature)
    end

    def validate_test(test)
      check_for_missing_test_tag(test)
      check_for_multiple_test_id_tags(test)
      check_for_duplicated_test_id_tags(test)

      if test.is_a?(CukeModeler::Outline)
        check_for_missing_id_columns(test)
        check_for_missing_row_tags(test)
        check_for_duplicated_row_tags(test)
        check_for_mismatched_row_tags(test)
        check_for_malformed_row_tags(test)
      end
    end

    def check_for_feature_level_test_tag(feature)
      add_to_results(feature, :feature_test_tag) if has_id_tag?(feature)
    end

    def check_for_duplicated_test_id_tags(test)
      unless @existing_tags
        @existing_tags = @model_repo.query do
          select tags
          from features, scenarios, outlines, examples
        end.collect { |result| result['tags'] }.flatten

        @existing_tags.collect! { |tag| tag.name } if Gem.loaded_specs['cuke_modeler'].version.version[/^1/]
      end

      test_id_tag = static_id_tag_for(test)

      matching_tags = @existing_tags.select { |tag| tag == test_id_tag }

      add_to_results(test, :duplicate_id_tag) if matching_tags.count > 1
    end

    def check_for_multiple_test_id_tags(test)
      tags = test.tags

      tags = tags.collect { |tag| tag.name } if Gem.loaded_specs['cuke_modeler'].version.version[/^1/]

      id_tags_found = tags.select { |tag| tag =~ @tag_pattern }

      add_to_results(test, :multiple_tags) if id_tags_found.count > 1
    end

    def check_for_missing_test_tag(test)
      add_to_results(test, :missing_tag) unless has_id_tag?(test)
    end

    def check_for_missing_id_columns(test)
      test.examples.each do |example|
        add_to_results(example, :missing_id_column) unless has_id_column?(example)
      end
    end

    def check_for_duplicated_row_tags(test)
      validate_rows(test, :duplicate_row_id, false, :has_duplicate_row_id?)
    end

    def check_for_missing_row_tags(test)
      validate_rows(test, :missing_row_id, true, :has_row_id?)
    end

    def check_for_mismatched_row_tags(test)
      validate_rows(test, :mismatched_row_id, true, :has_matching_id?)
    end

    def check_for_malformed_row_tags(test)
      test.examples.each do |example|
        if has_id_column?(example)
          example_rows_for(example).each do |row|
            add_to_results(row, :malformed_sub_id) if (has_row_id?(row) && !well_formed_sub_id?(row_id_for(row)))
          end
        end
      end
    end

    def validate_rows(test, rule, desired, row_check)
      test.examples.each do |example|
        if has_id_column?(example)
          example_rows_for(example).each do |row|
            if desired
              add_to_results(row, rule) unless self.send(row_check, row)
            else
              add_to_results(row, rule) if self.send(row_check, row)
            end
          end
        end
      end
    end

    def process_scenario(test)
      apply_tag_if_needed(test)
    end

    def process_outline(test)
      apply_tag_if_needed(test)
      update_parameters_if_needed(test)
      update_rows_if_needed(test, determine_next_sub_id(test))
    end

    def apply_tag_if_needed(test)
      unless has_id_tag?(test)
        tag = "#{@tag_prefix}#{@next_index}"
        @next_index += 1

        tag_test(test, tag, (' ' * determine_test_indentation(test)))
      end
    end

    def has_id_tag?(test)
      !!fast_id_tag_for(test)
    end

    def has_id_column?(example)
      example.parameters.any? { |param| param =~ /test_case_id/ }
    end

    def row_id_for(row)
      id_index = determine_row_id_cell_index(row)

      if id_index
        cell_value = row.cells[id_index]
        cell_value = cell_value.value if Gem.loaded_specs['cuke_modeler'].version.version[/^1/]

        cell_value != '' ? cell_value : nil
      end
    end

    def has_row_id?(row)
      !!row_id_for(row)
    end

    def well_formed_sub_id?(id)
      !!(id =~ SUB_ID_PATTERN)
    end

    def has_matching_id?(row)
      row_id = row_id_for(row)

      # A lack of id counts as 'matching'
      return true if row_id.nil?

      parent_tag = static_id_tag_for(row.get_ancestor(:test))

      if parent_tag
        parent_id = parent_tag.sub(@tag_prefix, '')

        row_id =~ /#{parent_id}-/
      else
        row_id.nil?
      end
    end

    def has_duplicate_row_id?(row)
      row_id = row_id_for(row)

      return false unless row_id && well_formed_sub_id?(row_id)

      existing_ids = determine_used_sub_ids(row.get_ancestor(:test))
      matching_ids = existing_ids.select { |id| id == row_id[/\d+$/] }

      matching_ids.count > 1
    end

    def determine_next_sub_id(test)
      parent = test_id_for(test)
      explicit_index = @start_indexes[:sub][parent]

      explicit_index ? explicit_index : 1
    end

    def determine_used_sub_ids(test)
      ids = test.examples.collect do |example|
        if has_id_parameter?(example)
          example_rows_for(example).collect do |row|
            row_id_for(row)
          end
        else
          []
        end
      end

      ids.flatten!
      ids.delete_if { |id| !id.to_s.match(SUB_ID_PATTERN) }

      ids.collect! { |id| id.match(SUB_ID_MATCH_PATTERN)[1] }

      ids
    end

    def determine_row_id_cell_index(row)
      row.get_ancestor(:example).parameters.index { |param| param =~ /test_case_id/ }
    end

    def tag_test(test, tag, padding_string = '  ')
      feature_file = test.get_ancestor(:feature_file)
      file_path = feature_file.path

      index_adjustment = @file_line_increases[file_path]
      tag_index = source_line_to_use(test) + index_adjustment

      file_lines = File.readlines(file_path)
      file_lines.insert(tag_index, "#{padding_string}#{tag}\n")

      File.open(file_path, 'w') { |file| file.print file_lines.join }
      @file_line_increases[file_path] += 1

      if Gem.loaded_specs['cuke_modeler'].version.version[/^0/]
        test.tags << tag
      else
        new_tag = CukeModeler::Tag.new
        new_tag.name = tag
        test.tags << new_tag
      end
    end

    def update_parameters_if_needed(test)
      feature_file = test.get_ancestor(:feature_file)
      file_path = feature_file.path
      index_adjustment = @file_line_increases[file_path]
      method_for_rows = Gem.loaded_specs['cuke_modeler'].version.version[/^0/] ? :row_elements : :rows

      test.examples.each do |example|
        unless has_id_parameter?(example)
          parameter_line_index = (example.send(method_for_rows).first.source_line - 1) + index_adjustment

          file_lines = File.readlines(file_path)

          new_parameter = 'test_case_id'.ljust(parameter_spacing(example))
          update_parameter_row(file_lines, parameter_line_index, new_parameter)
          File.open(file_path, 'w') { |file| file.print file_lines.join }
        end
      end
    end

    def update_rows_if_needed(test, sub_id)
      feature_file = test.get_ancestor(:feature_file)
      file_path = feature_file.path
      index_adjustment = @file_line_increases[file_path]
      method_for_rows = Gem.loaded_specs['cuke_modeler'].version.version[/^0/] ? :row_elements : :rows

      tag_index = fast_id_tag_for(test)[/\d+/]

      file_lines = File.readlines(file_path)

      test.examples.each do |example|
        example.send(method_for_rows)[1..(example.send(method_for_rows).count - 1)].each do |row|
          unless has_row_id?(row)
            row_id = "#{tag_index}-#{sub_id}".ljust(parameter_spacing(example))

            row_line_index = (row.source_line - 1) + index_adjustment

            update_value_row(file_lines, row_line_index, row, row_id)
            sub_id += 1
          end
        end

        File.open(file_path, 'w') { |file| file.print file_lines.join }
      end
    end


    # Slowest way to get the id tag. Will check the object every time.
    def current_id_tag_for(thing)
      id_tag_for(thing)
    end

    # Faster way to get the id tag. Will skip checking the object if an id for it is already known.
    def fast_id_tag_for(thing)
      @known_id_tags ||= {}

      id = @known_id_tags[thing.object_id]

      unless id
        id = current_id_tag_for(thing)
        @known_id_tags[thing.object_id] = id
      end

      id
    end

    # Fastest way to get the id tag. Will skip checking the object if it has been checked before, even if no id was found.
    def static_id_tag_for(thing)
      @known_id_tags ||= {}
      id_key = thing.object_id

      return @known_id_tags[id_key] if @known_id_tags.has_key?(id_key)

      id = current_id_tag_for(thing)
      @known_id_tags[id_key] = id

      id
    end

    def id_tag_for(thing)
      tags = thing.tags

      tags = tags.collect { |tag| tag.name } unless Gem.loaded_specs['cuke_modeler'].version.version[/^0/]

      tags.select { |tag| tag =~ @tag_pattern }.first
    end

    def test_id_for(test)
      #todo - should probably be escaping these in case regex characters used in prefix...
      fast_id_tag_for(test).match(/#{@tag_prefix}(.*)/)[1]
    end

    def has_id_parameter?(example)
      #todo - make the id column name configurable
      example.parameters.any? { |parameter| parameter == 'test_case_id' }
    end

    def update_parameter_row(file_lines, line_index, parameter)
      append_row!(file_lines, line_index, " #{parameter} |")
    end

    def update_value_row(file_lines, line_index, row, row_id)
      case
        when needs_adding?(row)
          append_row!(file_lines, line_index, " #{row_id} |")
        when needs_filled_in?(row)
          fill_in_row(file_lines, line_index, row, row_id)
        else
          raise("Don't know how to update row")
      end
    end

    def needs_adding?(row)
      !has_id_parameter?(row.get_ancestor(:example))
    end

    def needs_filled_in?(row)
      has_id_parameter?(row.get_ancestor(:example))
    end

    def replace_row!(file_lines, line_index, new_line)
      file_lines[line_index] = new_line
    end

    def prepend_row!(file_lines, line_index, string)
      old_row = file_lines[line_index]
      new_row = string + old_row.lstrip
      file_lines[line_index] = new_row
    end

    def append_row!(file_lines, line_index, string)
      old_row = file_lines[line_index]
      trailing_bits = old_row[/\s*$/]
      new_row = old_row.rstrip + string + trailing_bits

      file_lines[line_index] = new_row
    end

    def example_rows_for(example)
      method_for_rows = Gem.loaded_specs['cuke_modeler'].version.version[/^0/] ? :row_elements : :rows

      rows = example.send(method_for_rows).dup
      rows.shift

      rows
    end

    def add_to_results(item, issue = nil)
      result = {:test => "#{item.get_ancestor(:feature_file).path}:#{item.source_line}", :object => item}
      result.merge!({:problem => issue}) if issue

      @results << result
    end

    def default_start_indexes(known_ids)
      primary_ids = known_ids.select { |id| id =~ /^\d+$/ }
      sub_ids = known_ids.select { |id| id =~ /^\d+-\d+$/ }

      max_primary_id = primary_ids.collect { |id| id.to_i }.max || 0
      default_indexes = {:primary => max_primary_id + 1,
                         :sub => {}}

      sub_primaries = sub_ids.collect { |sub_id| sub_id[/^\d+/] }

      sub_primaries.each do |primary|
        default_indexes[:sub][primary] = sub_ids.select { |sub_id| sub_id[/^\d+/] == primary }.collect { |sub_id| sub_id[/\d+$/].to_i }.max + 1
      end

      default_indexes
    end

    def merge_indexes(set1, set2)
      set1.merge(set2) { |key, set1_value, set2_value|
        key == :sub ? set1_value.merge(set2_value) : set2_value
      }
    end

    def parameter_spacing(example)
      test = example.get_ancestor(:test)
      test_id = fast_id_tag_for(test)[/\d+$/]
      row_count = test.examples.reduce(0) { |sum, example| sum += example.rows.count }

      max_id_length = test_id.length + 1 + row_count.to_s.length
      param_length = 'test_case_id'.length

      [param_length, max_id_length].max
    end

    def determine_test_indentation(test)
      #todo - replace with 'get_most_recent_file_text'
      feature_file = test.get_ancestor(:feature_file)
      file_path = feature_file.path

      index_adjustment = @file_line_increases[file_path]
      test_index = (test.source_line - 1) + index_adjustment

      file_lines = File.readlines(file_path)
      indentation = file_lines[test_index][/^\s*/].length

      indentation
    end

    def fill_in_row(file_lines, line_index, row, row_id)
      old_row = file_lines[line_index]
      sections = file_lines[line_index].split('|', -1)

      replacement_index = determine_row_id_cell_index(row)
      sections[replacement_index + 1] = " #{row_id} "

      new_row = sections.join('|')

      replace_row!(file_lines, line_index, new_row)
    end

    def source_line_to_use(test)
      case @tag_location
        when :above
          determine_highest_tag_line(test)
        when :below
          determine_lowest_tag_line(test)
        when :adjacent
          adjacent_tag_line(test)
        else
          raise(ArgumentError, "Don't know where #{@tag_location} is.")
      end
    end

    def determine_highest_tag_line(test)
      return adjacent_tag_line(test) if test.tags.empty?

      method_for_tag_models = Gem.loaded_specs['cuke_modeler'].version.version[/^0/] ? :tag_elements : :tags

      test.send(method_for_tag_models).collect { |tag_element| tag_element.source_line }.min - 1
    end

    def determine_lowest_tag_line(test)
      return adjacent_tag_line(test) if test.tags.empty?

      method_for_tag_models = Gem.loaded_specs['cuke_modeler'].version.version[/^0/] ? :tag_elements : :tags

      test.send(method_for_tag_models).collect { |tag_element| tag_element.source_line }.max
    end

    def adjacent_tag_line(test)
      (test.source_line - 1)
    end

  end
end