#!/usr/bin/env ruby

if RUBY_ENGINE != 'natalie' && ENV.fetch('DEBUG', false)
  require 'bundler/inline'

  gemfile do
    source 'https://rubygems.org'
    gem 'debug', '1.6.2'
  end
end

require_relative '../lib/optparse'
require 'tempfile'

require_relative '../lib/natalie'

options = { load_path: [], require: [], execute: [], profile: false }
OptionParser.new do |opts|
  opts.banner = 'Usage: natalie [options] file.rb'

  opts.program_name = 'Natalie'
  opts.version = '0.1'

  opts.on(
    '-d [type]', '--debug [type]',
    'Show debug output (specify option below)',
    options: {
      '{p1,p2,p3,p4}' => 'print intermediate instructions from individual compiler pass',
      'cpp'           => 'print generated C++',
      'transform'     => 'only transform the code to C++ without outputting it (can be used for profiling the compiler)',
      'cc-cmd'        => 'show the gcc/clang compiler command that will be run',
      'valgrind'      => 'run the resulting binary with valgrind',
      'kcachegrind'   => 'run the resulting binary with valgrind (callgrind) and open the profile in kcachegrind',
      'strace'        => 'run the resulting binary with strace',
      '{gdb,lldb}'    => 'run the resulting binary with gdb or lldb',
      'asan'          => 'enable AddressSanitizer',
    },
  ) do |type|
    case type
    when true, 'c', 'cpp', 'c++'
      options[:debug] = 'cpp'
    when /^p\d/, 'S', 'edit', 'cc-cmd', 'valgrind', 'kcachegrind', 'strace', 'asan'
      options[:debug] = type
    when 'gdb', 'lldb'
      options[:debug] = type
      options[:keep_cpp] = true
    else
      puts "Unrecognized -d argument: #{type.inspect}"
      exit 1
    end
    options[:expecting_script] = true
  end

  opts.on('-e one-line-script', 'Execute one-line script') do |e|
    options[:execute] << e
  end

  opts.on(
    '--profile-compiler format',
    'Run the compiler and gather a profile (must specify format of "text", "json", "graphviz", etc.)',
  ) do |format|
    options[:profile] = :compiler
    options[:profile_format] = format
    options[:expecting_script] = true
  end

  opts.on(
    '--profile-app',
    'Run the application and gather a profile with Trace Event Format',
  ) do |type|
    options[:profile] = :app
    options[:expecting_script] = true
  end

  opts.on('--ast', 'Show AST rather than compiling') do |ast|
    options[:ast] = ast
  end

  opts.on('--compile-bytecode path', 'Compile to bytecode') do |path|
    options[:compile] = path
    options[:bytecode] = path
    options[:expecting_script] = true
  end

  opts.on('-b path', '--bytecode path', 'Load from bytecode dump') do |path|
    options[:bytecode] = path
  end

  opts.on('--run-bytecode', 'Compile and run bytecode without an intermediate file') do |path|
    options[:run] = path
    options[:bytecode] = true
    options[:expecting_script] = true
  end

  opts.on('-c path', '--compile path', 'Compile to binary but do not execute') do |path|
    options[:compile] = path
    options[:expecting_script] = true
  end

  opts.on('--write-obj path', 'Write to C++ file for object file') do |path|
    options[:write_obj_path] = path
  end

  opts.on('-r library', 'Require the library before executing your script') do |library|
    options[:require] << library
  end

  opts.on('-I path', 'Specify $LOAD_PATH directory (may be used more than once)') do |value|
    value.split(':').each do |p|
      options[:load_path].unshift(p)
    end
  end

  opts.on('-i', '--interpret', 'Interpret Ruby code instead of compiling it (experimental)') do
    options[:interpret] = true
  end

  opts.on('--keep-cpp', 'Do not delete intermediate cpp file used for compilation') do
    options[:keep_cpp] = true
  end

  opts.on('--log-load-error', 'Log a message when natalie cannot load a required file') do
    options[:log_load_error] = true
  end

  opts.on('--print-objects', 'Disabled GC and prints all allocated objects on program termination') do
    options[:print_objects] = true
  end

  opts.on('-w', 'Turn on verbose mode / warnings') do
    options[:warnings] = true
  end

  opts.on_path do |path|
    ARGV.unshift(path)
    opts.stop_parsing!
  end
end.parse!

build_type_path = File.expand_path('../.build', __dir__)
build_setting = File.exist?(build_type_path) ? File.read(build_type_path).strip : 'debug'
options[:build] = build_setting

class Runner
  def initialize(options)
    @options = options
  end

  attr_reader :options, :source_path, :code

  def run
    load_code
    if options[:ast]
      raise 'No AST output for bytecode' if options[:bytecode]
      require 'pp'
      pp parser.ast
    elsif options[:bytecode] && options[:run]
      require 'stringio'
      io = StringIO.new(binary: true)
      compiler.compile_to_bytecode(io)
      io.rewind
      compile_and_run_bytecode(io, options)
    elsif options[:compile] && options[:bytecode]
      compiler.out_path = options[:compile]
      compiler.write_bytecode_to_file
    elsif options[:compile]
      compiler.out_path = options[:compile]
      compiler.compile
    elsif options[:write_obj_path]
      compiler.compile
    elsif options[:debug] == 'cc-cmd'
      compiler.out_path = 'temp'
      compiler.instructions # trigger compilation
      puts compiler.compiler_command
    elsif options[:debug] == 'cpp'
      if options[:interpret]
        puts compiler.instructions.map(&:to_s)
      else
        path = compiler.write_file_for_debugging
        print_cpp_source(path)
      end
    elsif options[:debug] == 'S'
      show_assembly
    elsif options[:debug] == 'edit'
      edit_compile_run_loop
    elsif options[:bytecode]
      io = File.open(@source_path, 'rb')
      compile_and_run_bytecode(io, options)
    else
      compile_and_run
    end
    if @run_result
      exit @run_result.exitstatus || 1
    end
  end

  private

  def compile_and_run
    out = Tempfile.create("natalie#{extension}")
    out.close
    compiler.out_path = out.path
    if options[:profile] == :app
      compiler.cxx_flags << '-DNAT_NATIVE_PROFILER'
    end
    if options[:print_objects]
      compiler.cxx_flags << '-DNAT_PRINT_OBJECTS'
    end

    case options[:debug]
    when 'gdb', 'lldb', 'strace'
      compiler.compile
      exec(options[:debug], out.path)
    when 'valgrind'
      compiler.cxx_flags << '-DNAT_GC_COLLECT_ALL_AT_EXIT'
      compiler.compile
      exec(
        'valgrind',
        '--leak-check=full',
        '--show-leak-kinds=all',
        '--track-origins=yes',
        '--error-exitcode=1',
        '--suppressions=test/valgrind-suppressions',
        out.path,
        *ARGV,
      )
    when 'kcachegrind'
      callgrind_out = Tempfile.create('callgrind.out')
      callgrind_out.close
      compiler.compile
      system('valgrind', '--tool=callgrind', "--callgrind-out-file=#{callgrind_out.path}", out.path, *ARGV)
      exec('kcachegrind', callgrind_out.path)
    else
      if options[:profile] == :compiler
        profile_compiler
      elsif options[:interpret]
        Natalie::VM.new(compiler.instructions, path: source_path).run
      else
        compiler.options[:dynamic_linking] = true
        compiler.compile
        begin
          run_temp_and_wait(out.path)
        ensure
          File.unlink(out.path)
        end
      end
    end
  end

  def edit_compile_run_loop
    compiler.backend.prepare_temp
    loop do
      system(ENV['EDITOR'] || 'vim', compiler.backend.cpp_path)
      out = Tempfile.create("natalie#{extension}")
      out.close
      compiler.out_path = out.path
      begin
        compiler.backend.compile_temp_to_binary
      rescue Natalie::Compiler::CompileError => e
        puts e
      else
        run_temp_and_wait(out.path)
      end
      print 'Edit again? [Yn] '
      response = gets.strip.downcase
      break if response == 'n'
    end
  end

  def compile_and_run_bytecode(io, options)
    loader = Natalie::Compiler::Bytecode::Loader.new(io)
    if /\Ap\d\z/.match?(options[:debug])
      puts loader.instructions
      exit 0
    end
    im = Natalie::Compiler::InstructionManager.new(loader.instructions)
    Natalie::VM.new(im, path: io.respond_to?(:path) ? io.path : options[:path]).run
  end

  def run_temp_and_wait(path)
    build_dir = File.expand_path('../build', __dir__)
    var_name = RbConfig::CONFIG['LIBPATHENV']
    env = { var_name => Natalie::Compiler::CppBackend::LIB_PATHS.join(':') }
    pid = spawn(env, path, *ARGV)
    Process.wait(pid)
    @run_result = $?
  end

  def show_assembly
    compiler.write_file
    compiler.out_path = '-'
    cmd = compiler.compiler_command.gsub(/-L [^ ]+|[^ ]+\.[ao]|-lnatalie/, '')
    puts `#{cmd} -S -fverbose-asm 2>&1`
  end

  def load_code
    if options[:execute].any?
      @source_path = '-e'
      @code = options[:execute].join("\n").gsub(/\\n/, "\n")
      if options[:require].any?
        @code = options[:require].map { |l| "require #{l.inspect}" }.join("\n") + "\n" + @code
      end
    elsif ARGV.any?
      @source_path = ARGV.shift
      @code = File.read(source_path)
      if options[:require].any?
        @code = options[:require].map { |l| "require #{l.inspect}" }.join("\n") + "\n" + @code
      end
    elsif options[:bytecode]
      @source_path = options[:bytecode]
    elsif options[:expecting_script]
      raise 'Expected a Ruby script, but none was given.'
    else
      @repl = true
      @source_path = File.expand_path('../lib/natalie/repl.rb', __dir__)
      @code = File.read(source_path)
    end
    if options[:warnings] && !@code.nil?
      @code = '$VERBOSE = true' + "\n" + @code
    end
  end

  def extension
    if RUBY_PLATFORM =~ /msys/
      '.exe'
    else
      ''
    end
  end

  def compiler
    return @compiler if @compiler

    ast = parser.ast
    encoding = parser.encoding
    @compiler = Natalie::Compiler.new(ast: ast, path: source_path, encoding: encoding, options: options).tap do |c|
      c.load_path = options[:load_path]
      c.write_obj_path = options[:write_obj_path]
    end
  end

  def parser
    @parser ||= Natalie::Parser.new(code, source_path)
  end

  def print_cpp_source(path)
    if system('which bat 2>&1 >/dev/null')
      system('bat', path, '-lcpp')
    elsif system('which batcat 2>&1 >/dev/null')
      system('batcat', path, '-lcpp')
    else
      puts File.read(path)
      unless ENV['DO_NOT_PRINT_CPP_PATH']
        puts '-' * 80
        puts path
      end
    end
  end

  def profile_compiler
    require 'stackprof'
    profile = StackProf.run(mode: :wall, raw: true) { compiler.compile }
    report = StackProf::Report.new(profile)
    report.send("print_#{options[:profile_format]}")
  end
end

Runner.new(options).run
