# Copyright (c) 2010-2019 Minero Aoki, Kenshi Muto
#               2002-2009 Minero Aoki
#
# This program is free software.
# You can distribute or modify this program under the terms of
# the GNU LGPL, Lesser General Public License version 2.1.
# For details of the GNU LGPL, see the file "COPYING".
#

require 'review/textutils'
require 'review/exception'
require 'review/preprocessor/directive'
require 'review/preprocessor/line'
require 'review/preprocessor/repository'
require 'review/loggable'
require 'open3'

module ReVIEW
  class Preprocessor
    include Loggable

    TYPES = %w[file range].freeze
    KNOWN_DIRECTIVES = %w[require provide warn ok].freeze
    INF_INDENT = 9999

    def initialize(param)
      @repository = ReVIEW::Preprocessor::Repository.new(param)
      @config = param ## do not use params in this class; only used in Repository
      @logger = ReVIEW.logger
      @leave_content = nil
    end

    def process(path)
      File.open(path) do |inf|
        @inf = inf
        @f = StringIO.new
        begin
          preproc(@inf)
        rescue Errno::ENOENT => e
          error! e.message
        end
        @f.string
      end
    end

    private

    def preproc(f)
      @vartable = {}
      @has_errors = false

      f.each_line do |line|
        begin
          case line
          when /\A\#@\#/, /\A\#\#\#\#/
            @f.print line

          when /\A\#@defvar/
            @f.print line
            direc = parse_directive(line, 2)
            defvar(*direc.args)

          when /\A\#@mapoutput/
            direc = parse_directive(line, 1, 'stderr')
            @f.print line
            get_output(expand(direc.arg), direc['stderr']).each { |out| @f.print out.string }
            skip_list(f)

          when /\A\#@mapfile/
            direc = parse_directive(line, 1, 'eval')
            path = expand(direc.arg)
            @leave_content = File.extname(path) == '.re'
            ent = if direc['eval']
                    evaluate(path, ent)
                  else
                    @repository.fetch_file(path)
                  end
            replace_block(f, line, ent, false) # FIXME: turn off lineno: tmp

          when /\A\#@map(?:range)?/
            direc = parse_directive(line, 2, 'unindent')
            path = expand(direc.args[0])
            @leave_content = File.extname(path) == '.re'
            ent = @repository.fetch_range(path, direc.args[1]) or
              app_error "unknown range: #{path}: #{direc.args[1]}"
            ent = (direc['unindent'] ? unindent(ent, direc['unindent']) : ent)
            replace_block(f, line, ent, false) # FIXME: turn off lineno: tmp

          when /\A\#@end/
            app_error 'unbaranced #@end'

          when /\A\#@/
            op = line.slice(/@(\w+)/, 1)
            warn "unknown directive: #{line.strip}", location: location unless known_directive?(op)
            if op == 'warn'
              warn line.strip.sub(/\#@warn\((.+)\)/, '\1'), location: location
            end
            @f.print line

          when /\A\s*\z/ # empty line
            @f.puts
          else # rubocop:disable Lint/DuplicateBranch
            @f.print line
          end
        rescue ApplicationError => e
          @has_errors = true
          error e.message, location: location
        end
      end

      if @has_erros
        error! 'preprocessor failed.'
      end
    end

    def known_directive?(op)
      KNOWN_DIRECTIVES.index(op)
    end

    def replace_block(f, directive_line, newlines, with_lineno)
      @f.print directive_line
      newlines.each do |line|
        if with_lineno
          print_number(line.number)
        end
        @f.print line.string
      end
      skip_list(f)
    end

    def print_number(num)
      @f.printf('%4s  ', (num ? num.to_s : ''))
    end

    def skip_list(f)
      begline = f.lineno
      f.each_line do |line|
        case line
        when /\A\#@end/
          @f.print line
          return nil
        when %r{\A//\}}
          unless @leave_content
            warn '//} seen in list', location: location
            @f.print line
            return nil
          end
        when /\A\#@\w/
          warn "#{line.slice(/\A\#@\w+/)} seen in list", location: location
          @f.print line
        when /\A\#@/
          @f.print line
        end
      end
      app_error "list reached end of file (beginning line = #{begline})"
    end

    def parse_directive(line, argc, *optdecl)
      m = /\A\#@(\w+)\((.*?)\)(?:\[(.*?)\])?\z/.match(line.strip) or
        app_error "wrong directive: #{line.strip}"
      op = m[1]
      args = m[2].split(/,\s*/)
      opts = parse_optargs(m[3])
      return if (argc == 0) && args.empty?

      if argc == -1
        # Any number of arguments are allowed.
      elsif args.size != argc
        app_error 'wrong arg size'
      end
      if opts
        wrong_opts = opts.keys - optdecl
        unless wrong_opts.empty?
          app_error "wrong option: #{wrong_opts.keys.join(' ')}"
        end
      end
      Directive.new(op, args, opts || {})
    end

    def parse_optargs(str)
      return nil unless str

      table = {}
      str.split(/,\s*/).each do |a|
        name, spec = a.split('=', 2)
        table[name] = optarg_value(spec)
      end
      table
    end

    def optarg_value(spec)
      case spec
      when 'true' # [name=true], [name]
        true
      when 'false' # [name=false]
        false
      when 'nil' # [name=nil]
        nil
      when /^\d+$/ # [name=8]
        $&.to_i
      else # [name=val]
        spec
      end
    end

    def defvar(name, value)
      @vartable[name] = value
    end

    def expand(str)
      str.gsub(/\$\w+/) do |name|
        s = @vartable[name.sub('$', '')]
        s ? expand(s) : name
      end
    end

    def unindent(chunk, n)
      n = minimum_indent(chunk) unless n.is_a?(Integer)
      re = /\A#{' ' * n}/
      chunk.map { |line| line.edit { |s| s.sub(re, '') } }
    end

    def minimum_indent(chunk)
      n = chunk.map { |line| line.empty? ? INF_INDENT : line.num_indent }.min
      n == INF_INDENT ? 0 : n
    end

    def evaluate(path, chunk)
      outputs = get_output("ruby #{path}", false).split("\n").map(&:strip)
      chunk.map do |line|
        if /\# \$\d+/ =~ line.string
          # map result into source.
          line.edit { |s| s.sub(/\$(\d+)/) { outputs[$1.to_i - 1] } }
        else
          line
        end
      end
    end

    def get_output(cmd, use_stderr)
      out = err = nil
      Open3.popen3(cmd) do |_stdin, stdout, stderr|
        out = stdout.readlines
        if use_stderr
          out.concat(stderr.readlines)
        else
          err = stderr.readlines
        end
      end
      if err && !err.empty?
        $stderr.puts '[unexpected stderr message]'
        err.each { |line| $stderr.print line }
        app_error 'get_output: got unexpected output'
      end
      num = 0
      out.map { |line| Line.new(num += 1, line) }
    end

    def location
      "#{filename}:#{lineno}"
    end

    def filename
      @inf.path
    end

    def lineno
      @inf.lineno
    end
  end
end
