require "mruby/core_ext"
require "mruby/build/load_gems"
require "mruby/build/command"
autoload :Find, "find"

module MRuby
  autoload :Gem, "mruby/gem"
  autoload :Lockfile, "mruby/lockfile"
  autoload :Presym, "mruby/presym"

  INSTALL_PREFIX = ENV['PREFIX'] || ENV['INSTALL_PREFIX'] || '/usr/local'
  INSTALL_DESTDIR = ENV['DESTDIR'] || ''

  class << self
    def targets
      @targets ||= {}
    end

    def each_target(&block)
      return to_enum(:each_target) if block.nil?
      @targets.each do |key, target|
        target.instance_eval(&block)
      end
    end
  end

  class Toolchain
    class << self
      attr_accessor :toolchains

      def guess
        if cc = ENV["CC"] || ENV["CXX"]
          return "clang" if cc.include?("clang")
        else
          return "clang" if RUBY_PLATFORM =~ /darwin|(?:free|open)bsd/
          return "gcc" if RUBY_PLATFORM.include?("cygwin")
          return "visualcpp" if ENV.include?("VisualStudioVersion")
          return "visualcpp" if ENV.include?("VSINSTALLDIR")
        end
        "gcc"
      end
    end

    def initialize(name, &block)
      @name, @initializer = name.to_s, block
      MRuby::Toolchain.toolchains[@name] = self
    end

    def setup(conf, params={})
      conf.instance_exec(conf, params, &@initializer)
    end

    self.toolchains = {}
  end

  class Build
    class << self
      attr_accessor :current

      def mruby_config_path
        path = ENV['MRUBY_CONFIG'] || ENV['CONFIG']
        if path.nil? || path.empty?
          path = if Dir.pwd != MRUBY_ROOT && File.file?("./build_config.rb")
            "./build_config.rb"
          else
            "#{MRUBY_ROOT}/build_config/default.rb"
          end
        elsif !File.file?(path) && !Pathname.new(path).absolute?
          f = "#{MRUBY_ROOT}/build_config/#{path}.rb"
          path = File.exist?(f) ? f : File.extname(path).empty? ? f : path
        end
        path
      end

      def install_dir
        @install_dir ||= ENV['INSTALL_DIR'] || "#{MRUBY_ROOT}/bin"
      end
    end

    include Rake::DSL
    include LoadGems
    attr_accessor :name, :bins, :exts, :file_separator, :build_dir, :gem_clone_dir, :defines, :libdir_name
    attr_reader :products, :libmruby_core_objs, :libmruby_objs, :gems, :toolchains, :presym, :mrbc_build, :gem_dir_to_repo_url
    attr_reader :install_excludes, :port_names

    alias libmruby libmruby_objs

    COMPILERS = %w(cc cxx objc asm)
    COMMANDS = COMPILERS + %w(linker archiver yacc gperf git exts mrbc)
    attr_block MRuby::Build::COMMANDS

    Exts = Struct.new(:object, :executable, :library, :presym_preprocessed)

    def initialize(name='host', build_dir=nil, internal: false, &block)
      @name = name.to_s

      unless current = MRuby.targets[@name]
        if ENV['OS'] == 'Windows_NT'
          @exts = Exts.new('.o', '.exe', '.a', '.pi')
        else
          @exts = Exts.new('.o', '', '.a', '.pi')
        end

        build_dir = build_dir || ENV['MRUBY_BUILD_DIR'] || "#{MRUBY_ROOT}/build"

        @file_separator = '/'
        @build_dir = "#{build_dir}/#{@name}"
        @gem_clone_dir = "#{build_dir}/repos/#{@name}"
        @libdir_name = (self.kind_of?(MRuby::CrossBuild) ? nil : ENV["MRUBY_SYSTEM_LIBDIR_NAME"]) || "lib"
        @install_prefix = nil
        @install_excludes = []
        @defines = []
        @cc = Command::Compiler.new(self, %w(.c), label: "CC")
        @cxx = Command::Compiler.new(self, %w(.cc .cxx .cpp), label: "CXX")
        @objc = Command::Compiler.new(self, %w(.m), label: "OBJC")
        @asm = Command::Compiler.new(self, %w(.S .asm .s), label: "ASM")
        @linker = Command::Linker.new(self)
        @archiver = Command::Archiver.new(self)
        @yacc = Command::Yacc.new(self)
        @gperf = Command::Gperf.new(self)
        @git = Command::Git.new(self)
        @mrbc = Command::Mrbc.new(self)

        @products = []
        @bins = []
        @gems = MRuby::Gem::List.new
        @libmruby_core_objs = []
        @libmruby_objs = [@libmruby_core_objs]
        @enable_libmruby = true
        @build_mrbtest_lib_only = false
        @cxx_exception_enabled = false
        @cxx_exception_disabled = false
        @cxx_abi_enabled = false
        @enable_bintest = false
        @enable_test = false
        @enable_lock = true
        @enable_benchmark = true
        @mrbcfile_external = false
        @internal = internal
        @toolchains = []
        @port_names = nil
        @gem_dir_to_repo_url = {}

        # Add lambda instead of string because libdir_name or lib may be changed by user configuration
        libmruby_core_name = nil
        @install_excludes << ->(file) {
          libmruby_core_name ||= File.join(libdir_name, libfile("libmruby_core"))
          file == libmruby_core_name
        }

        MRuby.targets[@name] = current = self
      end

      MRuby::Build.current = current
      begin
        current.instance_eval(&block)
      ensure
        if current.libmruby_enabled? && !current.mrbcfile_external?
          current.create_mrbc_build if current.host? || current.gems["mruby-bin-mrbc"]
        end
        current.presym = Presym.new(current)
      end
    end

    def libmruby_enabled?
      @enable_libmruby
    end

    def disable_libmruby
      @enable_libmruby = false
    end

    def debug_enabled?
      @enable_debug
    end

    def enable_debug
      compilers.each do |c|
        c.defines += %w(MRB_DEBUG)
        c.setup_debug(self)
      end
      @mrbc.compile_options += ' -g'

      @enable_debug = true
    end

    # Set target port names for this build.
    # Each gem compiles the first matching ports/<name>/ directory;
    # later names in the list act as fallbacks for gems that don't
    # ship a port for the earlier names.
    #   conf.ports :esp32
    #   conf.ports :rp2040, :posix    # use rp2040 if available, else posix
    def ports(*names)
      @port_names = names.map { |n| n.to_s }
    end

    # Returns the effective port names for this build.
    # If not explicitly set, auto-detects :posix or :win for host builds.
    def effective_ports
      return @port_names if @port_names
      if kind_of?(MRuby::CrossBuild)
        []
      elsif ENV['OS'] == 'Windows_NT' ||
            ('A'..'Z').any? { |v| Dir.exist?("#{v}:") }
        ['win']
      else
        ['posix']
      end
    end

    def disable_lock
      @enable_lock = false
    end

    def lock_enabled?
      Lockfile.enabled? && @enable_lock
    end

    def disable_cxx_exception
      if @cxx_exception_enabled or @cxx_abi_enabled
        raise "cxx_exception already enabled"
      end
      @cxx_exception_disabled = true
    end

    def enable_cxx_exception
      return if @cxx_exception_enabled
      return if @cxx_abi_enabled
      if @cxx_exception_disabled
        raise "cxx_exception disabled"
      end
      @cxx_exception_enabled = true
      compilers.each { |c|
        c.defines += %w(MRB_USE_CXX_EXCEPTION)
        c.flags << c.cxx_exception_flag
      }
      linker.command = cxx.command if toolchains.find { |v| v == 'gcc' }
    end

    def cxx_exception_enabled?
      @cxx_exception_enabled
    end

    def cxx_abi_enabled?
      @cxx_abi_enabled
    end

    def enable_cxx_abi
      return if @cxx_abi_enabled
      if @cxx_exception_enabled
        raise "cxx_exception already enabled"
      end
      compilers.each { |c|
        c.defines += %w(MRB_USE_CXX_EXCEPTION MRB_USE_CXX_ABI)
        c.flags << c.cxx_compile_flag
        c.flags = c.flags.flatten - c.cxx_invalid_flags.flatten
      }
      linker.command = cxx.command if toolchains.find { |v| v == 'gcc' }
      @cxx_abi_enabled = true
    end

    def benchmark_enabled?
      @enable_benchmark
    end

    def disable_benchmark
      @enable_benchmark = false
    end

    def compile_as_cxx(src, cxx_src = nil, obj = nil, includes = [])
      #
      # If `cxx_src` is specified, this method behaves the same as before as
      # compatibility mode, but `.d` file is not read.
      #
      # If `cxx_src` is omitted, `.d` file is read by using mruby standard
      # Rake rule (C++ source name is also changed).
      #
      if cxx_src
        obj ||= cxx_src + @exts.object
        dsts = [obj]
        dsts << (cxx_src + @exts.presym_preprocessed)
        defines = []
        include_paths = ["#{MRUBY_ROOT}/src", *includes]
        dsts.each do |dst|
          file dst => cxx_src do |t|
            cxx.run t.name, t.prerequisites.first, defines, include_paths
          end
        end
      else
        cxx_src = "#{build_dir}/#{src.relative_path.to_s.remove_leading_parents}".ext << "-cxx.cxx"
        obj = cxx_src.ext(@exts.object)
      end

      file cxx_src => [src, __FILE__] do |t|
        mkdir_p File.dirname t.name
        IO.write t.name, <<EOS
#define __STDC_CONSTANT_MACROS
#define __STDC_LIMIT_MACROS

#ifndef MRB_USE_CXX_ABI
extern "C" {
#endif
#include "#{File.absolute_path src}"
#ifndef MRB_USE_CXX_ABI
}
#endif
EOS
      end

      obj
    end

    def enable_bintest
      @enable_bintest = true
    end

    def bintest_enabled?
      @enable_bintest
    end

    def toolchain(name=Toolchain.guess, params={})
      name = name.to_s
      tc = Toolchain.toolchains[name] || begin
        path = "#{MRUBY_ROOT}/tasks/toolchains/#{name}.rake"
        fail "Unknown #{name} toolchain" unless File.exist?(path)
        load path
        Toolchain.toolchains[name]
      end
      tc.setup(self, params)
      @toolchains.unshift name
    end

    def primary_toolchain
      @toolchains.first
    end

    def root
      MRUBY_ROOT
    end

    def enable_test
      @enable_test = true
    end
    alias build_mrbtest enable_test

    def test_enabled?
      @enable_test
    end

    def build_mrbc_exec
      gem :core => 'mruby-bin-mrbc' unless @gems['mruby-bin-mrbc']
    end

    def locks
      Lockfile.build(@name)
    end

    def mrbcfile
      return @mrbcfile if @mrbcfile

      gem_name = "mruby-bin-mrbc"
      if (gem = @gems[gem_name])
        @mrbcfile = exefile("#{gem.build.build_dir}/bin/mrbc")
      elsif !host? && (host = MRuby.targets["host"])
        if (gem = host.gems[gem_name])
          @mrbcfile = exefile("#{gem.build.build_dir}/bin/mrbc")
        elsif host.mrbcfile_external?
          @mrbcfile = host.mrbcfile
        end
      end
      @mrbcfile || fail("external mrbc or mruby-bin-mrbc gem in current('#{@name}') or 'host' build is required")
    end

    def mrbcfile=(path)
      @mrbcfile = path
      @mrbcfile_external = true
    end

    def mrbcfile_external?
      @mrbcfile_external
    end

    def compilers
      COMPILERS.map do |c|
        instance_variable_get("@#{c}")
      end
    end

    def define_rules
      compilers.each do |compiler|
        compiler.defines << "MRB_NO_GEMS" unless enable_gems? && libmruby_enabled?
      end
      [@cc, *(@cxx if cxx_exception_enabled?)].each do |compiler|
        compiler.define_rules(@build_dir, MRUBY_ROOT, @exts.object)
        compiler.define_rules(@build_dir, MRUBY_ROOT, @exts.presym_preprocessed)
      end
    end

    def define_installer_outline(src, dst)
      file dst => src do
        _pp "GEN", src.relative_path, dst.relative_path
        mkdir_p(File.dirname(dst))
        yield dst
      end
      dst
    end

    if ENV['OS'] == 'Windows_NT'
      def define_installer(src)
        dst = "#{self.class.install_dir}/#{File.basename(src)}".pathmap("%X.bat")
        define_installer_outline(src, dst) do
          File.write dst, <<~BATCHFILE
            @echo off
            call "#{File.expand_path(src)}" %*
          BATCHFILE
        end
      end
    else
      def define_installer(src)
        dst = "#{self.class.install_dir}/#{File.basename(src)}"
        define_installer_outline(src, dst) do
          File.unlink(dst) rescue nil
          File.symlink(src.relative_path_from(self.class.install_dir), dst)
        end
      end
    end

    def define_installer_if_needed(bin)
      exe = exefile("#{build_dir}/bin/#{bin}")
      host? ? define_installer(exe) : exe
    end

    def filename(name)
      if name.is_a?(Array)
        name.flatten.map { |n| filename(n) }
      else
        name.gsub('/', file_separator)
      end
    end

    def exefile(name)
      if name.is_a?(Array)
        name.flatten.map { |n| exefile(n) }
      elsif File.extname(name).empty?
        "#{name}#{exts.executable}"
      else
        # `name` sometimes have (non-standard) extension (e.g. `.bat`).
        name
      end
    end

    def objfile(name)
      if name.is_a?(Array)
        name.flatten.map { |n| objfile(n) }
      else
        "#{name}#{exts.object}"
      end
    end

    def libfile(name)
      if name.is_a?(Array)
        name.flatten.map { |n| libfile(n) }
      else
        "#{name}#{exts.library}"
      end
    end

    def build_mrbtest_lib_only
      @build_mrbtest_lib_only = true
    end

    def build_mrbtest_lib_only?
      @build_mrbtest_lib_only
    end

    def verbose_flag
      Rake.verbose ? ' -v' : ''
    end

    def run_test
      puts ">>> Test #{name} <<<"
      mrbtest = exefile("#{build_dir}/bin/mrbtest")
      sh "#{filename mrbtest.relative_path}#{verbose_flag}"
      puts
    end

    def run_bintest
      puts ">>> Bintest #{name} <<<"
      targets = @gems.select { |v| File.directory? "#{v.dir}/bintest" }.map { |v| filename v.dir }
      mrbc = @gems["mruby-bin-mrbc"] ? exefile("#{@build_dir}/bin/mrbc") : mrbcfile
      env = {"BUILD_DIR" => @build_dir, "MRBCFILE" => mrbc}
      bintest = File.join(MRUBY_ROOT, "test/bintest.rb")
      sh env, "ruby #{bintest}#{verbose_flag} #{targets.join ' '}"
    end

    def print_build_summary
      puts "================================================"
      puts "      Config Name: #{@name}"
      puts " Output Directory: #{self.build_dir.relative_path}"
      puts "         Binaries: #{@bins.join(', ')}" unless @bins.empty?
      unless @gems.empty?
        puts "    Included Gems:"
        gems = @gems.sort_by { |gem| gem.name }
        gems.each do |gem|
          gem_version = " - #{gem.version}" if gem.version != '0.0.0'
          gem_summary = " - #{gem.summary}" if gem.summary
          puts "             #{gem.name}#{gem_version}#{gem_summary}"
          puts "               - Binaries: #{gem.bins.join(', ')}" unless gem.bins.empty?
        end
      end
      puts "================================================"
      puts
    end

    def libmruby_static
      libfile("#{build_dir}/#{libdir_name}/libmruby")
    end

    def libmruby_core_static
      libfile("#{build_dir}/#{libdir_name}/libmruby_core")
    end

    def libraries
      [libmruby_static]
    end

    def host?
      @name == "host"
    end

    def internal?
      @internal
    end

    def each_header_files(&block)
      return to_enum(__method__) unless block

      basedir = File.join(MRUBY_ROOT, "include")
      Find.find(basedir) do |d|
        next unless File.file? d
        yield d
      end

      @gems.each { |g| g.each_header_files(&block) }

      self
    end

    def install_prefix
      @install_prefix || (self.name == "host" ? MRuby::INSTALL_PREFIX :
                                                File.join(MRuby::INSTALL_PREFIX, "mruby/#{self.name}"))
    end

    def install_prefix=(dir)
      @install_prefix = dir&.to_s
    end

    protected

    attr_writer :presym

    def create_mrbc_build
      exclusions = %i[@name @build_dir @gems @enable_test @enable_bintest @internal @install_excludes]
      name = "#{@name}/mrbc"
      MRuby.targets.delete(name)
      build = self.class.new(name, internal: true){}
      build.build_dir = "#{@build_dir}/mrbc"
      instance_variables.each do |n|
        next if exclusions.include?(n)
        v = instance_variable_get(n)
        v = case v
            when nil, true, false, Numeric; v
            when String; v.clone
            when Command; v.clone.tap { |u| u.build = build }
            else Marshal.load(Marshal.dump(v))  # deep clone
            end
        build.instance_variable_set(n, v)
      end
      build.build_mrbc_exec
      build.disable_libmruby
      build.presym = Presym.new(build)
      @mrbc_build = build
      self.mrbcfile = build.mrbcfile
      build
    end
  end # Build

  class CrossBuild < Build
    attr_block %w(test_runner)
    # cross compiling targets for building native extensions.
    # host  - arch of where the built binary will run
    # build - arch of the machine building the binary
    attr_accessor :host_target, :build_target

    def initialize(name, build_dir=nil, &block)
      @test_runner = Command::CrossTestRunner.new(self)
      super
      unless mrbcfile_external? || MRuby.targets['host']
        # add minimal 'host'
        MRuby::Build.new('host') do |conf|
          conf.toolchain
          conf.build_mrbc_exec
          conf.disable_libmruby
        end
      end
    end

    def mrbcfile
      mrbcfile_external? ? super : MRuby::targets['host'].mrbcfile
    end

    def run_test
      @test_runner.runner_options << verbose_flag
      mrbtest = exefile("#{build_dir}/bin/mrbtest")
      if (@test_runner.command == nil)
        puts "You should run #{mrbtest} on target device."
        puts
      else
        @test_runner.run(mrbtest)
      end
    end

    def run_bintest
      puts ">>> Bintest #{name} <<<"
      targets = @gems.select { |v| File.directory? "#{v.dir}/bintest" }.map { |v| filename v.dir }
      mrbc = @gems["mruby-bin-mrbc"] ? exefile("#{@build_dir}/bin/mrbc") : mrbcfile

      env = {
        "BUILD_DIR" => @build_dir,
        "MRBCFILE" => mrbc,
        "EMULATOR" => @test_runner.emulator,
      }
      bintest = File.join(MRUBY_ROOT, "test/bintest.rb")
      sh env, "ruby #{bintest}#{verbose_flag} #{targets.join ' '}"
    end

    protected

    def create_mrbc_build; end
  end # CrossBuild
end # MRuby
