# frozen_string_literal: true

require 'fileutils'
require 'json'
require 'ruby-progressbar'

module RailsBestPractices
  # RailsBestPractices Analyzer helps you to analyze your rails code, according to best practices on https://rails-bestpractices.
  # if it finds any violatioins to best practices, it will give you some readable suggestions.
  #
  # The analysis process is partitioned into two parts,
  #
  # 1. prepare process, it checks only model and mailer files, do some preparations, such as remember model names and associations.
  # 2. review process, it checks all files, according to configuration, it really check if codes violate the best practices, if so, remember the violations.
  #
  # After analyzing, output the violations.
  class Analyzer
    attr_accessor :runner
    attr_reader :path

    DEFAULT_CONFIG = File.join(File.dirname(__FILE__), '..', '..', 'rails_best_practices.yml')
    GITHUB_URL = 'https://github.com/'

    # initialize
    #
    # @param [String] path where to generate the configuration yaml file
    # @param [Hash] options
    def initialize(path, options = {})
      @path = File.expand_path(path || '.')

      @options = options
      @options['exclude'] ||= []
      @options['only'] ||= []
    end

    # generate configuration yaml file.
    def generate
      FileUtils.cp DEFAULT_CONFIG, File.join(@path, 'config/rails_best_practices.yml')
    end

    # Analyze rails codes.
    #
    # there are two steps to check rails codes,
    #
    # 1. prepare process, check all model and mailer files.
    # 2. review process, check all files.
    #
    # if there are violations to rails best practices, output them.
    #
    # @param [String] path the directory of rails project
    # @param [Hash] options
    def analyze
      Core::Runner.base_path = @path
      Core::Runner.config_path = @options['config']
      @runner = Core::Runner.new

      analyze_source_codes
      analyze_vcs
    end

    # Output the analyze result.
    def output
      case @options['format']
      when 'html'
        @options['output-file'] ||= 'rails_best_practices_output.html'
        output_html_errors
      when 'json'
        @options['output-file'] ||= 'rails_best_practices_output.json'
        output_json_errors
      when 'yaml'
        @options['output-file'] ||= 'rails_best_practices_output.yaml'
        output_yaml_errors
      when 'xml'
        @options['output-file'] ||= 'rails_best_practices_output.xml'
        output_xml_errors
      else
        output_terminal_errors
      end
    end

    # process lexical, prepare or reivew.
    #
    # get all files for the process, analyze each file,
    # and increment progress bar unless debug.
    #
    # @param [String] process the process name, lexical, prepare or review.
    def process(process)
      parse_files.each do |file|
        begin
          puts file if @options['debug']
          @runner.send(process, file, File.read(file))
        rescue StandardError
          if @options['debug']
            warning = "#{file} looks like it's not a valid Ruby file.  Skipping..."
            plain_output(warning, 'red')
          end
        end
        @bar.increment if display_bar?
      end
      @runner.send("after_#{process}")
    end

    # get all files for parsing.
    #
    # @return [Array] all files for parsing
    def parse_files
      @parse_files ||=
        begin
          files = expand_dirs_to_files(@path)
          files = file_sort(files)

          if @options['only'].present?
            files = file_accept(files, @options['only'])
          end

          # By default, tmp, vender, spec, test, features are ignored.
          %w[vendor spec test features tmp].each do |dir|
            files = file_ignore(files, File.join(@path, dir)) unless @options[dir]
          end

          # Exclude files based on exclude regexes if the option is set.
          @options['exclude'].each do |pattern|
            files = file_ignore(files, pattern)
          end

          %w[Capfile Gemfile Gemfile.lock].each do |file|
            files.unshift File.join(@path, file)
          end

          files.compact
        end
    end

    # expand all files with extenstion rb, erb, haml, slim, builder and rxml under the dirs
    #
    # @param [Array] dirs what directories to expand
    # @return [Array] all files expanded
    def expand_dirs_to_files(*dirs)
      extensions = %w[rb erb rake rhtml haml slim builder rxml rabl]

      dirs.flatten.map do |entry|
        next unless File.exist? entry

        if File.directory? entry
          Dir[File.join(entry, '**', "*.{#{extensions.join(',')}}")]
        else
          entry
        end
      end.flatten
    end

    # sort files, models first, mailers, helpers, and then sort other files by characters.
    #
    # models and mailers first as for prepare process.
    #
    # @param [Array] files
    # @return [Array] sorted files
    def file_sort(files)
      models = files.find_all { |file| file =~ Core::Check::MODEL_FILES }
      mailers = files.find_all { |file| file =~ Core::Check::MAILER_FILES }
      helpers = files.find_all { |file| file =~ Core::Check::HELPER_FILES }
      others =
        files.find_all do |file|
          file !~ Core::Check::MAILER_FILES && file !~ Core::Check::MODEL_FILES && file !~ Core::Check::HELPER_FILES
        end
      models + mailers + helpers + others
    end

    # ignore specific files.
    #
    # @param [Array] files
    # @param [Regexp] pattern files match the pattern will be ignored
    # @return [Array] files that not match the pattern
    def file_ignore(files, pattern)
      files.reject { |file| file.index(pattern) }
    end

    # accept specific files.
    #
    # @param [Array] files
    # @param [Regexp] patterns, files match any pattern will be accepted
    def file_accept(files, patterns)
      files.select { |file| patterns.any? { |pattern| file =~ pattern } }
    end

    # output errors on terminal.
    def output_terminal_errors
      errors.each { |error| plain_output(error.to_s, 'red') }
      plain_output("\nPlease go to https://rails-bestpractices.com to see more useful Rails Best Practices.", 'green')
      if errors.empty?
        plain_output("\nNo warning found. Cool!", 'green')
      else
        plain_output("\nFound #{errors.size} warnings.", 'red')
      end
    end

    # load hg commit and hg username info.
    def load_hg_info
      hg_progressbar = ProgressBar.create(title: 'Hg Info', total: errors.size) if display_bar?
      errors.each do |error|
        info_command = "cd #{@runner.class.base_path}"
        info_command += " && hg blame -lvcu #{error.filename[@runner.class.base_path.size..-1].gsub(%r{^/}, '')}"
        info_command += " | sed -n /:#{error.line_number.split(',').first}:/p"
        hg_info = system(info_command)
        unless hg_info == ''
          hg_commit_username = hg_info.split(':')[0].strip
          error.hg_username = hg_commit_username.split(/\ /)[0..-2].join(' ')
          error.hg_commit = hg_commit_username.split(/\ /)[-1]
        end
        hg_progressbar.increment if display_bar?
      end
      hg_progressbar.finish if display_bar?
    end

    # load git commit and git username info.
    def load_git_info
      git_progressbar = ProgressBar.create(title: 'Git Info', total: errors.size) if display_bar?
      start = @runner.class.base_path =~ %r{/$} ? @runner.class.base_path.size : @runner.class.base_path.size + 1
      errors.each do |error|
        info_command = "cd #{@runner.class.base_path}"
        info_command += " && git blame -L #{error.line_number.split(',').first},+1 #{error.filename[start..-1]}"
        git_info = system(info_command)
        unless git_info == ''
          git_commit, git_username = git_info.split(/\d{4}-\d{2}-\d{2}/).first.split('(')
          error.git_commit = git_commit.split(' ').first.strip
          error.git_username = git_username.strip
        end
        git_progressbar.increment if display_bar?
      end
      git_progressbar.finish if display_bar?
    end

    # output errors with html format.
    def output_html_errors
      require 'erubis'
      template =
        @options['template'] ?
          File.read(File.expand_path(@options['template'])) :
          File.read(File.join(File.dirname(__FILE__), '..', '..', 'assets', 'result.html.erb'))

      if @options['with-github']
        last_commit_id = @options['last-commit-id'] || `cd #{@runner.class.base_path} && git rev-parse HEAD`.chomp
        unless @options['github-name'].start_with?('https')
          @options['github-name'] = GITHUB_URL + @options['github-name']
        end
      end
      File.open(@options['output-file'], 'w+') do |file|
        eruby = Erubis::Eruby.new(template)
        file.puts eruby.evaluate(
                    errors: errors,
                    error_types: error_types,
                    atom: @options['with-atom'],
                    textmate: @options['with-textmate'],
                    vscode: @options['with-vscode'],
                    sublime: @options['with-sublime'],
                    mvim: @options['with-mvim'],
                    github: @options['with-github'],
                    github_name: @options['github-name'],
                    last_commit_id: last_commit_id,
                    git: @options['with-git'],
                    hg: @options['with-hg']
                  )
      end
    end

    def output_xml_errors
      require 'rexml/document'

      document =
        REXML::Document.new.tap do |d|
          d << REXML::XMLDecl.new
        end

      checkstyle = REXML::Element.new('checkstyle', document)

      errors.group_by(&:filename).each do |file, group|
        REXML::Element.new('file', checkstyle).tap do |f|
          f.attributes['name'] = file
          group.each do |error|
            REXML::Element.new('error', f).tap do |e|
              e.attributes['line'] = error.line_number
              e.attributes['column'] = 0
              e.attributes['severity'] = 'error'
              e.attributes['message'] = error.message
              e.attributes['source'] = 'com.puppycrawl.tools.checkstyle.' + error.type
            end
          end
        end
      end

      formatter = REXML::Formatters::Default.new
      File.open(@options['output-file'], 'w+') do |result|
        formatter.write(document, result)
      end
    end

    # output errors with yaml format.
    def output_yaml_errors
      File.open(@options['output-file'], 'w+') do |file|
        file.write YAML.dump(errors)
      end
    end

    # output errors with json format.
    def output_json_errors
      errors_as_hashes =
        errors.map do |err|
          { filename: err.filename, line_number: err.line_number, message: err.message }
        end

      File.open(@options['output-file'], 'w+') do |file|
        file.write JSON.dump(errors_as_hashes)
      end
    end

    # plain output with color.
    #
    # @param [String] message to output
    # @param [String] color
    def plain_output(message, color)
      if @options['without-color']
        puts message
      else
        puts Colorize.send(color, message)
      end
    end

    # analyze source codes.
    def analyze_source_codes
      @bar = ProgressBar.create(title: 'Source Code', total: parse_files.size * 4) if display_bar?
      %w[lexical prepare review inline_disable].each { |process| send(:process, process) }
      @bar.finish if display_bar?
    end

    # analyze version control system info.
    def analyze_vcs
      load_git_info if @options['with-git']
      load_hg_info if @options['with-hg']
    end

    # if disaply progress bar.
    def display_bar?
      !@options['debug'] && !@options['silent']
    end

    # unique error types.
    def error_types
      errors.map(&:type).uniq
    end

    # delegate errors to runner
    def errors
      @runner.errors
    end
  end
end
